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, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE, IF_NONE_MATCH,
22 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";
53#[cfg(windows)]
54const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
55#[cfg(windows)]
56const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
57const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
58 "Installed package discovery is not supported on this platform; returning no installed packages.";
59const INSTALL_UNSUPPORTED_WARNING: &str =
60 "Installing packages is not supported on this platform; no changes were made.";
61const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
62const REPAIR_REINSTALL_WARNING: &str =
63 "Pinget repair currently re-runs the package install flow for the selected package.";
64const UNINSTALL_UNSUPPORTED_WARNING: &str =
65 "Uninstalling packages is not supported on this platform; no changes were made.";
66#[cfg(windows)]
67const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
68const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
69 "LocalManifestFiles",
70 "BypassCertificatePinningForMicrosoftStore",
71 "InstallerHashOverride",
72 "LocalArchiveMalwareScanOverride",
73 "ProxyCommandLineOptions",
74];
75const REST_SUPPORTED_CONTRACTS: &[&str] = &[
76 "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",
77];
78
79#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
80pub enum SourceKind {
81 #[serde(rename = "PreIndexed", alias = "preIndexed")]
82 PreIndexed,
83 #[serde(rename = "Rest", alias = "rest")]
84 Rest,
85}
86
87impl Display for SourceKind {
88 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
89 match self {
90 SourceKind::PreIndexed => f.write_str("preindexed"),
91 SourceKind::Rest => f.write_str("rest"),
92 }
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SourceRecord {
98 pub name: String,
99 pub kind: SourceKind,
100 pub arg: String,
101 pub identifier: String,
102 #[serde(default = "default_source_trust_level")]
103 pub trust_level: String,
104 #[serde(default)]
105 pub explicit: bool,
106 #[serde(default)]
107 pub priority: i32,
108 #[serde(default)]
109 pub last_update: Option<DateTime<Utc>>,
110 #[serde(default)]
111 pub source_version: Option<String>,
112 #[serde(default)]
113 pub etag: Option<String>,
114 #[serde(default)]
115 pub last_modified: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119struct SourceStore {
120 sources: Vec<SourceRecord>,
121}
122
123impl Default for SourceStore {
124 fn default() -> Self {
125 Self {
126 sources: vec![
127 SourceRecord {
128 name: "winget".to_owned(),
129 kind: SourceKind::PreIndexed,
130 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
131 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
132 trust_level: "Trusted".to_owned(),
133 explicit: false,
134 priority: 0,
135 last_update: None,
136 source_version: None,
137 etag: None,
138 last_modified: None,
139 },
140 SourceRecord {
141 name: "msstore".to_owned(),
142 kind: SourceKind::Rest,
143 arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
144 identifier: "StoreEdgeFD".to_owned(),
145 trust_level: "Trusted".to_owned(),
146 explicit: false,
147 priority: 0,
148 last_update: None,
149 source_version: None,
150 etag: None,
151 last_modified: None,
152 },
153 ],
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct RepositoryOptions {
161 pub app_root: PathBuf,
162 pub user_agent: String,
163 pub source_mode: SourceMode,
164 pub pre_indexed_source_auto_update_interval: Option<Duration>,
165 pub install_progress: Option<fn(&InstallProgress)>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SourceMode {
170 Auto,
171 Private,
172 SystemWingetMirror,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum EffectiveSourceMode {
177 Private,
178 SystemWingetDirect,
179 SystemWingetMirror,
180}
181
182impl RepositoryOptions {
183 pub fn new(app_root: impl Into<PathBuf>) -> Self {
185 Self {
186 app_root: app_root.into(),
187 user_agent: DEFAULT_USER_AGENT.to_owned(),
188 source_mode: SourceMode::Private,
189 pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
190 install_progress: None,
191 }
192 }
193
194 pub fn for_current_user() -> Result<Self> {
196 let source_mode = if std::env::var_os("PINGET_APPROOT").is_some() {
197 SourceMode::Private
198 } else {
199 SourceMode::Auto
200 };
201 Ok(Self::new(default_app_root()?).with_source_mode(source_mode))
202 }
203
204 #[must_use]
206 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
207 self.user_agent = user_agent.into();
208 self
209 }
210
211 #[must_use]
212 pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self {
213 self.source_mode = source_mode;
214 self
215 }
216
217 #[must_use]
219 pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
220 self.pre_indexed_source_auto_update_interval = interval;
221 self
222 }
223
224 #[must_use]
228 pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self {
229 self.install_progress = Some(progress);
230 self
231 }
232}
233
234#[derive(Debug, Clone, Default)]
235pub struct PackageQuery {
236 pub query: Option<String>,
237 pub id: Option<String>,
238 pub name: Option<String>,
239 pub moniker: Option<String>,
240 pub tag: Option<String>,
241 pub command: Option<String>,
242 pub source: Option<String>,
243 pub count: Option<usize>,
244 pub exact: bool,
245 pub version: Option<String>,
246 pub channel: Option<String>,
247 pub locale: Option<String>,
248 pub installer_type: Option<String>,
249 pub installer_architecture: Option<String>,
250 pub platform: Option<String>,
251 pub os_version: Option<String>,
252 pub install_scope: Option<String>,
253}
254
255#[derive(Debug, Clone, Default)]
256pub struct ListQuery {
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 product_code: Option<String>,
264 pub version: Option<String>,
265 pub source: Option<String>,
266 pub count: Option<usize>,
267 pub exact: bool,
268 pub install_scope: Option<String>,
269 pub upgrade_only: bool,
270 pub include_unknown: bool,
271 pub include_pinned: bool,
272}
273
274#[derive(Debug, Clone, serde::Serialize)]
275#[serde(rename_all = "PascalCase")]
276pub struct SearchMatch {
277 pub source_name: String,
278 pub source_kind: SourceKind,
279 pub id: String,
280 pub name: String,
281 pub moniker: Option<String>,
282 pub version: Option<String>,
283 pub channel: Option<String>,
284 pub match_criteria: Option<String>,
285}
286
287#[derive(Debug, Clone, serde::Serialize)]
288#[serde(rename_all = "PascalCase")]
289pub struct SearchResponse {
290 pub matches: Vec<SearchMatch>,
291 pub warnings: Vec<String>,
292 pub truncated: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296#[serde(rename_all = "PascalCase")]
297pub struct ListMatch {
298 pub name: String,
299 pub id: String,
300 pub local_id: String,
301 pub installed_version: String,
302 pub available_version: Option<String>,
303 pub source_name: Option<String>,
304 pub publisher: Option<String>,
305 pub scope: Option<String>,
306 pub installer_category: Option<String>,
307 pub install_location: Option<String>,
308 pub package_family_names: Vec<String>,
309 pub product_codes: Vec<String>,
310 pub upgrade_codes: Vec<String>,
311}
312
313#[derive(Debug, Clone, serde::Serialize)]
314#[serde(rename_all = "PascalCase")]
315pub struct ListResponse {
316 pub matches: Vec<ListMatch>,
317 pub warnings: Vec<String>,
318 pub truncated: bool,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
322#[serde(rename_all = "PascalCase")]
323pub struct VersionKey {
324 pub version: String,
325 pub channel: String,
326}
327
328#[derive(Debug, Clone, serde::Serialize)]
329pub struct Manifest {
330 pub id: String,
331 pub name: String,
332 pub version: String,
333 pub channel: String,
334 pub publisher: Option<String>,
335 pub description: Option<String>,
336 pub moniker: Option<String>,
337 pub package_url: Option<String>,
338 pub publisher_url: Option<String>,
339 pub publisher_support_url: Option<String>,
340 pub license: Option<String>,
341 pub license_url: Option<String>,
342 pub privacy_url: Option<String>,
343 pub author: Option<String>,
344 pub copyright: Option<String>,
345 pub copyright_url: Option<String>,
346 pub release_notes: Option<String>,
347 pub release_notes_url: Option<String>,
348 pub tags: Vec<String>,
349 pub agreements: Vec<PackageAgreement>,
350 pub package_dependencies: Vec<String>,
351 pub documentation: Vec<Documentation>,
352 pub icons: Vec<PackageIcon>,
353 pub installers: Vec<Installer>,
354 pub require_explicit_upgrade: bool,
359}
360
361impl Manifest {
362 pub fn icon(&self) -> Option<&PackageIcon> {
363 select_best_icon(&self.icons)
364 }
365
366 pub fn icon_url(&self) -> Option<&str> {
367 self.icon().and_then(|icon| icon.icon_url.as_deref())
368 }
369}
370
371#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
372#[serde(rename_all = "PascalCase")]
373pub struct PackageIcon {
374 pub icon_url: Option<String>,
375 pub icon_file_type: Option<String>,
376 pub icon_resolution: Option<String>,
377 pub icon_theme: Option<String>,
378 pub icon_sha256: Option<String>,
379}
380
381impl PackageIcon {
382 fn is_empty(&self) -> bool {
383 self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
384 && self
385 .icon_file_type
386 .as_deref()
387 .is_none_or(|value| value.trim().is_empty())
388 && self
389 .icon_resolution
390 .as_deref()
391 .is_none_or(|value| value.trim().is_empty())
392 && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
393 && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
394 }
395}
396
397fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
398 icons
399 .iter()
400 .enumerate()
401 .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
402 .max_by(|(left_index, left), (right_index, right)| {
403 icon_theme_score(left.icon_theme.as_deref())
404 .cmp(&icon_theme_score(right.icon_theme.as_deref()))
405 .then_with(|| {
406 icon_resolution_score(left.icon_resolution.as_deref())
407 .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
408 })
409 .then_with(|| {
410 icon_file_type_score(left.icon_file_type.as_deref())
411 .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
412 })
413 .then_with(|| right_index.cmp(left_index))
414 })
415 .map(|(_, icon)| icon)
416}
417
418fn icon_theme_score(theme: Option<&str>) -> usize {
419 match theme {
420 None => 1,
421 Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
422 Some(_) => 0,
423 }
424}
425
426fn icon_file_type_score(file_type: Option<&str>) -> usize {
427 match file_type {
428 Some(value) if value.eq_ignore_ascii_case("ico") => 2,
429 Some(value) if value.eq_ignore_ascii_case("png") => 1,
430 _ => 0,
431 }
432}
433
434fn icon_resolution_score(resolution: Option<&str>) -> usize {
435 let Some(resolution) = resolution else {
436 return 0;
437 };
438 let resolution = resolution.trim();
439 let Some(separator) = resolution.find(['x', 'X']) else {
440 return 0;
441 };
442 let (width, height) = resolution.split_at(separator);
443 let height = &height[1..];
444 let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
445 return 0;
446 };
447 if width == height { width } else { 0 }
448}
449
450#[derive(Debug, Clone, serde::Serialize)]
451pub struct Documentation {
452 pub label: Option<String>,
453 pub url: String,
454}
455
456#[derive(Debug, Clone, serde::Serialize)]
457pub struct PackageAgreement {
458 pub label: Option<String>,
459 pub text: Option<String>,
460 pub url: Option<String>,
461}
462
463#[derive(Debug, Clone, Default, serde::Serialize)]
464pub struct Installer {
465 pub architecture: Option<String>,
466 pub installer_type: Option<String>,
467 pub nested_installer_type: Option<String>,
468 pub nested_installer_files: Vec<NestedInstallerFile>,
469 pub url: Option<String>,
470 pub sha256: Option<String>,
471 pub product_code: Option<String>,
472 pub locale: Option<String>,
473 pub scope: Option<String>,
474 pub release_date: Option<String>,
475 pub package_family_name: Option<String>,
476 pub upgrade_code: Option<String>,
477 pub platforms: Vec<String>,
478 pub minimum_os_version: Option<String>,
479 pub switches: InstallerSwitches,
480 pub commands: Vec<String>,
481 pub package_dependencies: Vec<String>,
482 pub require_explicit_upgrade: bool,
483}
484
485#[derive(Debug, Clone, Default, serde::Serialize)]
486pub struct NestedInstallerFile {
487 pub relative_file_path: String,
489 pub portable_command_alias: Option<String>,
493}
494
495#[derive(Debug, Clone, Default, serde::Serialize)]
496pub struct InstallerSwitches {
497 pub silent: Option<String>,
498 pub silent_with_progress: Option<String>,
499 pub interactive: Option<String>,
500 pub custom: Option<String>,
501 pub log: Option<String>,
502 pub install_location: Option<String>,
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506pub enum InstallerMode {
507 Interactive,
508 SilentWithProgress,
509 Silent,
510}
511
512#[derive(Debug, Clone)]
513pub struct InstallRequest {
514 pub query: PackageQuery,
515 pub manifest_path: Option<PathBuf>,
516 pub mode: InstallerMode,
517 pub log_path: Option<PathBuf>,
518 pub custom: Option<String>,
519 pub override_args: Option<String>,
520 pub install_location: Option<String>,
521 pub skip_dependencies: bool,
522 pub dependencies_only: bool,
523 pub accept_package_agreements: bool,
524 pub force: bool,
525 pub rename: Option<String>,
526 pub uninstall_previous: bool,
527 pub ignore_security_hash: bool,
528 pub dependency_source: Option<String>,
529 pub no_upgrade: bool,
530}
531
532impl InstallRequest {
533 pub fn new(query: PackageQuery) -> Self {
534 Self {
535 query,
536 manifest_path: None,
537 mode: InstallerMode::SilentWithProgress,
538 log_path: None,
539 custom: None,
540 override_args: None,
541 install_location: None,
542 skip_dependencies: false,
543 dependencies_only: false,
544 accept_package_agreements: false,
545 force: false,
546 rename: None,
547 uninstall_previous: false,
548 ignore_security_hash: false,
549 dependency_source: None,
550 no_upgrade: false,
551 }
552 }
553}
554
555#[derive(Debug, Clone)]
556pub struct UninstallRequest {
557 pub query: PackageQuery,
558 pub manifest_path: Option<PathBuf>,
559 pub product_code: Option<String>,
560 pub mode: InstallerMode,
561 pub all_versions: bool,
562 pub force: bool,
563 pub purge: bool,
564 pub preserve: bool,
565 pub log_path: Option<PathBuf>,
566}
567
568impl UninstallRequest {
569 pub fn new(query: PackageQuery) -> Self {
570 Self {
571 query,
572 manifest_path: None,
573 product_code: None,
574 mode: InstallerMode::SilentWithProgress,
575 all_versions: false,
576 force: false,
577 purge: false,
578 preserve: false,
579 log_path: None,
580 }
581 }
582}
583
584#[derive(Debug, Clone)]
585pub struct RepairRequest {
586 pub query: PackageQuery,
587 pub manifest_path: Option<PathBuf>,
588 pub product_code: Option<String>,
589 pub mode: InstallerMode,
590 pub log_path: Option<PathBuf>,
591 pub accept_package_agreements: bool,
592 pub force: bool,
593 pub ignore_security_hash: bool,
594}
595
596impl RepairRequest {
597 pub fn new(query: PackageQuery) -> Self {
598 Self {
599 query,
600 manifest_path: None,
601 product_code: None,
602 mode: InstallerMode::SilentWithProgress,
603 log_path: None,
604 accept_package_agreements: false,
605 force: false,
606 ignore_security_hash: false,
607 }
608 }
609}
610
611#[derive(Debug, Clone, serde::Serialize)]
612pub struct ShowResult {
613 pub package: SearchMatch,
614 pub manifest: Manifest,
615 pub selected_installer: Option<Installer>,
616 pub cached_files: Vec<PathBuf>,
617 pub warnings: Vec<String>,
618 #[serde(skip_serializing)]
619 manifest_documents: JsonValue,
620}
621
622impl ShowResult {
623 pub fn structured_document(&self) -> JsonValue {
624 add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
625 }
626}
627
628#[derive(Debug, Clone, serde::Serialize)]
633pub struct DownloadOutput {
634 pub manifest: Manifest,
635 pub installer_path: PathBuf,
636 pub manifest_path: PathBuf,
637}
638
639struct FetchedInstaller {
643 manifest: Manifest,
644 manifest_documents: JsonValue,
645 installer: Installer,
646 installer_path: PathBuf,
647}
648
649#[derive(Debug, Clone, serde::Serialize)]
650#[serde(rename_all = "PascalCase")]
651pub struct VersionsResult {
652 pub package: SearchMatch,
653 pub versions: Vec<VersionKey>,
654 pub warnings: Vec<String>,
655}
656
657#[derive(Debug, Clone, serde::Serialize)]
658#[serde(rename_all = "PascalCase")]
659pub struct CacheWarmResult {
660 pub package: SearchMatch,
661 pub cached_files: Vec<PathBuf>,
662 pub warnings: Vec<String>,
663}
664
665#[derive(Debug, Clone, serde::Serialize)]
666#[serde(rename_all = "PascalCase")]
667pub struct SourceUpdateResult {
668 pub name: String,
669 pub kind: SourceKind,
670 pub detail: String,
671}
672
673#[derive(Debug, Clone, serde::Serialize)]
674#[serde(rename_all = "PascalCase")]
675pub struct PinRecord {
676 pub package_id: String,
677 pub version: String,
678 pub source_id: String,
679 pub pin_type: PinType,
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
683pub enum PinType {
684 Pinning,
685 Blocking,
686 Gating,
687}
688
689impl Display for PinType {
690 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
691 match self {
692 PinType::Pinning => f.write_str("Pinning"),
693 PinType::Blocking => f.write_str("Blocking"),
694 PinType::Gating => f.write_str("Gating"),
695 }
696 }
697}
698
699#[derive(Debug, Clone, serde::Serialize)]
701pub struct InstallResult {
702 pub package_id: String,
703 pub version: String,
704 pub installer_path: PathBuf,
705 pub installer_type: String,
706 pub exit_code: i32,
707 pub success: bool,
708 pub no_op: bool,
709 pub warnings: Vec<String>,
710}
711
712#[derive(Debug, Clone)]
713pub enum InstallProgress {
714 ResolvingManifest,
715 ResolvedManifest { package_id: String, version: String },
716 CheckingInstalled,
717 InstallingDependencies,
718 SelectingInstaller,
719 DownloadingInstaller { url: String, path: PathBuf },
720 VerifyingInstaller { path: PathBuf },
721 StartingInstaller { installer_type: String, path: PathBuf },
722 WaitingForInstaller { installer_type: String },
723 InstallerFinished { exit_code: i32 },
724}
725
726impl InstallerSwitches {
727 fn with_fallback(&self, fallback: &Self) -> Self {
728 Self {
729 silent: self.silent.clone().or_else(|| fallback.silent.clone()),
730 silent_with_progress: self
731 .silent_with_progress
732 .clone()
733 .or_else(|| fallback.silent_with_progress.clone()),
734 interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
735 custom: self.custom.clone().or_else(|| fallback.custom.clone()),
736 log: self.log.clone().or_else(|| fallback.log.clone()),
737 install_location: self
738 .install_location
739 .clone()
740 .or_else(|| fallback.install_location.clone()),
741 }
742 }
743}
744
745fn installed_package_discovery_supported() -> bool {
746 cfg!(windows)
747}
748
749fn package_actions_supported() -> bool {
750 cfg!(windows)
751}
752
753fn unsupported_action_result(
754 package_id: impl Into<String>,
755 version: impl Into<String>,
756 installer_type: impl Into<String>,
757 warning: &str,
758) -> InstallResult {
759 InstallResult {
760 package_id: package_id.into(),
761 version: version.into(),
762 installer_path: PathBuf::new(),
763 installer_type: installer_type.into(),
764 exit_code: 0,
765 success: true,
766 no_op: true,
767 warnings: vec![warning.to_owned()],
768 }
769}
770
771#[derive(Debug, Clone)]
772struct InstalledPackage {
773 name: String,
774 local_id: String,
775 installed_version: String,
776 publisher: Option<String>,
777 scope: Option<String>,
778 installer_category: Option<String>,
779 install_location: Option<String>,
780 winget_package_identifier: Option<String>,
781 winget_source_identifier: Option<String>,
782 package_family_names: Vec<String>,
783 product_codes: Vec<String>,
784 upgrade_codes: Vec<String>,
785 correlated: Option<SearchMatch>,
786 installed_version_canonical: bool,
793 correlated_requires_explicit_upgrade: bool,
797 correlated_lacks_compatible_installer: bool,
801}
802
803#[derive(Debug, Clone)]
804struct LocatedMatch {
805 display: SearchMatch,
806 source_index: usize,
807 locator: MatchLocator,
808}
809
810#[derive(Debug)]
811struct SearchSourceMatches {
812 matches: Vec<LocatedMatch>,
813 truncated: bool,
814}
815
816#[derive(Debug, Clone)]
817struct SourceSearchFailure {
818 source_name: String,
819 message: String,
820}
821
822#[derive(Debug)]
823struct SearchLocatedResult {
824 matches: Vec<LocatedMatch>,
825 warnings: Vec<String>,
826 failures: Vec<SourceSearchFailure>,
827 truncated: bool,
828}
829
830fn single_match_from_located(
831 located: SearchLocatedResult,
832 report_first_source_failure: bool,
833) -> Result<(LocatedMatch, Vec<String>)> {
834 if located.matches.is_empty() {
835 if report_first_source_failure && let Some(failure) = located.failures.first() {
836 bail!("failed to search source '{}': {}", failure.source_name, failure.message);
837 }
838 bail!("no package matched the supplied query");
839 }
840
841 if located.matches.len() > 1 {
842 let choices = located
843 .matches
844 .iter()
845 .take(10)
846 .map(|item| {
847 format!(
848 "{} [{}] ({})",
849 item.display.name, item.display.id, item.display.source_name
850 )
851 })
852 .collect::<Vec<_>>()
853 .join(", ");
854 bail!("multiple packages matched: {choices}");
855 }
856
857 Ok((located.matches.into_iter().next().expect("one match"), located.warnings))
858}
859
860fn should_retry_show_across_system_winget_sources(
861 uses_system_winget_sources: bool,
862 query: &PackageQuery,
863 located: &SearchLocatedResult,
864) -> bool {
865 uses_system_winget_sources
866 && query
867 .source
868 .as_deref()
869 .is_some_and(|source| source.eq_ignore_ascii_case("winget"))
870 && located.matches.is_empty()
871 && located.failures.is_empty()
872}
873
874#[derive(Debug, Clone)]
875enum MatchLocator {
876 PreIndexedV1 {
877 package_rowid: i64,
878 },
879 PreIndexedV2 {
880 package_rowid: i64,
881 package_hash: String,
882 },
883 Rest {
884 package_id: String,
885 versions: Vec<VersionKey>,
886 },
887}
888
889#[derive(Debug, Clone, Copy, PartialEq, Eq)]
890enum SearchSemantics {
891 Many,
892 Single,
893}
894
895#[derive(Debug, Clone, Serialize, Deserialize)]
896struct RestInfoCache {
897 expires_at: DateTime<Utc>,
898 value: RestInformation,
899}
900
901#[derive(Debug, Clone, Default, Serialize, Deserialize)]
902struct RestInformation {
903 #[serde(rename = "SourceIdentifier")]
904 source_identifier: String,
905 #[serde(rename = "ServerSupportedVersions", default)]
906 server_supported_versions: Vec<String>,
907 #[serde(rename = "RequiredPackageMatchFields", default)]
908 required_package_match_fields: Vec<String>,
909 #[serde(rename = "UnsupportedPackageMatchFields", default)]
910 unsupported_package_match_fields: Vec<String>,
911 #[serde(rename = "RequiredQueryParameters", default)]
912 required_query_parameters: Vec<String>,
913 #[serde(rename = "UnsupportedQueryParameters", default)]
914 unsupported_query_parameters: Vec<String>,
915}
916
917#[derive(Debug, Clone, Deserialize)]
918struct PackageVersionDataDocument {
919 #[serde(rename = "vD", default)]
920 versions: Vec<PackageVersionDataEntry>,
921}
922
923#[derive(Debug, Clone, Deserialize)]
924struct PackageVersionDataEntry {
925 #[serde(rename = "v")]
926 version: String,
927 #[serde(rename = "rP")]
928 manifest_relative_path: String,
929 #[serde(rename = "s256H")]
930 manifest_hash: String,
931 #[serde(rename = "aMiV", default)]
936 arp_min_version: Option<String>,
937 #[serde(rename = "aMaV", default)]
938 arp_max_version: Option<String>,
939}
940
941pub struct Repository {
942 app_root: PathBuf,
943 user_agent: String,
944 client: Client,
945 store: SourceStore,
946 source_mode: EffectiveSourceMode,
947 install_progress: Option<fn(&InstallProgress)>,
948 pre_indexed_source_auto_update_interval: Option<Duration>,
949 preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
950}
951
952#[derive(Debug, Clone)]
953struct PreindexedRefreshAttempt {
954 attempted_at: DateTime<Utc>,
955 succeeded: bool,
956}
957
958#[derive(Debug, Clone, Default)]
959struct PreindexedRefreshOutcome {
960 attempted: bool,
961 reused_recent_attempt: bool,
962 error: Option<String>,
963}
964
965struct PreindexedOpenConnection {
966 connection: Connection,
967 refresh: PreindexedRefreshOutcome,
968}
969
970#[derive(Debug)]
971struct MissingPackageVersionError {
972 package_id: String,
973 source_name: String,
974 requested_version: String,
975 requested_channel: Option<String>,
976}
977
978impl Display for MissingPackageVersionError {
979 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
980 let channel = self
981 .requested_channel
982 .as_deref()
983 .filter(|value| !value.is_empty())
984 .map(|value| format!(" with channel '{value}'"))
985 .unwrap_or_default();
986 write!(
987 f,
988 "Version '{}'{} was not found for package '{}' in source '{}'.",
989 self.requested_version, channel, self.package_id, self.source_name
990 )
991 }
992}
993
994impl std::error::Error for MissingPackageVersionError {}
995
996fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
997 static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
998 LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
999}
1000
1001impl Repository {
1002 pub fn open() -> Result<Self> {
1004 Self::open_with_options(RepositoryOptions::for_current_user()?)
1005 }
1006
1007 pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
1009 ensure_app_dirs(&options.app_root)?;
1010 let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?;
1011 let client = Client::builder()
1012 .user_agent(&options.user_agent)
1013 .timeout(StdDuration::from_secs(30 * 60))
1014 .build()
1015 .context("failed to build HTTP client")?;
1016 Ok(Self {
1017 app_root: options.app_root,
1018 user_agent: options.user_agent,
1019 client,
1020 store,
1021 source_mode,
1022 install_progress: options.install_progress,
1023 pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
1024 preindexed_refresh_attempts: HashMap::new(),
1025 })
1026 }
1027
1028 fn uses_system_winget_sources(&self) -> bool {
1029 self.source_mode != EffectiveSourceMode::Private
1030 }
1031
1032 fn mirrors_system_winget_sources(&self) -> bool {
1033 self.source_mode == EffectiveSourceMode::SystemWingetMirror
1034 }
1035
1036 fn emit_install_progress(&self, progress: InstallProgress) {
1037 if let Some(callback) = self.install_progress {
1038 callback(&progress);
1039 }
1040 }
1041
1042 pub fn app_root(&self) -> &Path {
1043 &self.app_root
1044 }
1045
1046 pub fn list_sources(&self) -> Vec<SourceRecord> {
1047 self.store.sources.clone()
1048 }
1049
1050 pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
1051 self.add_source_with_metadata(name, arg, kind, None, false, 0)
1052 }
1053
1054 pub fn add_source_with_metadata(
1055 &mut self,
1056 name: &str,
1057 arg: &str,
1058 kind: SourceKind,
1059 trust_level: Option<&str>,
1060 explicit: bool,
1061 priority: i32,
1062 ) -> Result<()> {
1063 if self.uses_system_winget_sources() {
1064 system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
1065 self.refresh_system_winget_sources()?;
1066 return Ok(());
1067 }
1068
1069 if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
1070 bail!("A source with name '{name}' already exists.");
1071 }
1072 if self.store.sources.iter().any(|s| s.arg == arg) {
1073 bail!("A source with argument '{arg}' already exists.");
1074 }
1075 let identifier = name.to_owned();
1076 self.store.sources.push(SourceRecord {
1077 name: name.to_owned(),
1078 kind,
1079 arg: arg.to_owned(),
1080 identifier,
1081 trust_level: normalize_source_trust_level(trust_level)?,
1082 explicit,
1083 priority,
1084 last_update: None,
1085 source_version: None,
1086 etag: None,
1087 last_modified: None,
1088 });
1089 if self.source_mode != EffectiveSourceMode::SystemWingetDirect {
1090 self.save_store()?;
1091 }
1092 Ok(())
1093 }
1094
1095 pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
1096 if self.uses_system_winget_sources() {
1097 bail!("Editing system WinGet sources is not supported by winget source commands.");
1098 }
1099
1100 let source = self
1101 .store
1102 .sources
1103 .iter_mut()
1104 .find(|source| source.name.eq_ignore_ascii_case(name))
1105 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1106
1107 if let Some(explicit) = explicit {
1108 source.explicit = explicit;
1109 }
1110
1111 if trust_level.is_some() {
1112 source.trust_level = normalize_source_trust_level(trust_level)?;
1113 }
1114
1115 self.save_store()?;
1116 Ok(())
1117 }
1118
1119 pub fn remove_source(&mut self, name: &str) -> Result<()> {
1120 if self.uses_system_winget_sources() {
1121 system_winget_remove_source(name)?;
1122 self.refresh_system_winget_sources()?;
1123 return Ok(());
1124 }
1125
1126 let idx = self
1127 .store
1128 .sources
1129 .iter()
1130 .position(|s| s.name.eq_ignore_ascii_case(name))
1131 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1132 let source = self.store.sources.remove(idx);
1133 let state_dir = self.source_state_dir(&source);
1135 if state_dir.exists() {
1136 let _ = fs::remove_dir_all(&state_dir);
1137 }
1138 self.save_store()?;
1139 Ok(())
1140 }
1141
1142 pub fn reset_source(&mut self, name: &str) -> Result<()> {
1143 if self.uses_system_winget_sources() {
1144 system_winget_reset_source(name)?;
1145 self.refresh_system_winget_sources()?;
1146 return Ok(());
1147 }
1148
1149 let idx = self
1150 .store
1151 .sources
1152 .iter()
1153 .position(|source| source.name.eq_ignore_ascii_case(name))
1154 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1155
1156 let source = self.store.sources[idx].clone();
1157 let state_dir = self.source_state_dir(&source);
1158 if state_dir.exists() {
1159 let _ = fs::remove_dir_all(&state_dir);
1160 }
1161
1162 if let Some(default_source) = SourceStore::default()
1163 .sources
1164 .into_iter()
1165 .find(|source| source.name.eq_ignore_ascii_case(name))
1166 {
1167 self.store.sources[idx] = default_source;
1168 } else {
1169 self.store.sources[idx].last_update = None;
1170 self.store.sources[idx].source_version = None;
1171 self.store.sources[idx].etag = None;
1172 self.store.sources[idx].last_modified = None;
1173 }
1174
1175 self.save_store()?;
1176 Ok(())
1177 }
1178
1179 pub fn reset_sources(&mut self) -> Result<()> {
1180 if self.uses_system_winget_sources() {
1181 system_winget_reset_sources()?;
1182 self.refresh_system_winget_sources()?;
1183 return Ok(());
1184 }
1185
1186 for source in &self.store.sources {
1188 let state_dir = self.source_state_dir(source);
1189 if state_dir.exists() {
1190 let _ = fs::remove_dir_all(&state_dir);
1191 }
1192 }
1193 self.store = SourceStore::default();
1194 self.save_store()?;
1195 Ok(())
1196 }
1197
1198 pub fn supported_admin_settings() -> &'static [&'static str] {
1199 SUPPORTED_ADMIN_SETTINGS
1200 }
1201
1202 pub fn get_user_settings(&self) -> Result<JsonValue> {
1203 Ok(JsonValue::Object(load_json_object(&user_settings_path(
1204 &self.app_root,
1205 ))?))
1206 }
1207
1208 pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1209 let update = match user_settings {
1210 JsonValue::Object(object) => object.clone(),
1211 _ => bail!("user settings must be a JSON object"),
1212 };
1213
1214 let effective = if merge {
1215 merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1216 } else {
1217 update
1218 };
1219 save_json_object(user_settings_path(&self.app_root), &effective)?;
1220 Ok(JsonValue::Object(effective))
1221 }
1222
1223 pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1224 let current = self.get_user_settings()?;
1225 Ok(if ignore_not_set {
1226 json_contains(¤t, expected)
1227 } else {
1228 current == *expected
1229 })
1230 }
1231
1232 pub fn get_admin_settings(&self) -> Result<JsonValue> {
1233 let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1234 for name in SUPPORTED_ADMIN_SETTINGS {
1235 settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1236 }
1237
1238 Ok(JsonValue::Object(settings))
1239 }
1240
1241 pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1242 let normalized = normalize_admin_setting_name(name)?;
1243 let mut settings = match self.get_admin_settings()? {
1244 JsonValue::Object(settings) => settings,
1245 _ => JsonMap::new(),
1246 };
1247 settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1248 save_json_object(admin_settings_path(&self.app_root), &settings)
1249 }
1250
1251 pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1252 if !reset_all && name.is_none() {
1253 bail!("resetting admin settings requires a setting name or reset-all");
1254 }
1255
1256 let mut settings = match self.get_admin_settings()? {
1257 JsonValue::Object(settings) => settings,
1258 _ => JsonMap::new(),
1259 };
1260
1261 if reset_all {
1262 for setting_name in SUPPORTED_ADMIN_SETTINGS {
1263 settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1264 }
1265 } else {
1266 settings.insert(
1267 normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1268 JsonValue::Bool(false),
1269 );
1270 }
1271
1272 save_json_object(admin_settings_path(&self.app_root), &settings)
1273 }
1274
1275 pub fn ensure_settings_files(&self) -> Result<()> {
1276 let user_settings = user_settings_path(&self.app_root);
1277 if !user_settings.exists() {
1278 save_json_object(user_settings, &JsonMap::new())?;
1279 }
1280
1281 let admin_settings = admin_settings_path(&self.app_root);
1282 if !admin_settings.exists() {
1283 let mut values = JsonMap::new();
1284 for name in SUPPORTED_ADMIN_SETTINGS {
1285 values.insert((*name).to_owned(), JsonValue::Bool(false));
1286 }
1287 save_json_object(admin_settings, &values)?;
1288 }
1289
1290 Ok(())
1291 }
1292
1293 pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1294 if self.source_mode == EffectiveSourceMode::SystemWingetDirect {
1295 return self.update_system_winget_sources(source_name);
1296 }
1297
1298 if self.mirrors_system_winget_sources() {
1299 self.refresh_system_winget_sources()?;
1300 }
1301
1302 let indexes = self.resolve_source_indexes(source_name)?;
1303 let mut results = Vec::new();
1304
1305 for index in indexes {
1306 let detail = match self.store.sources[index].kind {
1307 SourceKind::PreIndexed => self.update_preindexed(index)?,
1308 SourceKind::Rest => self.update_rest(index)?,
1309 };
1310
1311 results.push(SourceUpdateResult {
1312 name: self.store.sources[index].name.clone(),
1313 kind: self.store.sources[index].kind,
1314 detail,
1315 });
1316 }
1317
1318 self.save_store()?;
1319 Ok(results)
1320 }
1321
1322 pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1323 let located = self.search_located(query, SearchSemantics::Many)?;
1324 Ok(SearchResponse {
1325 matches: located.matches.into_iter().map(|item| item.display).collect(),
1326 warnings: located.warnings,
1327 truncated: located.truncated,
1328 })
1329 }
1330
1331 pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1332 let located = self.search_located(query, SearchSemantics::Many)?;
1333 let mut structured_documents = Vec::with_capacity(located.matches.len());
1334 for located in located.matches {
1335 let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1336 structured_documents.push(manifest_documents);
1337 }
1338
1339 Ok(collapse_structured_documents(&structured_documents))
1340 }
1341
1342 pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1343 if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1344 bail!("--include-unknown and --include-pinned require --upgrade-available");
1345 }
1346 if query.source.is_some()
1347 && !query.upgrade_only
1348 && query.query.is_none()
1349 && query.id.is_none()
1350 && query.name.is_none()
1351 && query.moniker.is_none()
1352 && query.tag.is_none()
1353 && query.command.is_none()
1354 {
1355 bail!("list --source currently requires a query or explicit filter");
1356 }
1357
1358 self.refresh_system_winget_sources()?;
1359
1360 let has_filter = list_query_needs_available_lookup(query);
1361 let needs_available = has_filter || query.upgrade_only;
1362
1363 let mut warnings = Vec::new();
1364 if !installed_package_discovery_supported() {
1365 warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1366 }
1367 let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1368
1369 warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?);
1374 warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1375 warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1376
1377 if needs_available && has_filter {
1378 let available_query = package_query_from_list_query(query);
1381 let located = self.search_located(&available_query, SearchSemantics::Many)?;
1382 warnings.extend(located.warnings);
1383 let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1384 for package in &mut installed {
1385 if package.correlated.is_some() {
1386 continue;
1387 }
1388 package.correlated =
1389 correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1390 }
1391 } else if needs_available && !query.upgrade_only {
1392 warnings.extend(self.correlate_all_installed(&mut installed)?);
1397 }
1398
1399 if needs_available {
1400 warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1411 }
1412
1413 if query.upgrade_only {
1420 installed = dedupe_correlated_for_upgrade(installed);
1421 }
1422
1423 let mut matches = installed
1424 .into_iter()
1425 .filter(|package| {
1426 list_package_matches(package, query)
1427 && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1428 })
1429 .collect::<Vec<_>>();
1430
1431 matches.sort_by(|left, right| {
1432 list_sort_weight(left)
1433 .cmp(&list_sort_weight(right))
1434 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1435 .then_with(|| left.local_id.cmp(&right.local_id))
1436 });
1437
1438 let pins = if query.upgrade_only {
1439 self.list_pins(query.source.as_deref())?
1440 } else {
1441 Vec::new()
1442 };
1443 let mut list_matches = matches
1444 .into_iter()
1445 .map(|package| list_match_from_installed(package, &self.store.sources))
1446 .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1447 .collect::<Vec<_>>();
1448 if !query.upgrade_only {
1449 list_matches = suppress_duplicate_available_versions(list_matches);
1450 }
1451 let truncated = if let Some(limit) = query.count {
1452 let was_truncated = list_matches.len() > limit;
1453 list_matches.truncate(limit);
1454 was_truncated
1455 } else {
1456 false
1457 };
1458
1459 Ok(ListResponse {
1460 matches: list_matches,
1461 warnings,
1462 truncated,
1463 })
1464 }
1465
1466 fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1470 if installed.iter().all(|p| p.correlated.is_some()) {
1471 return Ok(Vec::new());
1472 }
1473 let all_query = PackageQuery {
1474 query: None,
1475 id: None,
1476 name: None,
1477 moniker: None,
1478 tag: None,
1479 command: None,
1480 source: None,
1481 count: Some(100_000), exact: false,
1483 version: None,
1484 channel: None,
1485 locale: None,
1486 installer_type: None,
1487 installer_architecture: None,
1488 platform: None,
1489 os_version: None,
1490 install_scope: None,
1491 };
1492 let located = self.search_located(&all_query, SearchSemantics::Many)?;
1493 let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1494
1495 for package in installed.iter_mut() {
1496 if package.correlated.is_some() {
1497 continue;
1498 }
1499 package.correlated = correlate_installed_package(package, &candidates, true);
1500 }
1501
1502 Ok(located.warnings)
1503 }
1504
1505 fn correlate_installed_via_installed_db(
1510 &mut self,
1511 installed: &mut [InstalledPackage],
1512 requested_source: Option<&str>,
1513 ) -> Result<Vec<String>> {
1514 let source_indices = self
1515 .store
1516 .sources
1517 .iter()
1518 .enumerate()
1519 .filter(|(_, source)| {
1520 requested_source
1521 .map(|name| source.name.eq_ignore_ascii_case(name))
1522 .unwrap_or(true)
1523 })
1524 .map(|(index, _)| index)
1525 .collect::<Vec<_>>();
1526
1527 for source_index in source_indices {
1528 let source = self.source_clone(source_index);
1529 let installed_db_path = self.source_installed_db_path(&source);
1530 if !installed_db_path.exists() {
1531 continue;
1532 }
1533
1534 let connection = Self::open_sqlite_connection(installed_db_path)?;
1535 if !installed_db_identity_tables_present(&connection)? {
1536 continue;
1537 }
1538
1539 for package in installed.iter_mut() {
1540 if package.correlated.is_some() {
1541 continue;
1542 }
1543 let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else {
1544 continue;
1545 };
1546 let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed {
1547 match self
1548 .open_preindexed_connection(source_index)
1549 .and_then(|index_connection| {
1550 let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else {
1551 return Ok((id.clone(), name.clone(), None, None));
1552 };
1553 let (canonical_installed, anchored_latest) =
1554 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1555 Ok((entries, _)) => (
1556 map_arp_version_to_catalog(&entries, &package.installed_version)
1557 .or_else(|| {
1558 less_than_latest_arp_anchored_version(
1559 &entries,
1560 &package.installed_version,
1561 )
1562 })
1563 .or_else(|| {
1564 greater_than_latest_arp_anchored_version(
1565 &entries,
1566 &package.installed_version,
1567 &meta.latest_version,
1568 )
1569 }),
1570 latest_arp_anchored_version(&entries),
1571 ),
1572 Err(_) => (None, None),
1573 };
1574 let version = match (&canonical_installed, anchored_latest) {
1575 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1576 (Some(_), Some(anchored)) => Some(anchored),
1577 (Some(_), None) => None,
1578 _ => Some(meta.latest_version.clone()),
1579 };
1580 if let Some(version) = canonical_installed {
1581 package.installed_version = version;
1582 package.installed_version_canonical = true;
1583 }
1584 Ok((meta.id, meta.name, meta.moniker, version))
1585 }) {
1586 Ok(enriched) => enriched,
1587 Err(_) => (id, name, None, None),
1588 }
1589 } else {
1590 (id, name, None, None)
1591 };
1592 package.correlated = Some(SearchMatch {
1593 source_name: source.name.clone(),
1594 source_kind: source.kind,
1595 id,
1596 name,
1597 moniker,
1598 version,
1599 channel: None,
1600 match_criteria: Some(matched_by.to_owned()),
1601 });
1602 }
1603 }
1604
1605 Ok(Vec::new())
1606 }
1607
1608 fn correlate_installed_via_index(
1617 &mut self,
1618 installed: &mut [InstalledPackage],
1619 requested_source: Option<&str>,
1620 ) -> Result<Vec<String>> {
1621 let source_indices: Vec<usize> = self
1622 .store
1623 .sources
1624 .iter()
1625 .enumerate()
1626 .filter(|(_, source)| {
1627 source.kind == SourceKind::PreIndexed
1628 && match requested_source {
1629 Some(name) => source.name.eq_ignore_ascii_case(name),
1630 None => true,
1631 }
1632 })
1633 .map(|(index, _)| index)
1634 .collect();
1635
1636 for source_index in source_indices {
1637 let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1638 let connection = match self.open_preindexed_connection(source_index) {
1639 Ok(c) => c,
1640 Err(_) => continue,
1641 };
1642 if !v2_identity_tables_present(&connection) {
1643 continue;
1644 }
1645 let mut acc = Vec::new();
1646 for (idx, package) in installed.iter().enumerate() {
1647 if package.correlated.is_some() {
1648 continue;
1649 }
1650 let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1651 continue;
1652 };
1653 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1654 continue;
1655 };
1656 acc.push((idx, meta, by));
1657 }
1658 acc
1659 };
1660
1661 let source = self.source_clone(source_index);
1662 for (idx, meta, by) in resolved {
1663 let (canonical_installed, anchored_latest) =
1664 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1665 Ok((entries, _)) => (
1666 map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1667 .or_else(|| {
1668 less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1669 })
1670 .or_else(|| {
1671 greater_than_latest_arp_anchored_version(
1672 &entries,
1673 &installed[idx].installed_version,
1674 &meta.latest_version,
1675 )
1676 }),
1677 latest_arp_anchored_version(&entries),
1678 ),
1679 Err(_) => (None, None),
1680 };
1681 let available_version = match (&canonical_installed, anchored_latest) {
1688 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1689 (Some(_), Some(anchored)) => Some(anchored),
1690 (Some(_), None) => None,
1691 _ => Some(meta.latest_version),
1692 };
1693 let installed_pkg = &mut installed[idx];
1694 installed_pkg.correlated = Some(SearchMatch {
1695 source_name: source.name.clone(),
1696 source_kind: source.kind,
1697 id: meta.id,
1698 name: meta.name,
1699 moniker: meta.moniker,
1700 version: available_version,
1701 channel: None,
1702 match_criteria: Some(by.to_owned()),
1703 });
1704 if let Some(version) = canonical_installed {
1705 installed_pkg.installed_version = version;
1706 installed_pkg.installed_version_canonical = true;
1707 }
1708 }
1709 }
1710
1711 Ok(Vec::new())
1712 }
1713
1714 fn correlate_installed_by_normalized_identity(
1723 &mut self,
1724 installed: &mut [InstalledPackage],
1725 requested_source: Option<&str>,
1726 ) -> Result<Vec<String>> {
1727 let source_indices: Vec<usize> = self
1728 .store
1729 .sources
1730 .iter()
1731 .enumerate()
1732 .filter(|(_, source)| {
1733 source.kind == SourceKind::PreIndexed
1734 && match requested_source {
1735 Some(name) => source.name.eq_ignore_ascii_case(name),
1736 None => true,
1737 }
1738 })
1739 .map(|(index, _)| index)
1740 .collect();
1741
1742 for source_index in source_indices {
1743 let resolved: Vec<(usize, V2PackageMetadata)> = {
1744 let connection = match self.open_preindexed_connection(source_index) {
1745 Ok(c) => c,
1746 Err(_) => continue,
1747 };
1748 if !v2_normalized_identity_tables_present(&connection) {
1749 continue;
1750 }
1751 let mut acc = Vec::new();
1752 for (idx, pkg) in installed.iter().enumerate() {
1753 if pkg.correlated.is_some() {
1754 continue;
1755 }
1756 if pkg.local_id.starts_with("MSIX\\") {
1761 continue;
1762 }
1763 let Some(publisher) = pkg.publisher.as_deref() else {
1764 continue;
1765 };
1766 let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1767 if normalized_name.is_empty() {
1768 continue;
1769 }
1770 let normalized_publisher = name_normalization::normalize_publisher(publisher);
1771 if normalized_publisher.is_empty() {
1772 continue;
1773 }
1774 let Some(rowid) =
1775 lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1776 else {
1777 continue;
1778 };
1779 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1780 continue;
1781 };
1782 acc.push((idx, meta));
1783 }
1784 acc
1785 };
1786
1787 let source = self.source_clone(source_index);
1788 for (idx, meta) in resolved {
1789 let (canonical_installed, anchored_latest) =
1790 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1791 Ok((entries, _)) => (
1792 map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1793 .or_else(|| {
1794 less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1795 })
1796 .or_else(|| {
1797 greater_than_latest_arp_anchored_version(
1798 &entries,
1799 &installed[idx].installed_version,
1800 &meta.latest_version,
1801 )
1802 }),
1803 latest_arp_anchored_version(&entries),
1804 ),
1805 Err(_) => (None, None),
1806 };
1807 let available_version = match (&canonical_installed, anchored_latest) {
1808 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1809 (Some(_), Some(anchored)) => Some(anchored),
1810 (Some(_), None) => None,
1811 _ => Some(meta.latest_version),
1812 };
1813 let installed_pkg = &mut installed[idx];
1814 installed_pkg.correlated = Some(SearchMatch {
1815 source_name: source.name.clone(),
1816 source_kind: source.kind,
1817 id: meta.id,
1818 name: meta.name,
1819 moniker: meta.moniker,
1820 version: available_version,
1821 channel: None,
1822 match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1823 });
1824 if let Some(version) = canonical_installed {
1825 installed_pkg.installed_version = version;
1826 installed_pkg.installed_version_canonical = true;
1827 }
1828 }
1829 }
1830
1831 Ok(Vec::new())
1832 }
1833
1834 fn enrich_correlated_via_index(
1846 &mut self,
1847 installed: &mut [InstalledPackage],
1848 requested_source: Option<&str>,
1849 ) -> Result<Vec<String>> {
1850 for package in installed.iter_mut() {
1852 apply_msix_resource_string_name_fix(package);
1853 }
1854
1855 let source_indices: Vec<usize> = self
1858 .store
1859 .sources
1860 .iter()
1861 .enumerate()
1862 .filter(|(_, source)| {
1863 source.kind == SourceKind::PreIndexed
1864 && match requested_source {
1865 Some(name) => source.name.eq_ignore_ascii_case(name),
1866 None => true,
1867 }
1868 })
1869 .map(|(index, _)| index)
1870 .collect();
1871
1872 for source_index in source_indices {
1873 let needs: Vec<(usize, V2PackageMetadata)> = {
1874 let connection = match self.open_preindexed_connection(source_index) {
1875 Ok(c) => c,
1876 Err(_) => continue,
1877 };
1878 if !v2_identity_tables_present(&connection) {
1879 continue;
1880 }
1881 let mut acc = Vec::new();
1882 for (idx, pkg) in installed.iter().enumerate() {
1883 let Some(candidate) = pkg.correlated.as_ref() else {
1884 continue;
1885 };
1886 if !candidate
1890 .source_name
1891 .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1892 {
1893 continue;
1894 }
1895 let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1896 continue;
1897 };
1898 acc.push((idx, meta));
1899 }
1900 acc
1901 };
1902
1903 let source = self.source_clone(source_index);
1904 for (idx, meta) in needs {
1905 let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1910 Ok((entries, _)) => entries,
1911 Err(_) => continue,
1912 };
1913
1914 if !installed[idx].installed_version_canonical {
1915 let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1916 .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version))
1917 .or_else(|| {
1918 greater_than_latest_arp_anchored_version(
1919 &entries,
1920 &installed[idx].installed_version,
1921 &meta.latest_version,
1922 )
1923 });
1924 let anchored_latest = latest_arp_anchored_version(&entries);
1925 if let Some(canonical) = canonical_installed {
1926 let greater_than_latest = is_greater_than_version_marker(&canonical);
1927 installed[idx].installed_version = canonical;
1928 installed[idx].installed_version_canonical = true;
1929 if let Some(correlated) = installed[idx].correlated.as_mut() {
1930 correlated.version = if greater_than_latest { None } else { anchored_latest };
1931 }
1932 }
1933 }
1934
1935 if let Some(latest) = entries.first() {
1941 let bytes_result = self.get_cached_source_file(
1942 "V2_M",
1943 &source,
1944 &latest.manifest_relative_path,
1945 Some(latest.manifest_hash.as_str()),
1946 );
1947 if let Ok(bytes) = bytes_result
1948 && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1949 {
1950 installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1951 installed[idx].correlated_lacks_compatible_installer =
1952 !manifest_has_compatible_installer(&manifest);
1953 }
1954 }
1955 }
1956 }
1957
1958 Ok(Vec::new())
1959 }
1960
1961 pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1962 let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1963 let versions = self.versions_for_match(&located, query)?;
1964 Ok(VersionsResult {
1965 package: located.display,
1966 versions,
1967 warnings,
1968 })
1969 }
1970
1971 pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1972 let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
1973 let versions = self.versions_for_match(&located, query)?;
1974 Ok(VersionsResult {
1975 package: located.display,
1976 versions,
1977 warnings,
1978 })
1979 }
1980
1981 pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1982 let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
1983 let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1984 let selected_installer = select_installer(&manifest.installers, query);
1985
1986 Ok(ShowResult {
1987 package: located.display,
1988 manifest,
1989 selected_installer,
1990 cached_files,
1991 warnings,
1992 manifest_documents,
1993 })
1994 }
1995
1996 pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1997 let (located, warnings) = self.find_single_match(query)?;
1998 let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1999
2000 Ok(CacheWarmResult {
2001 package: located.display,
2002 cached_files,
2003 warnings,
2004 })
2005 }
2006
2007 pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
2010 let db_path = pins_db_path(&self.app_root);
2011 if !db_path.exists() {
2012 return Ok(Vec::new());
2013 }
2014 let conn = Self::open_sqlite_connection(db_path)?;
2015 let pin_type_column = match resolve_pin_type_column(&conn)? {
2016 Some(column) => column,
2017 None => return Ok(Vec::new()),
2018 };
2019 let sql = if source_id.is_some() {
2020 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
2021 } else {
2022 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
2023 };
2024 let params = if let Some(source_id) = source_id {
2025 vec![SqlValue::Text(source_id.to_owned())]
2026 } else {
2027 Vec::new()
2028 };
2029 query_rows(&conn, &sql, params, |row| {
2030 let pin_type_int = row_i64(row, 3)?;
2031 Ok(PinRecord {
2032 package_id: row_string(row, 0)?,
2033 version: row_string(row, 1)?,
2034 source_id: row_string(row, 2)?,
2035 pin_type: decode_pin_type(pin_type_int),
2036 })
2037 })
2038 }
2039
2040 pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
2041 let db_path = pins_db_path(&self.app_root);
2042 let conn = Self::open_sqlite_connection(db_path)?;
2043 execute_batch_sql(
2044 &conn,
2045 "CREATE TABLE IF NOT EXISTS pin (
2046 package_id TEXT NOT NULL,
2047 version TEXT NOT NULL DEFAULT '*',
2048 source_id TEXT NOT NULL DEFAULT '',
2049 type INTEGER NOT NULL DEFAULT 0,
2050 PRIMARY KEY (package_id, source_id)
2051 )",
2052 )?;
2053 let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
2054 let type_int: i64 = match pin_type {
2055 PinType::Pinning => 2,
2056 PinType::Blocking => 4,
2057 PinType::Gating => 3,
2058 };
2059 execute_sql(
2060 &conn,
2061 &format!(
2062 "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
2063 ),
2064 vec![
2065 SqlValue::Text(package_id.to_owned()),
2066 SqlValue::Text(version.to_owned()),
2067 SqlValue::Text(source_id.to_owned()),
2068 SqlValue::Integer(type_int),
2069 ],
2070 )?;
2071 conn.cacheflush().context("failed to flush pin database")?;
2072 Ok(())
2073 }
2074
2075 pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
2076 let db_path = pins_db_path(&self.app_root);
2077 if !db_path.exists() {
2078 return Ok(false);
2079 }
2080 let conn = Self::open_sqlite_connection(db_path)?;
2081 let count = if let Some(source_id) = source_id {
2082 execute_sql(
2083 &conn,
2084 "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
2085 vec![
2086 SqlValue::Text(package_id.to_owned()),
2087 SqlValue::Text(source_id.to_owned()),
2088 ],
2089 )?
2090 } else {
2091 execute_sql(
2092 &conn,
2093 "DELETE FROM pin WHERE package_id = ?1",
2094 vec![SqlValue::Text(package_id.to_owned())],
2095 )?
2096 };
2097 conn.cacheflush().context("failed to flush pin database")?;
2098 Ok(count > 0)
2099 }
2100
2101 pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
2102 let db_path = pins_db_path(&self.app_root);
2103 if !db_path.exists() {
2104 return Ok(());
2105 }
2106
2107 if let Some(source_id) = source_id {
2108 let conn = Self::open_sqlite_connection(db_path)?;
2109 execute_sql(
2110 &conn,
2111 "DELETE FROM pin WHERE source_id = ?1",
2112 vec![SqlValue::Text(source_id.to_owned())],
2113 )?;
2114 conn.cacheflush().context("failed to flush pin database")?;
2115 } else {
2116 fs::remove_file(&db_path)?;
2117 }
2118 Ok(())
2119 }
2120
2121 fn save_store(&self) -> Result<()> {
2122 if self.mirrors_system_winget_sources() {
2123 save_system_winget_mirror_store(&self.app_root, &self.store)
2124 } else {
2125 save_store(&self.app_root, &self.store)
2126 }
2127 }
2128
2129 fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
2130 source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2131 }
2132
2133 fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf {
2134 source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2135 }
2136
2137 fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf {
2138 self.source_state_dir(source).join("source.msix")
2139 }
2140
2141 fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf {
2142 self.source_state_dir(source).join("index.db")
2143 }
2144
2145 fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf {
2146 self.source_state_dir(source).join("rest-information.json")
2147 }
2148
2149 fn rest_manifest_cache_path(
2150 &self,
2151 source: &SourceRecord,
2152 package_id: &str,
2153 version: &str,
2154 channel: &str,
2155 ) -> PathBuf {
2156 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
2157 let digest = sha256_hex(key.as_bytes());
2158 cache_root(&self.app_root)
2159 .join("REST_M")
2160 .join(sanitize_path_segment(&source.identifier))
2161 .join(format!("{digest}.json"))
2162 }
2163
2164 pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
2167 self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
2168 }
2169
2170 pub fn download_installer_for_request(
2171 &mut self,
2172 request: &InstallRequest,
2173 download_dir: &Path,
2174 ) -> Result<DownloadOutput> {
2175 let fetched = self.fetch_installer_for_request(request, download_dir)?;
2176 let manifest_yaml =
2177 build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
2178 .context("failed to render manifest YAML")?;
2179 let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
2180 let manifest_path = download_dir.join(&manifest_filename);
2181 fs::write(&manifest_path, manifest_yaml.as_bytes())
2182 .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
2183
2184 Ok(DownloadOutput {
2185 manifest: fetched.manifest,
2186 installer_path: fetched.installer_path,
2187 manifest_path,
2188 })
2189 }
2190
2191 fn fetch_installer_for_request(
2196 &mut self,
2197 request: &InstallRequest,
2198 download_dir: &Path,
2199 ) -> Result<FetchedInstaller> {
2200 let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
2201 let installer = select_installer(&manifest.installers, &request.query)
2202 .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
2203 let url = installer
2204 .url
2205 .as_deref()
2206 .ok_or_else(|| anyhow!("Installer has no URL"))?;
2207
2208 fs::create_dir_all(download_dir)?;
2209 let filename = request.rename.as_deref().unwrap_or_else(|| {
2210 url.rsplit('/')
2211 .next()
2212 .unwrap_or("installer")
2213 .split('?')
2214 .next()
2215 .unwrap_or("installer")
2216 });
2217 let dest = download_dir.join(filename);
2218
2219 self.emit_install_progress(InstallProgress::DownloadingInstaller {
2220 url: url.to_owned(),
2221 path: dest.clone(),
2222 });
2223 let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?;
2224
2225 self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() });
2226 if let Err(error) =
2227 verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash)
2228 {
2229 let _ = fs::remove_file(&dest);
2230 return Err(error);
2231 }
2232
2233 Ok(FetchedInstaller {
2234 manifest,
2235 manifest_documents,
2236 installer,
2237 installer_path: dest,
2238 })
2239 }
2240
2241 pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2242 let mut request = InstallRequest::new(query.clone());
2243 request.mode = if silent {
2244 InstallerMode::Silent
2245 } else {
2246 InstallerMode::SilentWithProgress
2247 };
2248 self.install_request(&request)
2249 }
2250
2251 pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
2252 let mut request = InstallRequest::new(query.clone());
2253 request.mode = mode;
2254 self.install_request(&request)
2255 }
2256
2257 pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
2258 self.emit_install_progress(InstallProgress::ResolvingManifest);
2259 let manifest = self.resolve_manifest_for_install(request)?;
2260 self.emit_install_progress(InstallProgress::ResolvedManifest {
2261 package_id: manifest.id.clone(),
2262 version: manifest.version.clone(),
2263 });
2264 if !package_actions_supported() {
2265 let installer_type = select_installer(&manifest.installers, &request.query)
2266 .and_then(|installer| installer.installer_type)
2267 .unwrap_or_else(|| "install".to_owned())
2268 .to_lowercase();
2269 return Ok(unsupported_action_result(
2270 manifest.id.clone(),
2271 manifest.version.clone(),
2272 installer_type,
2273 INSTALL_UNSUPPORTED_WARNING,
2274 ));
2275 }
2276
2277 self.emit_install_progress(InstallProgress::CheckingInstalled);
2278 let existing_match = self.find_installed_package_for_install(request, &manifest)?;
2279 if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
2280 return Ok(no_op_result);
2281 }
2282
2283 Self::ensure_package_agreements_accepted(&manifest, request)?;
2284 self.emit_install_progress(InstallProgress::InstallingDependencies);
2285 self.install_dependencies(&manifest, request, &mut HashSet::new())?;
2286
2287 if request.dependencies_only {
2288 return Ok(InstallResult {
2289 package_id: manifest.id.clone(),
2290 version: manifest.version.clone(),
2291 installer_path: PathBuf::new(),
2292 installer_type: "dependencies".to_owned(),
2293 exit_code: 0,
2294 success: true,
2295 no_op: false,
2296 warnings: Vec::new(),
2297 });
2298 }
2299
2300 self.emit_install_progress(InstallProgress::SelectingInstaller);
2301 let installer = select_installer(&manifest.installers, &request.query)
2302 .ok_or_else(|| anyhow!("No applicable installer found"))?;
2303
2304 if request.uninstall_previous {
2305 let mut uninstall_request = UninstallRequest::new(PackageQuery {
2306 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2307 query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
2308 source: request.query.source.clone(),
2309 exact: true,
2310 version: request.query.version.clone(),
2311 ..PackageQuery::default()
2312 });
2313 uninstall_request.product_code = installer.product_code.clone();
2314 uninstall_request.mode = InstallerMode::Silent;
2315 uninstall_request.all_versions = true;
2316 uninstall_request.force = true;
2317 let _ = self.uninstall_request(&uninstall_request);
2318 }
2319
2320 let temp_dir = std::env::temp_dir().join("pinget-install");
2321 let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
2322
2323 let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
2324
2325 self.emit_install_progress(InstallProgress::StartingInstaller {
2326 installer_type: installer_type.clone(),
2327 path: installer_path.clone(),
2328 });
2329 self.emit_install_progress(InstallProgress::WaitingForInstaller {
2330 installer_type: installer_type.clone(),
2331 });
2332 let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
2333 self.emit_install_progress(InstallProgress::InstallerFinished { exit_code });
2334
2335 Ok(InstallResult {
2336 package_id: manifest.id.clone(),
2337 version: manifest.version.clone(),
2338 installer_path,
2339 installer_type,
2340 exit_code,
2341 success: exit_code == 0,
2342 no_op: false,
2343 warnings: Vec::new(),
2344 })
2345 }
2346
2347 fn find_installed_package_for_install(
2348 &mut self,
2349 request: &InstallRequest,
2350 manifest: &Manifest,
2351 ) -> Result<Option<ListMatch>> {
2352 let installed_matches = self.list(&ListQuery {
2353 query: request.query.query.clone(),
2354 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2355 name: request.query.name.clone(),
2356 moniker: request.query.moniker.clone(),
2357 tag: None,
2358 command: None,
2359 product_code: None,
2360 version: None,
2361 source: request.query.source.clone(),
2362 count: Some(100),
2363 exact: request.query.exact || !manifest.id.trim().is_empty(),
2364 install_scope: request.query.install_scope.clone(),
2365 upgrade_only: false,
2366 include_unknown: false,
2367 include_pinned: false,
2368 })?;
2369
2370 Ok(installed_matches.matches.into_iter().find(|candidate| {
2371 candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2372 }))
2373 }
2374
2375 fn create_install_no_op_result(
2376 request: &InstallRequest,
2377 manifest: &Manifest,
2378 existing_match: Option<&ListMatch>,
2379 ) -> Option<InstallResult> {
2380 let existing_match = existing_match?;
2381
2382 if request.no_upgrade {
2383 return Some(InstallResult {
2384 package_id: manifest.id.clone(),
2385 version: existing_match.installed_version.clone(),
2386 installer_path: PathBuf::new(),
2387 installer_type: "install".to_owned(),
2388 exit_code: 0,
2389 success: true,
2390 no_op: true,
2391 warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2392 });
2393 }
2394
2395 if !request.force
2396 && !existing_match.installed_version.trim().is_empty()
2397 && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2398 {
2399 return Some(InstallResult {
2400 package_id: manifest.id.clone(),
2401 version: existing_match.installed_version.clone(),
2402 installer_path: PathBuf::new(),
2403 installer_type: "install".to_owned(),
2404 exit_code: 0,
2405 success: true,
2406 no_op: true,
2407 warnings: vec![
2408 "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2409 ],
2410 });
2411 }
2412
2413 None
2414 }
2415
2416 pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2417 if request.manifest_path.is_none() && !package_actions_supported() {
2418 let package_id = request
2419 .query
2420 .id
2421 .clone()
2422 .or_else(|| request.query.name.clone())
2423 .or_else(|| request.query.moniker.clone())
2424 .or_else(|| request.query.query.clone())
2425 .or_else(|| request.product_code.clone())
2426 .unwrap_or_else(|| "repair".to_owned());
2427 return Ok(unsupported_action_result(
2428 package_id,
2429 request.query.version.clone().unwrap_or_default(),
2430 "repair".to_owned(),
2431 REPAIR_UNSUPPORTED_WARNING,
2432 ));
2433 }
2434
2435 let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2436 let installed_match = if request.manifest_path.is_some() {
2437 None
2438 } else {
2439 let installed = self.list(&Self::create_repair_list_query(request))?;
2440 warnings.extend(installed.warnings);
2441 if installed.matches.is_empty() {
2442 bail!("No installed package matched the supplied repair query.");
2443 }
2444 if installed.matches.len() > 1 {
2445 bail!("Multiple installed packages matched the supplied repair query.");
2446 }
2447 installed.matches.into_iter().next()
2448 };
2449
2450 let mut result =
2451 self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2452 warnings.extend(result.warnings);
2453 result.warnings = warnings;
2454 Ok(result)
2455 }
2456
2457 pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2458 let mut request = UninstallRequest::new(query.clone());
2459 request.mode = if silent {
2460 InstallerMode::Silent
2461 } else {
2462 InstallerMode::SilentWithProgress
2463 };
2464 self.uninstall_request(&request)
2465 }
2466
2467 pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2468 if !package_actions_supported() {
2469 let (package_id, version) = if let Some(path) = &request.manifest_path {
2470 let manifest = Self::load_manifest_from_path(path)?;
2471 (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2472 } else {
2473 (
2474 request
2475 .query
2476 .id
2477 .clone()
2478 .or_else(|| request.query.query.clone())
2479 .or_else(|| request.query.name.clone())
2480 .or_else(|| request.query.moniker.clone())
2481 .or_else(|| request.product_code.clone())
2482 .unwrap_or_else(|| "unknown".to_owned()),
2483 request.query.version.clone().unwrap_or_default(),
2484 )
2485 };
2486
2487 return Ok(unsupported_action_result(
2488 package_id,
2489 version,
2490 "uninstall",
2491 UNINSTALL_UNSUPPORTED_WARNING,
2492 ));
2493 }
2494
2495 let matches = self.resolve_uninstall_matches(request)?;
2496 let mut exit_code = 0;
2497
2498 for installed in &matches {
2499 exit_code = uninstall_package(installed, request)?;
2500 if exit_code != 0 && !request.all_versions {
2501 break;
2502 }
2503 }
2504
2505 let installed = matches
2506 .first()
2507 .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2508
2509 Ok(InstallResult {
2510 package_id: installed.id.clone(),
2511 version: installed.installed_version.clone(),
2512 installer_path: PathBuf::new(),
2513 installer_type: "uninstall".to_owned(),
2514 exit_code,
2515 success: exit_code == 0,
2516 no_op: false,
2517 warnings: Vec::new(),
2518 })
2519 }
2520
2521 fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2522 self.resolve_manifest_and_documents_for_install(request)
2523 .map(|(manifest, _)| manifest)
2524 }
2525
2526 fn resolve_manifest_and_documents_for_install(
2527 &mut self,
2528 request: &InstallRequest,
2529 ) -> Result<(Manifest, JsonValue)> {
2530 if let Some(path) = &request.manifest_path {
2531 return Self::load_manifest_from_path_with_documents(path);
2532 }
2533
2534 let (located, _warnings) = self.find_single_match(&request.query)?;
2535 let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2536 Ok((manifest, documents))
2537 }
2538
2539 fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2540 if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2541 bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2542 }
2543 Ok(())
2544 }
2545
2546 fn install_dependencies(
2547 &mut self,
2548 manifest: &Manifest,
2549 request: &InstallRequest,
2550 visited: &mut HashSet<String>,
2551 ) -> Result<()> {
2552 if request.skip_dependencies {
2553 return Ok(());
2554 }
2555
2556 let dependency_ids = manifest
2557 .package_dependencies
2558 .iter()
2559 .chain(
2560 manifest
2561 .installers
2562 .iter()
2563 .flat_map(|installer| installer.package_dependencies.iter()),
2564 )
2565 .filter(|value| !value.trim().is_empty())
2566 .cloned()
2567 .collect::<BTreeSet<_>>();
2568
2569 for dependency_id in dependency_ids {
2570 if !visited.insert(dependency_id.clone()) {
2571 continue;
2572 }
2573
2574 let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2575 self.install_request(&dependency_request)?;
2576 }
2577
2578 Ok(())
2579 }
2580
2581 fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2582 let mut dependency_request = InstallRequest::new(PackageQuery {
2583 id: Some(dependency_id),
2584 source: request
2585 .dependency_source
2586 .clone()
2587 .or_else(|| request.query.source.clone()),
2588 exact: true,
2589 ..PackageQuery::default()
2590 });
2591 dependency_request.mode = match request.mode {
2592 InstallerMode::Interactive => InstallerMode::Interactive,
2593 InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2594 };
2595 dependency_request.accept_package_agreements = request.accept_package_agreements;
2596 dependency_request.no_upgrade = true;
2597 dependency_request.ignore_security_hash = request.ignore_security_hash;
2598 dependency_request.dependency_source = request.dependency_source.clone();
2599 dependency_request
2600 }
2601
2602 fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2603 ListQuery {
2604 query: request.query.query.clone(),
2605 id: request.query.id.clone(),
2606 name: request.query.name.clone(),
2607 moniker: request.query.moniker.clone(),
2608 product_code: request.product_code.clone(),
2609 version: request.query.version.clone(),
2610 source: request.query.source.clone(),
2611 count: Some(100),
2612 exact: request.query.exact,
2613 install_scope: request.query.install_scope.clone(),
2614 ..ListQuery::default()
2615 }
2616 }
2617
2618 fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2619 let mut query = request.query.clone();
2620 query.query = if installed_match.is_none() {
2621 request.query.query.clone()
2622 } else {
2623 None
2624 };
2625 query.id = installed_match
2626 .map(|item| item.id.clone())
2627 .or_else(|| request.query.id.clone());
2628 query.name = if installed_match.is_none() {
2629 request.query.name.clone()
2630 } else {
2631 None
2632 };
2633 query.moniker = if installed_match.is_none() {
2634 request.query.moniker.clone()
2635 } else {
2636 None
2637 };
2638 query.source = request
2639 .query
2640 .source
2641 .clone()
2642 .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2643 query.exact = true;
2644 query.version = request
2645 .query
2646 .version
2647 .clone()
2648 .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2649
2650 InstallRequest {
2651 query,
2652 manifest_path: request.manifest_path.clone(),
2653 mode: request.mode,
2654 log_path: request.log_path.clone(),
2655 custom: None,
2656 override_args: None,
2657 install_location: None,
2658 skip_dependencies: false,
2659 dependencies_only: false,
2660 accept_package_agreements: request.accept_package_agreements,
2661 force: true,
2662 rename: None,
2663 uninstall_previous: false,
2664 ignore_security_hash: request.ignore_security_hash,
2665 dependency_source: None,
2666 no_upgrade: false,
2667 }
2668 }
2669
2670 fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2671 let mut effective_query = request.query.clone();
2672 let mut effective_product_code = request.product_code.clone();
2673
2674 if let Some(path) = &request.manifest_path {
2675 let manifest = Self::load_manifest_from_path(path)?;
2676 effective_query = PackageQuery {
2677 query: Some(manifest.id.clone()),
2678 id: Some(manifest.id.clone()),
2679 name: Some(manifest.name.clone()),
2680 exact: true,
2681 version: effective_query.version.clone().or(Some(manifest.version)),
2682 ..PackageQuery::default()
2683 };
2684 effective_product_code = effective_product_code.or_else(|| {
2685 manifest
2686 .installers
2687 .iter()
2688 .find_map(|installer| installer.product_code.clone())
2689 });
2690 }
2691
2692 let list_query = ListQuery {
2693 query: effective_query.query.clone(),
2694 id: effective_query.id.clone(),
2695 name: effective_query.name.clone(),
2696 moniker: effective_query.moniker.clone(),
2697 tag: None,
2698 command: None,
2699 product_code: effective_product_code,
2700 version: effective_query.version.clone(),
2701 source: effective_query.source.clone(),
2702 count: if request.all_versions { None } else { Some(100) },
2703 exact: effective_query.exact,
2704 install_scope: effective_query.install_scope,
2705 upgrade_only: false,
2706 include_unknown: false,
2707 include_pinned: false,
2708 };
2709
2710 let list_result = self.list(&list_query)?;
2711 if list_result.matches.is_empty() {
2712 bail!("No installed package found matching the query");
2713 }
2714 if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2715 bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2716 }
2717
2718 Ok(if request.all_versions {
2719 list_result.matches
2720 } else {
2721 vec![list_result.matches[0].clone()]
2722 })
2723 }
2724
2725 fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2726 Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2727 }
2728
2729 fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2730 let resolved = resolve_manifest_path(manifest_path)?;
2731 let bytes =
2732 fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2733 parse_yaml_manifest_bundle(&bytes)
2734 }
2735
2736 fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2737 self.find_single_match_with_semantics(query, SearchSemantics::Single)
2738 }
2739
2740 fn find_single_match_for_show(
2741 &mut self,
2742 query: &PackageQuery,
2743 semantics: SearchSemantics,
2744 ) -> Result<(LocatedMatch, Vec<String>)> {
2745 let located = self.search_located(query, semantics)?;
2746 if self.should_retry_show_across_system_winget_sources(query, &located) {
2747 let mut fallback_query = query.clone();
2748 fallback_query.source = None;
2749 return single_match_from_located(self.search_located(&fallback_query, semantics)?, false);
2750 }
2751
2752 single_match_from_located(located, query.source.is_some())
2753 }
2754
2755 fn should_retry_show_across_system_winget_sources(
2756 &self,
2757 query: &PackageQuery,
2758 located: &SearchLocatedResult,
2759 ) -> bool {
2760 should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located)
2761 }
2762
2763 fn find_single_match_with_semantics(
2764 &mut self,
2765 query: &PackageQuery,
2766 semantics: SearchSemantics,
2767 ) -> Result<(LocatedMatch, Vec<String>)> {
2768 let located = self.search_located(query, semantics)?;
2769 single_match_from_located(located, query.source.is_some())
2770 }
2771
2772 fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result<SearchLocatedResult> {
2773 let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2774 let mut matches = Vec::new();
2775 let mut warnings = Vec::new();
2776 let mut failures = Vec::new();
2777 let mut truncated = false;
2778
2779 for index in indexes {
2780 match self.search_source(index, query, semantics) {
2781 Ok(mut source_matches) => {
2782 truncated |= source_matches.truncated;
2783 matches.append(&mut source_matches.matches);
2784 }
2785 Err(error) => {
2786 let source_name = self.store.sources[index].name.clone();
2787 let message = format!("{error:#}");
2788 warnings.push(format!(
2789 "Failed when searching source '{}'; results will not be included: {}",
2790 source_name, message
2791 ));
2792 failures.push(SourceSearchFailure { source_name, message });
2793 }
2794 }
2795 }
2796
2797 if semantics == SearchSemantics::Many {
2798 matches.sort_by(|left, right| {
2799 search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2800 });
2801 let limit = max_results(query);
2802 if matches.len() > limit {
2803 truncated = true;
2804 matches.truncate(limit);
2805 }
2806 }
2807
2808 Ok(SearchLocatedResult {
2809 matches,
2810 warnings,
2811 failures,
2812 truncated,
2813 })
2814 }
2815
2816 fn search_source(
2817 &mut self,
2818 source_index: usize,
2819 query: &PackageQuery,
2820 semantics: SearchSemantics,
2821 ) -> Result<SearchSourceMatches> {
2822 match self.store.sources[source_index].kind {
2823 SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2824 SourceKind::Rest => self.search_rest(source_index, query, semantics),
2825 }
2826 }
2827
2828 fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2829 match &located.locator {
2830 MatchLocator::PreIndexedV1 { package_rowid } => {
2831 let source = self.source_clone(located.source_index);
2832 let connection = self.open_preindexed_connection(located.source_index)?;
2833 let versions = query_v1_versions(&connection, *package_rowid)?;
2834 let mut keys = versions
2835 .into_iter()
2836 .map(|row| VersionKey {
2837 version: row.version,
2838 channel: row.channel,
2839 })
2840 .collect::<Vec<_>>();
2841 sort_versions_desc(&mut keys);
2842 if keys.is_empty() {
2843 bail!("no versions found for {} in {}", located.display.id, source.name);
2844 }
2845 Ok(keys)
2846 }
2847 MatchLocator::PreIndexedV2 {
2848 package_rowid,
2849 package_hash,
2850 } => {
2851 let source = self.source_clone(located.source_index);
2852 let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2853 let mut keys = entries
2854 .into_iter()
2855 .map(|entry| VersionKey {
2856 version: entry.version,
2857 channel: String::new(),
2858 })
2859 .collect::<Vec<_>>();
2860 sort_versions_desc(&mut keys);
2861 Ok(keys)
2862 }
2863 MatchLocator::Rest { versions, .. } => {
2864 let mut keys = versions.clone();
2865 sort_versions_desc(&mut keys);
2866 Ok(keys)
2867 }
2868 }
2869 }
2870
2871 fn manifest_for_match(
2872 &mut self,
2873 located: &LocatedMatch,
2874 query: &PackageQuery,
2875 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2876 self.manifest_for_match_with_refresh(located, query, true)
2877 }
2878
2879 fn manifest_for_match_with_refresh(
2880 &mut self,
2881 located: &LocatedMatch,
2882 query: &PackageQuery,
2883 allow_explicit_version_refresh: bool,
2884 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2885 match &located.locator {
2886 MatchLocator::PreIndexedV1 { package_rowid } => {
2887 let source = self.source_clone(located.source_index);
2888 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2889 let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2890 let selected = match select_v1_version(
2891 &versions,
2892 query.version.as_deref(),
2893 query.channel.as_deref(),
2894 &located.display.id,
2895 &source.name,
2896 ) {
2897 Ok(selected) => selected,
2898 Err(error)
2899 if allow_explicit_version_refresh
2900 && query.version.is_some()
2901 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2902 {
2903 let open_refresh = opened.refresh.clone();
2904 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2905 return missing_error_after_open_refresh(error, &open_refresh);
2906 }
2907 if self.has_recent_successful_preindexed_refresh_attempt(
2908 &self.preindexed_refresh_lock_key(&source),
2909 ) {
2910 return Err(error);
2911 }
2912 drop(opened);
2913 return self.refresh_preindexed_and_retry_manifest(
2914 located.source_index,
2915 &located.display,
2916 query,
2917 error,
2918 );
2919 }
2920 Err(error) => return Err(error),
2921 };
2922 let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2923 let bytes =
2924 self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2925 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2926 manifest.version = selected.version.clone();
2927 manifest.channel = selected.channel;
2928 Ok((manifest, manifest_documents, vec![bytes.path]))
2929 }
2930 MatchLocator::PreIndexedV2 {
2931 package_rowid,
2932 package_hash,
2933 } => {
2934 let source = self.source_clone(located.source_index);
2935 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2936 let (entries, version_data_file) =
2937 self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2938 let selected = match select_v2_version(
2939 &entries,
2940 query.version.as_deref(),
2941 query.channel.as_deref(),
2942 &located.display.id,
2943 &source.name,
2944 ) {
2945 Ok(selected) => selected,
2946 Err(error)
2947 if allow_explicit_version_refresh
2948 && query.version.is_some()
2949 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2950 {
2951 let open_refresh = opened.refresh.clone();
2952 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2953 return missing_error_after_open_refresh(error, &open_refresh);
2954 }
2955 if self.has_recent_successful_preindexed_refresh_attempt(
2956 &self.preindexed_refresh_lock_key(&source),
2957 ) {
2958 return Err(error);
2959 }
2960 drop(opened);
2961 return self.refresh_preindexed_and_retry_manifest(
2962 located.source_index,
2963 &located.display,
2964 query,
2965 error,
2966 );
2967 }
2968 Err(error) => return Err(error),
2969 };
2970 let manifest_bytes = self.get_cached_source_file(
2971 "V2_M",
2972 &source,
2973 &selected.manifest_relative_path,
2974 Some(selected.manifest_hash.as_str()),
2975 )?;
2976 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2977 manifest.version = selected.version;
2978 Ok((
2979 manifest,
2980 manifest_documents,
2981 vec![version_data_file, manifest_bytes.path],
2982 ))
2983 }
2984 MatchLocator::Rest { package_id, versions } => {
2985 let source = self.source_clone(located.source_index);
2986 let selected = select_rest_version(
2987 versions,
2988 query.version.as_deref(),
2989 query.channel.as_deref(),
2990 &located.display.id,
2991 &source.name,
2992 )?;
2993 let (bytes, cache_path) =
2994 self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2995 let (manifest, manifest_documents) =
2996 parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2997 Ok((manifest, manifest_documents, vec![cache_path]))
2998 }
2999 }
3000 }
3001
3002 fn refresh_preindexed_and_retry_manifest(
3003 &mut self,
3004 source_index: usize,
3005 previous_display: &SearchMatch,
3006 query: &PackageQuery,
3007 missing_error: anyhow::Error,
3008 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
3009 self.refresh_preindexed_source(source_index, true)
3010 .with_context(|| format!("{missing_error} The source refresh also failed"))?;
3011 let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
3012 self.manifest_for_match_with_refresh(&refreshed, query, false)
3013 }
3014
3015 fn find_refreshed_preindexed_match(
3016 &mut self,
3017 source_index: usize,
3018 previous_display: &SearchMatch,
3019 query: &PackageQuery,
3020 ) -> Result<LocatedMatch> {
3021 let source_name = self.store.sources[source_index].name.clone();
3022 let mut source_query = query.clone();
3023 source_query.source = Some(source_name.clone());
3024 let matches = self
3025 .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
3026 .matches;
3027 if let Some(found) = matches
3028 .iter()
3029 .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
3030 {
3031 return Ok(found.clone());
3032 }
3033 if matches.len() == 1 {
3034 return Ok(matches.into_iter().next().expect("single match"));
3035 }
3036 bail!(
3037 "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
3038 previous_display.id,
3039 source_name
3040 )
3041 }
3042
3043 fn search_preindexed(
3044 &mut self,
3045 source_index: usize,
3046 query: &PackageQuery,
3047 semantics: SearchSemantics,
3048 ) -> Result<SearchSourceMatches> {
3049 let connection = self.open_preindexed_connection(source_index)?;
3050 let source = self.source_clone(source_index);
3051
3052 match query_v2_matches(&connection, query, semantics) {
3053 Ok((rows, truncated)) => Ok(SearchSourceMatches {
3054 truncated,
3055 matches: rows
3056 .into_iter()
3057 .map(|row| LocatedMatch {
3058 display: SearchMatch {
3059 source_name: source.name.clone(),
3060 source_kind: source.kind,
3061 id: row.id.clone(),
3062 name: row.name.clone(),
3063 moniker: row.moniker.clone(),
3064 version: Some(row.version.clone()),
3065 channel: None,
3066 match_criteria: row.match_criteria.clone(),
3067 },
3068 source_index,
3069 locator: MatchLocator::PreIndexedV2 {
3070 package_rowid: row.package_rowid,
3071 package_hash: row.package_hash,
3072 },
3073 })
3074 .collect(),
3075 }),
3076 Err(error) if can_fallback_to_v1(&error) => {
3077 let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
3078 let grouped = group_v1_rows(rows);
3079
3080 Ok(SearchSourceMatches {
3081 truncated,
3082 matches: grouped
3083 .into_iter()
3084 .map(|row| LocatedMatch {
3085 display: SearchMatch {
3086 source_name: source.name.clone(),
3087 source_kind: source.kind,
3088 id: row.id.clone(),
3089 name: row.name.clone(),
3090 moniker: row.moniker.clone(),
3091 version: Some(row.version.clone()),
3092 channel: Some(row.channel.clone()),
3093 match_criteria: row.match_criteria.clone(),
3094 },
3095 source_index,
3096 locator: MatchLocator::PreIndexedV1 {
3097 package_rowid: row.package_rowid,
3098 },
3099 })
3100 .collect(),
3101 })
3102 }
3103 Err(error) => Err(error),
3104 }
3105 }
3106
3107 fn search_rest(
3108 &mut self,
3109 source_index: usize,
3110 query: &PackageQuery,
3111 semantics: SearchSemantics,
3112 ) -> Result<SearchSourceMatches> {
3113 let source = self.source_clone(source_index);
3114 let info = self.load_rest_information(source_index)?;
3115 let contract = choose_contract(&info.server_supported_versions)
3116 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3117
3118 let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
3119 let body = build_rest_search_body(query, &info, semantics)?;
3120 let response = self
3121 .client
3122 .post(url)
3123 .header("Version", contract)
3124 .json(&body)
3125 .send()
3126 .context("REST search request failed")?
3127 .error_for_status()
3128 .context("REST search request returned an error")?;
3129 let json = response
3130 .json::<JsonValue>()
3131 .context("failed to parse REST search response")?;
3132 let data = json
3133 .get("Data")
3134 .and_then(JsonValue::as_array)
3135 .cloned()
3136 .unwrap_or_default();
3137 let max_results = source_fetch_results(query, semantics);
3138
3139 let mut results = Vec::new();
3140 for item in data {
3141 let package_id = json_string(&item, "PackageIdentifier")
3142 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3143 let package_name =
3144 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3145 let mut versions = parse_rest_versions(&item)?;
3146 sort_versions_desc(&mut versions);
3147 let latest = versions
3148 .first()
3149 .cloned()
3150 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3151
3152 results.push(LocatedMatch {
3153 display: SearchMatch {
3154 source_name: source.name.clone(),
3155 source_kind: source.kind,
3156 id: package_id.clone(),
3157 name: package_name,
3158 moniker: json_string(&item, "Moniker"),
3159 version: Some(latest.version.clone()),
3160 channel: if latest.channel.is_empty() {
3161 None
3162 } else {
3163 Some(latest.channel.clone())
3164 },
3165 match_criteria: rest_match_criteria(&item, query, semantics),
3166 },
3167 source_index,
3168 locator: MatchLocator::Rest { package_id, versions },
3169 });
3170 }
3171
3172 Ok(SearchSourceMatches {
3173 truncated: results.len() >= max_results,
3174 matches: results,
3175 })
3176 }
3177
3178 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3179 Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3180 }
3181
3182 fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3183 let source = self.source_clone(source_index);
3184 let index_path = self.preindexed_index_path(&source);
3185 let mut refresh = PreindexedRefreshOutcome::default();
3186 if !index_path.exists() {
3187 let _ = self.refresh_preindexed_source(source_index, true)?;
3188 refresh.attempted = true;
3189 } else if self.is_preindexed_index_stale(&source, &index_path) {
3190 let lock_key = self.preindexed_refresh_lock_key(&source);
3191 if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3192 refresh.attempted = true;
3193 refresh.reused_recent_attempt = true;
3194 } else {
3195 refresh.attempted = true;
3196 if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3197 refresh.error = Some(format!("{error:#}"));
3198 }
3199 }
3200 }
3201
3202 Ok(PreindexedOpenConnection {
3203 connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3204 refresh,
3205 })
3206 }
3207
3208 fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3209 let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3210 return false;
3211 };
3212
3213 let index_modified = || {
3214 fs::metadata(index_path)
3215 .ok()
3216 .and_then(|metadata| metadata.modified().ok())
3217 .map(DateTime::<Utc>::from)
3218 };
3219
3220 let last_update = match (source.last_update, index_modified()) {
3221 (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3222 (Some(source_last_update), None) => Some(source_last_update),
3223 (None, Some(index_last_write)) => Some(index_last_write),
3224 (None, None) => None,
3225 };
3226
3227 match last_update {
3228 Some(last_update) => Utc::now() - last_update >= interval,
3229 None => true,
3230 }
3231 }
3232
3233 fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3234 format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3235 }
3236
3237 fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3238 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3239 Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3240 })
3241 }
3242
3243 fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3244 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3245 attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3246 })
3247 }
3248
3249 fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3250 self.preindexed_refresh_attempts.insert(
3251 lock_key,
3252 PreindexedRefreshAttempt {
3253 attempted_at: Utc::now(),
3254 succeeded,
3255 },
3256 );
3257 }
3258
3259 fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3260 {
3261 let locks = preindexed_refresh_locks()
3262 .read()
3263 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3264 if let Some(existing) = locks.get(lock_key) {
3265 return Ok(Arc::clone(existing));
3266 }
3267 }
3268
3269 let mut locks = preindexed_refresh_locks()
3270 .write()
3271 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3272 Ok(Arc::clone(
3273 locks
3274 .entry(lock_key.to_owned())
3275 .or_insert_with(|| Arc::new(Mutex::new(()))),
3276 ))
3277 }
3278
3279 fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3280 let source = self.source_clone(source_index);
3281 let index_path = self.preindexed_index_path(&source);
3282 let lock_key = self.preindexed_refresh_lock_key(&source);
3283 let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3284 let _guard = refresh_lock
3285 .lock()
3286 .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3287
3288 if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3289 return Ok("source already refreshed".to_owned());
3290 }
3291
3292 let result = self.update_preindexed(source_index);
3293 self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3294 let detail = result?;
3295 self.save_store()?;
3296 Ok(detail)
3297 }
3298
3299 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3300 let path_text = path.to_string_lossy().into_owned();
3301 let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3302 .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3303 database
3304 .connect()
3305 .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3306 }
3307
3308 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3309 let source_snapshot = self.store.sources[source_index].clone();
3310 let state_dir = self.source_state_dir(&source_snapshot);
3311 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3312 let index_path = self.preindexed_index_path(&source_snapshot);
3313 let has_local_index = index_path.exists();
3314
3315 let mut last_error = None;
3316 for candidate in PREINDEXED_CANDIDATES {
3317 let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3318 if has_local_index
3319 && let Some(detail) =
3320 self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3321 {
3322 return Ok(detail);
3323 }
3324
3325 let mut request = self.client.get(&url);
3326 if has_local_index {
3327 if let Some(etag) = &source_snapshot.etag
3328 && let Ok(value) = HeaderValue::from_str(etag)
3329 {
3330 request = request.header(IF_NONE_MATCH, value);
3331 }
3332 if let Some(last_modified) = &source_snapshot.last_modified
3333 && let Ok(value) = HeaderValue::from_str(last_modified)
3334 {
3335 request = request.header(IF_MODIFIED_SINCE, value);
3336 }
3337 }
3338
3339 match request.send() {
3340 Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3341 let source = &mut self.store.sources[source_index];
3342 update_preindexed_http_validators(source, response.headers());
3343 source.last_update = Some(Utc::now());
3344 return Ok(format!("already up to date from {}", candidate));
3345 }
3346 Ok(response) if response.status().is_success() => {
3347 let header_version = response
3348 .headers()
3349 .get("x-ms-meta-sourceversion")
3350 .and_then(|value| value.to_str().ok())
3351 .map(str::to_owned);
3352 let headers = response.headers().clone();
3353 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3354 let payload = bytes.to_vec();
3355 let index_bytes = extract_zip_member(&payload, "Public/index.db")
3356 .context("preindexed package did not contain Public/index.db")?;
3357
3358 fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3359 .context("failed to persist source package")?;
3360 let temp_index_path = state_dir.join(format!(
3361 "index.{}.tmp",
3362 Utc::now().timestamp_nanos_opt().unwrap_or_default()
3363 ));
3364 fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3365 if let Err(error) = replace_file(&temp_index_path, &index_path) {
3366 let _ = fs::remove_file(&temp_index_path);
3367 return Err(error).context("failed to persist source index");
3368 }
3369 remove_sqlite_sidecars(&index_path);
3374 let source = &mut self.store.sources[source_index];
3375 source.last_update = Some(Utc::now());
3376 source.source_version = header_version;
3377 update_preindexed_http_validators(source, &headers);
3378 return Ok(format!("downloaded {}", candidate));
3379 }
3380 Ok(response) => {
3381 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3382 }
3383 Err(error) => {
3384 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3385 }
3386 }
3387 }
3388
3389 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3390 }
3391
3392 fn try_skip_unchanged_preindexed_by_source_version(
3393 &mut self,
3394 source_index: usize,
3395 url: &str,
3396 candidate: &str,
3397 ) -> Option<String> {
3398 let current_version = self.store.sources[source_index].source_version.as_deref()?;
3399 let response = self.client.head(url).send().ok()?;
3400 if !response.status().is_success() {
3401 return None;
3402 }
3403
3404 let header_version = response
3405 .headers()
3406 .get("x-ms-meta-sourceversion")
3407 .and_then(|value| value.to_str().ok())?;
3408 if !header_version.eq_ignore_ascii_case(current_version) {
3409 return None;
3410 }
3411
3412 let source = &mut self.store.sources[source_index];
3413 update_preindexed_http_validators(source, response.headers());
3414 source.last_update = Some(Utc::now());
3415 Some(format!("already up to date from {} (v{})", candidate, header_version))
3416 }
3417
3418 fn update_rest(&mut self, source_index: usize) -> Result<String> {
3419 let info = self.fetch_rest_information(source_index, true)?;
3420 let source = &mut self.store.sources[source_index];
3421 if !info.source_identifier.is_empty() {
3422 source.identifier = info.source_identifier.clone();
3423 }
3424 source.last_update = Some(Utc::now());
3425 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3426 Ok("refreshed information cache".to_owned())
3427 }
3428
3429 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3430 let source = self.source_clone(source_index);
3431 let cache_path = self.rest_information_cache_path(&source);
3432
3433 if cache_path.exists() {
3434 let cache = serde_json::from_slice::<RestInfoCache>(
3435 &fs::read(&cache_path).context("failed to read REST information cache")?,
3436 )
3437 .context("failed to parse REST information cache")?;
3438 if cache.expires_at > Utc::now() {
3439 return Ok(cache.value);
3440 }
3441 }
3442
3443 self.fetch_rest_information(source_index, true)
3444 }
3445
3446 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3447 let source = self.source_clone(source_index);
3448 let url = format!("{}/information", source.arg.trim_end_matches('/'));
3449 let response = self
3450 .client
3451 .get(url)
3452 .send()
3453 .context("REST information request failed")?
3454 .error_for_status()
3455 .context("REST information request returned an error")?;
3456
3457 let max_age = cache_control_max_age(&response);
3458 let json = response
3459 .json::<JsonValue>()
3460 .context("failed to parse REST information response")?;
3461 let data = json
3462 .get("Data")
3463 .cloned()
3464 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3465 let info = serde_json::from_value::<RestInformation>(data)
3466 .context("failed to deserialize REST information payload")?;
3467
3468 if persist {
3469 let cache = RestInfoCache {
3470 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3471 value: info.clone(),
3472 };
3473 write_json(self.rest_information_cache_path(&source), &cache)?;
3474 }
3475
3476 Ok(info)
3477 }
3478
3479 fn load_v2_version_data(
3480 &self,
3481 source: &SourceRecord,
3482 package_rowid: i64,
3483 package_hash: &str,
3484 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3485 let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3486 .context("failed to reopen preindexed index for V2 version data")?;
3487 let package_hash = package_hash.to_ascii_lowercase();
3488 let package_id = query_optional_value(
3489 &connection,
3490 "SELECT id FROM packages WHERE rowid = ?1",
3491 vec![SqlValue::Integer(package_rowid)],
3492 |row| row_string(row, 0),
3493 )?
3494 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3495 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3496 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3497 let yaml = decompress_mszyml(&file.bytes)?;
3498 let document =
3499 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3500 Ok((document.versions, file.path))
3501 }
3502
3503 fn get_cached_source_file(
3504 &self,
3505 bucket: &str,
3506 source: &SourceRecord,
3507 relative_path: &str,
3508 expected_hash: Option<&str>,
3509 ) -> Result<CachedBytes> {
3510 let normalized_relative = relative_path.replace('\\', "/");
3511 let cache_path =
3512 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3513
3514 if cache_path.exists() {
3515 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3516 if hash_matches(expected_hash, &cached) {
3517 return Ok(CachedBytes {
3518 path: cache_path,
3519 bytes: cached,
3520 });
3521 }
3522 }
3523
3524 let url = format!(
3525 "{}/{}",
3526 source.arg.trim_end_matches('/'),
3527 normalized_relative.trim_start_matches('/')
3528 );
3529 let response = self
3530 .client
3531 .get(url)
3532 .send()
3533 .context("failed to fetch source file")?
3534 .error_for_status()
3535 .context("source file request returned an error")?;
3536 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3537
3538 if let Some(hash) = expected_hash {
3539 verify_hash(hash, &bytes)?;
3540 }
3541
3542 if let Some(parent) = cache_path.parent() {
3543 fs::create_dir_all(parent).context("failed to create cache directory")?;
3544 }
3545 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3546
3547 Ok(CachedBytes {
3548 path: cache_path,
3549 bytes,
3550 })
3551 }
3552
3553 fn get_or_fetch_rest_manifest(
3554 &mut self,
3555 source: &SourceRecord,
3556 package_id: &str,
3557 version: &str,
3558 channel: &str,
3559 ) -> Result<(Vec<u8>, PathBuf)> {
3560 let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3561 if cache_path.exists() {
3562 return Ok((
3563 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3564 cache_path,
3565 ));
3566 }
3567
3568 let info = self.load_rest_information_by_name(&source.name)?;
3569 let contract = choose_contract(&info.server_supported_versions)
3570 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3571 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3572
3573 let mut params = vec![("Version", version.to_owned())];
3574 if !channel.is_empty() {
3575 params.push(("Channel", channel.to_owned()));
3576 }
3577 if info
3578 .required_query_parameters
3579 .iter()
3580 .any(|value| value.eq_ignore_ascii_case("Market"))
3581 {
3582 params.push(("Market", default_market()));
3583 }
3584
3585 let response = self
3586 .client
3587 .get(url)
3588 .header("Version", contract)
3589 .query(¶ms)
3590 .send()
3591 .context("REST manifest request failed")?
3592 .error_for_status()
3593 .context("REST manifest request returned an error")?;
3594 let bytes = response
3595 .bytes()
3596 .context("failed to read REST manifest response")?
3597 .to_vec();
3598
3599 if let Some(parent) = cache_path.parent() {
3600 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3601 }
3602 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3603
3604 Ok((bytes, cache_path))
3605 }
3606
3607 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3608 let index = self
3609 .store
3610 .sources
3611 .iter()
3612 .position(|source| source.name.eq_ignore_ascii_case(name))
3613 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3614 self.load_rest_information(index)
3615 }
3616
3617 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3618 if let Some(name) = source_name {
3619 if let Some(index) = self
3620 .store
3621 .sources
3622 .iter()
3623 .position(|source| source.name.eq_ignore_ascii_case(name))
3624 {
3625 return Ok(vec![index]);
3626 }
3627
3628 let index = self
3629 .store
3630 .sources
3631 .iter()
3632 .position(|source| source.identifier.eq_ignore_ascii_case(name))
3633 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3634 return Ok(vec![index]);
3635 }
3636
3637 let mut indexes: Vec<_> = self
3638 .store
3639 .sources
3640 .iter()
3641 .enumerate()
3642 .filter(|(_, source)| !source.explicit)
3643 .collect();
3644 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3645 right_source
3646 .priority
3647 .cmp(&left_source.priority)
3648 .then_with(|| left_index.cmp(right_index))
3649 });
3650 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3651 }
3652
3653 fn source_clone(&self, index: usize) -> SourceRecord {
3654 self.store.sources[index].clone()
3655 }
3656
3657 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3658 self.store = match self.source_mode {
3659 EffectiveSourceMode::Private => self.store.clone(),
3660 EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3661 EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3662 };
3663 Ok(())
3664 }
3665
3666 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3667 self.refresh_system_winget_sources()?;
3668 let selected_names: Vec<_> = if source_name.is_none() {
3669 self.store.sources.iter().map(|source| source.name.clone()).collect()
3670 } else {
3671 self.resolve_source_indexes(source_name)?
3672 .into_iter()
3673 .map(|index| self.store.sources[index].name.clone())
3674 .collect()
3675 };
3676
3677 let detail = system_winget_update_sources(source_name)?;
3678 self.refresh_system_winget_sources()?;
3679
3680 Ok(selected_names
3681 .into_iter()
3682 .filter_map(|name| {
3683 self.store
3684 .sources
3685 .iter()
3686 .find(|source| source.name.eq_ignore_ascii_case(&name))
3687 .map(|source| SourceUpdateResult {
3688 name: source.name.clone(),
3689 kind: source.kind,
3690 detail: detail.clone(),
3691 })
3692 })
3693 .collect())
3694 }
3695}
3696
3697#[derive(Debug)]
3698struct CachedBytes {
3699 path: PathBuf,
3700 bytes: Vec<u8>,
3701}
3702
3703#[derive(Debug, Clone)]
3704struct V1SearchRow {
3705 manifest_rowid: i64,
3706 package_rowid: i64,
3707 version: String,
3708 channel: String,
3709 id: String,
3710 name: String,
3711 moniker: Option<String>,
3712 match_criteria: Option<String>,
3713}
3714
3715#[derive(Debug, Clone)]
3716struct V1VersionRow {
3717 version: String,
3718 channel: String,
3719 pathpart_id: i64,
3720 manifest_hash: Option<String>,
3721}
3722
3723#[derive(Debug, Clone)]
3724struct V2SearchRow {
3725 package_rowid: i64,
3726 package_hash: String,
3727 id: String,
3728 name: String,
3729 moniker: Option<String>,
3730 version: String,
3731 match_criteria: Option<String>,
3732}
3733
3734fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3735 query.query.is_some()
3736 || query.id.is_some()
3737 || query.name.is_some()
3738 || query.moniker.is_some()
3739 || query.tag.is_some()
3740 || query.command.is_some()
3741 || query.source.is_some()
3742}
3743
3744fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3745 PackageQuery {
3746 query: query.query.clone(),
3747 id: query.id.clone(),
3748 name: query.name.clone(),
3749 moniker: query.moniker.clone(),
3750 tag: query.tag.clone(),
3751 command: query.command.clone(),
3752 source: query.source.clone(),
3753 count: Some(LIST_LOOKUP_MAX_RESULTS),
3754 exact: query.exact,
3755 version: None,
3756 channel: None,
3757 locale: None,
3758 installer_type: None,
3759 installer_architecture: None,
3760 platform: None,
3761 os_version: None,
3762 install_scope: query.install_scope.clone(),
3763 }
3764}
3765
3766fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3767 query.query.is_some() || query.id.is_some() || query.name.is_some()
3768}
3769
3770fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3771 if is_greater_than_version_marker(&package.installed_version) {
3772 return false;
3773 }
3774 package
3775 .correlated
3776 .as_ref()
3777 .and_then(|candidate| candidate.version.as_deref())
3778 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3779}
3780
3781fn is_greater_than_version_marker(version: &str) -> bool {
3782 version.trim_start().starts_with('>')
3783}
3784
3785fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3786 package.installed_version.eq_ignore_ascii_case("Unknown")
3787}
3788
3789fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3790 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3793 return false;
3794 }
3795 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3798 return false;
3799 }
3800 installed_package_has_upgrade(package)
3801 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3802}
3803
3804fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3810 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3811 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3812
3813 for package in packages {
3814 match package.correlated.as_ref() {
3815 Some(candidate) => {
3816 let key = (
3817 candidate.id.to_ascii_lowercase(),
3818 candidate.source_name.to_ascii_lowercase(),
3819 );
3820 let keep_new = match by_id.get(&key) {
3821 Some(existing) => {
3822 match (
3828 package.installed_version_canonical,
3829 existing.installed_version_canonical,
3830 ) {
3831 (true, false) => true,
3832 (false, true) => false,
3833 _ => {
3834 compare_version(&package.installed_version, &existing.installed_version)
3835 == Ordering::Greater
3836 }
3837 }
3838 }
3839 None => true,
3840 };
3841 if keep_new {
3842 by_id.insert(key, package);
3843 }
3844 }
3845 None => uncorrelated.push(package),
3846 }
3847 }
3848
3849 let mut result = uncorrelated;
3850 result.extend(by_id.into_values());
3851 result
3852}
3853
3854fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3855 let mut source_specific = None;
3856 let mut source_agnostic = None;
3857
3858 for pin in pins {
3859 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3860 continue;
3861 }
3862
3863 if pin.source_id.is_empty() {
3864 if source_agnostic.is_none() {
3865 source_agnostic = Some(pin);
3866 }
3867 } else if item
3868 .source_name
3869 .as_deref()
3870 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3871 {
3872 source_specific = Some(pin);
3873 break;
3874 }
3875 }
3876
3877 source_specific.or(source_agnostic)
3878}
3879
3880fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3881 let Some(available_version) = item.available_version.as_deref() else {
3882 return false;
3883 };
3884 let Some(pin) = find_applicable_pin(item, pins) else {
3885 return false;
3886 };
3887
3888 match pin.pin_type {
3889 PinType::Blocking => true,
3890 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3891 }
3892}
3893
3894fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3895 if pattern.trim().is_empty() || pattern == "*" {
3896 return true;
3897 }
3898
3899 if !pattern.contains('*') {
3900 return version.eq_ignore_ascii_case(pattern);
3901 }
3902
3903 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3904 let prefix = &pattern[..pattern.len() - 1];
3905 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3906 }
3907
3908 version.eq_ignore_ascii_case(pattern)
3909}
3910
3911fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3912 let available_version = package.correlated.as_ref().and_then(|candidate| {
3913 candidate.version.as_ref().and_then(|candidate_version| {
3914 if !is_greater_than_version_marker(&package.installed_version)
3915 && (installed_package_has_unknown_version(&package)
3916 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3917 {
3918 Some(candidate_version.clone())
3919 } else {
3920 None
3921 }
3922 })
3923 });
3924 let mut source_name = package
3925 .correlated
3926 .as_ref()
3927 .map(|candidate| candidate.source_name.clone())
3928 .filter(|value| !value.is_empty());
3929 let mut id = package
3930 .correlated
3931 .as_ref()
3932 .map(|candidate| candidate.id.clone())
3933 .unwrap_or_else(|| package.local_id.clone());
3934
3935 if source_name.is_none()
3936 && let Some(package_id) = package
3937 .winget_package_identifier
3938 .as_deref()
3939 .filter(|value| !value.trim().is_empty())
3940 {
3941 id = package_id.to_owned();
3942 source_name = package
3943 .winget_source_identifier
3944 .as_deref()
3945 .filter(|value| !value.trim().is_empty())
3946 .map(|identifier| source_name_from_identifier(identifier, sources));
3947 }
3948
3949 if source_name.is_none()
3950 && let Some((local_package_id, local_source_name)) =
3951 winget_package_identity_from_local_id(&package.local_id, sources)
3952 {
3953 id = local_package_id;
3954 source_name = Some(local_source_name);
3955 }
3956
3957 ListMatch {
3958 name: package.name,
3959 id,
3960 local_id: package.local_id,
3961 installed_version: package.installed_version,
3962 available_version,
3963 source_name,
3964 publisher: package.publisher,
3965 scope: package.scope,
3966 installer_category: package.installer_category,
3967 install_location: package.install_location,
3968 package_family_names: package.package_family_names,
3969 product_codes: package.product_codes,
3970 upgrade_codes: package.upgrade_codes,
3971 }
3972}
3973
3974fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
3975 let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
3976 for (index, item) in matches.iter().enumerate() {
3977 let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
3978 continue;
3979 };
3980 grouped
3981 .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
3982 .or_default()
3983 .push(index);
3984 }
3985
3986 let mut keep = BTreeSet::new();
3987 for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
3988 let Some(best) = indexes
3989 .iter()
3990 .map(|index| matches[*index].installed_version.as_str())
3991 .max_by(|left, right| compare_version(left, right))
3992 else {
3993 continue;
3994 };
3995 for index in indexes {
3996 if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
3997 keep.insert(*index);
3998 }
3999 }
4000 }
4001
4002 matches
4003 .into_iter()
4004 .enumerate()
4005 .map(|(index, mut item)| {
4006 if item.available_version.is_some() && !keep.contains(&index) {
4007 let is_duplicate = item
4008 .source_name
4009 .as_ref()
4010 .map(|source_name| {
4011 grouped
4012 .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4013 .is_some_and(|indexes| indexes.len() > 1)
4014 })
4015 .unwrap_or(false);
4016 if is_duplicate {
4017 item.available_version = None;
4018 }
4019 }
4020 item
4021 })
4022 .collect()
4023}
4024
4025fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4026 sources
4027 .iter()
4028 .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4029 .map(|source| source.name.clone())
4030 .unwrap_or_else(|| identifier.to_owned())
4031}
4032
4033fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4034 let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4035 for source in sources {
4036 let source_suffix = format!("_{}", source.identifier);
4037 let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4038 continue;
4039 };
4040 if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4041 continue;
4042 }
4043 if source_identifier_start_index <= package_id_start_index {
4044 return None;
4045 }
4046 let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4047 if package_id.trim().is_empty() {
4048 return None;
4049 }
4050 return Some((package_id, source.name.clone()));
4051 }
4052 None
4053}
4054
4055fn list_sort_weight(package: &InstalledPackage) -> usize {
4056 if package.local_id.starts_with("ARP\\") {
4057 0
4058 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4059 1
4060 } else {
4061 2
4062 }
4063}
4064
4065fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4066 let correlated = package.correlated.as_ref();
4067
4068 if let Some(value) = &query.id {
4069 let local_match = matches_text(&package.local_id, value, query.exact);
4070 let correlated_match = correlated
4071 .map(|candidate| matches_text(&candidate.id, value, query.exact))
4072 .unwrap_or(false);
4073 if !local_match && !correlated_match {
4074 return false;
4075 }
4076 }
4077
4078 if let Some(value) = &query.name
4079 && !matches_text(&package.name, value, query.exact)
4080 {
4081 return false;
4082 }
4083
4084 if let Some(value) = &query.product_code
4085 && !package
4086 .product_codes
4087 .iter()
4088 .any(|code| code.eq_ignore_ascii_case(value))
4089 {
4090 return false;
4091 }
4092
4093 if let Some(value) = &query.version
4094 && !package.installed_version.eq_ignore_ascii_case(value)
4095 {
4096 return false;
4097 }
4098
4099 if let Some(value) = &query.query {
4100 let local_match =
4101 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4102 let correlated_match = correlated
4103 .map(|candidate| {
4104 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4105 })
4106 .unwrap_or(false);
4107 if !local_match && !correlated_match {
4108 return false;
4109 }
4110 }
4111
4112 if let Some(source) = &query.source
4113 && correlated
4114 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4115 .unwrap_or(true)
4116 {
4117 return false;
4118 }
4119
4120 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4121 return false;
4122 }
4123
4124 true
4125}
4126
4127fn correlate_installed_package(
4128 package: &InstalledPackage,
4129 candidates: &[SearchMatch],
4130 allow_loose_name_match: bool,
4131) -> Option<SearchMatch> {
4132 if package.local_id.starts_with("MSIX\\") {
4140 return None;
4141 }
4142
4143 let installed_name = normalize_correlation_name(&package.name);
4144 let installed_name_lower = package.name.to_ascii_lowercase();
4145 let candidate_names = correlation_name_candidates(&package.name);
4146
4147 let mut scored: Vec<(i32, SearchMatch)> = candidates
4148 .iter()
4149 .filter_map(|candidate| {
4150 let candidate_name = normalize_correlation_name(&candidate.name);
4151 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4152 1000
4153 } else if candidate_names.iter().any(|name| {
4154 let normalized = normalize_correlation_name(name);
4155 normalized == candidate_name
4156 }) {
4157 900
4158 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4159 700
4160 } else {
4161 0
4162 };
4163
4164 if base_score == 0 {
4165 return None;
4166 }
4167
4168 let candidate_lower = candidate.name.to_ascii_lowercase();
4181 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4182 100
4183 } else if installed_name_lower.contains(&candidate_lower) {
4184 50
4185 } else {
4186 0
4187 };
4188
4189 Some((base_score + prefix_bonus, candidate.clone()))
4190 })
4191 .collect();
4192
4193 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4194 let mut iter = scored.into_iter();
4195 let (top_score, top_match) = iter.next()?;
4196
4197 if let Some((next_score, _)) = iter.next()
4205 && next_score == top_score
4206 {
4207 return None;
4208 }
4209 Some(top_match)
4210}
4211
4212fn correlation_name_candidates(name: &str) -> Vec<String> {
4213 let mut candidates = Vec::new();
4214 candidates.push(name.trim().to_owned());
4215
4216 let mut trimmed = name.trim().to_owned();
4217 if let Some(index) = trimmed.find(" (") {
4218 trimmed.truncate(index);
4219 }
4220
4221 let mut words = Vec::new();
4222 for token in trimmed.split_whitespace() {
4223 let lower = token
4224 .trim_matches(|ch: char| ch == '(' || ch == ')')
4225 .to_ascii_lowercase();
4226 if !words.is_empty()
4227 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4228 {
4229 break;
4230 }
4231 words.push(token);
4232 }
4233
4234 if !words.is_empty() {
4235 candidates.push(words.join(" "));
4236 }
4237
4238 candidates.sort();
4239 candidates.dedup();
4240 candidates
4241}
4242
4243fn normalize_correlation_name(value: &str) -> String {
4244 value
4245 .chars()
4246 .filter(|ch| ch.is_ascii_alphanumeric())
4247 .flat_map(|ch| ch.to_lowercase())
4248 .collect()
4249}
4250
4251struct V2PackageMetadata {
4252 rowid: i64,
4253 id: String,
4254 name: String,
4255 moniker: Option<String>,
4256 latest_version: String,
4257 package_hash: String,
4258}
4259
4260fn lookup_identity_match_v2(
4266 connection: &Connection,
4267 package: &InstalledPackage,
4268) -> Result<Option<(i64, &'static str)>> {
4269 for pfn in &package.package_family_names {
4270 if let Some(rowid) = query_optional_value(
4271 connection,
4272 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4273 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4274 |row| row_i64(row, 0),
4275 )? {
4276 return Ok(Some((rowid, "PackageFamilyName")));
4277 }
4278 }
4279 for code in &package.upgrade_codes {
4288 if let Some(rowid) = query_optional_value(
4289 connection,
4290 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4291 vec![SqlValue::Text(code.to_ascii_lowercase())],
4292 |row| row_i64(row, 0),
4293 )? {
4294 return Ok(Some((rowid, "UpgradeCode")));
4295 }
4296 }
4297 for code in &package.product_codes {
4298 if let Some(rowid) = query_optional_value(
4299 connection,
4300 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4301 vec![SqlValue::Text(code.to_ascii_lowercase())],
4302 |row| row_i64(row, 0),
4303 )? {
4304 return Ok(Some((rowid, "ProductCode")));
4305 }
4306 }
4307 Ok(None)
4308}
4309
4310fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4311 Ok(table_exists(connection, "manifest")?
4312 && table_exists(connection, "ids")?
4313 && table_exists(connection, "names")?
4314 && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4315 || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4316 || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4317}
4318
4319fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4320 query_optional_value(
4321 connection,
4322 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4323 vec![SqlValue::Text(name.to_owned())],
4324 |row| row_i64(row, 0),
4325 )
4326 .map(|value| value.is_some())
4327}
4328
4329fn lookup_installed_db_identity_match(
4330 connection: &Connection,
4331 package: &InstalledPackage,
4332) -> Result<Option<(String, String, &'static str)>> {
4333 for pfn in &package.package_family_names {
4334 if let Some(hit) =
4335 lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4336 {
4337 return Ok(Some(hit));
4338 }
4339 }
4340 for code in &package.upgrade_codes {
4341 if let Some(hit) = lookup_installed_db_identity_match_value(
4342 connection,
4343 "upgradecodes",
4344 "upgradecodes_map",
4345 "upgradecode",
4346 code,
4347 "UpgradeCode",
4348 )? {
4349 return Ok(Some(hit));
4350 }
4351 }
4352 for code in &package.product_codes {
4353 if let Some(hit) = lookup_installed_db_identity_match_value(
4354 connection,
4355 "productcodes",
4356 "productcodes_map",
4357 "productcode",
4358 code,
4359 "ProductCode",
4360 )? {
4361 return Ok(Some(hit));
4362 }
4363 }
4364 Ok(None)
4365}
4366
4367fn lookup_installed_db_identity_match_value(
4368 connection: &Connection,
4369 value_table: &'static str,
4370 map_table: &'static str,
4371 value_column: &'static str,
4372 value: &str,
4373 matched_by: &'static str,
4374) -> Result<Option<(String, String, &'static str)>> {
4375 let sql = format!(
4376 "SELECT ids.id, names.name \
4377 FROM {map_table} map \
4378 JOIN {value_table} value ON map.{value_column} = value.rowid \
4379 JOIN manifest ON map.manifest = manifest.rowid \
4380 JOIN ids ON manifest.id = ids.rowid \
4381 JOIN names ON manifest.name = names.rowid \
4382 WHERE value.{value_column} = ?1 \
4383 LIMIT 1"
4384 );
4385 query_optional_value(
4386 connection,
4387 &sql,
4388 vec![SqlValue::Text(value.to_ascii_lowercase())],
4389 |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4390 )
4391}
4392
4393fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4397 query_optional_value(
4398 connection,
4399 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4400 vec![SqlValue::Text(package_id.to_owned())],
4401 |row| {
4402 Ok(V2PackageMetadata {
4403 rowid: row_i64(row, 0)?,
4404 id: row_string(row, 1)?,
4405 name: row_string(row, 2)?,
4406 moniker: row_opt_string(row, 3)?,
4407 latest_version: row_string(row, 4)?,
4408 package_hash: row_hex_string(row, 5)?,
4409 })
4410 },
4411 )
4412}
4413
4414fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4422 if !package.local_id.starts_with("MSIX\\") {
4423 return;
4424 }
4425 if !package.name.starts_with("ms-resource:") {
4426 return;
4427 }
4428 let Some(candidate) = package.correlated.as_ref() else {
4429 return;
4430 };
4431 if !candidate.name.is_empty() {
4432 package.name = candidate.name.clone();
4433 }
4434}
4435
4436fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4437 query_optional_value(
4438 connection,
4439 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4440 vec![SqlValue::Integer(package_rowid)],
4441 |row| {
4442 Ok(V2PackageMetadata {
4443 rowid: package_rowid,
4444 id: row_string(row, 0)?,
4445 name: row_string(row, 1)?,
4446 moniker: row_opt_string(row, 2)?,
4447 latest_version: row_string(row, 3)?,
4448 package_hash: row_hex_string(row, 4)?,
4449 })
4450 },
4451 )
4452}
4453
4454fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4455 query_optional_value(
4456 connection,
4457 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4458 Vec::new(),
4459 |row| row_i64(row, 0),
4460 )
4461 .ok()
4462 .flatten()
4463 .is_some()
4464}
4465
4466fn lookup_unique_normalized_identity(
4473 connection: &Connection,
4474 norm_name: &str,
4475 norm_publisher: &str,
4476) -> Result<Option<i64>> {
4477 let rows: Vec<i64> = query_rows(
4478 connection,
4479 "SELECT DISTINCT n.package FROM norm_names2 n \
4480 WHERE n.norm_name = ?1 \
4481 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4482 LIMIT 2",
4483 vec![
4484 SqlValue::Text(norm_name.to_owned()),
4485 SqlValue::Text(norm_publisher.to_owned()),
4486 ],
4487 |row| row_i64(row, 0),
4488 )?;
4489 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4490}
4491
4492fn v2_identity_tables_present(connection: &Connection) -> bool {
4493 query_optional_value(
4494 connection,
4495 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4496 Vec::new(),
4497 |row| row_i64(row, 0),
4498 )
4499 .ok()
4500 .flatten()
4501 .is_some()
4502}
4503
4504fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4511 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4512 return None;
4513 }
4514 for entry in entries {
4515 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4516 continue;
4517 };
4518 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4519 {
4520 return Some(entry.version.clone());
4521 }
4522 }
4523 None
4524}
4525
4526fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4532 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4533 return None;
4534 }
4535
4536 let latest = entries
4537 .iter()
4538 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4539 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4540 if compare_version(arp_version, &latest.version) != Ordering::Equal {
4541 return None;
4542 }
4543 let latest_min = latest.arp_min_version.as_deref()?;
4544 if compare_version(arp_version, latest_min) != Ordering::Less {
4545 return None;
4546 }
4547
4548 let previous_max = entries
4549 .iter()
4550 .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4551 .filter_map(|entry| entry.arp_max_version.as_deref())
4552 .max_by(|left, right| compare_version(left, right));
4553 if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4554 return None;
4555 }
4556
4557 Some(format!("< {}", latest.version))
4558}
4559
4560fn greater_than_latest_arp_anchored_version(
4565 entries: &[PackageVersionDataEntry],
4566 arp_version: &str,
4567 catalog_latest_version: &str,
4568) -> Option<String> {
4569 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4570 return None;
4571 }
4572
4573 let latest = entries
4574 .iter()
4575 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4576 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4577 let latest_max = latest.arp_max_version.as_deref()?;
4578 if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4579 return None;
4580 }
4581 if !has_different_leading_version_number(latest_max, &latest.version) {
4582 return None;
4583 }
4584 if compare_version(arp_version, latest_max) != Ordering::Greater {
4585 return None;
4586 }
4587
4588 Some(format!("> {}", latest.version))
4589}
4590
4591fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4592 let Some(left) = leading_version_number(left) else {
4593 return false;
4594 };
4595 let Some(right) = leading_version_number(right) else {
4596 return false;
4597 };
4598 !left.eq_ignore_ascii_case(right)
4599}
4600
4601fn leading_version_number(value: &str) -> Option<&str> {
4602 let trimmed = value.trim_start();
4603 let length = trimmed
4604 .chars()
4605 .take_while(|ch| ch.is_ascii_digit())
4606 .map(char::len_utf8)
4607 .sum();
4608 (length > 0).then_some(&trimmed[..length])
4609}
4610
4611fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4620 entries
4621 .iter()
4622 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4623 .max_by(|a, b| compare_version(&a.version, &b.version))
4624 .map(|entry| entry.version.clone())
4625}
4626
4627#[cfg(windows)]
4628fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4629 let mut packages = Vec::new();
4630 let mut seen = BTreeSet::new();
4631
4632 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4633 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4634
4635 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4643
4644 if machine {
4645 collect_uninstall_view(
4646 &mut packages,
4647 &mut seen,
4648 &upgrade_codes,
4649 RegKey::predef(HKEY_LOCAL_MACHINE),
4650 "Machine",
4651 "X64",
4652 KEY_READ | KEY_WOW64_64KEY,
4653 )?;
4654 collect_uninstall_view(
4655 &mut packages,
4656 &mut seen,
4657 &upgrade_codes,
4658 RegKey::predef(HKEY_LOCAL_MACHINE),
4659 "Machine",
4660 "X86",
4661 KEY_READ | KEY_WOW64_32KEY,
4662 )?;
4663 collect_appmodel_packages(
4664 &mut packages,
4665 &mut seen,
4666 RegKey::predef(HKEY_LOCAL_MACHINE),
4667 "Machine",
4668 KEY_READ | KEY_WOW64_64KEY,
4669 )?;
4670 }
4671
4672 if user {
4673 collect_uninstall_view(
4674 &mut packages,
4675 &mut seen,
4676 &upgrade_codes,
4677 RegKey::predef(HKEY_CURRENT_USER),
4678 "User",
4679 "X64",
4680 KEY_READ,
4681 )?;
4682 collect_appmodel_packages(
4683 &mut packages,
4684 &mut seen,
4685 RegKey::predef(HKEY_CURRENT_USER),
4686 "User",
4687 KEY_READ,
4688 )?;
4689 }
4690
4691 Ok(packages)
4692}
4693
4694#[cfg(windows)]
4700fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4701 let mut map = HashMap::new();
4702 if include_machine {
4703 collect_msi_upgrade_codes_from(
4704 RegKey::predef(HKEY_LOCAL_MACHINE),
4705 r"SOFTWARE\Classes\Installer\UpgradeCodes",
4706 KEY_READ | KEY_WOW64_64KEY,
4707 &mut map,
4708 );
4709 }
4710 if include_user {
4711 collect_msi_upgrade_codes_from(
4712 RegKey::predef(HKEY_CURRENT_USER),
4713 r"Software\Microsoft\Installer\UpgradeCodes",
4714 KEY_READ,
4715 &mut map,
4716 );
4717 }
4718 map
4719}
4720
4721#[cfg(windows)]
4722fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4723 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4724 return;
4725 };
4726 for key_name in upgrade_codes.enum_keys().flatten() {
4727 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4728 continue;
4729 };
4730 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4731 continue;
4732 };
4733 for (value_name, _) in subkey.enum_values().flatten() {
4734 if let Some(product_code) = unflip_packed_guid(&value_name) {
4735 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4739 }
4740 }
4741 }
4742}
4743
4744#[cfg(windows)]
4749fn unflip_packed_guid(packed: &str) -> Option<String> {
4750 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4751 return None;
4752 }
4753 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4754 let bytes = packed.as_bytes();
4755 let mut reversed = String::with_capacity(32);
4756 let mut offset = 0;
4757 for size in CHUNKS {
4758 let chunk = &bytes[offset..offset + size];
4759 for &c in chunk.iter().rev() {
4760 reversed.push(c as char);
4761 }
4762 offset += size;
4763 }
4764 let r = reversed.to_ascii_lowercase();
4765 Some(format!(
4766 "{{{}-{}-{}-{}-{}}}",
4767 &r[0..8],
4768 &r[8..12],
4769 &r[12..16],
4770 &r[16..20],
4771 &r[20..32]
4772 ))
4773}
4774
4775#[cfg(not(windows))]
4776fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4777 Ok(Vec::new())
4778}
4779
4780#[cfg(windows)]
4781fn collect_uninstall_view(
4782 packages: &mut Vec<InstalledPackage>,
4783 seen: &mut BTreeSet<String>,
4784 upgrade_code_map: &HashMap<String, String>,
4785 root: RegKey,
4786 scope: &str,
4787 arch: &str,
4788 flags: u32,
4789) -> Result<()> {
4790 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4791
4792 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4793 Ok(key) => key,
4794 Err(_) => return Ok(()),
4795 };
4796
4797 for key_name in uninstall.enum_keys().flatten() {
4798 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4799 Ok(key) => key,
4800 Err(_) => continue,
4801 };
4802 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4803 {
4804 continue;
4805 }
4806
4807 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4808 continue;
4809 };
4810
4811 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4812 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4813 let publisher = read_reg_string(&subkey, "Publisher");
4814 let install_location = read_reg_string(&subkey, "InstallLocation");
4815 let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4816 let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4817 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4818 .into_iter()
4819 .collect::<Vec<_>>();
4820 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4821 if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4822 product_codes.push(key_name.to_ascii_lowercase());
4823 }
4824 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4825 if upgrade_codes.is_empty() {
4826 for code in &product_codes {
4830 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4831 upgrade_codes.push(uc.clone());
4832 break;
4833 }
4834 }
4835 }
4836 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4837 .as_deref()
4838 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4839 {
4840 Some("portable".to_owned())
4846 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4847 Some("msi".to_owned())
4848 } else if key_name.starts_with("MSIX\\") {
4849 Some("msix".to_owned())
4850 } else {
4851 Some("exe".to_owned())
4852 };
4853
4854 let dedupe_key = format!(
4855 "{}|{}|{}|{}",
4856 local_id,
4857 name.to_ascii_lowercase(),
4858 installed_version.to_ascii_lowercase(),
4859 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4860 );
4861 if !seen.insert(dedupe_key) {
4862 continue;
4863 }
4864
4865 packages.push(InstalledPackage {
4866 name,
4867 local_id,
4868 installed_version,
4869 publisher,
4870 scope: Some(scope.to_owned()),
4871 installer_category,
4872 install_location,
4873 winget_package_identifier,
4874 winget_source_identifier,
4875 package_family_names,
4876 product_codes,
4877 upgrade_codes,
4878 correlated: None,
4879 installed_version_canonical: false,
4880 correlated_requires_explicit_upgrade: false,
4881 correlated_lacks_compatible_installer: false,
4882 });
4883 }
4884
4885 Ok(())
4886}
4887
4888#[cfg(windows)]
4889fn collect_appmodel_packages(
4890 packages: &mut Vec<InstalledPackage>,
4891 seen: &mut BTreeSet<String>,
4892 root: RegKey,
4893 scope: &str,
4894 flags: u32,
4895) -> Result<()> {
4896 const APPMODEL_PACKAGES_PATH: &str =
4897 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4898
4899 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4900 Ok(key) => key,
4901 Err(_) => return Ok(()),
4902 };
4903
4904 for key_name in appmodel.enum_keys().flatten() {
4905 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4906 Ok(key) => key,
4907 Err(_) => continue,
4908 };
4909
4910 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4911 if install_location.as_deref().is_some_and(is_windows_system_path) {
4912 continue;
4913 }
4914
4915 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4916 continue;
4917 };
4918 if is_msix_split_resource_package(&metadata.resource_id) {
4919 continue;
4920 }
4921
4922 let Some(name) = read_reg_string(&subkey, "DisplayName")
4923 .or_else(|| read_appmodel_display_name(&subkey))
4924 .filter(|value| !value.is_empty())
4925 else {
4926 continue;
4927 };
4928
4929 let local_id = format!(r"MSIX\{key_name}");
4930 let dedupe_key = format!(
4931 "{}|{}|{}",
4932 local_id,
4933 name.to_ascii_lowercase(),
4934 metadata.version.to_ascii_lowercase()
4935 );
4936 if !seen.insert(dedupe_key) {
4937 continue;
4938 }
4939
4940 packages.push(InstalledPackage {
4941 name,
4942 local_id,
4943 installed_version: metadata.version,
4944 publisher: None,
4945 scope: Some(scope.to_owned()),
4946 installer_category: Some("msix".to_owned()),
4947 install_location,
4948 winget_package_identifier: None,
4949 winget_source_identifier: None,
4950 package_family_names: vec![metadata.family_name],
4951 product_codes: Vec::new(),
4952 upgrade_codes: Vec::new(),
4953 correlated: None,
4954 installed_version_canonical: false,
4955 correlated_requires_explicit_upgrade: false,
4956 correlated_lacks_compatible_installer: false,
4957 });
4958 }
4959
4960 Ok(())
4961}
4962
4963#[cfg(windows)]
4964struct ParsedMsixPackageFullName {
4965 version: String,
4966 resource_id: String,
4967 family_name: String,
4968}
4969
4970#[cfg(windows)]
4971fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4972 let segments = value.split('_').collect::<Vec<_>>();
4973 if segments.len() < 5 {
4974 return None;
4975 }
4976
4977 let name = segments[..segments.len() - 4].join("_");
4978 if name.is_empty() {
4979 return None;
4980 }
4981
4982 let version = segments[segments.len() - 4].trim();
4983 let resource_id = segments[segments.len() - 2].trim();
4984 let publisher_id = segments[segments.len() - 1].trim();
4985 if version.is_empty() || publisher_id.is_empty() {
4986 return None;
4987 }
4988
4989 let family_name = if resource_id.is_empty() {
4990 format!("{name}_{publisher_id}")
4991 } else {
4992 format!("{name}_{resource_id}_{publisher_id}")
4993 };
4994
4995 Some(ParsedMsixPackageFullName {
4996 version: version.to_owned(),
4997 resource_id: resource_id.to_owned(),
4998 family_name,
4999 })
5000}
5001
5002#[cfg(windows)]
5003fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5004 let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5005 read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5006}
5007
5008#[cfg(windows)]
5009fn is_msix_split_resource_package(resource_id: &str) -> bool {
5010 resource_id.trim().to_ascii_lowercase().starts_with("split.")
5011}
5012
5013#[cfg(windows)]
5014fn is_windows_system_path(path: &str) -> bool {
5015 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5016}
5017
5018#[cfg(windows)]
5019fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5020 key.get_value::<String, _>(value_name)
5021 .ok()
5022 .map(|value| value.trim().to_owned())
5023 .filter(|value| !value.is_empty())
5024}
5025
5026#[cfg(windows)]
5027fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5028 key.get_value::<u32, _>(value_name).ok()
5029}
5030
5031fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5032 fs::create_dir_all(app_root).context("failed to create app root directory")?;
5033 if uses_packaged_layout(app_root) {
5034 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5035 let user_sources_path = packaged_user_sources_path(app_root)?;
5036 if let Some(parent) = user_sources_path.parent() {
5037 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5038 }
5039 } else {
5040 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5041 }
5042 Ok(())
5043}
5044
5045fn default_app_root() -> Result<PathBuf> {
5046 if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
5047 return Ok(PathBuf::from(app_root));
5048 }
5049
5050 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5051
5052 #[cfg(windows)]
5053 {
5054 if has_current_package_identity() {
5055 return Ok(local_app_data
5056 .join("Packages")
5057 .join(PACKAGED_FAMILY_NAME)
5058 .join("LocalState"));
5059 }
5060 Ok(local_app_data.join("Devolutions").join("Pinget"))
5061 }
5062
5063 #[cfg(not(windows))]
5064 {
5065 Ok(local_app_data.join("pinget"))
5066 }
5067}
5068
5069#[cfg(windows)]
5070fn has_current_package_identity() -> bool {
5071 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5074
5075 let mut length: u32 = 0;
5076 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5079 rc != APPMODEL_ERROR_NO_PACKAGE
5080}
5081
5082fn store_path(app_root: &Path) -> PathBuf {
5083 app_root.join("sources.json")
5084}
5085
5086fn user_settings_path(app_root: &Path) -> PathBuf {
5087 if uses_packaged_layout(app_root) {
5088 app_root.join("settings.json")
5089 } else {
5090 app_root.join("user-settings.json")
5091 }
5092}
5093
5094fn admin_settings_path(app_root: &Path) -> PathBuf {
5095 app_root.join("admin-settings.json")
5096}
5097
5098#[cfg(test)]
5099fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5100 source_state_dir_with_keying(app_root, source, false)
5101}
5102
5103fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5104 if uses_packaged_layout(app_root) {
5105 return app_root
5106 .join(packaged_source_type(source.kind))
5107 .join(sanitize_path_segment(&source.identifier));
5108 }
5109
5110 if identifier_keyed {
5111 return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5112 }
5113
5114 app_root.join("sources").join(sanitize_path_segment(&source.name))
5115}
5116
5117fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5118 if uses_packaged_layout(app_root) {
5119 return app_root
5120 .join(sanitize_path_segment(&source.identifier))
5121 .join("installed.db");
5122 }
5123
5124 source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5125}
5126
5127#[cfg(test)]
5128fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5129 source_state_dir(app_root, source).join("index.db")
5130}
5131
5132fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5133 if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5134 source.etag = Some(value.to_owned());
5135 }
5136
5137 if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5138 source.last_modified = Some(value.to_owned());
5139 }
5140}
5141
5142fn remove_sqlite_sidecars(db_path: &Path) {
5146 for suffix in ["-wal", "-shm"] {
5147 let mut sidecar = db_path.as_os_str().to_owned();
5148 sidecar.push(suffix);
5149 let _ = fs::remove_file(PathBuf::from(sidecar));
5150 }
5151}
5152
5153#[cfg(windows)]
5154const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5155
5156#[cfg(windows)]
5157fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5158 let source_wide = source
5159 .as_os_str()
5160 .encode_wide()
5161 .chain(std::iter::once(0))
5162 .collect::<Vec<_>>();
5163 let target_wide = target
5164 .as_os_str()
5165 .encode_wide()
5166 .chain(std::iter::once(0))
5167 .collect::<Vec<_>>();
5168 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5169 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5172 if ok == 0 {
5173 return Err(std::io::Error::last_os_error());
5174 }
5175 Ok(())
5176}
5177
5178#[cfg(windows)]
5179fn replace_file(source: &Path, target: &Path) -> Result<()> {
5180 let to_err = |error: std::io::Error| -> anyhow::Error {
5181 anyhow::Error::new(error).context(format!(
5182 "failed to replace {} with {}",
5183 target.display(),
5184 source.display()
5185 ))
5186 };
5187
5188 let first = match move_replace_existing(source, target) {
5190 Ok(()) => return Ok(()),
5191 Err(error) => error,
5192 };
5193
5194 if !source.exists() || !target.exists() {
5197 return Err(to_err(first));
5198 }
5199
5200 let mut last = first;
5205 for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5206 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5207 match move_replace_existing(source, target) {
5208 Ok(()) => return Ok(()),
5209 Err(error) => last = error,
5210 }
5211 }
5212
5213 let staged = target.with_extension("old.tmp");
5219 if move_replace_existing(target, &staged).is_err() {
5220 return Err(to_err(last));
5221 }
5222 match move_replace_existing(source, target) {
5223 Ok(()) => {
5224 let _ = fs::remove_file(&staged);
5225 Ok(())
5226 }
5227 Err(error) => {
5228 let _ = move_replace_existing(&staged, target);
5229 Err(to_err(error))
5230 }
5231 }
5232}
5233
5234#[cfg(not(windows))]
5235fn replace_file(source: &Path, target: &Path) -> Result<()> {
5236 fs::rename(source, target)
5237 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5238}
5239
5240fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5241 cache_root(app_root)
5242 .join(bucket)
5243 .join(sanitize_path_segment(identifier))
5244}
5245
5246fn load_store(app_root: &Path) -> Result<SourceStore> {
5247 if uses_packaged_layout(app_root)
5248 && let Some(store) = load_packaged_store(app_root)?
5249 {
5250 return Ok(store);
5251 }
5252
5253 let path = store_path(app_root);
5254 if !path.exists() {
5255 let store = SourceStore::default();
5256 save_store(app_root, &store)?;
5257 return Ok(store);
5258 }
5259
5260 let bytes = fs::read(path).context("failed to read source store")?;
5261 serde_json::from_slice(&bytes).context("failed to parse source store")
5262}
5263
5264fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5265 if uses_system_winget_source_commands(app_root) {
5266 return Ok((
5267 EffectiveSourceMode::SystemWingetDirect,
5268 load_system_winget_source_store()?,
5269 ));
5270 }
5271
5272 match source_mode {
5273 SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5274 SourceMode::SystemWingetMirror => {
5275 let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5276 Ok((EffectiveSourceMode::SystemWingetMirror, store))
5277 }
5278 SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5279 Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5280 Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5281 },
5282 }
5283}
5284
5285fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5286 app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5287}
5288
5289fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5290 let path = system_winget_mirror_store_path(app_root);
5291 if !path.exists() {
5292 return Ok(None);
5293 }
5294
5295 let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5296 serde_json::from_slice(&bytes)
5297 .context("failed to parse system WinGet mirror source store")
5298 .map(Some)
5299}
5300
5301fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5302 write_json(system_winget_mirror_store_path(app_root), store)
5303}
5304
5305fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5306 if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5307 return Ok(store);
5308 }
5309
5310 refresh_system_winget_mirror_store(app_root)
5311}
5312
5313fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5314 let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5315 let mut exported = load_system_winget_source_store()?;
5316 merge_source_cache_metadata(&mut exported, &prior);
5317 save_system_winget_mirror_store(app_root, &exported)?;
5318 Ok(exported)
5319}
5320
5321fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5322 let prior_by_identifier: HashMap<_, _> = prior
5323 .sources
5324 .iter()
5325 .map(|source| (source.identifier.to_ascii_lowercase(), source))
5326 .collect();
5327
5328 for source in &mut target.sources {
5329 let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5330 continue;
5331 };
5332 source.last_update = previous.last_update;
5333 source.source_version = previous.source_version.clone();
5334 source.etag = previous.etag.clone();
5335 source.last_modified = previous.last_modified.clone();
5336 }
5337}
5338
5339fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5340 if uses_system_winget_source_commands(app_root) {
5341 bail!("System WinGet source settings must be modified through winget.");
5342 }
5343
5344 if uses_packaged_layout(app_root) {
5345 return save_packaged_store(app_root, store);
5346 }
5347
5348 write_json(store_path(app_root), store)
5349}
5350
5351fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5352 let user_sources_path = packaged_user_sources_path(app_root)?;
5353 let metadata_path = app_root.join("sources_metadata");
5354 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5355 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5356 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5357}
5358
5359fn load_packaged_store_from_streams(
5360 user_sources_yaml: Option<&str>,
5361 metadata_yaml: Option<&str>,
5362) -> Result<Option<SourceStore>> {
5363 if is_secure_settings_stub(user_sources_yaml) {
5364 return load_system_winget_source_store().map(Some);
5365 }
5366
5367 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5368}
5369
5370fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5371 let user_sources_path = packaged_user_sources_path(app_root)?;
5372 if let Some(parent) = user_sources_path.parent() {
5373 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5374 }
5375
5376 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5377 .context("failed to write packaged user_sources stream")?;
5378 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5379 .context("failed to write packaged source metadata")?;
5380 Ok(())
5381}
5382
5383fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5384 if !uses_packaged_layout(app_root) {
5385 return false;
5386 }
5387
5388 #[cfg(windows)]
5389 {
5390 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5391 return false;
5392 };
5393
5394 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5395 is_secure_settings_stub(user_sources_yaml.as_deref())
5396 }
5397
5398 #[cfg(not(windows))]
5399 {
5400 false
5401 }
5402}
5403
5404fn is_secure_settings_stub(value: Option<&str>) -> bool {
5405 value
5406 .map(str::trim_start)
5407 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5408}
5409
5410fn load_system_winget_source_store() -> Result<SourceStore> {
5411 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5412 Ok(SourceStore {
5413 sources: parse_system_winget_source_export(&result.stdout)?,
5414 })
5415}
5416
5417fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5418 run_checked_winget_source_command(
5419 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5420 &format!("add WinGet source '{name}'"),
5421 )?;
5422 Ok(())
5423}
5424
5425fn system_winget_remove_source(name: &str) -> Result<()> {
5426 run_checked_winget_source_command(
5427 vec![
5428 "source".to_owned(),
5429 "remove".to_owned(),
5430 "--name".to_owned(),
5431 name.to_owned(),
5432 "--disable-interactivity".to_owned(),
5433 ],
5434 &format!("remove WinGet source '{name}'"),
5435 )?;
5436 Ok(())
5437}
5438
5439fn system_winget_reset_source(name: &str) -> Result<()> {
5440 run_checked_winget_source_command(
5441 vec![
5442 "source".to_owned(),
5443 "reset".to_owned(),
5444 "--name".to_owned(),
5445 name.to_owned(),
5446 "--force".to_owned(),
5447 "--disable-interactivity".to_owned(),
5448 ],
5449 &format!("reset WinGet source '{name}'"),
5450 )?;
5451 Ok(())
5452}
5453
5454fn system_winget_reset_sources() -> Result<()> {
5455 run_checked_winget_source_command(
5456 vec![
5457 "source".to_owned(),
5458 "reset".to_owned(),
5459 "--force".to_owned(),
5460 "--disable-interactivity".to_owned(),
5461 ],
5462 "reset WinGet sources",
5463 )?;
5464 Ok(())
5465}
5466
5467fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5468 let mut args = vec!["source".to_owned(), "update".to_owned()];
5469 if let Some(name) = name {
5470 args.push("--name".to_owned());
5471 args.push(name.to_owned());
5472 }
5473
5474 args.push("--disable-interactivity".to_owned());
5475 let result = run_checked_winget_source_command(
5476 args,
5477 if name.is_some() {
5478 "update WinGet source"
5479 } else {
5480 "update WinGet sources"
5481 },
5482 )?;
5483 let detail = result.stdout.trim();
5484 Ok(if detail.is_empty() {
5485 "Done".to_owned()
5486 } else {
5487 detail.to_owned()
5488 })
5489}
5490
5491fn build_winget_source_export_arguments() -> Vec<String> {
5492 vec![
5493 "source".to_owned(),
5494 "export".to_owned(),
5495 "--disable-interactivity".to_owned(),
5496 ]
5497}
5498
5499fn build_winget_source_add_arguments(
5500 name: &str,
5501 arg: &str,
5502 kind: SourceKind,
5503 trust_level: &str,
5504 explicit: bool,
5505) -> Vec<String> {
5506 let mut args = vec![
5507 "source".to_owned(),
5508 "add".to_owned(),
5509 "--name".to_owned(),
5510 name.to_owned(),
5511 "--arg".to_owned(),
5512 arg.to_owned(),
5513 "--type".to_owned(),
5514 packaged_source_type(kind).to_owned(),
5515 "--disable-interactivity".to_owned(),
5516 ];
5517
5518 if !trust_level.trim().is_empty() {
5519 args.push("--trust-level".to_owned());
5520 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5521 "trusted".to_owned()
5522 } else {
5523 "none".to_owned()
5524 });
5525 }
5526
5527 if explicit {
5528 args.push("--explicit".to_owned());
5529 }
5530
5531 args
5532}
5533
5534#[derive(Debug)]
5535struct WingetSourceCommandResult {
5536 exit_code: i32,
5537 stdout: String,
5538 stderr: String,
5539}
5540
5541type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5542
5543static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5544 RwLock::new(run_winget_source_command_process);
5545
5546fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5547 let result = run_winget_source_command(&args)?;
5548 if result.exit_code == 0 {
5549 return Ok(result);
5550 }
5551
5552 let detail = [result.stderr.trim(), result.stdout.trim()]
5553 .into_iter()
5554 .filter(|value| !value.is_empty())
5555 .collect::<Vec<_>>()
5556 .join("\n");
5557 if detail.is_empty() {
5558 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5559 }
5560
5561 bail!(
5562 "Failed to {action}; winget exited with code {}:\n{}",
5563 result.exit_code,
5564 detail
5565 );
5566}
5567
5568fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5569 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5570 .read()
5571 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5572 runner(args)
5573}
5574
5575fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5576 use std::process::Command;
5577
5578 let output = Command::new("winget")
5579 .args(args)
5580 .output()
5581 .context("failed to run winget source command")?;
5582
5583 Ok(WingetSourceCommandResult {
5584 exit_code: output.status.code().unwrap_or(-1),
5585 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5586 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5587 })
5588}
5589
5590fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5591 let trimmed = output.trim();
5592 if trimmed.is_empty() {
5593 return Ok(Vec::new());
5594 }
5595
5596 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5597 && let Some(sources) = parse_system_winget_source_export_value(&value)?
5598 {
5599 return Ok(sources);
5600 }
5601
5602 let mut sources = Vec::new();
5603 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5604 if line.is_empty() {
5605 continue;
5606 }
5607
5608 let value: JsonValue = serde_json::from_str(line)
5609 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5610 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5611 bail!("WinGet source export returned a non-source line: {line}");
5612 };
5613 sources.append(&mut parsed);
5614 }
5615
5616 Ok(sources)
5617}
5618
5619fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5620 match value {
5621 JsonValue::Array(items) => items
5622 .iter()
5623 .map(parse_system_winget_source_record)
5624 .collect::<Result<Vec<_>>>()
5625 .map(Some),
5626 JsonValue::Object(object) => {
5627 if let Some(JsonValue::Array(items)) = object.get("Sources") {
5628 items
5629 .iter()
5630 .map(parse_system_winget_source_record)
5631 .collect::<Result<Vec<_>>>()
5632 .map(Some)
5633 } else if object.contains_key("Name") {
5634 Ok(Some(vec![parse_system_winget_source_record(value)?]))
5635 } else {
5636 Ok(None)
5637 }
5638 }
5639 _ => Ok(None),
5640 }
5641}
5642
5643fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5644 let object = value
5645 .as_object()
5646 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5647 let name = required_json_string(object, "Name")?;
5648 let source_type = required_json_string(object, "Type")?;
5649 let arg = required_json_string(object, "Arg")?;
5650 let kind = parse_system_winget_source_kind(&source_type)?;
5651 let identifier = optional_json_string(object, "Identifier")
5652 .or_else(|| optional_json_string(object, "Data"))
5653 .unwrap_or_else(|| name.clone());
5654
5655 Ok(SourceRecord {
5656 name,
5657 kind,
5658 arg,
5659 identifier,
5660 trust_level: parse_system_winget_trust_level(object),
5661 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5662 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5663 last_update: None,
5664 source_version: None,
5665 etag: None,
5666 last_modified: None,
5667 })
5668}
5669
5670fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5671 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5672}
5673
5674fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5675 match object.get(key)? {
5676 JsonValue::String(value) => Some(value.clone()),
5677 JsonValue::Number(value) => Some(value.to_string()),
5678 _ => None,
5679 }
5680}
5681
5682fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5683 match object.get(key)? {
5684 JsonValue::Bool(value) => Some(*value),
5685 JsonValue::String(value) => value.parse().ok(),
5686 _ => None,
5687 }
5688}
5689
5690fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5691 match object.get(key)? {
5692 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5693 JsonValue::String(value) => value.parse().ok(),
5694 _ => None,
5695 }
5696}
5697
5698fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5699 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5700 Ok(SourceKind::PreIndexed)
5701 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5702 Ok(SourceKind::Rest)
5703 } else {
5704 bail!("Unsupported WinGet source type '{value}'.")
5705 }
5706}
5707
5708fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5709 let Some(value) = object.get("TrustLevel") else {
5710 return "None".to_owned();
5711 };
5712
5713 match value {
5714 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5715 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5716 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5717 _ => "None".to_owned(),
5718 }
5719}
5720
5721fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5722 value
5723 .as_str()
5724 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5725}
5726
5727fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5728 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5729 return None;
5730 }
5731
5732 let mut sources = SourceStore::default().sources;
5733 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5734 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5735 continue;
5736 };
5737
5738 if yaml_bool(entry.get("IsTombstone")) {
5739 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5740 continue;
5741 }
5742
5743 let Some(mapped) = map_packaged_source_entry(&entry) else {
5744 continue;
5745 };
5746
5747 if let Some(existing) = sources
5748 .iter_mut()
5749 .find(|source| source.name.eq_ignore_ascii_case(&name))
5750 {
5751 *existing = mapped;
5752 } else {
5753 sources.push(mapped);
5754 }
5755 }
5756
5757 for entry in parse_packaged_yaml_entries(metadata_yaml) {
5758 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5759 continue;
5760 };
5761
5762 let Some(source) = sources
5763 .iter_mut()
5764 .find(|source| source.name.eq_ignore_ascii_case(&name))
5765 else {
5766 continue;
5767 };
5768
5769 if let Some(last_update) = entry
5770 .get("LastUpdate")
5771 .and_then(|value| value.as_deref())
5772 .and_then(parse_packaged_datetime)
5773 {
5774 source.last_update = Some(last_update);
5775 }
5776
5777 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
5778 && !source_version.is_empty()
5779 {
5780 source.source_version = Some(source_version);
5781 }
5782
5783 if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
5784 && !etag.is_empty()
5785 {
5786 source.etag = Some(etag);
5787 }
5788
5789 if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
5790 && !last_modified.is_empty()
5791 {
5792 source.last_modified = Some(last_modified);
5793 }
5794 }
5795
5796 Some(SourceStore { sources })
5797}
5798
5799fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5800 let Some(yaml) = yaml else {
5801 return Vec::new();
5802 };
5803
5804 let mut entries = Vec::new();
5805 let mut current: Option<BTreeMap<String, Option<String>>> = None;
5806 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5807 let line = raw_line.trim_end();
5808 let trimmed = line.trim();
5809 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5810 continue;
5811 }
5812
5813 if let Some(remainder) = trimmed.strip_prefix("- ") {
5814 if let Some(entry) = current.take() {
5815 entries.push(entry);
5816 }
5817
5818 let mut entry = BTreeMap::new();
5819 add_packaged_yaml_key_value(&mut entry, remainder);
5820 current = Some(entry);
5821 continue;
5822 }
5823
5824 if let Some(entry) = current.as_mut() {
5825 add_packaged_yaml_key_value(entry, trimmed);
5826 }
5827 }
5828
5829 if let Some(entry) = current {
5830 entries.push(entry);
5831 }
5832
5833 entries
5834}
5835
5836fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5837 let Some((key, value)) = line.split_once(':') else {
5838 return;
5839 };
5840
5841 entry.insert(
5842 key.trim().to_owned(),
5843 Some(unquote_yaml_scalar(value.trim()).to_owned()),
5844 );
5845}
5846
5847fn unquote_yaml_scalar(value: &str) -> &str {
5848 if value.len() >= 2 {
5849 let bytes = value.as_bytes();
5850 let first = bytes[0] as char;
5851 let last = bytes[value.len() - 1] as char;
5852 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5853 return &value[1..value.len() - 1];
5854 }
5855 }
5856
5857 value
5858}
5859
5860fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5861 let name = entry.get("Name").and_then(|value| value.clone())?;
5862 let source_type = entry.get("Type").and_then(|value| value.clone())?;
5863 let arg = entry.get("Arg").and_then(|value| value.clone())?;
5864 let kind = parse_packaged_source_kind(&source_type)?;
5865
5866 Some(SourceRecord {
5867 name: name.clone(),
5868 kind,
5869 arg,
5870 identifier: entry
5871 .get("Identifier")
5872 .and_then(|value| value.clone())
5873 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5874 .unwrap_or(name),
5875 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5876 explicit: yaml_bool(entry.get("Explicit")),
5877 priority: entry
5878 .get("Priority")
5879 .and_then(|value| value.as_deref())
5880 .and_then(|value| value.parse::<i32>().ok())
5881 .unwrap_or_default(),
5882 last_update: None,
5883 source_version: None,
5884 etag: None,
5885 last_modified: None,
5886 })
5887}
5888
5889fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5890 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5891 Some(SourceKind::PreIndexed)
5892 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5893 Some(SourceKind::Rest)
5894 } else {
5895 None
5896 }
5897}
5898
5899fn parse_packaged_trust_level(value: Option<&str>) -> String {
5900 match value {
5901 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5902 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5903 _ => "None".to_owned(),
5904 }
5905}
5906
5907fn yaml_bool(value: Option<&Option<String>>) -> bool {
5908 value
5909 .and_then(|raw| raw.as_deref())
5910 .and_then(|raw| raw.parse::<bool>().ok())
5911 .unwrap_or(false)
5912}
5913
5914fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5915 if let Ok(unix_seconds) = value.parse::<i64>() {
5916 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5917 }
5918
5919 DateTime::parse_from_rfc3339(value)
5920 .ok()
5921 .map(|value| value.with_timezone(&Utc))
5922}
5923
5924fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5925 let mut lines = vec!["Sources:".to_owned()];
5926 for source in &store.sources {
5927 lines.push(format!(" - Name: {}", source.name));
5928 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
5929 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
5930 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
5931 lines.push(format!(
5932 " TrustLevel: {}",
5933 if source.trust_level.eq_ignore_ascii_case("Trusted") {
5934 1
5935 } else {
5936 0
5937 }
5938 ));
5939 lines.push(format!(" Explicit: {}", source.explicit));
5940 lines.push(format!(" Priority: {}", source.priority));
5941 lines.push(" IsTombstone: false".to_owned());
5942 }
5943 lines.push(String::new());
5944 lines.join("\n")
5945}
5946
5947fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5948 let mut lines = vec!["Sources:".to_owned()];
5949 for source in &store.sources {
5950 if source.last_update.is_none()
5951 && source.source_version.is_none()
5952 && source.etag.is_none()
5953 && source.last_modified.is_none()
5954 {
5955 continue;
5956 }
5957
5958 lines.push(format!(" - Name: {}", source.name));
5959 if let Some(last_update) = source.last_update {
5960 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
5961 }
5962 if let Some(source_version) = &source.source_version {
5963 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
5964 }
5965 if let Some(etag) = &source.etag {
5966 lines.push(format!(" ETag: {}", yaml_scalar(etag)));
5967 }
5968 if let Some(last_modified) = &source.last_modified {
5969 lines.push(format!(" LastModified: {}", yaml_scalar(last_modified)));
5970 }
5971 }
5972 lines.push(String::new());
5973 lines.join("\n")
5974}
5975
5976fn yaml_scalar(value: &str) -> String {
5977 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5978 format!("\"{}\"", value.replace('"', "\\\""))
5979 } else {
5980 value.to_owned()
5981 }
5982}
5983
5984fn uses_packaged_layout(app_root: &Path) -> bool {
5985 #[cfg(windows)]
5986 {
5987 try_get_packaged_local_state_root(app_root).is_some()
5988 }
5989
5990 #[cfg(not(windows))]
5991 {
5992 let _ = app_root;
5993 false
5994 }
5995}
5996
5997#[cfg(windows)]
5998fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5999 let local_state = app_root.file_name()?.to_string_lossy();
6000 if !local_state.eq_ignore_ascii_case("LocalState") {
6001 return None;
6002 }
6003
6004 let family = app_root.parent()?.file_name()?.to_string_lossy();
6005 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6006 return None;
6007 }
6008
6009 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6010 if !packages.eq_ignore_ascii_case("Packages") {
6011 return None;
6012 }
6013
6014 Some(app_root.to_path_buf())
6015}
6016
6017#[cfg(windows)]
6018fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6019 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6020 Ok(packaged_secure_settings_root()?.join("user_sources"))
6021}
6022
6023#[cfg(not(windows))]
6024fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6025 bail!("packaged WinGet source streams are only available on Windows")
6026}
6027
6028#[cfg(windows)]
6029fn packaged_secure_settings_root() -> Result<PathBuf> {
6030 let program_data = std::env::var_os("PROGRAMDATA")
6031 .map(PathBuf::from)
6032 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6033 Ok(program_data
6034 .join("Microsoft")
6035 .join("WinGet")
6036 .join(current_user_sid()?)
6037 .join("settings")
6038 .join("pkg")
6039 .join(PACKAGED_NAME))
6040}
6041
6042#[cfg(windows)]
6043#[allow(clippy::multiple_unsafe_ops_per_block)]
6044fn current_user_sid() -> Result<String> {
6045 fn close_token(token: HANDLE) {
6046 if !token.is_null() {
6047 unsafe {
6049 CloseHandle(token);
6050 }
6051 }
6052 }
6053
6054 let mut token: HANDLE = std::ptr::null_mut();
6055 let current_process = unsafe { GetCurrentProcess() };
6057 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6059 bail!("failed to open current process token")
6060 }
6061
6062 let mut length = 0;
6063 unsafe {
6065 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6066 }
6067
6068 let element_size = size_of::<TOKEN_USER>();
6069 let element_count = (length as usize).div_ceil(element_size);
6070 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6071 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6073 close_token(token);
6074 bail!("failed to get current user token information")
6075 }
6076
6077 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6079 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6080 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6082 close_token(token);
6083 bail!("failed to convert current user SID")
6084 }
6085
6086 let mut len = 0usize;
6087 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6089 len += 1;
6090 }
6091
6092 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6094 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6095 unsafe {
6097 LocalFree(sid_ptr.cast());
6098 }
6099 close_token(token);
6100 Ok(sid)
6101}
6102
6103fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6104 app_root.join("Microsoft").join("Windows Package Manager")
6105}
6106
6107fn cache_root(app_root: &Path) -> PathBuf {
6108 if uses_packaged_layout(app_root) {
6109 packaged_file_cache_root(app_root)
6110 } else {
6111 default_cache_root_fallback()
6112 }
6113}
6114
6115fn default_cache_root_fallback() -> PathBuf {
6116 std::env::temp_dir().join("cache")
6117}
6118
6119fn packaged_source_type(kind: SourceKind) -> &'static str {
6120 match kind {
6121 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6122 SourceKind::Rest => "Microsoft.Rest",
6123 }
6124}
6125
6126fn sanitize_path_segment(value: &str) -> String {
6127 value
6128 .chars()
6129 .map(|ch| match ch {
6130 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6131 _ => ch,
6132 })
6133 .collect()
6134}
6135
6136fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6137 if !path.exists() {
6138 return Ok(JsonMap::new());
6139 }
6140
6141 let bytes = fs::read(path).context("failed to read JSON file")?;
6142 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6143 Ok(match value {
6144 JsonValue::Object(object) => object,
6145 _ => JsonMap::new(),
6146 })
6147}
6148
6149fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6150 write_json(path, value)
6151}
6152
6153fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6154 if let Some(parent) = path.parent() {
6155 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6156 }
6157
6158 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6159 fs::write(path, bytes).context("failed to write JSON file")
6160}
6161
6162fn merge_json_objects(
6163 current: &JsonMap<String, JsonValue>,
6164 update: &JsonMap<String, JsonValue>,
6165) -> JsonMap<String, JsonValue> {
6166 let mut merged = current.clone();
6167 for (key, value) in update {
6168 match (merged.get(key), value) {
6169 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6170 merged.insert(
6171 key.clone(),
6172 JsonValue::Object(merge_json_objects(current_object, update_object)),
6173 );
6174 }
6175 _ => {
6176 merged.insert(key.clone(), value.clone());
6177 }
6178 }
6179 }
6180
6181 merged
6182}
6183
6184fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6185 match expected {
6186 JsonValue::Object(expected_object) => match current {
6187 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6188 current_object
6189 .get(key)
6190 .is_some_and(|current_child| json_contains(current_child, expected_child))
6191 }),
6192 _ => false,
6193 },
6194 JsonValue::Array(expected_array) => match current {
6195 JsonValue::Array(current_array) => {
6196 current_array.len() == expected_array.len()
6197 && current_array
6198 .iter()
6199 .zip(expected_array.iter())
6200 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6201 }
6202 _ => false,
6203 },
6204 _ => current == expected,
6205 }
6206}
6207
6208fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6209 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6210 "none" | "default" => Ok("None".to_owned()),
6211 "trusted" => Ok("Trusted".to_owned()),
6212 other => bail!("unsupported source trust level: {other}"),
6213 }
6214}
6215
6216fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6217 SUPPORTED_ADMIN_SETTINGS
6218 .iter()
6219 .copied()
6220 .find(|setting| setting.eq_ignore_ascii_case(name))
6221 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6222}
6223
6224fn default_source_trust_level() -> String {
6225 "None".to_owned()
6226}
6227
6228fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6229where
6230 F: FnMut(&SqlRow) -> Result<T>,
6231{
6232 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6233 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6234 let mut result = Vec::new();
6235
6236 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6237 result.push(map(&row)?);
6238 }
6239
6240 Ok(result)
6241}
6242
6243fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6244where
6245 F: FnOnce(&SqlRow) -> Result<T>,
6246{
6247 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6248 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6249
6250 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6251 Some(row) => Ok(Some(map(&row)?)),
6252 None => Ok(None),
6253 }
6254}
6255
6256fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6257 pollster::block_on(connection.execute(sql, params_from_iter(params)))
6258 .with_context(|| format!("failed to execute SQL statement: {sql}"))
6259}
6260
6261fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6262 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6263}
6264
6265fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6266 row.get_value(index)
6267 .with_context(|| format!("failed to read SQL column {index}"))
6268}
6269
6270fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6271 match row_value(row, index)? {
6272 SqlValue::Text(value) => Ok(value),
6273 SqlValue::Integer(value) => Ok(value.to_string()),
6274 SqlValue::Real(value) => Ok(value.to_string()),
6275 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6276 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6277 }
6278}
6279
6280fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6281 match row_value(row, index)? {
6282 SqlValue::Null => Ok(None),
6283 SqlValue::Text(value) => Ok(Some(value)),
6284 SqlValue::Integer(value) => Ok(Some(value.to_string())),
6285 SqlValue::Real(value) => Ok(Some(value.to_string())),
6286 SqlValue::Blob(value) => Ok(Some(
6287 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6288 )),
6289 }
6290}
6291
6292fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6293 match row_value(row, index)? {
6294 SqlValue::Integer(value) => Ok(value),
6295 SqlValue::Text(value) => value
6296 .parse::<i64>()
6297 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6298 SqlValue::Real(value) => f64_to_i64_checked(value, index),
6299 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6300 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6301 }
6302}
6303
6304fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6305 match row_value(row, index)? {
6306 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6307 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6308 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6309 SqlValue::Integer(value) => Ok(format!("{value:x}")),
6310 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6311 }
6312}
6313
6314fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6315 match row_value(row, index)? {
6316 SqlValue::Null => Ok(None),
6317 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6318 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6319 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6320 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6321 }
6322}
6323
6324fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6325 if !value.is_finite() {
6326 bail!("SQL column {index} contained a non-finite floating-point value");
6327 }
6328
6329 if value.fract() != 0.0 {
6330 bail!("SQL column {index} contained a non-integer floating-point value");
6331 }
6332
6333 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6334 bail!("SQL column {index} contained a floating-point value outside the i64 range");
6335 }
6336
6337 format!("{value:.0}")
6338 .parse::<i64>()
6339 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6340}
6341
6342fn bytes_to_hex(bytes: &[u8]) -> String {
6343 let mut result = String::with_capacity(bytes.len() * 2);
6344 for byte in bytes {
6345 result.push_str(&format!("{byte:02x}"));
6346 }
6347 result
6348}
6349
6350fn query_v2_matches(
6351 connection: &Connection,
6352 query: &PackageQuery,
6353 semantics: SearchSemantics,
6354) -> Result<(Vec<V2SearchRow>, bool)> {
6355 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6356 let limit = source_fetch_results(query, semantics);
6357 let sql = format!(
6358 "SELECT rowid, id, name, moniker, latest_version, hash \
6359 FROM packages WHERE {where_clause} LIMIT {}",
6360 limit + 1
6361 );
6362 let mut rows = query_rows(
6363 connection,
6364 &sql,
6365 params.into_iter().map(SqlValue::Text).collect(),
6366 |row| {
6367 Ok(V2SearchRow {
6368 package_rowid: row_i64(row, 0)?,
6369 id: row_string(row, 1)?,
6370 name: row_string(row, 2)?,
6371 moniker: row_opt_string(row, 3)?,
6372 version: row_string(row, 4)?,
6373 package_hash: row_hex_string(row, 5)?,
6374 match_criteria: None,
6375 })
6376 },
6377 )?;
6378 for row in &mut rows {
6379 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6380 }
6381 let truncated = rows.len() > limit;
6382 rows.truncate(limit);
6383 Ok((rows, truncated))
6384}
6385
6386fn query_v1_matches(
6387 connection: &Connection,
6388 query: &PackageQuery,
6389 semantics: SearchSemantics,
6390) -> Result<(Vec<V1SearchRow>, bool)> {
6391 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6392 let limit = source_fetch_results(query, semantics);
6393 let sql = format!(
6394 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6395 FROM manifest \
6396 JOIN ids ON manifest.id = ids.rowid \
6397 JOIN names ON manifest.name = names.rowid \
6398 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6399 JOIN versions ON manifest.version = versions.rowid \
6400 JOIN channels ON manifest.channel = channels.rowid \
6401 WHERE {where_clause} LIMIT {}",
6402 limit + 1
6403 );
6404 let mut rows = query_rows(
6405 connection,
6406 &sql,
6407 params.into_iter().map(SqlValue::Text).collect(),
6408 |row| {
6409 Ok(V1SearchRow {
6410 manifest_rowid: row_i64(row, 0)?,
6411 package_rowid: row_i64(row, 1)?,
6412 version: row_string(row, 2)?,
6413 channel: row_string(row, 3)?,
6414 id: row_string(row, 4)?,
6415 name: row_string(row, 5)?,
6416 moniker: row_opt_string(row, 6)?,
6417 match_criteria: None,
6418 })
6419 },
6420 )?;
6421 for row in &mut rows {
6422 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6423 }
6424 let truncated = rows.len() > limit;
6425 rows.truncate(limit);
6426 Ok((rows, truncated))
6427}
6428
6429fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6430 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6431
6432 for row in rows {
6433 match grouped.get(&row.package_rowid) {
6434 Some(existing)
6435 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6436 != Ordering::Less => {}
6437 _ => {
6438 grouped.insert(row.package_rowid, row);
6439 }
6440 }
6441 }
6442
6443 grouped.into_values().collect()
6444}
6445
6446fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6447 let has_hash = table_has_column(connection, "manifest", "hash")?;
6448 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6449 let sql = format!(
6450 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6451 FROM manifest \
6452 JOIN versions ON manifest.version = versions.rowid \
6453 JOIN channels ON manifest.channel = channels.rowid \
6454 WHERE manifest.id = ?1"
6455 );
6456 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6457 Ok(V1VersionRow {
6458 version: row_string(row, 0)?,
6459 channel: row_string(row, 1)?,
6460 pathpart_id: row_i64(row, 2)?,
6461 manifest_hash: row_opt_hex_string(row, 3)?,
6462 })
6463 })
6464}
6465
6466fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6467 let mut parts = Vec::new();
6468 let mut current = Some(pathpart_id);
6469
6470 while let Some(id) = current {
6471 let (parent, pathpart) = query_optional_value(
6472 connection,
6473 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6474 vec![SqlValue::Integer(id)],
6475 |row| {
6476 Ok((
6477 row_opt_string(row, 0)?
6478 .and_then(|value| value.parse::<i64>().ok())
6479 .filter(|value| *value != 0),
6480 row_string(row, 1)?,
6481 ))
6482 },
6483 )?
6484 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6485 parts.push(pathpart);
6486 current = parent;
6487 }
6488
6489 parts.reverse();
6490 Ok(parts.join("/"))
6491}
6492
6493fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6494 format!(
6495 "packages/{}/{}/versionData.mszyml",
6496 package_id,
6497 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6498 )
6499}
6500
6501fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6502 let message = format!("{error:#}").to_ascii_lowercase();
6503 message.contains("no such table") && message.contains("packages")
6504}
6505
6506fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6507 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6508 row_string(row, 1)
6509 })? {
6510 if value.eq_ignore_ascii_case(column) {
6511 return Ok(true);
6512 }
6513 }
6514
6515 Ok(false)
6516}
6517
6518fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6519 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6520 let id_column = if v2 { "id" } else { "ids.id" };
6521 let name_column = if v2 { "name" } else { "names.name" };
6522 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6523 let exact_match = query.exact || semantics == SearchSemantics::Single;
6524 let mut params = Vec::new();
6525
6526 if let Some(value) = &query.id {
6527 return (
6528 single_field_condition(id_column, value, exact_match, &mut params),
6529 params,
6530 );
6531 }
6532 if let Some(value) = &query.name {
6533 return (
6534 single_field_condition(name_column, value, exact_match, &mut params),
6535 params,
6536 );
6537 }
6538 if let Some(value) = &query.moniker {
6539 return (
6540 single_field_condition(moniker_column, value, exact_match, &mut params),
6541 params,
6542 );
6543 }
6544 if let Some(value) = &query.tag {
6545 return (
6546 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6547 params,
6548 );
6549 }
6550 if let Some(value) = &query.command {
6551 return (
6552 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6553 params,
6554 );
6555 }
6556 if let Some(value) = &query.query {
6557 if exact_match {
6558 let conditions = [
6559 single_field_condition(id_column, value, true, &mut params),
6560 single_field_condition(name_column, value, true, &mut params),
6561 single_field_condition(moniker_column, value, true, &mut params),
6562 ];
6563 return (format!("({})", conditions.join(" OR ")), params);
6564 }
6565
6566 let mut conditions = vec![
6567 single_field_condition(id_column, value, false, &mut params),
6568 single_field_condition(name_column, value, false, &mut params),
6569 single_field_condition(moniker_column, value, false, &mut params),
6570 ];
6571 if semantics == SearchSemantics::Many {
6572 conditions.push(mapped_field_condition(
6573 v2,
6574 "tag",
6575 value,
6576 rowid_column,
6577 false,
6578 &mut params,
6579 ));
6580 conditions.push(mapped_field_condition(
6581 v2,
6582 "command",
6583 value,
6584 rowid_column,
6585 false,
6586 &mut params,
6587 ));
6588 }
6589 return (format!("({})", conditions.join(" OR ")), params);
6590 }
6591
6592 ("1 = 1".to_owned(), Vec::new())
6593}
6594
6595fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6596 params.push(match_parameter(value, exact));
6597 format!("{column} LIKE ?{}", params.len())
6598}
6599
6600fn mapped_field_condition(
6601 v2: bool,
6602 value_name: &str,
6603 value: &str,
6604 rowid_column: &str,
6605 exact: bool,
6606 params: &mut Vec<String>,
6607) -> String {
6608 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6609 (
6610 format!("{value_name}s2"),
6611 format!("{value_name}s2_map"),
6612 value_name.to_owned(),
6613 "package".to_owned(),
6614 )
6615 } else {
6616 (
6617 format!("{value_name}s"),
6618 format!("{value_name}s_map"),
6619 value_name.to_owned(),
6620 "manifest".to_owned(),
6621 )
6622 };
6623 params.push(match_parameter(value, exact));
6624 let parameter = params.len();
6625 format!(
6632 "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6633 FROM {map_table_name} JOIN {table_name} ON \
6634 {map_table_name}.{value_name} = {table_name}.rowid \
6635 WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6636 )
6637}
6638
6639fn match_parameter(value: &str, exact: bool) -> String {
6640 if exact { value.to_owned() } else { format!("%{value}%") }
6641}
6642
6643fn build_rest_search_body(
6644 query: &PackageQuery,
6645 info: &RestInformation,
6646 semantics: SearchSemantics,
6647) -> Result<JsonValue> {
6648 let mut root = serde_json::Map::new();
6649 root.insert(
6650 "MaximumResults".to_owned(),
6651 JsonValue::from(source_fetch_results(query, semantics) as u64),
6652 );
6653 let exact_match = query.exact || semantics == SearchSemantics::Single;
6654
6655 if let Some(value) = &query.query {
6656 if semantics == SearchSemantics::Single {
6657 let mut filters = vec![
6658 rest_filter("PackageIdentifier", value, true),
6659 rest_filter("PackageName", value, true),
6660 rest_filter("Moniker", value, true),
6661 ];
6662 append_required_rest_filters(&mut filters, info);
6663 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6664 return Ok(JsonValue::Object(root));
6665 }
6666
6667 root.insert(
6668 "Query".to_owned(),
6669 serde_json::json!({
6670 "KeyWord": value,
6671 "MatchType": if exact_match { "Exact" } else { "Substring" },
6672 }),
6673 );
6674 }
6675
6676 let mut filters = Vec::new();
6677 if let Some(value) = &query.id {
6678 filters.push(rest_filter("PackageIdentifier", value, exact_match));
6679 }
6680 if let Some(value) = &query.name {
6681 filters.push(rest_filter("PackageName", value, exact_match));
6682 }
6683 if let Some(value) = &query.moniker {
6684 filters.push(rest_filter("Moniker", value, exact_match));
6685 }
6686 if let Some(value) = &query.tag {
6687 filters.push(rest_filter("Tag", value, true));
6688 }
6689 if let Some(value) = &query.command {
6690 filters.push(rest_filter("Command", value, true));
6691 }
6692 append_required_rest_filters(&mut filters, info);
6693
6694 if !filters.is_empty() {
6695 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6696 }
6697
6698 Ok(JsonValue::Object(root))
6699}
6700
6701fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6702 if info
6703 .required_package_match_fields
6704 .iter()
6705 .any(|field| field.eq_ignore_ascii_case("Market"))
6706 {
6707 filters.push(rest_filter("Market", &default_market(), true));
6708 }
6709}
6710
6711fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6712 serde_json::json!({
6713 "PackageMatchField": field,
6714 "RequestMatch": {
6715 "KeyWord": value,
6716 "MatchType": if exact { "Exact" } else { "Substring" },
6717 }
6718 })
6719}
6720
6721fn max_results(query: &PackageQuery) -> usize {
6722 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6723}
6724
6725fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6726 match semantics {
6727 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6728 SearchSemantics::Single => max_results(query),
6729 }
6730}
6731
6732fn infer_preindexed_match_criteria_v2(
6733 connection: &Connection,
6734 row: &V2SearchRow,
6735 query: &PackageQuery,
6736 semantics: SearchSemantics,
6737) -> Result<Option<String>> {
6738 infer_match_criteria(
6739 &row.id,
6740 &row.name,
6741 row.moniker.as_deref(),
6742 query,
6743 semantics,
6744 |value| {
6745 find_mapped_value_v2(
6746 connection,
6747 "tags2",
6748 "tags2_map",
6749 "tag",
6750 row.package_rowid,
6751 value,
6752 query.exact,
6753 )
6754 },
6755 |value| {
6756 find_mapped_value_v2(
6757 connection,
6758 "commands2",
6759 "commands2_map",
6760 "command",
6761 row.package_rowid,
6762 value,
6763 query.exact,
6764 )
6765 },
6766 )
6767}
6768
6769fn infer_preindexed_match_criteria_v1(
6770 connection: &Connection,
6771 row: &V1SearchRow,
6772 query: &PackageQuery,
6773 semantics: SearchSemantics,
6774) -> Result<Option<String>> {
6775 infer_match_criteria(
6776 &row.id,
6777 &row.name,
6778 row.moniker.as_deref(),
6779 query,
6780 semantics,
6781 |value| {
6782 find_mapped_value_v1(
6783 connection,
6784 "tags",
6785 "tags_map",
6786 "tag",
6787 row.manifest_rowid,
6788 value,
6789 query.exact,
6790 )
6791 },
6792 |value| {
6793 find_mapped_value_v1(
6794 connection,
6795 "commands",
6796 "commands_map",
6797 "command",
6798 row.manifest_rowid,
6799 value,
6800 query.exact,
6801 )
6802 },
6803 )
6804}
6805
6806fn infer_match_criteria<FTag, FCommand>(
6807 id: &str,
6808 name: &str,
6809 moniker: Option<&str>,
6810 query: &PackageQuery,
6811 semantics: SearchSemantics,
6812 find_tag: FTag,
6813 find_command: FCommand,
6814) -> Result<Option<String>>
6815where
6816 FTag: FnOnce(&str) -> Result<Option<String>>,
6817 FCommand: FnOnce(&str) -> Result<Option<String>>,
6818{
6819 if let Some(value) = &query.tag {
6820 return Ok(Some(format_match_criteria("Tag", value)));
6821 }
6822 if let Some(value) = &query.command {
6823 return Ok(Some(format_match_criteria("Command", value)));
6824 }
6825 if let Some(value) = &query.moniker {
6826 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6827 }
6828 if semantics == SearchSemantics::Single {
6829 return Ok(None);
6830 }
6831 if let Some(value) = &query.query {
6832 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6833 return Ok(None);
6834 }
6835 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6836 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6837 }
6838 if let Some(tag) = find_tag(value)? {
6839 return Ok(Some(format_match_criteria("Tag", &tag)));
6840 }
6841 if let Some(command) = find_command(value)? {
6842 return Ok(Some(format_match_criteria("Command", &command)));
6843 }
6844 }
6845 Ok(None)
6846}
6847
6848fn find_mapped_value_v2(
6849 connection: &Connection,
6850 table_name: &str,
6851 map_table_name: &str,
6852 value_name: &str,
6853 package_rowid: i64,
6854 query: &str,
6855 exact: bool,
6856) -> Result<Option<String>> {
6857 let sql = format!(
6858 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6859 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6860 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6861 );
6862 let values = query_rows(
6863 connection,
6864 &sql,
6865 vec![
6866 SqlValue::Integer(package_rowid),
6867 SqlValue::Text(match_parameter(query, exact)),
6868 ],
6869 |row| row_string(row, 0),
6870 )?;
6871 Ok(select_best_text_match(values, query, exact))
6872}
6873
6874fn find_mapped_value_v1(
6875 connection: &Connection,
6876 table_name: &str,
6877 map_table_name: &str,
6878 value_name: &str,
6879 manifest_rowid: i64,
6880 query: &str,
6881 exact: bool,
6882) -> Result<Option<String>> {
6883 let sql = format!(
6884 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6885 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6886 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6887 );
6888 let values = query_rows(
6889 connection,
6890 &sql,
6891 vec![
6892 SqlValue::Integer(manifest_rowid),
6893 SqlValue::Text(match_parameter(query, exact)),
6894 ],
6895 |row| row_string(row, 0),
6896 )?;
6897 Ok(select_best_text_match(values, query, exact))
6898}
6899
6900fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6901 if let Some(value) = &query.tag {
6902 return Some(format_match_criteria("Tag", value));
6903 }
6904 if let Some(value) = &query.command {
6905 return Some(format_match_criteria("Command", value));
6906 }
6907 if let Some(value) = &query.moniker {
6908 return Some(format_match_criteria("Moniker", value));
6909 }
6910 if semantics == SearchSemantics::Single {
6911 return None;
6912 }
6913 let value = query.query.as_deref()?;
6914 if json_string(item, "PackageIdentifier")
6915 .as_deref()
6916 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6917 || json_string(item, "PackageName")
6918 .as_deref()
6919 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6920 {
6921 return None;
6922 }
6923 json_string(item, "Moniker")
6924 .filter(|candidate| matches_text(candidate, value, query.exact))
6925 .map(|candidate| format_match_criteria("Moniker", &candidate))
6926}
6927
6928fn format_match_criteria(field: &str, value: &str) -> String {
6929 format!("{field}: {value}")
6930}
6931
6932fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6933 if exact {
6934 candidate.eq_ignore_ascii_case(query)
6935 } else {
6936 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6937 }
6938}
6939
6940fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6941 let mut score = 0;
6942
6943 if let Some(value) = &query.query {
6944 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6945 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6946 if let Some(moniker) = candidate.moniker.as_deref() {
6947 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6948 }
6949 }
6950
6951 if let Some(value) = &query.id {
6952 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6953 }
6954 if let Some(value) = &query.name {
6955 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6956 }
6957 if let Some(value) = &query.moniker
6958 && let Some(moniker) = candidate.moniker.as_deref()
6959 {
6960 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6961 }
6962 if let Some(value) = &query.tag
6963 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6964 {
6965 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6966 }
6967 if let Some(value) = &query.command
6968 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6969 {
6970 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6971 }
6972
6973 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6974 score += 5;
6975 }
6976 if search_match_has_unknown_version(candidate) {
6977 score = score.saturating_sub(10);
6978 }
6979
6980 score
6981}
6982
6983fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6984 candidate
6985 .version
6986 .as_deref()
6987 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6988}
6989
6990fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6999 let mut document = match documents {
7000 JsonValue::Object(map) => map.clone(),
7001 _ => serde_json::Map::new(),
7002 };
7003
7004 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7005 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7006 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7007 if !manifest.channel.is_empty() {
7008 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7009 } else {
7010 document.remove("Channel");
7011 }
7012
7013 let mut single_installer = serde_json::Map::new();
7016 if let Some(arch) = installer.architecture.as_ref() {
7017 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7018 }
7019 if let Some(installer_type) = installer.installer_type.as_ref() {
7020 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7021 }
7022 if let Some(url) = installer.url.as_ref() {
7023 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7024 }
7025 if let Some(sha) = installer.sha256.as_ref() {
7026 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7027 }
7028 if let Some(scope) = installer.scope.as_ref() {
7029 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7030 }
7031 if let Some(locale) = installer.locale.as_ref() {
7032 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7033 }
7034 if let Some(product_code) = installer.product_code.as_ref() {
7035 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7036 }
7037 if let Some(release_date) = installer.release_date.as_ref() {
7038 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7039 }
7040
7041 document.insert(
7042 "Installers".to_owned(),
7043 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7044 );
7045 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7046
7047 let value = JsonValue::Object(document);
7048 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7049}
7050
7051fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7056 let mut filename = derive_download_filename_stem(manifest, installer);
7057 filename.push_str(".yaml");
7058 filename
7059}
7060
7061fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7062 let mut stem = sanitize_filename_component(&manifest.name);
7063 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7064 stem.push('_');
7065 stem.push_str(&sanitize_filename_component(&manifest.version));
7066 }
7067 if let Some(scope) = installer.scope.as_deref() {
7068 stem.push('_');
7069 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7070 }
7071 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7072 stem.push('_');
7073 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7074
7075 let installer_type = installer.installer_type.as_deref().unwrap_or("");
7076 if !installer_type.is_empty() {
7077 stem.push('_');
7078 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7079 }
7080 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7081 stem.push('_');
7082 stem.push_str(&sanitize_filename_component(locale));
7083 }
7084
7085 stem
7086}
7087
7088fn capitalize_first(value: &str) -> String {
7089 let mut chars = value.chars();
7090 match chars.next() {
7091 Some(first) => {
7092 let mut out = first.to_ascii_uppercase().to_string();
7093 out.push_str(&chars.as_str().to_ascii_lowercase());
7094 out
7095 }
7096 None => String::new(),
7097 }
7098}
7099
7100fn format_architecture(value: &str) -> String {
7101 match value.to_ascii_lowercase().as_str() {
7102 "x64" => "X64".to_owned(),
7103 "x86" => "X86".to_owned(),
7104 "arm" => "Arm".to_owned(),
7105 "arm64" => "Arm64".to_owned(),
7106 "neutral" => "Neutral".to_owned(),
7107 other => capitalize_first(other),
7108 }
7109}
7110
7111fn sanitize_filename_component(value: &str) -> String {
7112 value
7113 .chars()
7114 .map(|ch| match ch {
7115 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7116 ch if ch.is_control() => '_',
7117 ch => ch,
7118 })
7119 .collect()
7120}
7121
7122fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7123 if manifest_path.is_file() {
7124 return Ok(manifest_path.to_path_buf());
7125 }
7126
7127 if !manifest_path.is_dir() {
7128 bail!("Manifest path not found: {}", manifest_path.display());
7129 }
7130
7131 let candidate = fs::read_dir(manifest_path)?
7132 .filter_map(|entry| entry.ok())
7133 .map(|entry| entry.path())
7134 .filter(|path| {
7135 path.extension()
7136 .and_then(|value| value.to_str())
7137 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7138 })
7139 .min();
7140
7141 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7142}
7143
7144fn score_text_match(
7145 candidate: &str,
7146 query: &str,
7147 exact: bool,
7148 exact_score: usize,
7149 prefix_score: usize,
7150 substring_score: usize,
7151) -> usize {
7152 if candidate.eq_ignore_ascii_case(query) {
7153 return exact_score;
7154 }
7155 if exact {
7156 return 0;
7157 }
7158
7159 let candidate_lower = candidate.to_ascii_lowercase();
7160 let query_lower = query.to_ascii_lowercase();
7161 if candidate_lower.starts_with(&query_lower) {
7162 prefix_score
7163 } else if candidate_lower.contains(&query_lower) {
7164 substring_score
7165 } else {
7166 0
7167 }
7168}
7169
7170fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7171where
7172 I: IntoIterator<Item = String>,
7173{
7174 values
7175 .into_iter()
7176 .filter_map(|candidate| {
7177 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7178 (score > 0).then_some((score, candidate))
7179 })
7180 .max_by(|(left_score, left_value), (right_score, right_value)| {
7181 left_score
7182 .cmp(right_score)
7183 .then_with(|| right_value.len().cmp(&left_value.len()))
7184 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7185 })
7186 .map(|(_, candidate)| candidate)
7187}
7188
7189fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7190 let (field, value) = criteria.split_once(": ")?;
7191 Some((field, value))
7192}
7193
7194fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7195 let versions = item
7196 .get("Versions")
7197 .and_then(JsonValue::as_array)
7198 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7199 let mut result = Vec::new();
7200 for version in versions {
7201 let value = json_string(version, "PackageVersion")
7202 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7203 let channel = json_string(version, "Channel").unwrap_or_default();
7204 result.push(VersionKey {
7205 version: value,
7206 channel,
7207 });
7208 }
7209 Ok(result)
7210}
7211
7212fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7213 let mut merged = YamlMapping::new();
7214 let mut documents = Vec::new();
7215 for document in serde_yaml::Deserializer::from_slice(bytes) {
7216 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7217 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7218 if let Some(mapping) = value.as_mapping() {
7219 merge_yaml_mapping(&mut merged, mapping);
7220 }
7221 }
7222
7223 let id = yaml_string_from_root(&merged, "PackageIdentifier")
7224 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7225 let version =
7226 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7227 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7228
7229 let installers = parse_yaml_installers(&merged);
7230 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7231 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7232
7233 Ok((
7234 Manifest {
7235 id,
7236 name,
7237 version,
7238 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7239 publisher: yaml_localized_string(&merged, "Publisher"),
7240 description: yaml_localized_string(&merged, "Description")
7241 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7242 moniker: yaml_string_from_root(&merged, "Moniker"),
7243 package_url: yaml_localized_string(&merged, "PackageUrl"),
7244 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7245 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7246 license: yaml_localized_string(&merged, "License"),
7247 license_url: yaml_localized_string(&merged, "LicenseUrl"),
7248 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7249 author: yaml_localized_string(&merged, "Author"),
7250 copyright: yaml_localized_string(&merged, "Copyright"),
7251 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7252 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7253 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7254 tags: yaml_string_list(&merged, "Tags"),
7255 agreements: yaml_agreement_list(&merged),
7256 package_dependencies: yaml_package_dependencies(&merged),
7257 documentation: yaml_documentation_list(&merged),
7258 icons: yaml_icon_list(&merged),
7259 installers,
7260 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7261 },
7262 collapse_structured_document(&JsonValue::Array(documents)),
7263 ))
7264}
7265
7266fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7267 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7268}
7269
7270fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7271 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7272 let data = root
7273 .get("Data")
7274 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7275 let versions = data
7276 .get("Versions")
7277 .and_then(JsonValue::as_array)
7278 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7279 let selected = versions
7280 .iter()
7281 .find(|item| {
7282 json_string(item, "PackageVersion").as_deref() == Some(version)
7283 && json_string(item, "Channel").unwrap_or_default() == channel
7284 })
7285 .or_else(|| versions.first())
7286 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7287
7288 let default_locale = selected
7289 .get("DefaultLocale")
7290 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7291 let package_default_locale = data.get("DefaultLocale");
7292 let name = json_string(default_locale, "PackageName")
7293 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7294 let installer_switch_defaults = json_installer_switches(selected);
7295 let top_platforms = json_string_list(selected, "Platform");
7296 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7297 let top_installer_type = json_string(selected, "InstallerType");
7298 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7299 let top_nested_installer_files = json_nested_installer_files(selected);
7300
7301 let installers = selected
7302 .get("Installers")
7303 .and_then(JsonValue::as_array)
7304 .map(|items| {
7305 items
7306 .iter()
7307 .map(|item| Installer {
7308 platforms: {
7309 let platforms = json_string_list(item, "Platform");
7310 if platforms.is_empty() {
7311 top_platforms.clone()
7312 } else {
7313 platforms
7314 }
7315 },
7316 minimum_os_version: json_string(item, "MinimumOSVersion")
7317 .or_else(|| top_minimum_os_version.clone()),
7318 architecture: json_string(item, "Architecture"),
7319 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7320 nested_installer_type: json_string(item, "NestedInstallerType")
7321 .or_else(|| top_nested_installer_type.clone()),
7322 nested_installer_files: {
7323 let files = json_nested_installer_files(item);
7324 if files.is_empty() {
7325 top_nested_installer_files.clone()
7326 } else {
7327 files
7328 }
7329 },
7330 url: json_string(item, "InstallerUrl"),
7331 sha256: json_string(item, "InstallerSha256"),
7332 product_code: json_string(item, "ProductCode"),
7333 locale: json_string(item, "InstallerLocale"),
7334 scope: json_string(item, "Scope"),
7335 release_date: json_string(item, "ReleaseDate"),
7336 package_family_name: json_string(item, "PackageFamilyName"),
7337 upgrade_code: json_string(item, "UpgradeCode"),
7338 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7339 commands: json_string_list(item, "Commands"),
7340 package_dependencies: json_package_dependencies(item),
7341 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7342 })
7343 .collect::<Vec<_>>()
7344 })
7345 .unwrap_or_default();
7346 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7347 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7348 let mut icons = json_icon_list(default_locale);
7349 if icons.is_empty() {
7350 icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7351 }
7352
7353 let manifest = Manifest {
7354 id: package_id.to_owned(),
7355 name,
7356 version: version.to_owned(),
7357 channel: channel.to_owned(),
7358 publisher: json_string(default_locale, "Publisher"),
7359 description: json_string(default_locale, "Description")
7360 .or_else(|| json_string(default_locale, "ShortDescription")),
7361 moniker: json_string(default_locale, "Moniker"),
7362 package_url: json_string(default_locale, "PackageUrl"),
7363 publisher_url: json_string(default_locale, "PublisherUrl"),
7364 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7365 license: json_string(default_locale, "License"),
7366 license_url: json_string(default_locale, "LicenseUrl"),
7367 privacy_url: json_string(default_locale, "PrivacyUrl"),
7368 author: json_string(default_locale, "Author"),
7369 copyright: json_string(default_locale, "Copyright"),
7370 copyright_url: json_string(default_locale, "CopyrightUrl"),
7371 release_notes: json_string(default_locale, "ReleaseNotes"),
7372 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7373 tags: json_string_list(default_locale, "Tags"),
7374 agreements: json_agreement_list(default_locale),
7375 package_dependencies: json_package_dependencies(selected),
7376 documentation: json_documentation_list(default_locale),
7377 icons,
7378 installers,
7379 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7380 };
7381
7382 Ok((
7383 manifest,
7384 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7385 ))
7386}
7387
7388fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7389 match document {
7390 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7391 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7392 }
7393 JsonValue::Array(documents) => merge_manifest_documents(documents),
7394 JsonValue::Object(_) if is_merged_manifest_document(document) => {
7395 merge_manifest_documents(&split_merged_manifest_document(document))
7396 }
7397 _ => document.clone(),
7398 }
7399}
7400
7401fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7402 documents.iter().map(collapse_structured_document).collect()
7403}
7404
7405fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7406 let Some(root) = document.as_object_mut() else {
7407 return document;
7408 };
7409
7410 root.insert(
7411 "IconUrl".to_owned(),
7412 manifest
7413 .icon_url()
7414 .map(|url| JsonValue::String(url.to_owned()))
7415 .unwrap_or(JsonValue::Null),
7416 );
7417 root.insert(
7418 "Icon".to_owned(),
7419 manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7420 );
7421 root.entry("Icons".to_owned())
7422 .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7423
7424 document
7425}
7426
7427fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7428 serde_json::to_value(icon).expect("package icon serialization cannot fail")
7429}
7430
7431fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7432 let version = documents.iter().find(|document| {
7433 document
7434 .get("ManifestType")
7435 .and_then(JsonValue::as_str)
7436 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7437 });
7438 let default_locale = documents.iter().find(|document| {
7439 document
7440 .get("ManifestType")
7441 .and_then(JsonValue::as_str)
7442 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7443 });
7444 let installer = documents.iter().find(|document| {
7445 document
7446 .get("ManifestType")
7447 .and_then(JsonValue::as_str)
7448 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7449 });
7450
7451 let mut singleton = JsonMap::new();
7452 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7453 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7454 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7455
7456 if !singleton.contains_key("PackageLocale")
7457 && let Some(package_locale) = default_locale
7458 .and_then(|document| json_string(document, "PackageLocale"))
7459 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7460 {
7461 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7462 }
7463
7464 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7465 singleton.insert(
7466 "ManifestVersion".to_owned(),
7467 JsonValue::String(
7468 installer
7469 .and_then(|document| json_string(document, "ManifestVersion"))
7470 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7471 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7472 .unwrap_or_else(|| "1.10.0".to_owned()),
7473 ),
7474 );
7475 singleton.remove("DefaultLocale");
7476
7477 JsonValue::Object(singleton)
7478}
7479
7480fn is_merged_manifest_document(document: &JsonValue) -> bool {
7481 document
7482 .get("ManifestType")
7483 .and_then(JsonValue::as_str)
7484 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7485}
7486
7487fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7488 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7489 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7490 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7491 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7492
7493 let version_document = serde_json::json!({
7494 "PackageIdentifier": package_identifier,
7495 "PackageVersion": package_version,
7496 "DefaultLocale": package_locale,
7497 "ManifestType": "version",
7498 "ManifestVersion": manifest_version,
7499 });
7500
7501 let mut default_locale_document = project_manifest_document(
7502 merged,
7503 &[
7504 "PackageIdentifier",
7505 "PackageVersion",
7506 "PackageLocale",
7507 "Publisher",
7508 "PublisherUrl",
7509 "PublisherSupportUrl",
7510 "PrivacyUrl",
7511 "Author",
7512 "PackageName",
7513 "PackageUrl",
7514 "License",
7515 "LicenseUrl",
7516 "Copyright",
7517 "CopyrightUrl",
7518 "ShortDescription",
7519 "Description",
7520 "Moniker",
7521 "Tags",
7522 "Agreements",
7523 "ReleaseNotes",
7524 "ReleaseNotesUrl",
7525 "PurchaseUrl",
7526 "InstallationNotes",
7527 "Documentations",
7528 "Icons",
7529 ],
7530 );
7531 default_locale_document.insert(
7532 "PackageIdentifier".to_owned(),
7533 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7534 );
7535 default_locale_document.insert(
7536 "PackageVersion".to_owned(),
7537 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7538 );
7539 default_locale_document.insert(
7540 "PackageLocale".to_owned(),
7541 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7542 );
7543 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7544 default_locale_document.insert(
7545 "ManifestVersion".to_owned(),
7546 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7547 );
7548
7549 let mut installer_document = project_manifest_document(
7550 merged,
7551 &[
7552 "PackageIdentifier",
7553 "PackageVersion",
7554 "Channel",
7555 "InstallerLocale",
7556 "Platform",
7557 "MinimumOSVersion",
7558 "InstallerType",
7559 "NestedInstallerType",
7560 "NestedInstallerFiles",
7561 "Scope",
7562 "InstallModes",
7563 "InstallerSwitches",
7564 "InstallerSuccessCodes",
7565 "ExpectedReturnCodes",
7566 "UpgradeBehavior",
7567 "Commands",
7568 "Protocols",
7569 "FileExtensions",
7570 "Dependencies",
7571 "PackageFamilyName",
7572 "ProductCode",
7573 "Capabilities",
7574 "RestrictedCapabilities",
7575 "Markets",
7576 "InstallerAbortsTerminal",
7577 "ReleaseDate",
7578 "InstallLocationRequired",
7579 "RequireExplicitUpgrade",
7580 "DisplayInstallWarnings",
7581 "UnsupportedOSArchitectures",
7582 "UnsupportedArguments",
7583 "AppsAndFeaturesEntries",
7584 "ElevationRequirement",
7585 "InstallationMetadata",
7586 "DownloadCommandProhibited",
7587 "RepairBehavior",
7588 "ArchiveBinariesDependOnPath",
7589 "Authentication",
7590 "Installers",
7591 ],
7592 );
7593 installer_document.insert(
7594 "PackageIdentifier".to_owned(),
7595 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7596 );
7597 installer_document.insert(
7598 "PackageVersion".to_owned(),
7599 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7600 );
7601 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7602 installer_document.insert(
7603 "ManifestVersion".to_owned(),
7604 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7605 );
7606
7607 vec![
7608 version_document,
7609 JsonValue::Object(default_locale_document),
7610 JsonValue::Object(installer_document),
7611 ]
7612}
7613
7614fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7615 let mut result = JsonMap::new();
7616 if let Some(source) = source.as_object() {
7617 for key in keys {
7618 if let Some(value) = source.get(*key) {
7619 result.insert((*key).to_owned(), value.clone());
7620 }
7621 }
7622 }
7623
7624 result
7625}
7626
7627fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7628 if let Some(source) = source.and_then(JsonValue::as_object) {
7629 for key in keys {
7630 if let Some(value) = source.get(*key) {
7631 target.insert((*key).to_owned(), value.clone());
7632 }
7633 }
7634 }
7635}
7636
7637fn copy_all_manifest_keys_except(
7638 target: &mut JsonMap<String, JsonValue>,
7639 source: Option<&JsonValue>,
7640 excluded_keys: &[&str],
7641) {
7642 if let Some(source) = source.and_then(JsonValue::as_object) {
7643 for (key, value) in source {
7644 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7645 target.insert(key.clone(), value.clone());
7646 }
7647 }
7648 }
7649}
7650
7651fn build_rest_manifest_documents(
7652 data: &JsonValue,
7653 installer_source: &JsonValue,
7654 default_locale: &JsonValue,
7655 package_id: &str,
7656 version: &str,
7657 channel: &str,
7658) -> JsonValue {
7659 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7660 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7661 let manifest_version = json_string(installer_source, "ManifestVersion")
7662 .or_else(|| json_string(default_locale, "ManifestVersion"))
7663 .or_else(|| json_string(data, "ManifestVersion"))
7664 .unwrap_or_else(|| "1.10.0".to_owned());
7665
7666 let mut version_document = JsonMap::new();
7667 version_document.insert(
7668 "PackageIdentifier".to_owned(),
7669 JsonValue::String(package_identifier.clone()),
7670 );
7671 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7672 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7673 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7674 version_document.insert(
7675 "ManifestVersion".to_owned(),
7676 JsonValue::String(manifest_version.clone()),
7677 );
7678
7679 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7680 default_locale_document.insert(
7681 "PackageIdentifier".to_owned(),
7682 JsonValue::String(package_identifier.clone()),
7683 );
7684 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7685 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7686 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7687 default_locale_document.insert(
7688 "ManifestVersion".to_owned(),
7689 JsonValue::String(manifest_version.clone()),
7690 );
7691
7692 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7693 installer_document.remove("DefaultLocale");
7694 installer_document.remove("Locales");
7695 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7696 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7697 if !channel.is_empty() {
7698 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7699 }
7700 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7701 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7702
7703 let documents = vec![
7704 JsonValue::Object(version_document),
7705 JsonValue::Object(default_locale_document),
7706 JsonValue::Object(installer_document),
7707 ];
7708
7709 merge_manifest_documents(&documents)
7710}
7711
7712fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7713 let base = installer_defaults(root);
7714 let base_switches = yaml_installer_switches(root);
7715 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7716 let installers = items
7717 .iter()
7718 .filter_map(YamlValue::as_mapping)
7719 .map(|item| {
7720 let mut merged = base.clone();
7721 merge_yaml_mapping(&mut merged, item);
7722 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7723 installer_from_yaml(&merged, switches)
7724 })
7725 .collect::<Vec<_>>();
7726 if !installers.is_empty() {
7727 return installers;
7728 }
7729 }
7730
7731 if base.is_empty() {
7732 Vec::new()
7733 } else {
7734 vec![installer_from_yaml(&base, base_switches)]
7735 }
7736}
7737
7738fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7739 let keys = [
7740 "Architecture",
7741 "InstallerType",
7742 "NestedInstallerType",
7743 "NestedInstallerFiles",
7744 "InstallerUrl",
7745 "InstallerSha256",
7746 "ProductCode",
7747 "InstallerLocale",
7748 "Platform",
7749 "MinimumOSVersion",
7750 "Scope",
7751 "ReleaseDate",
7752 "PackageFamilyName",
7753 "UpgradeCode",
7754 "Commands",
7755 ];
7756 let mut defaults = YamlMapping::new();
7757 for key in keys {
7758 if let Some(value) = root.get(YamlValue::from(key)) {
7759 defaults.insert(YamlValue::from(key), value.clone());
7760 }
7761 }
7762 defaults
7763}
7764
7765fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7766 Installer {
7767 architecture: yaml_string(root, "Architecture"),
7768 installer_type: yaml_string(root, "InstallerType"),
7769 nested_installer_type: yaml_string(root, "NestedInstallerType"),
7770 nested_installer_files: yaml_nested_installer_files(root),
7771 url: yaml_string(root, "InstallerUrl"),
7772 sha256: yaml_string(root, "InstallerSha256"),
7773 product_code: yaml_string(root, "ProductCode"),
7774 locale: yaml_string(root, "InstallerLocale"),
7775 scope: yaml_string(root, "Scope"),
7776 release_date: yaml_string(root, "ReleaseDate"),
7777 package_family_name: yaml_string(root, "PackageFamilyName"),
7778 upgrade_code: yaml_string(root, "UpgradeCode"),
7779 platforms: yaml_string_list(root, "Platform"),
7780 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
7781 switches,
7782 commands: yaml_string_list(root, "Commands"),
7783 package_dependencies: yaml_package_dependencies(root),
7784 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
7785 }
7786}
7787
7788fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
7791 let Some(value) = root.get(YamlValue::from(key)) else {
7792 return false;
7793 };
7794 match value {
7795 YamlValue::Bool(b) => *b,
7796 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
7797 _ => false,
7798 }
7799}
7800
7801fn json_bool(value: &JsonValue, key: &str) -> bool {
7802 match value.get(key) {
7803 Some(JsonValue::Bool(b)) => *b,
7804 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
7805 _ => false,
7806 }
7807}
7808
7809fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7810 let mapping = root
7811 .get(YamlValue::from("InstallerSwitches"))
7812 .or_else(|| root.get(YamlValue::from("Switches")))
7813 .and_then(YamlValue::as_mapping);
7814 yaml_installer_switches_from_mapping(mapping)
7815}
7816
7817fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7818 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7819
7820 InstallerSwitches {
7821 silent: string("Silent"),
7822 silent_with_progress: string("SilentWithProgress"),
7823 interactive: string("Interactive"),
7824 custom: string("Custom"),
7825 log: string("Log"),
7826 install_location: string("InstallLocation"),
7827 }
7828}
7829
7830fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7831 yaml_string(root, key).or_else(|| {
7832 root.get(YamlValue::from("DefaultLocale"))
7833 .and_then(YamlValue::as_mapping)
7834 .and_then(|mapping| yaml_string(mapping, key))
7835 })
7836}
7837
7838fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7839 yaml_string(root, key)
7840}
7841
7842fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7843 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7844}
7845
7846fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7847 match value {
7848 YamlValue::Null => None,
7849 YamlValue::Bool(value) => Some(value.to_string()),
7850 YamlValue::Number(value) => Some(value.to_string()),
7851 YamlValue::String(value) => Some(value.clone()),
7852 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7853 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7854 }
7855}
7856
7857fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7858 root.get(YamlValue::from(key))
7859 .and_then(YamlValue::as_sequence)
7860 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7861 .unwrap_or_default()
7862}
7863
7864fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7865 root.get(YamlValue::from("Documentations"))
7866 .and_then(YamlValue::as_sequence)
7867 .map(|items| {
7868 items
7869 .iter()
7870 .filter_map(YamlValue::as_mapping)
7871 .filter_map(|item| {
7872 let url = yaml_string(item, "DocumentUrl")?;
7873 Some(Documentation {
7874 label: yaml_string(item, "DocumentLabel"),
7875 url,
7876 })
7877 })
7878 .collect()
7879 })
7880 .unwrap_or_default()
7881}
7882
7883fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7884 root.get(YamlValue::from("Icons"))
7885 .and_then(YamlValue::as_sequence)
7886 .map(|items| {
7887 items
7888 .iter()
7889 .filter_map(YamlValue::as_mapping)
7890 .filter_map(|item| {
7891 let icon = PackageIcon {
7892 icon_url: yaml_string(item, "IconUrl"),
7893 icon_file_type: yaml_string(item, "IconFileType"),
7894 icon_resolution: yaml_string(item, "IconResolution"),
7895 icon_theme: yaml_string(item, "IconTheme"),
7896 icon_sha256: yaml_string(item, "IconSha256"),
7897 };
7898 (!icon.is_empty()).then_some(icon)
7899 })
7900 .collect()
7901 })
7902 .unwrap_or_default()
7903}
7904
7905fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7906 root.get(YamlValue::from("Agreements"))
7907 .and_then(YamlValue::as_sequence)
7908 .map(|items| {
7909 items
7910 .iter()
7911 .filter_map(YamlValue::as_mapping)
7912 .map(|item| PackageAgreement {
7913 label: yaml_string(item, "AgreementLabel"),
7914 text: yaml_string(item, "Agreement"),
7915 url: yaml_string(item, "AgreementUrl"),
7916 })
7917 .collect()
7918 })
7919 .unwrap_or_default()
7920}
7921
7922fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7923 root.get(YamlValue::from("Dependencies"))
7924 .and_then(YamlValue::as_mapping)
7925 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7926 .and_then(YamlValue::as_sequence)
7927 .map(|items| {
7928 items
7929 .iter()
7930 .filter_map(YamlValue::as_mapping)
7931 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7932 .collect()
7933 })
7934 .unwrap_or_default()
7935}
7936
7937fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7938 root.get(YamlValue::from("NestedInstallerFiles"))
7939 .and_then(YamlValue::as_sequence)
7940 .map(|items| {
7941 items
7942 .iter()
7943 .filter_map(YamlValue::as_mapping)
7944 .filter_map(|item| {
7945 let relative = yaml_string(item, "RelativeFilePath")?;
7946 Some(NestedInstallerFile {
7947 relative_file_path: relative,
7948 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7949 })
7950 })
7951 .collect()
7952 })
7953 .unwrap_or_default()
7954}
7955
7956fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7957 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7958}
7959
7960fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7961 value
7962 .get(key)
7963 .and_then(JsonValue::as_array)
7964 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7965 .unwrap_or_default()
7966}
7967
7968fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7969 value
7970 .get("Documentations")
7971 .and_then(JsonValue::as_array)
7972 .map(|items| {
7973 items
7974 .iter()
7975 .filter_map(|item| {
7976 let url = json_string(item, "DocumentUrl")?;
7977 Some(Documentation {
7978 label: json_string(item, "DocumentLabel"),
7979 url,
7980 })
7981 })
7982 .collect()
7983 })
7984 .unwrap_or_default()
7985}
7986
7987fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
7988 value
7989 .get("Icons")
7990 .and_then(JsonValue::as_array)
7991 .map(|items| {
7992 items
7993 .iter()
7994 .filter_map(|item| {
7995 let icon = PackageIcon {
7996 icon_url: json_string(item, "IconUrl"),
7997 icon_file_type: json_string(item, "IconFileType"),
7998 icon_resolution: json_string(item, "IconResolution"),
7999 icon_theme: json_string(item, "IconTheme"),
8000 icon_sha256: json_string(item, "IconSha256"),
8001 };
8002 (!icon.is_empty()).then_some(icon)
8003 })
8004 .collect()
8005 })
8006 .unwrap_or_default()
8007}
8008
8009fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8010 value
8011 .get("Agreements")
8012 .and_then(JsonValue::as_array)
8013 .map(|items| {
8014 items
8015 .iter()
8016 .map(|item| PackageAgreement {
8017 label: json_string(item, "AgreementLabel"),
8018 text: json_string(item, "Agreement"),
8019 url: json_string(item, "AgreementUrl"),
8020 })
8021 .collect()
8022 })
8023 .unwrap_or_default()
8024}
8025
8026fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8027 value
8028 .get("Dependencies")
8029 .and_then(JsonValue::as_object)
8030 .and_then(|deps| deps.get("PackageDependencies"))
8031 .and_then(JsonValue::as_array)
8032 .map(|items| {
8033 items
8034 .iter()
8035 .filter_map(|item| json_string(item, "PackageIdentifier"))
8036 .collect()
8037 })
8038 .unwrap_or_default()
8039}
8040
8041fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8042 value
8043 .get("NestedInstallerFiles")
8044 .and_then(JsonValue::as_array)
8045 .map(|items| {
8046 items
8047 .iter()
8048 .filter_map(|item| {
8049 let relative = json_string(item, "RelativeFilePath")?;
8050 Some(NestedInstallerFile {
8051 relative_file_path: relative,
8052 portable_command_alias: json_string(item, "PortableCommandAlias"),
8053 })
8054 })
8055 .collect()
8056 })
8057 .unwrap_or_default()
8058}
8059
8060fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8061 let switches = value
8062 .get("InstallerSwitches")
8063 .or_else(|| value.get("Switches"))
8064 .and_then(JsonValue::as_object);
8065
8066 let string = |key: &str| {
8067 switches
8068 .and_then(|switches| switches.get(key))
8069 .and_then(JsonValue::as_str)
8070 .map(str::to_owned)
8071 };
8072
8073 InstallerSwitches {
8074 silent: string("Silent"),
8075 silent_with_progress: string("SilentWithProgress"),
8076 interactive: string("Interactive"),
8077 custom: string("Custom"),
8078 log: string("Log"),
8079 install_location: string("InstallLocation"),
8080 }
8081}
8082
8083fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8084 for (key, value) in source {
8085 target.insert(key.clone(), value.clone());
8086 }
8087}
8088
8089fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8090 let reader = Cursor::new(bytes.to_vec());
8091 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8092 let mut file = archive
8093 .by_name(member_name)
8094 .with_context(|| format!("zip payload missing {member_name}"))?;
8095 let mut output = Vec::new();
8096 file.read_to_end(&mut output)
8097 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8098 Ok(output)
8099}
8100
8101fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8102 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8103 return Ok(output);
8104 }
8105
8106 if !bytes.starts_with(b"CK")
8107 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8108 {
8109 return decompress_mszyml_payload(&bytes[offset + 2..]);
8110 }
8111
8112 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8113}
8114
8115fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8116 let mut decoder = flate2::read::DeflateDecoder::new(payload);
8117 let mut output = String::new();
8118 decoder.read_to_string(&mut output)?;
8119 Ok(output)
8120}
8121
8122fn cache_control_max_age(response: &Response) -> u64 {
8123 response
8124 .headers()
8125 .get(reqwest::header::CACHE_CONTROL)
8126 .and_then(|value| value.to_str().ok())
8127 .and_then(|header| {
8128 header.split(',').find_map(|part| {
8129 let trimmed = part.trim();
8130 trimmed
8131 .strip_prefix("max-age=")
8132 .and_then(|value| value.parse::<u64>().ok())
8133 })
8134 })
8135 .unwrap_or(60)
8136}
8137
8138fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8139 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8140 let (major, minor) = major_minor(candidate);
8141 server_versions.iter().any(|version| {
8142 let (server_major, server_minor) = major_minor(version);
8143 server_major == major && server_minor == minor
8144 })
8145 })
8146}
8147
8148fn major_minor(version: &str) -> (String, String) {
8149 let mut parts = version.split('.');
8150 (
8151 parts.next().unwrap_or_default().to_owned(),
8152 parts.next().unwrap_or_default().to_owned(),
8153 )
8154}
8155
8156fn default_market() -> String {
8157 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8158}
8159
8160fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8161 let requested_locale = query.locale.as_deref();
8162 let requested_architecture = query.installer_architecture.as_deref();
8163 let requested_type = query.installer_type.as_deref();
8164 let requested_scope = query.install_scope.as_deref();
8165 let requested_platform = query.platform.as_deref();
8166 let requested_os_version = query.os_version.as_deref();
8167 let system_architecture = current_architecture();
8168 let system_platform = current_platform();
8169 let current_os_version = current_os_version();
8170
8171 installers
8172 .iter()
8173 .enumerate()
8174 .filter(|(_, installer)| {
8175 installer_matches_requested(
8176 installer,
8177 requested_type,
8178 requested_scope,
8179 requested_platform,
8180 requested_os_version,
8181 system_platform,
8182 current_os_version.as_deref(),
8183 )
8184 })
8185 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8186 .max_by_key(|(index, installer)| {
8187 (
8188 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8189 std::cmp::Reverse(*index),
8190 )
8191 })
8192 .map(|(_, installer)| installer)
8193 .cloned()
8194}
8195
8196fn installer_matches_requested(
8197 installer: &Installer,
8198 requested_type: Option<&str>,
8199 requested_scope: Option<&str>,
8200 requested_platform: Option<&str>,
8201 requested_os_version: Option<&str>,
8202 system_platform: Option<&str>,
8203 current_os_version: Option<&str>,
8204) -> bool {
8205 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8206 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8207 && installer_matches_platform(installer, requested_platform, system_platform)
8208 && installer_matches_os_version(installer, requested_os_version, current_os_version)
8209}
8210
8211fn installer_matches_platform(
8212 installer: &Installer,
8213 requested_platform: Option<&str>,
8214 system_platform: Option<&str>,
8215) -> bool {
8216 if installer.platforms.is_empty() {
8217 return true;
8218 }
8219
8220 let Some(effective_platform) = requested_platform.or(system_platform) else {
8221 return true;
8222 };
8223
8224 installer
8225 .platforms
8226 .iter()
8227 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8228}
8229
8230fn installer_matches_os_version(
8231 installer: &Installer,
8232 requested_os_version: Option<&str>,
8233 current_os_version: Option<&str>,
8234) -> bool {
8235 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8236 return true;
8237 };
8238
8239 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8240 return true;
8241 };
8242
8243 match (
8244 parse_dotted_version(minimum_os_version),
8245 parse_dotted_version(actual_os_version),
8246 ) {
8247 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8248 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8249 }
8250}
8251
8252fn installer_matches_architecture(
8253 installer: &Installer,
8254 requested_architecture: Option<&str>,
8255 system_architecture: &str,
8256) -> bool {
8257 let Some(architecture) = installer.architecture.as_deref() else {
8258 return true;
8259 };
8260
8261 let architecture = architecture.to_ascii_lowercase();
8262 if let Some(requested) = requested_architecture {
8263 return architecture.eq_ignore_ascii_case(requested);
8264 }
8265
8266 preferred_architectures(system_architecture)
8267 .iter()
8268 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8269}
8270
8271fn installer_rank(
8272 installer: &Installer,
8273 requested_locale: Option<&str>,
8274 requested_architecture: Option<&str>,
8275 system_architecture: &str,
8276) -> (i32, i32, i32) {
8277 let architecture_rank = architecture_rank(
8278 installer.architecture.as_deref(),
8279 requested_architecture.unwrap_or(system_architecture),
8280 requested_architecture.is_some(),
8281 system_architecture,
8282 );
8283 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8284 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8285 (architecture_rank, locale_rank, command_rank)
8286}
8287
8288fn architecture_rank(
8289 installer_architecture: Option<&str>,
8290 preferred_architecture: &str,
8291 strict: bool,
8292 system_architecture: &str,
8293) -> i32 {
8294 let Some(value) = installer_architecture else {
8295 return 0;
8296 };
8297 if value.eq_ignore_ascii_case(preferred_architecture) {
8298 return 5;
8299 }
8300 if value.eq_ignore_ascii_case("neutral") {
8301 return 4;
8302 }
8303 if strict {
8304 return -1;
8305 }
8306
8307 preferred_architectures(system_architecture)
8308 .iter()
8309 .rev()
8310 .position(|candidate| value.eq_ignore_ascii_case(candidate))
8311 .and_then(|index| i32::try_from(index).ok())
8312 .and_then(|index| index.checked_add(1))
8313 .unwrap_or(-1)
8314}
8315
8316fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8320 if manifest.installers.is_empty() {
8321 return true;
8322 }
8323 let allowed = preferred_architectures(current_architecture());
8324 manifest.installers.iter().any(|installer| {
8325 let arch = installer.architecture.as_deref().unwrap_or("neutral");
8326 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8327 })
8328}
8329
8330fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8331 match system_architecture {
8332 "arm64" => &["arm64", "neutral", "x64", "x86"],
8333 "x64" => &["x64", "neutral", "x86"],
8334 "x86" => &["x86", "neutral"],
8335 _ => &["neutral"],
8336 }
8337}
8338
8339fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8340 match (installer_locale, requested_locale) {
8341 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8342 (Some(installer), Some(requested))
8343 if installer
8344 .split('-')
8345 .next()
8346 .zip(requested.split('-').next())
8347 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8348 {
8349 2
8350 }
8351 (None, Some(_)) => 1,
8352 (Some(_), None) => 1,
8353 (None, None) => 0,
8354 _ => -1,
8355 }
8356}
8357
8358fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8359 match requested {
8360 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8361 None => true,
8362 }
8363}
8364
8365#[cfg(windows)]
8366fn current_architecture() -> &'static str {
8367 use std::sync::OnceLock;
8368 static CELL: OnceLock<&'static str> = OnceLock::new();
8369 CELL.get_or_init(|| {
8370 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8375 let mut process_machine: u16 = 0;
8376 let mut native_machine: u16 = 0;
8377 let handle = unsafe { GetCurrentProcess() };
8380 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8382 if ok != 0 {
8383 match native_machine {
8384 0xAA64 => return "arm64",
8385 0x8664 => return "x64",
8386 0x014C => return "x86",
8387 _ => {}
8388 }
8389 }
8390 compiled_architecture()
8391 })
8392}
8393
8394#[cfg(not(windows))]
8395fn current_architecture() -> &'static str {
8396 compiled_architecture()
8397}
8398
8399fn compiled_architecture() -> &'static str {
8400 match std::env::consts::ARCH {
8401 "x86_64" => "x64",
8402 "x86" => "x86",
8403 "aarch64" => "arm64",
8404 _ => "neutral",
8405 }
8406}
8407
8408fn current_platform() -> Option<&'static str> {
8409 if cfg!(windows) { Some("Windows.Desktop") } else { None }
8410}
8411
8412#[cfg(windows)]
8413fn current_os_version() -> Option<String> {
8414 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8415 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8416 let build: String = key.get_value("CurrentBuildNumber").ok()?;
8417 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8418 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8419 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8420 Some(format!("{major}.{minor}.{build}.{ubr}"))
8421}
8422
8423#[cfg(not(windows))]
8424fn current_os_version() -> Option<String> {
8425 None
8426}
8427
8428fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8429 let mut parsed = Vec::new();
8430 for component in version.split('.') {
8431 if component.is_empty() {
8432 continue;
8433 }
8434 parsed.push(component.parse().ok()?);
8435 }
8436
8437 if parsed.is_empty() { None } else { Some(parsed) }
8438}
8439
8440fn select_v1_version(
8441 versions: &[V1VersionRow],
8442 requested_version: Option<&str>,
8443 requested_channel: Option<&str>,
8444 package_id: &str,
8445 source_name: &str,
8446) -> Result<V1VersionRow> {
8447 let mut ordered = versions.to_vec();
8448 ordered.sort_by(|left, right| {
8449 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8450 });
8451
8452 if let Some(version) = requested_version {
8453 let channel = requested_channel.unwrap_or_default();
8454 return ordered
8455 .iter()
8456 .find(|candidate| {
8457 candidate.version.eq_ignore_ascii_case(version)
8458 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8459 })
8460 .cloned()
8461 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8462 }
8463
8464 if let Some(channel) = requested_channel {
8465 return ordered
8466 .iter()
8467 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8468 .cloned()
8469 .ok_or_else(|| {
8470 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8471 });
8472 }
8473
8474 ordered
8475 .into_iter()
8476 .next()
8477 .ok_or_else(|| anyhow!("package had no versions"))
8478}
8479
8480fn select_v2_version(
8481 versions: &[PackageVersionDataEntry],
8482 requested_version: Option<&str>,
8483 requested_channel: Option<&str>,
8484 package_id: &str,
8485 source_name: &str,
8486) -> Result<PackageVersionDataEntry> {
8487 let mut ordered = versions.to_vec();
8488 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8489
8490 if let Some(channel) = requested_channel {
8491 bail!(
8492 "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."
8493 );
8494 }
8495
8496 if let Some(version) = requested_version {
8497 return ordered
8498 .iter()
8499 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8500 .cloned()
8501 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8502 }
8503
8504 ordered
8505 .into_iter()
8506 .next()
8507 .ok_or_else(|| anyhow!("package had no V2 versions"))
8508}
8509
8510fn missing_package_version(
8511 package_id: &str,
8512 source_name: &str,
8513 requested_version: &str,
8514 requested_channel: Option<&str>,
8515) -> anyhow::Error {
8516 anyhow!(MissingPackageVersionError {
8517 package_id: package_id.to_owned(),
8518 source_name: source_name.to_owned(),
8519 requested_version: requested_version.to_owned(),
8520 requested_channel: requested_channel.map(str::to_owned),
8521 })
8522}
8523
8524fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8525 if let Some(refresh_error) = refresh.error.as_deref() {
8526 let missing_error = error.to_string();
8527 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8528 }
8529
8530 Err(error)
8531}
8532
8533fn select_rest_version<'a>(
8534 versions: &'a [VersionKey],
8535 requested_version: Option<&str>,
8536 requested_channel: Option<&str>,
8537 package_id: &str,
8538 source_name: &str,
8539) -> Result<&'a VersionKey> {
8540 if let Some(version) = requested_version {
8541 let channel = requested_channel.unwrap_or_default();
8542 return versions
8543 .iter()
8544 .find(|candidate| {
8545 candidate.version.eq_ignore_ascii_case(version)
8546 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8547 })
8548 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8549 }
8550
8551 if let Some(channel) = requested_channel {
8552 return versions
8553 .iter()
8554 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8555 .ok_or_else(|| {
8556 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8557 });
8558 }
8559
8560 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8561}
8562
8563fn sort_versions_desc(versions: &mut [VersionKey]) {
8564 versions.sort_by(|left, right| {
8565 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8566 });
8567}
8568
8569fn compare_version_and_channel(
8570 left_version: &str,
8571 left_channel: &str,
8572 right_version: &str,
8573 right_channel: &str,
8574) -> Ordering {
8575 compare_version(left_version, right_version).then_with(|| {
8576 left_channel
8577 .to_ascii_lowercase()
8578 .cmp(&right_channel.to_ascii_lowercase())
8579 })
8580}
8581
8582fn compare_version(left: &str, right: &str) -> Ordering {
8583 let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8584 let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8585 match (left_less_than, right_less_than) {
8586 (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8587 (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8588 (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8589 _ => {}
8590 }
8591
8592 let left_parts = tokenize_version(left);
8593 let right_parts = tokenize_version(right);
8594 let max_len = left_parts.len().max(right_parts.len());
8595
8596 for index in 0..max_len {
8597 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8598 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8599 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8600
8601 let ordering = if let Some((left_number, right_number)) = numeric {
8602 left_number.cmp(&right_number)
8603 } else {
8604 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8605 };
8606
8607 if ordering != Ordering::Equal {
8608 return ordering;
8609 }
8610 }
8611
8612 Ordering::Equal
8613}
8614
8615fn tokenize_version(value: &str) -> Vec<String> {
8616 let mut parts = Vec::new();
8617 let mut current = String::new();
8618 let mut current_is_digit = None;
8619
8620 for ch in value.chars() {
8621 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8622 if !current.is_empty() {
8623 parts.push(std::mem::take(&mut current));
8624 }
8625 current_is_digit = None;
8626 continue;
8627 }
8628
8629 let is_digit = ch.is_ascii_digit();
8630 match current_is_digit {
8631 Some(previous) if previous != is_digit => {
8632 parts.push(std::mem::take(&mut current));
8633 current.push(ch);
8634 current_is_digit = Some(is_digit);
8635 }
8636 Some(_) => current.push(ch),
8637 None => {
8638 current.push(ch);
8639 current_is_digit = Some(is_digit);
8640 }
8641 }
8642 }
8643
8644 if !current.is_empty() {
8645 parts.push(current);
8646 }
8647
8648 parts
8649}
8650
8651fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8652 if !hash_matches(Some(expected_hash), bytes) {
8653 bail!("downloaded file hash did not match expected SHA256");
8654 }
8655 Ok(())
8656}
8657
8658#[cfg(test)]
8659fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8660 let Some(expected_hash) = expected_hash else {
8661 return Ok(());
8662 };
8663 if ignore_security_hash {
8664 return Ok(());
8665 }
8666
8667 if !hash_matches(Some(expected_hash), bytes) {
8668 let actual = sha256_hex(bytes);
8669 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8670 }
8671 Ok(())
8672}
8673
8674fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8675 let Some(expected_hash) = expected_hash else {
8676 return Ok(());
8677 };
8678 if ignore_security_hash {
8679 return Ok(());
8680 }
8681
8682 if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8683 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8684 }
8685 Ok(())
8686}
8687
8688const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8689const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8690const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8691const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8692const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8693
8694#[derive(Debug, Clone, Serialize, Deserialize)]
8695struct InstallerDownloadMetadata {
8696 url: String,
8697 etag: Option<String>,
8698 last_modified: Option<String>,
8699 total_length: Option<u64>,
8700 offset: u64,
8701}
8702
8703#[derive(Debug)]
8704struct DownloadAttempt {
8705 total_length: Option<u64>,
8706}
8707
8708fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8709 let runtime = tokio::runtime::Builder::new_current_thread()
8710 .enable_all()
8711 .build()
8712 .context("failed to create installer download runtime")?;
8713 runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8714}
8715
8716async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8717 let part_path = sidecar_path(dest, "part");
8718 let metadata_path = sidecar_path(dest, "part.json");
8719 let started_at = Instant::now();
8720 let mut last_error = None;
8721
8722 for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8723 if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8724 break;
8725 }
8726
8727 let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8728 .checked_sub(started_at.elapsed())
8729 .unwrap_or_else(|| StdDuration::from_secs(1));
8730 match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8731 Ok(()) => {
8732 let actual_hash = sha256_file_hex(&part_path)?;
8733 replace_file(&part_path, dest)?;
8734 let _ = fs::remove_file(&metadata_path);
8735 return Ok(actual_hash);
8736 }
8737 Err(error) => {
8738 last_error = Some(error);
8739 }
8740 }
8741 }
8742
8743 Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8744}
8745
8746async fn try_download_installer_to_part(
8747 user_agent: &str,
8748 url: &str,
8749 part_path: &Path,
8750 metadata_path: &Path,
8751 remaining: StdDuration,
8752) -> Result<()> {
8753 let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8754 let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8755 let mut request = reqwest::Client::builder()
8756 .user_agent(user_agent)
8757 .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8758 .timeout(remaining)
8759 .build()
8760 .context("failed to build installer download client")?
8761 .get(url)
8762 .header(ACCEPT_ENCODING, "identity");
8763
8764 if offset > 0 {
8765 request = request.header(RANGE, format!("bytes={offset}-"));
8766 if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8767 request = request.header(IF_RANGE, if_range);
8768 }
8769 }
8770
8771 let mut response = request.send().await.context("failed to download installer")?;
8772 let headers = response.headers().clone();
8773 let status = response.status();
8774 let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
8775 parse_content_range(headers.get(CONTENT_RANGE), offset)?
8776 } else if status == StatusCode::OK {
8777 offset = 0;
8778 DownloadAttempt {
8779 total_length: content_length(&headers),
8780 }
8781 } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
8782 let _ = fs::remove_file(part_path);
8783 let _ = fs::remove_file(metadata_path);
8784 bail!("installer partial download could not be resumed; retrying from the beginning");
8785 } else if status.is_success() {
8786 bail!("unexpected installer download HTTP status {status}");
8787 } else {
8788 bail!("Download failed: HTTP {status}");
8789 };
8790
8791 if offset == 0 {
8792 let _ = fs::remove_file(part_path);
8793 }
8794
8795 let mut file = if offset == 0 {
8796 fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
8797 } else {
8798 OpenOptions::new()
8799 .append(true)
8800 .open(part_path)
8801 .with_context(|| format!("failed to open {}", part_path.display()))?
8802 };
8803
8804 let mut metadata = InstallerDownloadMetadata {
8805 url: url.to_owned(),
8806 etag: strong_etag(&headers),
8807 last_modified: header_string(&headers, LAST_MODIFIED),
8808 total_length: attempt.total_length,
8809 offset,
8810 };
8811 write_installer_download_metadata(metadata_path, &metadata)?;
8812
8813 let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8814 while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
8815 .await
8816 .context("installer download stalled while waiting for data")?
8817 .context("failed to read installer response body")?
8818 {
8819 file.write_all(&chunk)
8820 .with_context(|| format!("failed to write {}", part_path.display()))?;
8821 offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
8822
8823 if offset >= next_metadata_offset {
8824 file.flush()
8825 .with_context(|| format!("failed to flush {}", part_path.display()))?;
8826 metadata.offset = offset;
8827 write_installer_download_metadata(metadata_path, &metadata)?;
8828 next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8829 }
8830 }
8831
8832 file.flush()
8833 .with_context(|| format!("failed to flush {}", part_path.display()))?;
8834 file.sync_all()
8835 .with_context(|| format!("failed to sync {}", part_path.display()))?;
8836
8837 if let Some(total_length) = attempt.total_length
8838 && offset != total_length
8839 {
8840 bail!("installer download ended early: received {offset} of {total_length} bytes");
8841 }
8842
8843 metadata.offset = offset;
8844 metadata.total_length = attempt.total_length;
8845 write_installer_download_metadata(metadata_path, &metadata)?;
8846 Ok(())
8847}
8848
8849fn read_installer_download_metadata(
8850 metadata_path: &Path,
8851 part_path: &Path,
8852 url: &str,
8853) -> Result<Option<InstallerDownloadMetadata>> {
8854 let Some(mut metadata) = fs::read(metadata_path)
8855 .ok()
8856 .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
8857 else {
8858 let _ = fs::remove_file(part_path);
8859 let _ = fs::remove_file(metadata_path);
8860 return Ok(None);
8861 };
8862
8863 if metadata.url != url || metadata.offset == 0 {
8864 let _ = fs::remove_file(part_path);
8865 let _ = fs::remove_file(metadata_path);
8866 return Ok(None);
8867 }
8868
8869 let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
8870 metadata.offset = metadata.offset.min(part_len);
8871 if metadata.offset == 0 {
8872 let _ = fs::remove_file(part_path);
8873 let _ = fs::remove_file(metadata_path);
8874 return Ok(None);
8875 }
8876
8877 OpenOptions::new()
8878 .write(true)
8879 .open(part_path)
8880 .with_context(|| format!("failed to open {}", part_path.display()))?
8881 .set_len(metadata.offset)
8882 .with_context(|| format!("failed to truncate {}", part_path.display()))?;
8883
8884 Ok(Some(metadata))
8885}
8886
8887fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
8888 let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
8889 fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
8890}
8891
8892fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
8893 metadata
8894 .etag
8895 .as_ref()
8896 .filter(|etag| !etag.starts_with("W/"))
8897 .cloned()
8898 .or_else(|| metadata.last_modified.clone())
8899}
8900
8901fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
8902 let value = value
8903 .and_then(|value| value.to_str().ok())
8904 .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
8905 let range = value
8906 .strip_prefix("bytes ")
8907 .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
8908 let (span, total) = range
8909 .split_once('/')
8910 .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
8911 let (start, _) = span
8912 .split_once('-')
8913 .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
8914 let start = start
8915 .parse::<u64>()
8916 .with_context(|| format!("invalid Content-Range start: {value}"))?;
8917 if start != expected_start {
8918 bail!("partial installer response started at {start}, expected {expected_start}");
8919 }
8920 let total_length = if total == "*" {
8921 None
8922 } else {
8923 Some(
8924 total
8925 .parse::<u64>()
8926 .with_context(|| format!("invalid Content-Range total: {value}"))?,
8927 )
8928 };
8929 Ok(DownloadAttempt { total_length })
8930}
8931
8932fn content_length(headers: &HeaderMap) -> Option<u64> {
8933 headers
8934 .get(CONTENT_LENGTH)
8935 .and_then(|value| value.to_str().ok())
8936 .and_then(|value| value.parse::<u64>().ok())
8937}
8938
8939fn strong_etag(headers: &HeaderMap) -> Option<String> {
8940 header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
8941}
8942
8943fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
8944 headers
8945 .get(name)
8946 .and_then(|value| value.to_str().ok())
8947 .map(str::to_owned)
8948}
8949
8950fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
8951 let mut value = path.as_os_str().to_os_string();
8952 value.push(".");
8953 value.push(suffix);
8954 PathBuf::from(value)
8955}
8956
8957fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
8958 expected_hash
8959 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
8960 .unwrap_or(true)
8961}
8962
8963fn sha256_hex(bytes: &[u8]) -> String {
8964 let digest = Sha256::digest(bytes);
8965 sha256_digest_hex(digest)
8966}
8967
8968fn sha256_file_hex(path: &Path) -> Result<String> {
8969 let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
8970 let mut hasher = Sha256::new();
8971 let mut buffer = [0u8; 128 * 1024];
8972 loop {
8973 let read = file
8974 .read(&mut buffer)
8975 .with_context(|| format!("failed to read {}", path.display()))?;
8976 if read == 0 {
8977 break;
8978 }
8979 hasher.update(&buffer[..read]);
8980 }
8981 Ok(sha256_digest_hex(hasher.finalize()))
8982}
8983
8984fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
8985 let digest = digest.as_ref();
8986 let mut output = String::with_capacity(digest.len() * 2);
8987 for byte in digest {
8988 output.push_str(&format!("{byte:02X}"));
8989 }
8990 output
8991}
8992
8993fn pins_db_path(app_root: &Path) -> PathBuf {
8994 if uses_packaged_layout(app_root) {
8995 app_root.join("pinning.db")
8996 } else {
8997 app_root.join("pins.db")
8998 }
8999}
9000
9001fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9002 let mut has_current_column = false;
9003 let mut has_legacy_column = false;
9004
9005 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9006 match column_name.as_str() {
9007 "type" => has_current_column = true,
9008 "pin_type" => has_legacy_column = true,
9009 _ => {}
9010 }
9011 }
9012
9013 Ok(if has_current_column {
9014 Some("type")
9015 } else if has_legacy_column {
9016 Some("pin_type")
9017 } else {
9018 None
9019 })
9020}
9021
9022fn decode_pin_type(pin_type_int: i64) -> PinType {
9023 match pin_type_int {
9024 4 => PinType::Blocking,
9025 3 => PinType::Gating,
9026 _ => PinType::Pinning,
9027 }
9028}
9029
9030#[cfg(windows)]
9031fn dispatch_installer(
9032 installer_path: &Path,
9033 installer_type: &str,
9034 request: &InstallRequest,
9035 manifest: &Manifest,
9036 installer: &Installer,
9037) -> Result<i32> {
9038 match installer_type {
9039 "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9040 installer_path,
9041 &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9042 ),
9043 "msi" | "wix" => shell_execute_installer(
9044 Path::new("msiexec.exe"),
9045 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9046 should_elevate_msi_shell_execute(installer),
9047 ),
9048 "msix" | "appx" => {
9049 use std::process::Command;
9050
9051 let mut cmd = Command::new("powershell");
9052 cmd.arg("-NoProfile")
9053 .arg("-Command")
9054 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9055 let status = cmd.status().context("failed to run Add-AppxPackage")?;
9056 Ok(status.code().unwrap_or(-1))
9057 }
9058 "zip" => {
9059 if installer
9065 .nested_installer_type
9066 .as_deref()
9067 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9068 {
9069 return install_portable(installer_path, request, manifest, installer);
9070 }
9071 let target = dirs::data_local_dir()
9072 .unwrap_or_else(|| PathBuf::from("."))
9073 .join("Programs");
9074 fs::create_dir_all(&target)?;
9075 let file = fs::File::open(installer_path)?;
9076 let mut archive = ZipArchive::new(file)?;
9077 archive.extract(&target)?;
9078 Ok(0)
9079 }
9080 "portable" => install_portable(installer_path, request, manifest, installer),
9081 _ => shell_execute_installer(
9084 installer_path,
9085 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9086 false,
9087 ),
9088 }
9089}
9090
9091#[cfg(windows)]
9092fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9093 request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9094}
9095
9096#[cfg(windows)]
9097fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9098 installer
9099 .scope
9100 .as_deref()
9101 .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9102}
9103
9104#[cfg(windows)]
9105fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9106 use std::ffi::OsStr;
9107
9108 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9109 use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9110 use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9111 use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9112
9113 let file = wide_null(installer_path.as_os_str());
9114 let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9115 let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9116
9117 let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9120 exec_info.cbSize =
9121 u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9122 exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9123 exec_info.lpFile = file.as_ptr();
9124 exec_info.lpParameters = parameters.as_ptr();
9125 if let Some(verb) = verb.as_ref() {
9126 exec_info.lpVerb = verb.as_ptr();
9127 }
9128 exec_info.nShow = SW_SHOW;
9129
9130 if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9133 bail!(
9134 "failed to launch installer through ShellExecute (Win32 error {})",
9135 unsafe { GetLastError() }
9137 );
9138 }
9139
9140 if exec_info.hProcess.is_null() {
9141 bail!("ShellExecute did not return an installer process handle");
9142 }
9143
9144 let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9147 if wait_result != WAIT_OBJECT_0 {
9148 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9150 bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9151 }
9152
9153 let mut exit_code = 0u32;
9154 let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9156 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9158 if got_exit_code == 0 {
9159 let error = unsafe { GetLastError() };
9161 bail!("failed to retrieve installer exit code (Win32 error {error})");
9162 }
9163
9164 Ok(exit_code.cast_signed())
9165}
9166
9167#[cfg(windows)]
9168fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9169 value.encode_wide().chain(std::iter::once(0)).collect()
9170}
9171
9172#[cfg(windows)]
9173fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9174 use std::ffi::OsStr;
9175 use std::ptr::{null, null_mut};
9176
9177 use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9178 INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9179 };
9180
9181 let parsed = parse_msi_direct_arguments(args)?;
9182 let package_path = wide_null(installer_path.as_os_str());
9183 let properties = wide_null(OsStr::new(&parsed.properties));
9184 let property_ptr = if parsed.properties.is_empty() {
9185 null()
9186 } else {
9187 properties.as_ptr()
9188 };
9189 let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9190
9191 if let Some(path) = log_path.as_ref() {
9192 let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9195 if result != 0 {
9196 bail!("failed to enable MSI logging (Win32 error {result})");
9197 }
9198 } else {
9199 let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9201 }
9202
9203 let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9204 (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9205 } else {
9206 parsed.ui_level
9207 };
9208 unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9211 let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9214 Ok(result.cast_signed())
9215}
9216
9217#[cfg(any(windows, test))]
9218#[derive(Debug, PartialEq, Eq)]
9219struct MsiDirectArguments {
9220 ui_level: u32,
9221 properties: String,
9222 log_mode: u32,
9223 log_path: Option<String>,
9224 log_attributes: u32,
9225}
9226
9227#[cfg(any(windows, test))]
9228fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9229 const INSTALLUILEVEL_DEFAULT: u32 = 1;
9230 const INSTALLUILEVEL_NONE: u32 = 2;
9231 const INSTALLUILEVEL_BASIC: u32 = 3;
9232 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9233 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9234 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9235 const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9236 const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9237 const LOGMODE_VERBOSE: u32 = 4096;
9238 const LOGMODE_EXTRADEBUG: u32 = 8192;
9239 const LOGATTR_APPEND: u32 = 1;
9240 const LOGATTR_FLUSH: u32 = 2;
9241
9242 let mut parsed = MsiDirectArguments {
9243 ui_level: INSTALLUILEVEL_DEFAULT,
9244 properties: String::new(),
9245 log_mode: 0,
9246 log_path: None,
9247 log_attributes: 0,
9248 };
9249
9250 let mut index = 0;
9251 while index < args.len() {
9252 let token = &args[index];
9253 if is_msi_switch(token) {
9254 let option = &token[1..];
9255 if option.eq_ignore_ascii_case("quiet") {
9256 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9257 } else if option.eq_ignore_ascii_case("passive") {
9258 parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9259 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9260 } else if option.eq_ignore_ascii_case("norestart") {
9261 append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9262 } else if option.eq_ignore_ascii_case("forcerestart") {
9263 append_msi_property(&mut parsed.properties, "REBOOT=Force");
9264 } else if option.eq_ignore_ascii_case("promptrestart") {
9265 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9266 } else if option.eq_ignore_ascii_case("log") {
9267 index += 1;
9268 let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9269 parsed.log_mode = LOGMODE_ALL;
9270 parsed.log_path = Some(path.clone());
9271 } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9272 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9273 } else if option.starts_with(['q', 'Q']) {
9274 parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9275 } else if option.starts_with(['l', 'L']) {
9276 index += 1;
9277 let path = args
9278 .get(index)
9279 .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9280 let (mode, attributes) = parse_msi_log_mode(
9281 &option[1..],
9282 LOGMODE_DEFAULT,
9283 LOGMODE_ALL,
9284 LOGMODE_VERBOSE,
9285 LOGMODE_EXTRADEBUG,
9286 LOGATTR_APPEND,
9287 LOGATTR_FLUSH,
9288 )?;
9289 parsed.log_mode = mode;
9290 parsed.log_attributes = attributes;
9291 parsed.log_path = Some(path.clone());
9292 } else {
9293 bail!("unsupported MSI switch for direct MSI execution: {token}");
9294 }
9295 } else {
9296 append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9297 }
9298 index += 1;
9299 }
9300
9301 Ok(parsed)
9302}
9303
9304#[cfg(any(windows, test))]
9305fn is_msi_switch(token: &str) -> bool {
9306 token.starts_with('/') || token.starts_with('-')
9307}
9308
9309#[cfg(any(windows, test))]
9310fn append_msi_property(properties: &mut String, property: &str) {
9311 if !properties.is_empty() {
9312 properties.push(' ');
9313 }
9314 properties.push_str(property);
9315}
9316
9317#[cfg(any(windows, test))]
9318fn format_msi_property(token: &str) -> Result<String> {
9319 let Some((name, value)) = token.split_once('=') else {
9320 bail!("invalid MSI property argument: {token}");
9321 };
9322 if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9323 bail!("invalid MSI property name: {name}");
9324 }
9325 if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9326 Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9327 } else {
9328 Ok(token.to_owned())
9329 }
9330}
9331
9332#[cfg(any(windows, test))]
9333fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9334 const INSTALLUILEVEL_NONE: u32 = 2;
9335 const INSTALLUILEVEL_BASIC: u32 = 3;
9336 const INSTALLUILEVEL_REDUCED: u32 = 4;
9337 const INSTALLUILEVEL_FULL: u32 = 5;
9338 const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9339 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9340 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9341 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9342
9343 let modifier = if modifier.is_empty() { "n" } else { modifier };
9344 let mut chars = modifier.chars();
9345 let Some(first) = chars.next() else {
9346 return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9347 };
9348 let mut ui_level = match first.to_ascii_lowercase() {
9349 'f' => INSTALLUILEVEL_FULL,
9350 'r' => INSTALLUILEVEL_REDUCED,
9351 'b' => INSTALLUILEVEL_BASIC,
9352 'n' => INSTALLUILEVEL_NONE,
9353 '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9354 _ => bail!("invalid MSI /q modifier: {modifier}"),
9355 };
9356
9357 for ch in chars {
9358 match ch {
9359 '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9360 '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9361 '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9362 '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9363 _ => bail!("invalid MSI /q modifier: {modifier}"),
9364 }
9365 }
9366
9367 if ui_level & 0xF == INSTALLUILEVEL_NONE {
9368 ui_level |= INSTALLUILEVEL_UACONLY;
9369 }
9370 Ok(ui_level)
9371}
9372
9373#[cfg(any(windows, test))]
9374#[allow(clippy::too_many_arguments)]
9375fn parse_msi_log_mode(
9376 modifier: &str,
9377 default_mode: u32,
9378 all_mode: u32,
9379 verbose: u32,
9380 extra_debug: u32,
9381 append: u32,
9382 flush: u32,
9383) -> Result<(u32, u32)> {
9384 let mut mode = 0;
9385 let mut attributes = 0;
9386 for ch in modifier.chars() {
9387 match ch.to_ascii_lowercase() {
9388 '*' => mode |= all_mode,
9389 'm' => mode |= 1,
9390 'e' => mode |= 2,
9391 'w' => mode |= 4,
9392 'u' => mode |= 8,
9393 'i' => mode |= 16,
9394 'o' => mode |= 128,
9395 'a' => mode |= 256,
9396 'r' => mode |= 512,
9397 'p' => mode |= 1024,
9398 'c' => mode |= 2048,
9399 'v' => mode |= verbose,
9400 'x' => mode |= extra_debug,
9401 '+' => attributes |= append,
9402 '!' => attributes |= flush,
9403 _ => bail!("invalid MSI /l modifier: {modifier}"),
9404 }
9405 }
9406 if mode == 0 {
9407 mode = default_mode;
9408 }
9409 Ok((mode, attributes))
9410}
9411
9412#[cfg(not(windows))]
9413fn dispatch_installer(
9414 _installer_path: &Path,
9415 _installer_type: &str,
9416 _request: &InstallRequest,
9417 _manifest: &Manifest,
9418 _installer: &Installer,
9419) -> Result<i32> {
9420 bail!("Installing packages is only supported on Windows")
9421}
9422
9423#[cfg(test)]
9424fn installer_command_arguments(
9425 installer_type: &str,
9426 request: &InstallRequest,
9427 _manifest: &Manifest,
9428 installer_path: &Path,
9429 installer: &Installer,
9430) -> Vec<String> {
9431 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9432}
9433
9434#[cfg(any(windows, test))]
9435fn installer_direct_msi_arguments(
9436 installer_type: &str,
9437 request: &InstallRequest,
9438 _manifest: &Manifest,
9439 installer_path: &Path,
9440 installer: &Installer,
9441) -> Vec<String> {
9442 installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9443}
9444
9445#[cfg(any(windows, test))]
9446fn installer_shell_execute_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_command_arguments_with_options(
9458 installer_type: &str,
9459 request: &InstallRequest,
9460 installer_path: &Path,
9461 installer: &Installer,
9462 include_msi_install_target: bool,
9463) -> Vec<String> {
9464 if let Some(override_args) = request.override_args.as_deref() {
9465 return split_installer_switches(override_args);
9466 }
9467
9468 let mut args = Vec::new();
9469 match installer_type {
9470 "msi" | "wix" if include_msi_install_target => {
9471 args.push("/i".to_owned());
9472 args.push(installer_path.display().to_string());
9473 }
9474 "msix" | "appx" | "zip" => return Vec::new(),
9475 _ => {}
9476 }
9477
9478 let configured = match request.mode {
9479 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9480 InstallerMode::SilentWithProgress => installer
9481 .switches
9482 .silent_with_progress
9483 .as_deref()
9484 .or(installer.switches.silent.as_deref()),
9485 InstallerMode::Silent => installer
9486 .switches
9487 .silent
9488 .as_deref()
9489 .or(installer.switches.silent_with_progress.as_deref()),
9490 };
9491
9492 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9493 args.extend(split_installer_switches(value));
9494 } else {
9495 args.extend(default_installer_arguments(installer_type, request.mode));
9496 }
9497
9498 append_switches(
9499 &mut args,
9500 resolve_template_switch(
9501 installer.switches.log.as_deref(),
9502 default_log_switch(installer_type),
9503 request.log_path.as_ref().map(|path| path.display().to_string()),
9504 "<LOGPATH>",
9505 ),
9506 );
9507 append_switches(&mut args, installer.switches.custom.clone());
9508 append_switches(&mut args, request.custom.clone());
9509 append_switches(
9510 &mut args,
9511 resolve_template_switch(
9512 installer.switches.install_location.as_deref(),
9513 default_install_location_switch(installer_type),
9514 request.install_location.clone(),
9515 "<INSTALLPATH>",
9516 ),
9517 );
9518 args
9519}
9520
9521#[cfg(any(windows, test))]
9522fn join_windows_arguments(args: &[String]) -> String {
9523 args.iter()
9524 .map(|arg| quote_windows_argument(arg))
9525 .collect::<Vec<_>>()
9526 .join(" ")
9527}
9528
9529#[cfg(any(windows, test))]
9530fn quote_windows_argument(arg: &str) -> String {
9531 if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9532 return arg.to_owned();
9533 }
9534
9535 let mut quoted = String::from("\"");
9536 let mut backslashes = 0usize;
9537 for ch in arg.chars() {
9538 match ch {
9539 '\\' => backslashes += 1,
9540 '"' => {
9541 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9542 quoted.push('"');
9543 backslashes = 0;
9544 }
9545 _ => {
9546 quoted.push_str(&"\\".repeat(backslashes));
9547 backslashes = 0;
9548 quoted.push(ch);
9549 }
9550 }
9551 }
9552 quoted.push_str(&"\\".repeat(backslashes * 2));
9553 quoted.push('"');
9554 quoted
9555}
9556
9557#[cfg(any(windows, test))]
9558fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9559 match mode {
9560 InstallerMode::Interactive => Vec::new(),
9561 InstallerMode::SilentWithProgress => match installer_type {
9562 "inno" => vec![
9563 "/SP-".to_owned(),
9564 "/SILENT".to_owned(),
9565 "/SUPPRESSMSGBOXES".to_owned(),
9566 "/NORESTART".to_owned(),
9567 ],
9568 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9569 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9570 _ => vec!["/SILENT".to_owned()],
9571 },
9572 InstallerMode::Silent => match installer_type {
9573 "inno" => vec![
9574 "/SP-".to_owned(),
9575 "/VERYSILENT".to_owned(),
9576 "/SUPPRESSMSGBOXES".to_owned(),
9577 "/NORESTART".to_owned(),
9578 ],
9579 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9580 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9581 _ => vec!["/S".to_owned()],
9582 },
9583 }
9584}
9585
9586#[cfg(any(windows, test))]
9587fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9588 match installer_type {
9589 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9590 "inno" => Some("/LOG=\"<LOGPATH>\""),
9591 _ => None,
9592 }
9593}
9594
9595#[cfg(any(windows, test))]
9596fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9597 match installer_type {
9598 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9599 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9600 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9601 _ => None,
9602 }
9603}
9604
9605#[cfg(any(windows, test))]
9606fn resolve_template_switch(
9607 manifest_value: Option<&str>,
9608 fallback: Option<&str>,
9609 replacement: Option<String>,
9610 token: &str,
9611) -> Option<String> {
9612 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9613 let replacement = replacement?;
9614 Some(template.replace(token, &replacement))
9615}
9616
9617#[cfg(any(windows, test))]
9618fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9619 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9620 args.extend(split_installer_switches(&value));
9621 }
9622}
9623
9624#[cfg(any(windows, test))]
9625fn split_installer_switches(value: &str) -> Vec<String> {
9626 let mut args = Vec::new();
9627 let mut current = String::new();
9628 let mut in_quotes = false;
9629
9630 for ch in value.chars() {
9631 match ch {
9632 '"' => in_quotes = !in_quotes,
9633 c if c.is_whitespace() && !in_quotes => {
9634 if !current.is_empty() {
9635 args.push(std::mem::take(&mut current));
9636 }
9637 }
9638 _ => current.push(ch),
9639 }
9640 }
9641
9642 if !current.is_empty() {
9643 args.push(current);
9644 }
9645
9646 args
9647}
9648
9649#[cfg(windows)]
9650fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9651 if request.purge && request.preserve {
9652 bail!("--purge and --preserve cannot be used together.");
9653 }
9654
9655 if installed
9656 .installer_category
9657 .as_deref()
9658 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9659 {
9660 return uninstall_portable(installed, request);
9661 }
9662
9663 if (request.purge || request.preserve) && !request.force {
9664 bail!("--purge and --preserve are currently only supported for portable packages.");
9665 }
9666
9667 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9668 return Ok(exit_code);
9669 }
9670
9671 if let Some(exit_code) = try_uninstall_msix(installed)? {
9672 return Ok(exit_code);
9673 }
9674
9675 bail!(
9676 "No uninstall command found for installed package '{}' ({})",
9677 installed.name,
9678 installed.local_id
9679 );
9680}
9681
9682#[cfg(windows)]
9683fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9684 use std::os::windows::process::CommandExt;
9685 use std::process::Command;
9686
9687 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9688 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9689
9690 let arp_paths = [
9691 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9692 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9693 ];
9694
9695 for root in [&hklm, &hkcu] {
9696 for arp_path in &arp_paths {
9697 if let Ok(key) = root.open_subkey(arp_path) {
9698 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9699 if let Ok(subkey) = key.open_subkey(&subkey_name) {
9700 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9701 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9702 if !registry_entry_matches_installed_package(
9703 &subkey_name,
9704 &display_name,
9705 product_code.as_deref(),
9706 installed,
9707 ) {
9708 continue;
9709 }
9710
9711 if let Some(exit_code) =
9712 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9713 {
9714 return Ok(Some(exit_code));
9715 }
9716
9717 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9718 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9719 subkey.get_value("UninstallString").ok()
9720 } else {
9721 quiet_uninstall_cmd
9722 .clone()
9723 .or_else(|| subkey.get_value("UninstallString").ok())
9724 }
9725 .context("No uninstall command found in registry")?;
9726
9727 if is_winget_uninstall_command(&uninstall_cmd)
9728 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9729 {
9730 return Ok(Some(exit_code));
9731 }
9732
9733 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9734 let uninstall_line = build_uninstall_command_with_mode(
9735 &uninstall_cmd,
9736 request.mode,
9737 quiet_uninstall_cmd.is_some(),
9738 log_path.as_deref(),
9739 );
9740 let mut cmd = Command::new("cmd");
9749 cmd.raw_arg("/S /C");
9750 cmd.raw_arg(format!("\"{uninstall_line}\""));
9751 let status = cmd.status().context("failed to run uninstaller")?;
9752 return Ok(Some(status.code().unwrap_or(-1)));
9753 }
9754 }
9755 }
9756 }
9757 }
9758
9759 Ok(None)
9760}
9761
9762#[cfg(windows)]
9763fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9764 use std::process::Command;
9765
9766 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9767 return Ok(None);
9768 }
9769
9770 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9771 let mut cmd = Command::new("powershell");
9772 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9773 package_full_name,
9774 &installed.package_family_names,
9775 ));
9776 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
9777 Ok(Some(status.code().unwrap_or(-1)))
9778}
9779
9780#[cfg(windows)]
9781fn arp_subkey_name(local_id: &str) -> Option<&str> {
9782 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
9783}
9784
9785#[cfg(windows)]
9786fn registry_entry_matches_installed_package(
9787 subkey_name: &str,
9788 display_name: &str,
9789 product_code: Option<&str>,
9790 installed: &ListMatch,
9791) -> bool {
9792 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
9793 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
9794 {
9795 return true;
9796 }
9797
9798 if installed.product_codes.iter().any(|code| {
9799 code.eq_ignore_ascii_case(subkey_name)
9800 || product_code
9801 .map(|value| code.eq_ignore_ascii_case(value))
9802 .unwrap_or(false)
9803 }) {
9804 return true;
9805 }
9806
9807 display_name.eq_ignore_ascii_case(&installed.name)
9808}
9809
9810#[cfg(all(windows, test))]
9811fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
9812 build_uninstall_command_with_mode(
9813 uninstall_cmd,
9814 if silent {
9815 InstallerMode::Silent
9816 } else {
9817 InstallerMode::Interactive
9818 },
9819 has_quiet_command,
9820 None,
9821 )
9822}
9823
9824#[cfg(windows)]
9825fn build_uninstall_command_with_mode(
9826 uninstall_cmd: &str,
9827 mode: InstallerMode,
9828 has_quiet_command: bool,
9829 log_path: Option<&str>,
9830) -> String {
9831 let uninstall_cmd = log_path
9832 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
9833 .unwrap_or_else(|| uninstall_cmd.to_owned());
9834 if mode == InstallerMode::Interactive || has_quiet_command {
9835 return uninstall_cmd;
9836 }
9837
9838 let lower = uninstall_cmd.to_ascii_lowercase();
9839 if is_winget_uninstall_command_lower(&lower) {
9840 return uninstall_cmd;
9841 }
9842 if lower.contains("/quiet")
9843 || lower.contains("/passive")
9844 || lower.contains("/verysilent")
9845 || lower.contains("/silent")
9846 || lower.contains(" /s")
9847 {
9848 return uninstall_cmd;
9849 }
9850
9851 format!("{uninstall_cmd} /S")
9852}
9853
9854#[cfg(windows)]
9855fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9856 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
9857 return Ok(None);
9858 };
9859
9860 let exit_code = run_winget_uninstall(&args)?;
9861 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
9862 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
9863 return Ok(Some(exit_code));
9864 };
9865
9866 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
9867 }
9868
9869 Ok(Some(exit_code))
9870}
9871
9872#[cfg(windows)]
9873fn run_winget_uninstall(args: &[String]) -> Result<i32> {
9874 use std::process::Command;
9875
9876 let status = Command::new("winget")
9877 .args(args)
9878 .status()
9879 .context("failed to run delegated winget uninstall")?;
9880 Ok(status.code().unwrap_or(-1))
9881}
9882
9883#[cfg(windows)]
9884fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
9885 build_winget_uninstall_arguments_with_scope(installed, request, true)
9886}
9887
9888#[cfg(windows)]
9889fn build_winget_uninstall_arguments_with_scope(
9890 installed: &ListMatch,
9891 request: &UninstallRequest,
9892 include_scope: bool,
9893) -> Option<Vec<String>> {
9894 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
9895 return None;
9896 }
9897
9898 let mut args = vec![
9899 "uninstall".to_owned(),
9900 "--id".to_owned(),
9901 installed.id.clone(),
9902 "--exact".to_owned(),
9903 "--disable-interactivity".to_owned(),
9904 ];
9905
9906 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
9907 args.push("--source".to_owned());
9908 args.push(source_name.to_owned());
9909 }
9910
9911 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
9912 args.push("--scope".to_owned());
9913 args.push(scope.to_owned());
9914 }
9915
9916 if request.mode == InstallerMode::Silent {
9917 args.push("--silent".to_owned());
9918 }
9919
9920 Some(args)
9921}
9922
9923#[cfg(windows)]
9924fn is_winget_uninstall_command(command: &str) -> bool {
9925 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
9926}
9927
9928#[cfg(windows)]
9929fn is_winget_uninstall_command_lower(command: &str) -> bool {
9930 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
9931}
9932
9933#[cfg(windows)]
9934fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
9935 let full_name_literal = package_full_name
9936 .map(|value| format!("'{}'", value.replace('\'', "''")))
9937 .unwrap_or_else(|| "$null".to_owned());
9938 let family_names_literal = if package_family_names.is_empty() {
9939 "@()".to_owned()
9940 } else {
9941 format!(
9942 "@({})",
9943 package_family_names
9944 .iter()
9945 .map(|value| format!("'{}'", value.replace('\'', "''")))
9946 .collect::<Vec<_>>()
9947 .join(", ")
9948 )
9949 };
9950
9951 format!(
9952 "$fullName = {full_name_literal}; \
9953 $familyNames = {family_names_literal}; \
9954 $targets = Get-AppxPackage | Where-Object {{ \
9955 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
9956 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
9957 if (-not $targets) {{ exit 1 }}; \
9958 $targets | Remove-AppxPackage"
9959 )
9960}
9961
9962#[cfg(windows)]
9963fn try_run_msi_uninstall(
9964 installed: &ListMatch,
9965 subkey_name: &str,
9966 product_code: Option<&str>,
9967 request: &UninstallRequest,
9968) -> Result<Option<i32>> {
9969 use std::process::Command;
9970
9971 let uninstall_code = installed
9972 .product_codes
9973 .iter()
9974 .map(String::as_str)
9975 .chain(product_code)
9976 .chain(Some(subkey_name))
9977 .find(|value| is_product_code_like(value));
9978
9979 let Some(uninstall_code) = uninstall_code else {
9980 return Ok(None);
9981 };
9982
9983 let mut cmd = Command::new("msiexec");
9984 cmd.arg("/x").arg(uninstall_code);
9985 match request.mode {
9986 InstallerMode::Interactive => {}
9987 InstallerMode::SilentWithProgress => {
9988 cmd.arg("/passive").arg("/norestart");
9989 }
9990 InstallerMode::Silent => {
9991 cmd.arg("/quiet").arg("/norestart");
9992 }
9993 }
9994 if let Some(log_path) = &request.log_path {
9995 cmd.arg("/log").arg(log_path);
9996 }
9997
9998 let status = cmd.status().context("failed to run msiexec uninstall")?;
9999 Ok(Some(status.code().unwrap_or(-1)))
10000}
10001
10002#[cfg(windows)]
10003fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10004 let known_symlink = read_portable_symlink_full_path(installed);
10010
10011 let Some(location) = installed.install_location.as_deref() else {
10012 if request.force {
10013 try_remove_portable_symlinks(installed, &known_symlink, None);
10014 try_remove_portable_registry_entry(installed);
10015 return Ok(0);
10016 }
10017 bail!(
10018 "Portable package '{}' does not expose an install location.",
10019 installed.name
10020 );
10021 };
10022
10023 if request.preserve {
10024 return Ok(0);
10025 }
10026
10027 let path = Path::new(location);
10028 let removed = if path.is_dir() {
10029 fs::remove_dir_all(path)?;
10030 true
10031 } else if path.is_file() {
10032 fs::remove_file(path)?;
10033 true
10034 } else {
10035 false
10036 };
10037
10038 if removed || request.force {
10039 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10042 try_remove_portable_registry_entry(installed);
10043 return Ok(0);
10044 }
10045
10046 bail!("Portable package location not found: {location}")
10047}
10048
10049#[cfg(windows)]
10055fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10056 const ARP_PREFIX: &str = r"ARP\";
10057 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10058 let mut parts = rest.splitn(3, '\\');
10059 let scope = parts.next().unwrap_or("");
10060 let _arch = parts.next();
10061 let subkey_name = parts.next().unwrap_or("");
10062 if subkey_name.is_empty() {
10063 return None;
10064 }
10065 let hive = if scope.eq_ignore_ascii_case("User") {
10066 RegKey::predef(HKEY_CURRENT_USER)
10067 } else {
10068 RegKey::predef(HKEY_LOCAL_MACHINE)
10069 };
10070 let subkey = hive
10071 .open_subkey(format!(
10072 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10073 ))
10074 .ok()?;
10075 return read_reg_string(&subkey, "PortableSymlinkFullPath");
10076 }
10077 None
10078}
10079
10080#[cfg(windows)]
10086fn try_remove_portable_symlinks(
10087 _installed: &ListMatch,
10088 known_symlink: &Option<String>,
10089 install_location: Option<&Path>,
10090) {
10091 if let Some(path) = known_symlink.as_deref() {
10092 let _ = fs::remove_file(path);
10093 }
10094
10095 let Some(install_dir) = install_location else { return };
10100
10101 let Some(local) = dirs::data_local_dir() else { return };
10102 let links_root = local.join("Microsoft").join("WinGet").join("Links");
10103 let Ok(entries) = fs::read_dir(&links_root) else { return };
10104 for entry in entries.flatten() {
10105 let path = entry.path();
10106 let Ok(target) = fs::read_link(&path) else { continue };
10107 if path_starts_with_case_insensitive(&target, install_dir) {
10108 let _ = fs::remove_file(&path);
10109 }
10110 }
10111}
10112
10113#[cfg(windows)]
10120fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10121 let lower_components = |p: &Path| -> Vec<String> {
10122 p.components()
10123 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10124 .collect()
10125 };
10126 let parent_components = lower_components(parent);
10127 let child_components = lower_components(child);
10128 if parent_components.is_empty() || parent_components.len() > child_components.len() {
10129 return false;
10130 }
10131 parent_components
10132 .iter()
10133 .zip(child_components.iter())
10134 .all(|(a, b)| a == b)
10135}
10136
10137#[cfg(windows)]
10144fn try_remove_portable_registry_entry(installed: &ListMatch) {
10145 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10148 let mut parts = rest.splitn(3, '\\');
10150 let scope = parts.next().unwrap_or("");
10151 let _arch = parts.next();
10152 let subkey_name = parts.next().unwrap_or("");
10153 if subkey_name.is_empty() {
10154 return;
10155 }
10156 let is_hkcu = scope.eq_ignore_ascii_case("User");
10157 vec![(
10158 is_hkcu,
10159 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10160 )]
10161 } else {
10162 let mut found = Vec::new();
10164 for (is_hkcu, hive) in [
10165 (true, RegKey::predef(HKEY_CURRENT_USER)),
10166 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10167 ] {
10168 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10169 continue;
10170 };
10171 for name in uninstall.enum_keys().flatten() {
10172 let Ok(subkey) = uninstall.open_subkey(&name) else {
10173 continue;
10174 };
10175 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10176 found.push((
10177 is_hkcu,
10178 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10179 ));
10180 }
10181 }
10182 }
10183 found
10184 };
10185
10186 for (is_hkcu, path) in candidates {
10187 let hive = if is_hkcu {
10188 RegKey::predef(HKEY_CURRENT_USER)
10189 } else {
10190 RegKey::predef(HKEY_LOCAL_MACHINE)
10191 };
10192 let _ = hive.delete_subkey_all(&path);
10193 }
10194}
10195
10196#[cfg(windows)]
10197fn is_product_code_like(value: &str) -> bool {
10198 value.starts_with('{') && value.ends_with('}')
10199}
10200
10201#[cfg(not(windows))]
10202fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10203 bail!("Uninstalling packages is only supported on Windows")
10204}
10205
10206#[cfg(windows)]
10228fn install_portable(
10229 installer_path: &Path,
10230 request: &InstallRequest,
10231 manifest: &Manifest,
10232 installer: &Installer,
10233) -> Result<i32> {
10234 let source_identifier = portable_source_identifier(&request.query);
10235 let existing_entry = read_existing_portable_entry(&manifest.id);
10236 let subkey_name = existing_entry
10239 .as_ref()
10240 .map(|entry| entry.subkey_name.clone())
10241 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10242
10243 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10244
10245 let prev_dir_created = existing_entry
10250 .as_ref()
10251 .and_then(|entry| entry.install_directory_created)
10252 == Some(1);
10253 let dir_existed = target_dir.is_dir();
10254 if prev_dir_created && dir_existed {
10255 clean_directory_contents(&target_dir).with_context(|| {
10256 format!(
10257 "failed to clean existing portable install directory: {}",
10258 target_dir.display()
10259 )
10260 })?;
10261 }
10262
10263 fs::create_dir_all(&target_dir)
10264 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10265 let install_directory_created = !dir_existed || prev_dir_created;
10269
10270 let installer_type = installer.installer_type.as_deref().unwrap_or("");
10271 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10272 let file = fs::File::open(installer_path)
10273 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10274 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10275 archive
10276 .extract(&target_dir)
10277 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10278 installer
10282 .nested_installer_files
10283 .first()
10284 .map(|file| target_dir.join(&file.relative_file_path))
10285 .unwrap_or_else(|| target_dir.clone())
10286 } else {
10287 let basename = installer_path
10291 .file_name()
10292 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10293 let copied = target_dir.join(basename);
10294 fs::copy(installer_path, &copied)
10295 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10296 copied
10297 };
10298
10299 let alias = determine_portable_alias(installer);
10304 let symlink_full_path = match alias.as_deref() {
10305 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10306 _ => None,
10307 };
10308
10309 let added_to_path = if symlink_full_path.is_some() {
10313 try_add_links_dir_to_user_path()
10314 } else {
10315 false
10316 };
10317
10318 write_portable_arp_entry(&PortableArpEntry {
10319 subkey_name: &subkey_name,
10320 install_location: &target_dir,
10321 portable_target_full_path: &portable_target_full_path,
10322 portable_symlink_full_path: symlink_full_path.as_deref(),
10323 install_directory_created,
10324 added_to_path,
10325 source_identifier: &source_identifier,
10326 manifest,
10327 })?;
10328
10329 Ok(0)
10330}
10331
10332#[cfg(windows)]
10333struct PortableArpEntry<'a> {
10334 subkey_name: &'a str,
10335 install_location: &'a Path,
10336 portable_target_full_path: &'a Path,
10337 portable_symlink_full_path: Option<&'a Path>,
10338 install_directory_created: bool,
10339 added_to_path: bool,
10340 source_identifier: &'a str,
10341 manifest: &'a Manifest,
10342}
10343
10344#[cfg(windows)]
10350fn determine_portable_alias(installer: &Installer) -> Option<String> {
10351 installer
10352 .nested_installer_files
10353 .iter()
10354 .find_map(|file| file.portable_command_alias.clone())
10355 .or_else(|| installer.commands.first().cloned())
10356}
10357
10358#[cfg(windows)]
10359fn winget_links_dir() -> PathBuf {
10360 dirs::data_local_dir()
10361 .unwrap_or_else(|| PathBuf::from("."))
10362 .join("Microsoft")
10363 .join("WinGet")
10364 .join("Links")
10365}
10366
10367#[cfg(windows)]
10372fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10373 use std::os::windows::fs::symlink_file;
10374 let links_dir = winget_links_dir();
10375 if fs::create_dir_all(&links_dir).is_err() {
10376 return None;
10377 }
10378 let link_path = links_dir.join(format!("{alias}.exe"));
10379
10380 if link_path.symlink_metadata().is_ok() {
10383 let _ = fs::remove_file(&link_path);
10384 }
10385
10386 symlink_file(target, &link_path).ok().map(|()| link_path)
10391}
10392
10393#[cfg(windows)]
10399fn try_add_links_dir_to_user_path() -> bool {
10400 let links_dir = winget_links_dir();
10401 let links_str = links_dir.to_string_lossy().to_string();
10402 let normalized_links = links_str.to_ascii_lowercase();
10403
10404 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10405 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10406 return false;
10407 };
10408 let existing: String = env_key.get_value("Path").unwrap_or_default();
10409 let already_present = existing
10410 .split(';')
10411 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10412 if already_present {
10413 return false;
10414 }
10415
10416 let new_path = if existing.is_empty() {
10417 links_str
10418 } else if existing.ends_with(';') {
10419 format!("{existing}{links_str}")
10420 } else {
10421 format!("{existing};{links_str}")
10422 };
10423
10424 if env_key.set_value("Path", &new_path).is_err() {
10425 return false;
10426 }
10427
10428 broadcast_environment_change();
10429 true
10430}
10431
10432#[cfg(windows)]
10433fn broadcast_environment_change() {
10434 use std::ffi::OsStr;
10435 use std::os::windows::ffi::OsStrExt;
10436
10437 use windows_sys::Win32::UI::WindowsAndMessaging::{
10438 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10439 };
10440
10441 let param: Vec<u16> = OsStr::new("Environment")
10442 .encode_wide()
10443 .chain(std::iter::once(0))
10444 .collect();
10445 unsafe {
10449 let mut result: usize = 0;
10450 SendMessageTimeoutW(
10451 HWND_BROADCAST,
10452 WM_SETTINGCHANGE,
10453 0usize,
10454 param.as_ptr() as isize,
10455 SMTO_ABORTIFHUNG,
10456 5000,
10457 &mut result,
10458 );
10459 }
10460}
10461
10462#[cfg(windows)]
10467fn portable_source_identifier(query: &PackageQuery) -> String {
10468 match query.source.as_deref() {
10469 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10470 Some(name) if !name.is_empty() => name.to_owned(),
10471 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10472 }
10473}
10474
10475#[cfg(windows)]
10476fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10477 format!("{package_id}_{source_identifier}")
10478}
10479
10480#[cfg(windows)]
10481struct ExistingPortableEntry {
10482 install_location: Option<PathBuf>,
10483 install_directory_created: Option<u32>,
10484 subkey_name: String,
10485}
10486
10487#[cfg(windows)]
10488fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10489 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10490 let uninstall = hkcu
10491 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10492 .ok()?;
10493 for name in uninstall.enum_keys().flatten() {
10494 let Ok(subkey) = uninstall.open_subkey(&name) else {
10495 continue;
10496 };
10497 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10498 continue;
10499 }
10500 if !read_reg_string(&subkey, "WinGetInstallerType")
10501 .as_deref()
10502 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10503 {
10504 continue;
10505 }
10506 return Some(ExistingPortableEntry {
10507 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10508 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10509 subkey_name: name,
10510 });
10511 }
10512 None
10513}
10514
10515#[cfg(windows)]
10516fn resolve_portable_install_location(
10517 request: &InstallRequest,
10518 existing: &Option<ExistingPortableEntry>,
10519 subkey_name: &str,
10520) -> PathBuf {
10521 resolve_portable_install_location_pure(
10522 request.install_location.as_deref(),
10523 existing
10524 .as_ref()
10525 .and_then(|e| e.install_location.as_deref())
10526 .and_then(Path::to_str),
10527 subkey_name,
10528 &default_user_portable_root(),
10529 )
10530}
10531
10532#[cfg(windows)]
10537fn resolve_portable_install_location_pure(
10538 request_location: Option<&str>,
10539 existing_location: Option<&str>,
10540 subkey_name: &str,
10541 default_user_portable_root: &Path,
10542) -> PathBuf {
10543 if let Some(custom) = request_location
10544 && !custom.is_empty()
10545 {
10546 return PathBuf::from(custom);
10547 }
10548 if let Some(loc) = existing_location
10549 && !loc.is_empty()
10550 {
10551 return PathBuf::from(loc);
10552 }
10553 default_user_portable_root.join(subkey_name)
10554}
10555
10556#[cfg(windows)]
10557fn default_user_portable_root() -> PathBuf {
10558 dirs::data_local_dir()
10559 .unwrap_or_else(|| PathBuf::from("."))
10560 .join("Microsoft")
10561 .join("WinGet")
10562 .join("Packages")
10563}
10564
10565#[cfg(windows)]
10566fn clean_directory_contents(dir: &Path) -> Result<()> {
10567 if !dir.is_dir() {
10568 return Ok(());
10569 }
10570 for entry in fs::read_dir(dir)? {
10571 let entry = entry?;
10572 let path = entry.path();
10573 if path.is_dir() {
10574 fs::remove_dir_all(&path)?;
10575 } else {
10576 fs::remove_file(&path)?;
10577 }
10578 }
10579 Ok(())
10580}
10581
10582#[cfg(windows)]
10586fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10587 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10588 let uninstall_path = format!(
10589 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10590 entry.subkey_name
10591 );
10592 let (subkey, _) = hkcu
10593 .create_subkey(&uninstall_path)
10594 .context("failed to create portable ARP registry subkey")?;
10595
10596 let manifest = entry.manifest;
10597 let install_location_str = entry.install_location.to_string_lossy().to_string();
10598 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10599 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10600 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10601 subkey.set_value("InstallLocation", &install_location_str)?;
10602 subkey.set_value(
10603 "PortableTargetFullPath",
10604 &entry.portable_target_full_path.to_string_lossy().to_string(),
10605 )?;
10606 if let Some(symlink_path) = entry.portable_symlink_full_path {
10607 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10608 }
10609 subkey.set_value(
10610 "InstallDirectoryAddedToPath",
10611 &(if entry.added_to_path { 1u32 } else { 0u32 }),
10612 )?;
10613 subkey.set_value(
10614 "InstallDirectoryCreated",
10615 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10616 )?;
10617 subkey.set_value(
10618 "DisplayName",
10619 &if manifest.name.is_empty() {
10620 manifest.id.clone()
10621 } else {
10622 manifest.name.clone()
10623 },
10624 )?;
10625 subkey.set_value("DisplayVersion", &manifest.version)?;
10626 if let Some(publisher) = manifest.publisher.as_ref() {
10627 subkey.set_value("Publisher", publisher)?;
10628 }
10629 subkey.set_value(
10630 "UninstallString",
10631 &format!("winget uninstall --product-code {}", entry.subkey_name),
10632 )?;
10633 let today = Utc::now().format("%Y%m%d").to_string();
10634 subkey.set_value("InstallDate", &today)?;
10635 if let Some(url) = manifest.package_url.as_ref() {
10636 subkey.set_value("URLInfoAbout", url)?;
10637 }
10638
10639 Ok(())
10640}
10641
10642#[cfg(test)]
10643mod tests {
10644 use std::io::{Read as _, Write};
10645 use std::net::{SocketAddr, TcpListener, TcpStream};
10646 use std::path::{Path, PathBuf};
10647 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10648 use std::thread::{self, JoinHandle};
10649
10650 use flate2::Compression;
10651 use flate2::write::DeflateEncoder;
10652 use zip::write::SimpleFileOptions;
10653
10654 use super::*;
10655
10656 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10657 Manifest {
10658 id: id.to_owned(),
10659 name: name.to_owned(),
10660 version: version.to_owned(),
10661 channel: String::new(),
10662 publisher: Some("Acme".to_owned()),
10663 description: Some("desc".to_owned()),
10664 moniker: None,
10665 package_url: None,
10666 publisher_url: None,
10667 publisher_support_url: None,
10668 license: None,
10669 license_url: None,
10670 privacy_url: None,
10671 author: None,
10672 copyright: None,
10673 copyright_url: None,
10674 release_notes: None,
10675 release_notes_url: None,
10676 tags: Vec::new(),
10677 agreements: Vec::new(),
10678 package_dependencies: Vec::new(),
10679 documentation: Vec::new(),
10680 icons: Vec::new(),
10681 installers: Vec::new(),
10682 require_explicit_upgrade: false,
10683 }
10684 }
10685
10686 #[test]
10687 fn derive_download_filename_matches_winget_pattern() {
10688 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10689 let installer = Installer {
10690 architecture: Some("arm64".to_owned()),
10691 installer_type: Some("exe".to_owned()),
10692 scope: Some("user".to_owned()),
10693 locale: Some("en-US".to_owned()),
10694 ..Installer::default()
10695 };
10696 assert_eq!(
10697 derive_download_manifest_filename(&manifest, &installer),
10698 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10699 );
10700 }
10701
10702 #[test]
10703 fn derive_download_filename_omits_optional_components() {
10704 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10705 let installer = Installer {
10706 architecture: Some("x64".to_owned()),
10707 installer_type: Some("exe".to_owned()),
10708 ..Installer::default()
10709 };
10710 assert_eq!(
10712 derive_download_manifest_filename(&manifest, &installer),
10713 "7-Zip_26.01_X64_exe.yaml"
10714 );
10715 }
10716
10717 #[test]
10718 fn derive_download_filename_skips_unknown_version() {
10719 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10720 let installer = Installer {
10721 architecture: Some("neutral".to_owned()),
10722 installer_type: Some("msi".to_owned()),
10723 ..Installer::default()
10724 };
10725 assert_eq!(
10726 derive_download_manifest_filename(&manifest, &installer),
10727 "Test_Neutral_msi.yaml"
10728 );
10729 }
10730
10731 #[test]
10732 fn derive_download_filename_sanitizes_illegal_chars() {
10733 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10734 let installer = Installer {
10735 architecture: Some("x64".to_owned()),
10736 installer_type: Some("exe".to_owned()),
10737 ..Installer::default()
10738 };
10739 let filename = derive_download_manifest_filename(&manifest, &installer);
10741 assert!(!filename.contains('/') && !filename.contains(':'));
10742 assert!(filename.starts_with("Some_Bad_Name_1.0"));
10743 }
10744
10745 #[test]
10746 fn build_download_manifest_yaml_overrides_identity_fields() {
10747 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10748 let documents = serde_json::json!({
10751 "PackageIdentifier": "stale-id",
10752 "PackageVersion": "",
10753 "PackageName": "Old Name",
10754 "Channel": "preview",
10755 "Installers": [
10756 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10757 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10758 ],
10759 });
10760 let installer = Installer {
10761 architecture: Some("x64".to_owned()),
10762 installer_type: Some("exe".to_owned()),
10763 url: Some("https://example.com/a".to_owned()),
10764 ..Installer::default()
10765 };
10766 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10767 assert!(yaml.contains("PackageIdentifier: App.Id"));
10768 assert!(yaml.contains("PackageVersion: 2.0.0"));
10769 assert!(yaml.contains("PackageName: App Name"));
10770 assert!(yaml.contains("ManifestType: merged"));
10771 assert!(!yaml.contains("Channel: preview"));
10774 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
10776 assert!(yaml.contains("https://example.com/a"));
10777 assert!(!yaml.contains("https://example.com/b"));
10778 }
10779
10780 #[test]
10781 fn build_download_manifest_yaml_preserves_typed_channel() {
10782 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10783 manifest.channel = "beta".to_owned();
10784 let documents = serde_json::json!({
10785 "PackageIdentifier": "App.Id",
10786 "PackageVersion": "2.0.0",
10787 "Installers": [],
10788 });
10789 let installer = Installer::default();
10790 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10791 assert!(yaml.contains("Channel: beta"));
10792 }
10793
10794 fn temp_app_root(label: &str) -> PathBuf {
10795 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
10796 std::env::temp_dir().join(format!(
10797 "pinget-rs-tests-{label}-{}-{}",
10798 std::process::id(),
10799 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
10800 ))
10801 }
10802
10803 #[cfg(windows)]
10804 #[test]
10805 fn path_prefix_check_rejects_string_prefix_collisions() {
10806 assert!(!path_starts_with_case_insensitive(
10810 Path::new(r"C:\Foobar\bin.exe"),
10811 Path::new(r"C:\Foo"),
10812 ));
10813 assert!(!path_starts_with_case_insensitive(
10814 Path::new(r"C:\Foo\bin.exe.bak"),
10815 Path::new(r"C:\Foo\bin.exe"),
10816 ));
10817 }
10818
10819 #[cfg(windows)]
10820 #[test]
10821 fn path_prefix_check_accepts_real_children_and_self() {
10822 assert!(path_starts_with_case_insensitive(
10824 Path::new(r"C:\Foo\sub\bin.exe"),
10825 Path::new(r"C:\Foo"),
10826 ));
10827 assert!(path_starts_with_case_insensitive(
10829 Path::new(r"C:\Foo\bin.exe"),
10830 Path::new(r"C:\Foo\bin.exe"),
10831 ));
10832 }
10833
10834 #[cfg(windows)]
10835 #[test]
10836 fn path_prefix_check_is_case_insensitive() {
10837 assert!(path_starts_with_case_insensitive(
10840 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
10841 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
10842 ));
10843 }
10844
10845 #[cfg(windows)]
10846 #[test]
10847 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
10848 let query = PackageQuery {
10849 source: Some("winget".to_owned()),
10850 ..PackageQuery::default()
10851 };
10852 assert_eq!(
10853 portable_source_identifier(&query),
10854 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10855 );
10856 let query = PackageQuery {
10858 source: Some("WINGET".to_owned()),
10859 ..PackageQuery::default()
10860 };
10861 assert_eq!(
10862 portable_source_identifier(&query),
10863 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10864 );
10865 }
10866
10867 #[cfg(windows)]
10868 #[test]
10869 fn portable_source_identifier_passes_through_custom_sources() {
10870 let query = PackageQuery {
10871 source: Some("internal-feed".to_owned()),
10872 ..PackageQuery::default()
10873 };
10874 assert_eq!(portable_source_identifier(&query), "internal-feed");
10875 }
10876
10877 #[cfg(windows)]
10878 #[test]
10879 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
10880 let query = PackageQuery {
10881 source: None,
10882 ..PackageQuery::default()
10883 };
10884 assert_eq!(
10885 portable_source_identifier(&query),
10886 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10887 );
10888 let query = PackageQuery {
10889 source: Some(String::new()),
10890 ..PackageQuery::default()
10891 };
10892 assert_eq!(
10893 portable_source_identifier(&query),
10894 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10895 );
10896 }
10897
10898 #[cfg(windows)]
10899 #[test]
10900 fn portable_subkey_name_matches_winget_format() {
10901 assert_eq!(
10902 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
10903 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
10904 );
10905 }
10906
10907 #[cfg(windows)]
10908 #[test]
10909 fn determine_portable_alias_prefers_nested_installer_alias() {
10910 let installer = Installer {
10911 nested_installer_files: vec![NestedInstallerFile {
10912 relative_file_path: "ripgrep/rg.exe".to_owned(),
10913 portable_command_alias: Some("rg".to_owned()),
10914 }],
10915 commands: vec!["other-name".to_owned()],
10916 ..Installer::default()
10917 };
10918 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
10919 }
10920
10921 #[cfg(windows)]
10922 #[test]
10923 fn determine_portable_alias_falls_back_to_commands() {
10924 let installer = Installer {
10925 commands: vec!["nuget".to_owned()],
10927 ..Installer::default()
10928 };
10929 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
10930
10931 let installer = Installer {
10933 nested_installer_files: vec![NestedInstallerFile {
10934 relative_file_path: "subdir/bin.exe".to_owned(),
10935 portable_command_alias: None,
10936 }],
10937 commands: vec!["bin".to_owned()],
10938 ..Installer::default()
10939 };
10940 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
10941 }
10942
10943 #[cfg(windows)]
10944 #[test]
10945 fn determine_portable_alias_returns_none_without_alias_or_commands() {
10946 let installer = Installer::default();
10947 assert!(determine_portable_alias(&installer).is_none());
10948 }
10949
10950 #[cfg(windows)]
10951 #[test]
10952 fn resolve_portable_install_location_prefers_request_override() {
10953 let result = resolve_portable_install_location_pure(
10954 Some(r"D:\Tools\rg"),
10955 Some(r"C:\Software\rg-test"),
10956 "Sub_Source",
10957 Path::new(r"C:\default"),
10958 );
10959 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
10960 }
10961
10962 #[cfg(windows)]
10963 #[test]
10964 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
10965 let result = resolve_portable_install_location_pure(
10969 None,
10970 Some(r"C:\Software\rg-test"),
10971 "Sub_Source",
10972 Path::new(r"C:\default"),
10973 );
10974 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
10975 }
10976
10977 #[cfg(windows)]
10978 #[test]
10979 fn resolve_portable_install_location_falls_back_to_default_root() {
10980 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
10981 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10982 }
10983
10984 #[cfg(windows)]
10985 #[test]
10986 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
10987 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
10990 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10991 }
10992
10993 #[cfg(windows)]
10994 #[test]
10995 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
10996 let root = std::env::temp_dir().join(format!(
10997 "pinget-clean-test-{}",
10998 Utc::now().timestamp_nanos_opt().unwrap_or_default()
10999 ));
11000 fs::create_dir_all(&root).expect("create temp dir");
11001 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11002 fs::create_dir_all(root.join("nested")).expect("create nested dir");
11003 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11004
11005 clean_directory_contents(&root).expect("clean root contents");
11006
11007 assert!(root.is_dir(), "root dir itself must survive");
11008 assert_eq!(
11009 fs::read_dir(&root).expect("read root").count(),
11010 0,
11011 "root must be empty after clean"
11012 );
11013 let _ = fs::remove_dir_all(&root);
11014 }
11015
11016 #[cfg(windows)]
11017 #[test]
11018 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11019 let bogus = std::env::temp_dir().join(format!(
11020 "pinget-clean-test-bogus-{}",
11021 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11022 ));
11023 assert!(!bogus.exists());
11024 clean_directory_contents(&bogus).expect("clean nonexistent dir");
11025 assert!(!bogus.exists());
11027 }
11028
11029 #[cfg(windows)]
11030 #[test]
11031 fn path_prefix_check_handles_mixed_separators() {
11032 assert!(path_starts_with_case_insensitive(
11035 Path::new(r"C:\Foo\sub/bin.exe"),
11036 Path::new(r"C:\Foo"),
11037 ));
11038 }
11039
11040 #[test]
11041 fn repository_options_capture_custom_host_settings() {
11042 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11043 .with_user_agent("pinget-rs-tests/1.0")
11044 .with_pre_indexed_source_auto_update_interval(None);
11045
11046 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11047 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11048 assert!(options.pre_indexed_source_auto_update_interval.is_none());
11049 }
11050
11051 #[test]
11052 fn dependency_install_requests_are_silent_and_not_forced() {
11053 let mut parent = InstallRequest::new(PackageQuery {
11054 source: Some("winget".to_owned()),
11055 ..PackageQuery::default()
11056 });
11057 parent.mode = InstallerMode::SilentWithProgress;
11058 parent.force = true;
11059 parent.accept_package_agreements = true;
11060 parent.ignore_security_hash = true;
11061 parent.dependency_source = Some("dependencies".to_owned());
11062
11063 let dependency =
11064 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11065
11066 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11067 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11068 assert_eq!(dependency.mode, InstallerMode::Silent);
11069 assert!(!dependency.force);
11070 assert!(dependency.no_upgrade);
11071 assert!(dependency.accept_package_agreements);
11072 assert!(dependency.ignore_security_hash);
11073 }
11074
11075 #[test]
11076 fn storage_paths_use_configured_app_root() {
11077 let app_root = PathBuf::from(r"C:\temp\pinget-test");
11078 let source = SourceRecord {
11079 name: "winget/test".to_owned(),
11080 kind: SourceKind::PreIndexed,
11081 arg: "https://example.com/cache".to_owned(),
11082 identifier: "Test.Source".to_owned(),
11083 trust_level: "None".to_owned(),
11084 explicit: false,
11085 priority: 0,
11086 last_update: None,
11087 source_version: None,
11088 etag: None,
11089 last_modified: None,
11090 };
11091
11092 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11093 assert_eq!(
11094 source_state_dir(&app_root, &source),
11095 app_root.join("sources").join("winget_test")
11096 );
11097 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11098 }
11099
11100 #[cfg(windows)]
11101 #[test]
11102 fn packaged_layout_paths_resolve_from_packaged_app_root() {
11103 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11104 let app_root = local_app_data
11105 .join("Packages")
11106 .join(PACKAGED_FAMILY_NAME)
11107 .join("LocalState");
11108 let source = SourceStore::default()
11109 .sources
11110 .into_iter()
11111 .find(|source| source.name == "winget")
11112 .expect("winget source");
11113 assert!(uses_packaged_layout(&app_root));
11114 assert!(
11115 user_settings_path(&app_root)
11116 .display()
11117 .to_string()
11118 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11119 );
11120 assert!(
11121 packaged_file_cache_root(&app_root)
11122 .display()
11123 .to_string()
11124 .ends_with(&format!(
11125 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11126 PACKAGED_FAMILY_NAME
11127 ))
11128 );
11129 assert!(
11130 source_state_dir(&app_root, &source)
11131 .display()
11132 .to_string()
11133 .ends_with(&format!(
11134 r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11135 PACKAGED_FAMILY_NAME
11136 ))
11137 );
11138 assert!(
11139 source_installed_db_path_with_keying(&app_root, &source, false)
11140 .display()
11141 .to_string()
11142 .ends_with(&format!(
11143 r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11144 PACKAGED_FAMILY_NAME
11145 ))
11146 );
11147 }
11148
11149 #[cfg(windows)]
11150 #[test]
11151 fn default_app_root_outside_package_avoids_packaged_layout() {
11152 let prior = std::env::var_os("PINGET_APPROOT");
11156 unsafe { std::env::remove_var("PINGET_APPROOT") };
11158
11159 let result = default_app_root();
11160
11161 if let Some(prior) = prior {
11162 unsafe { std::env::set_var("PINGET_APPROOT", prior) };
11164 }
11165
11166 let app_root = result.expect("default app root");
11167 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11168 assert!(
11169 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11170 "unexpected fallback app root: {app_root:?}"
11171 );
11172 }
11173
11174 #[test]
11175 fn packaged_source_stream_overlays_defaults_and_metadata() {
11176 let store = parse_packaged_source_store(
11177 Some(
11178 "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",
11179 ),
11180 Some(
11181 "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",
11182 ),
11183 )
11184 .expect("packaged store");
11185
11186 assert!(!store.sources.iter().any(|source| source.name == "winget"));
11187 let corp = store
11188 .sources
11189 .iter()
11190 .find(|source| source.name == "corp")
11191 .expect("corp source");
11192 assert_eq!(corp.kind, SourceKind::Rest);
11193 assert_eq!(corp.identifier, "Contoso.Rest");
11194 assert_eq!(corp.trust_level, "Trusted");
11195 assert!(corp.explicit);
11196 assert_eq!(corp.priority, 7);
11197 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11198 assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11199 assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11200 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11201 }
11202
11203 #[test]
11204 fn system_winget_export_parses_json_lines() {
11205 let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11206{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11207{"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"}
11208{"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"}
11209"#;
11210
11211 let sources = parse_system_winget_source_export(output).expect("source export");
11212
11213 assert_eq!(
11214 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11215 vec!["winget.pro", "msstore", "winget", "winget-font"]
11216 );
11217 assert_eq!(sources[0].kind, SourceKind::Rest);
11218 assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11219 assert_eq!(sources[0].identifier, "api.winget.pro");
11220 assert_eq!(sources[0].trust_level, "Trusted");
11221 assert!(!sources[0].explicit);
11222 assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11223 assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11224 assert!(!sources[2].explicit);
11225 }
11226
11227 #[test]
11228 fn system_winget_export_parses_wrapped_sources() {
11229 let output = r#"{
11230 "Sources": [
11231 {
11232 "Name": "corp",
11233 "Type": "Microsoft.Rest",
11234 "Arg": "https://packages.contoso.test/api",
11235 "Identifier": "Contoso.Rest",
11236 "TrustLevel": "Trusted",
11237 "Explicit": true,
11238 "Priority": 9
11239 }
11240 ]
11241}"#;
11242
11243 let sources = parse_system_winget_source_export(output).expect("source export");
11244 let source = sources.first().expect("source");
11245 assert_eq!(sources.len(), 1);
11246 assert_eq!(source.name, "corp");
11247 assert_eq!(source.kind, SourceKind::Rest);
11248 assert_eq!(source.identifier, "Contoso.Rest");
11249 assert_eq!(source.trust_level, "Trusted");
11250 assert!(source.explicit);
11251 assert_eq!(source.priority, 9);
11252 }
11253
11254 #[test]
11255 fn system_winget_source_store_detects_secure_settings_stub() {
11256 assert!(is_secure_settings_stub(Some(
11257 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11258 )));
11259 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
11260 }
11261
11262 #[test]
11263 fn system_winget_source_store_builds_add_arguments() {
11264 let args = build_winget_source_add_arguments(
11265 "corp",
11266 "https://packages.contoso.test/api",
11267 SourceKind::Rest,
11268 "Trusted",
11269 true,
11270 );
11271
11272 assert_eq!(
11273 args,
11274 vec![
11275 "source",
11276 "add",
11277 "--name",
11278 "corp",
11279 "--arg",
11280 "https://packages.contoso.test/api",
11281 "--type",
11282 "Microsoft.Rest",
11283 "--disable-interactivity",
11284 "--trust-level",
11285 "trusted",
11286 "--explicit",
11287 ]
11288 );
11289 }
11290
11291 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11292 Ok(WingetSourceCommandResult {
11293 exit_code: 0,
11294 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11295"#
11296 .to_owned(),
11297 stderr: String::new(),
11298 })
11299 }
11300
11301 #[test]
11302 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11303 let original_runner = {
11304 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11305 let original = *runner;
11306 *runner = fake_system_winget_source_export;
11307 original
11308 };
11309
11310 let result = load_packaged_store_from_streams(
11311 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11312 None,
11313 );
11314
11315 {
11316 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11317 *runner = original_runner;
11318 }
11319
11320 let store = result.expect("packaged store").expect("packaged store");
11321 let source = store.sources.first().expect("source");
11322 assert_eq!(store.sources.len(), 1);
11323 assert_eq!(source.name, "contoso");
11324 assert_eq!(source.kind, SourceKind::Rest);
11325 }
11326
11327 #[test]
11328 fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11329 let original_runner = {
11330 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11331 let original = *runner;
11332 *runner = fake_system_winget_source_export;
11333 original
11334 };
11335
11336 let app_root = temp_app_root("system-winget-mirror");
11337 let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11338
11339 {
11340 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11341 *runner = original_runner;
11342 }
11343
11344 let (mode, mut store) = result.expect("mirror store");
11345 assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11346 assert!(system_winget_mirror_store_path(&app_root).exists());
11347 assert!(
11348 !store_path(&app_root).exists(),
11349 "mirror mode must not write private sources.json"
11350 );
11351
11352 let source = store.sources.first_mut().expect("source");
11353 source.last_update = Some(Utc::now());
11354 source.source_version = Some("cached-contract".to_owned());
11355 source.etag = Some("\"etag\"".to_owned());
11356 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11357 save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11358
11359 let original_runner = {
11360 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11361 let original = *runner;
11362 *runner = fake_system_winget_source_export;
11363 original
11364 };
11365 let refreshed = refresh_system_winget_mirror_store(&app_root);
11366 {
11367 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11368 *runner = original_runner;
11369 }
11370
11371 let refreshed = refreshed.expect("refresh mirror");
11372 let source = refreshed.sources.first().expect("source");
11373 assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11374 assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11375 assert_eq!(
11376 source_state_dir_with_keying(&app_root, source, true),
11377 app_root.join("sources").join("api.contoso.test")
11378 );
11379 assert_eq!(
11380 source_state_dir_with_keying(&app_root, source, false),
11381 app_root.join("sources").join("contoso")
11382 );
11383
11384 let _ = fs::remove_dir_all(&app_root);
11385 }
11386
11387 #[test]
11388 fn source_metadata_and_named_reset_round_trip() {
11389 let app_root = temp_app_root("source-reset");
11390 let result = (|| -> Result<()> {
11391 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11392 repository.add_source_with_metadata(
11393 "test",
11394 "https://example.com/test",
11395 SourceKind::Rest,
11396 Some("trusted"),
11397 true,
11398 4,
11399 )?;
11400
11401 repository.edit_source("test", Some(false), None)?;
11402 let edited = repository
11403 .list_sources()
11404 .into_iter()
11405 .find(|source| source.name == "test")
11406 .expect("source");
11407 assert_eq!(edited.trust_level, "Trusted");
11408 assert!(!edited.explicit);
11409 assert_eq!(edited.priority, 4);
11410
11411 repository.reset_source("test")?;
11412 let reset = repository
11413 .list_sources()
11414 .into_iter()
11415 .find(|source| source.name == "test")
11416 .expect("source");
11417 assert_eq!(reset.trust_level, "Trusted");
11418 assert!(!reset.explicit);
11419 assert_eq!(reset.priority, 4);
11420 assert!(reset.last_update.is_none());
11421 assert!(reset.source_version.is_none());
11422 Ok(())
11423 })();
11424
11425 let _ = fs::remove_dir_all(&app_root);
11426 result.expect("source metadata");
11427 }
11428
11429 #[test]
11430 fn explicit_sources_are_skipped_by_default_search_resolution() {
11431 let app_root = temp_app_root("source-resolution");
11432 let result = (|| -> Result<()> {
11433 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11434 repository.add_source_with_metadata(
11435 "explicit-test",
11436 "https://example.com/explicit",
11437 SourceKind::Rest,
11438 Some("trusted"),
11439 true,
11440 10,
11441 )?;
11442
11443 let indexes = repository.resolve_source_indexes(None)?;
11444 let names: Vec<_> = indexes
11445 .into_iter()
11446 .map(|index| repository.list_sources()[index].name.clone())
11447 .collect();
11448 assert!(!names.iter().any(|name| name == "explicit-test"));
11449 Ok(())
11450 })();
11451
11452 let _ = fs::remove_dir_all(&app_root);
11453 result.expect("source resolution");
11454 }
11455
11456 #[test]
11457 fn source_resolution_accepts_source_identifier() {
11458 let app_root = temp_app_root("source-identifier-resolution");
11459 let result = (|| -> Result<()> {
11460 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11461 repository.add_source_with_metadata(
11462 "winget.pro",
11463 "https://api.winget.pro/feed",
11464 SourceKind::Rest,
11465 Some("trusted"),
11466 false,
11467 0,
11468 )?;
11469 repository.store.sources[0].identifier = "api.winget.pro".to_owned();
11470
11471 let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
11472 assert_eq!(indexes, vec![0]);
11473 Ok(())
11474 })();
11475
11476 let _ = fs::remove_dir_all(&app_root);
11477 result.expect("source identifier resolution");
11478 }
11479
11480 #[test]
11481 fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
11482 let query = PackageQuery {
11483 id: Some("tessl.tessl".to_owned()),
11484 source: Some("winget".to_owned()),
11485 exact: true,
11486 ..PackageQuery::default()
11487 };
11488 let clean_miss = SearchLocatedResult {
11489 matches: Vec::new(),
11490 warnings: Vec::new(),
11491 failures: Vec::new(),
11492 truncated: false,
11493 };
11494 assert!(should_retry_show_across_system_winget_sources(
11495 true,
11496 &query,
11497 &clean_miss
11498 ));
11499
11500 let private_mode = false;
11501 assert!(!should_retry_show_across_system_winget_sources(
11502 private_mode,
11503 &query,
11504 &clean_miss
11505 ));
11506
11507 let explicit_custom_source = PackageQuery {
11508 source: Some("winget.pro".to_owned()),
11509 ..query.clone()
11510 };
11511 assert!(!should_retry_show_across_system_winget_sources(
11512 true,
11513 &explicit_custom_source,
11514 &clean_miss
11515 ));
11516
11517 let failed_source = SearchLocatedResult {
11518 failures: vec![SourceSearchFailure {
11519 source_name: "winget".to_owned(),
11520 message: "network failure".to_owned(),
11521 }],
11522 ..clean_miss
11523 };
11524 assert!(!should_retry_show_across_system_winget_sources(
11525 true,
11526 &query,
11527 &failed_source
11528 ));
11529 }
11530
11531 #[test]
11532 fn user_and_admin_settings_round_trip() {
11533 let app_root = temp_app_root("settings");
11534 let result = (|| -> Result<()> {
11535 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11536 repository.set_user_settings(
11537 &serde_json::json!({
11538 "visual": {
11539 "progressBar": "retro"
11540 }
11541 }),
11542 false,
11543 )?;
11544 repository.set_user_settings(
11545 &serde_json::json!({
11546 "experimentalFeatures": {
11547 "directMSI": true
11548 }
11549 }),
11550 true,
11551 )?;
11552
11553 let user_settings = repository.get_user_settings()?;
11554 assert_eq!(
11555 user_settings["visual"]["progressBar"],
11556 serde_json::Value::String("retro".to_owned())
11557 );
11558 assert_eq!(
11559 user_settings["experimentalFeatures"]["directMSI"],
11560 serde_json::Value::Bool(true)
11561 );
11562 assert!(repository.test_user_settings(
11563 &serde_json::json!({
11564 "experimentalFeatures": {
11565 "directMSI": true
11566 }
11567 }),
11568 true
11569 )?);
11570
11571 repository.set_admin_setting("LocalManifestFiles", true)?;
11572 repository.set_admin_setting("InstallerHashOverride", true)?;
11573 let admin_settings = repository.get_admin_settings()?;
11574 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
11575 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
11576
11577 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
11578 let reset_one = repository.get_admin_settings()?;
11579 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
11580
11581 repository.reset_admin_setting(None, true)?;
11582 let reset_all = repository.get_admin_settings()?;
11583 for name in Repository::supported_admin_settings() {
11584 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
11585 }
11586 Ok(())
11587 })();
11588
11589 let _ = fs::remove_dir_all(&app_root);
11590 result.expect("settings");
11591 }
11592
11593 #[test]
11594 fn show_result_structured_document_is_manifest_oriented() {
11595 let result = ShowResult {
11596 package: SearchMatch {
11597 source_name: "winget".to_owned(),
11598 source_kind: SourceKind::PreIndexed,
11599 id: "Test.Package".to_owned(),
11600 name: "Test Package".to_owned(),
11601 moniker: Some("testpkg".to_owned()),
11602 version: Some("1.2.3".to_owned()),
11603 channel: Some("stable".to_owned()),
11604 match_criteria: Some("Id".to_owned()),
11605 },
11606 manifest: Manifest {
11607 id: "Test.Package".to_owned(),
11608 name: "Test Package".to_owned(),
11609 version: "1.2.3".to_owned(),
11610 channel: "stable".to_owned(),
11611 publisher: Some("Contoso".to_owned()),
11612 description: Some("Structured output".to_owned()),
11613 moniker: Some("testpkg".to_owned()),
11614 package_url: None,
11615 publisher_url: None,
11616 publisher_support_url: None,
11617 license: None,
11618 license_url: None,
11619 privacy_url: None,
11620 author: None,
11621 copyright: None,
11622 copyright_url: None,
11623 release_notes: None,
11624 release_notes_url: None,
11625 tags: vec!["utility".to_owned()],
11626 agreements: Vec::new(),
11627 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
11628 documentation: vec![Documentation {
11629 label: Some("Docs".to_owned()),
11630 url: "https://example.test/docs".to_owned(),
11631 }],
11632 icons: vec![PackageIcon {
11633 icon_url: Some("https://example.test/default-256.ico".to_owned()),
11634 icon_file_type: Some("ico".to_owned()),
11635 icon_resolution: Some("256x256".to_owned()),
11636 icon_theme: Some("default".to_owned()),
11637 icon_sha256: Some("DEF456".to_owned()),
11638 }],
11639 installers: vec![Installer {
11640 architecture: Some("x64".to_owned()),
11641 installer_type: Some("msix".to_owned()),
11642 url: Some("https://example.test/Test.Package.msix".to_owned()),
11643 sha256: Some("ABC123".to_owned()),
11644 product_code: None,
11645 locale: Some("en-US".to_owned()),
11646 scope: Some("machine".to_owned()),
11647 release_date: None,
11648 package_family_name: None,
11649 upgrade_code: None,
11650 platforms: Vec::new(),
11651 minimum_os_version: None,
11652 switches: InstallerSwitches {
11653 silent: Some("/quiet".to_owned()),
11654 ..InstallerSwitches::default()
11655 },
11656 commands: vec!["testpkg".to_owned()],
11657 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11658 require_explicit_upgrade: false,
11659 nested_installer_type: None,
11660 nested_installer_files: Vec::new(),
11661 }],
11662 require_explicit_upgrade: false,
11663 },
11664 selected_installer: Some(Installer {
11665 architecture: Some("x64".to_owned()),
11666 installer_type: Some("msix".to_owned()),
11667 url: Some("https://example.test/Test.Package.msix".to_owned()),
11668 sha256: Some("ABC123".to_owned()),
11669 product_code: None,
11670 locale: Some("en-US".to_owned()),
11671 scope: Some("machine".to_owned()),
11672 release_date: None,
11673 package_family_name: None,
11674 upgrade_code: None,
11675 platforms: Vec::new(),
11676 minimum_os_version: None,
11677 switches: InstallerSwitches {
11678 silent: Some("/quiet".to_owned()),
11679 ..InstallerSwitches::default()
11680 },
11681 commands: vec!["testpkg".to_owned()],
11682 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11683 require_explicit_upgrade: false,
11684 nested_installer_type: None,
11685 nested_installer_files: Vec::new(),
11686 }),
11687 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
11688 warnings: vec!["cache warmed".to_owned()],
11689 manifest_documents: JsonValue::Array(vec![
11690 serde_json::json!({
11691 "PackageIdentifier": "Test.Package",
11692 "PackageVersion": "1.2.3",
11693 "DefaultLocale": "en-US",
11694 "ManifestType": "version",
11695 "ManifestVersion": "1.10.0"
11696 }),
11697 serde_json::json!({
11698 "PackageIdentifier": "Test.Package",
11699 "PackageVersion": "1.2.3",
11700 "PackageLocale": "en-US",
11701 "PackageName": "Test Package",
11702 "Publisher": "Example",
11703 "License": "MIT",
11704 "ShortDescription": "Structured output",
11705 "Icons": [
11706 {
11707 "IconUrl": "https://example.test/default-256.ico",
11708 "IconFileType": "ico",
11709 "IconResolution": "256x256",
11710 "IconTheme": "default",
11711 "IconSha256": "DEF456"
11712 }
11713 ],
11714 "ManifestType": "defaultLocale",
11715 "ManifestVersion": "1.10.0"
11716 }),
11717 serde_json::json!({
11718 "PackageIdentifier": "Test.Package",
11719 "PackageVersion": "1.2.3",
11720 "ManifestType": "installer",
11721 "ManifestVersion": "1.10.0",
11722 "Installers": [
11723 {
11724 "Architecture": "x64",
11725 "InstallerType": "msix",
11726 "InstallerUrl": "https://example.test/Test.Package.msix",
11727 "InstallerSha256": "ABC123",
11728 "Commands": ["testpkg"],
11729 "InstallerSwitches": { "Silent": "/quiet" },
11730 "Dependencies": {
11731 "PackageDependencies": [
11732 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
11733 ]
11734 }
11735 }
11736 ]
11737 }),
11738 ]),
11739 };
11740
11741 let document = result.structured_document();
11742
11743 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
11744 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
11745 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
11746 assert_eq!(
11747 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
11748 Some("Microsoft.VCRedist.2015+.x64")
11749 );
11750 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
11751 assert_eq!(
11752 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
11753 Some("/quiet")
11754 );
11755 assert_eq!(
11756 document["IconUrl"].as_str(),
11757 Some("https://example.test/default-256.ico")
11758 );
11759 assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
11760 assert_eq!(
11761 document["Icons"][0]["IconUrl"].as_str(),
11762 Some("https://example.test/default-256.ico")
11763 );
11764 }
11765
11766 #[test]
11767 fn manifest_parses_require_explicit_upgrade_at_top_level() {
11768 let yaml = r#"
11773PackageIdentifier: Test.Package
11774PackageVersion: 1.2.3
11775DefaultLocale: en-US
11776ManifestType: singleton
11777ManifestVersion: 1.10.0
11778PackageLocale: en-US
11779PackageName: Test Package
11780Publisher: Example
11781License: MIT
11782ShortDescription: explicit-upgrade fixture
11783RequireExplicitUpgrade: true
11784Installers:
11785 - Architecture: x64
11786 InstallerType: exe
11787 InstallerUrl: https://example.test/Test.Package.exe
11788 InstallerSha256: ABC123
11789"#;
11790 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11791 assert!(manifest.require_explicit_upgrade);
11792 }
11793
11794 #[test]
11795 fn manifest_parses_require_explicit_upgrade_on_installer() {
11796 let yaml = r#"
11800PackageIdentifier: Test.Package
11801PackageVersion: 1.2.3
11802DefaultLocale: en-US
11803ManifestType: singleton
11804ManifestVersion: 1.10.0
11805PackageLocale: en-US
11806PackageName: Test Package
11807Publisher: Example
11808License: MIT
11809ShortDescription: explicit-upgrade fixture
11810Installers:
11811 - Architecture: x64
11812 InstallerType: exe
11813 InstallerUrl: https://example.test/Test.Package.x64.exe
11814 InstallerSha256: ABC123
11815 - Architecture: arm64
11816 InstallerType: exe
11817 InstallerUrl: https://example.test/Test.Package.arm64.exe
11818 InstallerSha256: DEF456
11819 RequireExplicitUpgrade: true
11820"#;
11821 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11822 assert!(manifest.require_explicit_upgrade);
11823 assert!(!manifest.installers[0].require_explicit_upgrade);
11824 assert!(manifest.installers[1].require_explicit_upgrade);
11825 }
11826
11827 #[test]
11828 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
11829 let yaml = r#"
11830PackageIdentifier: Test.Package
11831PackageVersion: 1.2.3
11832DefaultLocale: en-US
11833ManifestType: singleton
11834ManifestVersion: 1.10.0
11835PackageLocale: en-US
11836PackageName: Test Package
11837Publisher: Example
11838License: MIT
11839ShortDescription: baseline fixture
11840Installers:
11841 - Architecture: x64
11842 InstallerType: exe
11843 InstallerUrl: https://example.test/Test.Package.exe
11844 InstallerSha256: ABC123
11845"#;
11846 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11847 assert!(!manifest.require_explicit_upgrade);
11848 }
11849
11850 #[test]
11851 fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
11852 let yaml = r#"
11853PackageIdentifier: Test.Package
11854PackageVersion: 1.2.3
11855DefaultLocale: en-US
11856ManifestType: singleton
11857ManifestVersion: 1.10.0
11858PackageLocale: en-US
11859PackageName: Test Package
11860Publisher: Example
11861License: MIT
11862ShortDescription: icon fixture
11863Icons:
11864 - IconUrl: https://example.test/dark-512.png
11865 IconFileType: png
11866 IconResolution: 512x512
11867 IconTheme: dark
11868 IconSha256: DARK
11869 - IconUrl: https://example.test/default-64.png
11870 IconFileType: png
11871 IconResolution: 64x64
11872 IconTheme: default
11873 - IconUrl: https://example.test/default-256.ico
11874 IconFileType: ico
11875 IconResolution: 256x256
11876 IconTheme: default
11877 - IconFileType: png
11878 IconResolution: 1024x1024
11879Installers:
11880 - Architecture: x64
11881 InstallerType: exe
11882 InstallerUrl: https://example.test/Test.Package.exe
11883 InstallerSha256: ABC123
11884"#;
11885 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11886
11887 assert_eq!(manifest.icons.len(), 4);
11888 assert_eq!(
11889 manifest.icons[0].icon_url.as_deref(),
11890 Some("https://example.test/dark-512.png")
11891 );
11892 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11893 let selected = manifest.icon().expect("selected icon");
11894 assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
11895 assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
11896 }
11897
11898 #[test]
11899 fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
11900 let yaml = r#"
11901PackageIdentifier: Test.Package
11902PackageVersion: 1.2.3
11903DefaultLocale: en-US
11904ManifestType: singleton
11905ManifestVersion: 1.10.0
11906PackageLocale: en-US
11907PackageName: Test Package
11908Publisher: Example
11909License: MIT
11910ShortDescription: no-icon fixture
11911Installers:
11912 - Architecture: x64
11913 InstallerType: exe
11914 InstallerUrl: https://example.test/Test.Package.exe
11915 InstallerSha256: ABC123
11916"#;
11917 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11918
11919 assert!(manifest.icons.is_empty());
11920 assert!(manifest.icon().is_none());
11921 assert!(manifest.icon_url().is_none());
11922 }
11923
11924 #[test]
11925 fn rest_manifest_parses_icons_from_default_locale() {
11926 let response = serde_json::json!({
11927 "Data": {
11928 "PackageIdentifier": "Test.Package",
11929 "Versions": [
11930 {
11931 "PackageVersion": "1.2.3",
11932 "DefaultLocale": {
11933 "PackageName": "Test Package",
11934 "Publisher": "Example",
11935 "ShortDescription": "REST icon fixture",
11936 "Icons": [
11937 {
11938 "IconUrl": "https://example.test/default-128.png",
11939 "IconFileType": "png",
11940 "IconResolution": "128x128",
11941 "IconTheme": "default"
11942 },
11943 {
11944 "IconUrl": "https://example.test/default-256.ico",
11945 "IconFileType": "ico",
11946 "IconResolution": "256x256"
11947 }
11948 ]
11949 },
11950 "Installers": [
11951 {
11952 "Architecture": "x64",
11953 "InstallerType": "exe",
11954 "InstallerUrl": "https://example.test/Test.Package.exe",
11955 "InstallerSha256": "ABC123"
11956 }
11957 ],
11958 "ManifestVersion": "1.10.0"
11959 }
11960 ],
11961 "ManifestVersion": "1.10.0"
11962 }
11963 });
11964 let bytes = serde_json::to_vec(&response).expect("json");
11965
11966 let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
11967
11968 assert_eq!(manifest.icons.len(), 2);
11969 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11970 assert_eq!(
11971 document["Icons"][0]["IconUrl"].as_str(),
11972 Some("https://example.test/default-128.png")
11973 );
11974 }
11975
11976 #[test]
11977 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
11978 let mut pkg = InstalledPackage {
11982 name: "Edge".to_owned(),
11983 local_id: r"ARP\Machine\X64\Edge".to_owned(),
11984 installed_version: "100.0".to_owned(),
11985 publisher: None,
11986 scope: Some("Machine".to_owned()),
11987 installer_category: Some("exe".to_owned()),
11988 install_location: None,
11989 package_family_names: Vec::new(),
11990 product_codes: Vec::new(),
11991 upgrade_codes: Vec::new(),
11992 correlated: Some(SearchMatch {
11993 source_name: "winget".to_owned(),
11994 source_kind: SourceKind::PreIndexed,
11995 id: "Microsoft.Edge".to_owned(),
11996 name: "Microsoft Edge".to_owned(),
11997 moniker: None,
11998 version: Some("110.0".to_owned()),
11999 channel: None,
12000 match_criteria: None,
12001 }),
12002 winget_package_identifier: None,
12003 winget_source_identifier: None,
12004 installed_version_canonical: false,
12005 correlated_requires_explicit_upgrade: true,
12006 correlated_lacks_compatible_installer: false,
12007 };
12008 let bulk_query = ListQuery {
12009 upgrade_only: true,
12010 ..ListQuery::default()
12011 };
12012 assert!(
12013 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12014 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12015 );
12016
12017 let filtered_query = ListQuery {
12020 upgrade_only: true,
12021 id: Some("Microsoft.Edge".to_owned()),
12022 ..ListQuery::default()
12023 };
12024 assert!(
12025 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12026 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12027 );
12028
12029 pkg.correlated_requires_explicit_upgrade = false;
12031 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12032 }
12033
12034 #[test]
12035 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12036 let mut pkg = InstalledPackage {
12037 name: "Foo".to_owned(),
12038 local_id: r"ARP\Machine\X64\Foo".to_owned(),
12039 installed_version: "1.0".to_owned(),
12040 publisher: None,
12041 scope: Some("Machine".to_owned()),
12042 installer_category: Some("exe".to_owned()),
12043 install_location: None,
12044 package_family_names: Vec::new(),
12045 product_codes: Vec::new(),
12046 upgrade_codes: Vec::new(),
12047 correlated: Some(SearchMatch {
12048 source_name: "winget".to_owned(),
12049 source_kind: SourceKind::PreIndexed,
12050 id: "Test.Foo".to_owned(),
12051 name: "Foo".to_owned(),
12052 moniker: None,
12053 version: Some("2.0".to_owned()),
12054 channel: None,
12055 match_criteria: None,
12056 }),
12057 winget_package_identifier: None,
12058 winget_source_identifier: None,
12059 installed_version_canonical: false,
12060 correlated_requires_explicit_upgrade: false,
12061 correlated_lacks_compatible_installer: true,
12062 };
12063 let bulk_query = ListQuery {
12064 upgrade_only: true,
12065 ..ListQuery::default()
12066 };
12067 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12068
12069 let filtered_query = ListQuery {
12071 upgrade_only: true,
12072 id: Some("Test.Foo".to_owned()),
12073 ..ListQuery::default()
12074 };
12075 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12076
12077 pkg.correlated_lacks_compatible_installer = false;
12079 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12080 }
12081
12082 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12083 let installers = arches
12084 .iter()
12085 .map(|a| Installer {
12086 architecture: Some((*a).to_owned()),
12087 installer_type: None,
12088 url: None,
12089 sha256: None,
12090 product_code: None,
12091 locale: None,
12092 scope: None,
12093 release_date: None,
12094 package_family_name: None,
12095 upgrade_code: None,
12096 platforms: Vec::new(),
12097 minimum_os_version: None,
12098 switches: InstallerSwitches::default(),
12099 commands: Vec::new(),
12100 package_dependencies: Vec::new(),
12101 require_explicit_upgrade: false,
12102 nested_installer_type: None,
12103 nested_installer_files: Vec::new(),
12104 })
12105 .collect();
12106 Manifest {
12107 id: "Test".to_owned(),
12108 name: "Test".to_owned(),
12109 version: "1.0".to_owned(),
12110 channel: String::new(),
12111 publisher: None,
12112 description: None,
12113 moniker: None,
12114 package_url: None,
12115 publisher_url: None,
12116 publisher_support_url: None,
12117 license: None,
12118 license_url: None,
12119 privacy_url: None,
12120 author: None,
12121 copyright: None,
12122 copyright_url: None,
12123 release_notes: None,
12124 release_notes_url: None,
12125 tags: Vec::new(),
12126 agreements: Vec::new(),
12127 package_dependencies: Vec::new(),
12128 documentation: Vec::new(),
12129 icons: Vec::new(),
12130 installers,
12131 require_explicit_upgrade: false,
12132 }
12133 }
12134
12135 #[test]
12136 fn manifest_compatible_installer_neutral_always_matches() {
12137 assert!(manifest_has_compatible_installer(
12139 &synthetic_manifest_with_installer_arches(&["neutral"])
12140 ));
12141 }
12142
12143 #[test]
12144 fn manifest_compatible_installer_empty_installers_pass_through() {
12145 assert!(manifest_has_compatible_installer(
12148 &synthetic_manifest_with_installer_arches(&[])
12149 ));
12150 }
12151
12152 #[test]
12153 fn manifest_compatible_installer_rejects_alien_arch_only() {
12154 assert!(!manifest_has_compatible_installer(
12156 &synthetic_manifest_with_installer_arches(&["ppc"])
12157 ));
12158 }
12159
12160 #[test]
12161 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12162 assert!(manifest_has_compatible_installer(
12164 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12165 ));
12166 }
12167
12168 #[test]
12169 fn lookup_unique_normalized_identity_returns_unique_match() {
12170 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12172 execute_batch_sql(
12173 &connection,
12174 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12175 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12176 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12177 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12178 )
12179 .expect("seed schema");
12180
12181 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12182 .expect("query")
12183 .expect("unique match");
12184 assert_eq!(rowid, 100);
12185 }
12186
12187 #[test]
12188 fn lookup_installed_db_identity_match_returns_product_code_match() {
12189 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12190 execute_batch_sql(
12191 &connection,
12192 "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12193 CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12194 CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12195 CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12196 CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12197 INSERT INTO ids VALUES (1, 'Git.Git');\n\
12198 INSERT INTO names VALUES (1, 'Git');\n\
12199 INSERT INTO manifest VALUES (10, 1, 1);\n\
12200 INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12201 INSERT INTO productcodes_map VALUES (10, 20);",
12202 )
12203 .expect("seed schema");
12204 let package = InstalledPackage {
12205 name: "Git".to_owned(),
12206 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12207 installed_version: "2.54.0".to_owned(),
12208 publisher: None,
12209 scope: None,
12210 installer_category: None,
12211 install_location: None,
12212 package_family_names: Vec::new(),
12213 product_codes: vec!["git_is1".to_owned()],
12214 upgrade_codes: Vec::new(),
12215 correlated: None,
12216 winget_package_identifier: None,
12217 winget_source_identifier: None,
12218 installed_version_canonical: false,
12219 correlated_requires_explicit_upgrade: false,
12220 correlated_lacks_compatible_installer: false,
12221 };
12222
12223 let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12224 .expect("query")
12225 .expect("product code match");
12226
12227 assert_eq!(id, "Git.Git");
12228 assert_eq!(name, "Git");
12229 assert_eq!(matched_by, "ProductCode");
12230 }
12231
12232 #[test]
12233 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12234 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12239 execute_batch_sql(
12240 &connection,
12241 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12242 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12243 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12244 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12245 )
12246 .expect("seed schema");
12247
12248 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12249 assert!(rowid.is_none(), "ambiguous match must not correlate");
12250 }
12251
12252 #[test]
12253 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12254 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12258 execute_batch_sql(
12259 &connection,
12260 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12261 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12262 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12263 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12264 )
12265 .expect("seed schema");
12266
12267 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12268 assert_eq!(rowid, Some(100));
12269 }
12270
12271 #[test]
12272 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12273 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12276 execute_batch_sql(
12277 &connection,
12278 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12279 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12280 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12281 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12282 )
12283 .expect("seed schema");
12284
12285 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12286 assert!(
12287 rowid.is_none(),
12288 "name matches package 100 but publisher matches 200 — no intersection"
12289 );
12290 }
12291
12292 #[test]
12293 fn parse_yaml_manifest_bundle_returns_singleton_document() {
12294 let yaml = r#"
12295PackageIdentifier: Test.Package
12296PackageVersion: 1.2.3
12297DefaultLocale: en-US
12298ManifestType: version
12299ManifestVersion: 1.10.0
12300---
12301PackageIdentifier: Test.Package
12302PackageVersion: 1.2.3
12303PackageLocale: en-US
12304PackageName: Test Package
12305Publisher: Example
12306License: MIT
12307ShortDescription: Structured output
12308ManifestType: defaultLocale
12309ManifestVersion: 1.10.0
12310---
12311PackageIdentifier: Test.Package
12312PackageVersion: 1.2.3
12313ManifestType: installer
12314ManifestVersion: 1.10.0
12315Installers:
12316 - Architecture: x64
12317 InstallerType: exe
12318 InstallerUrl: https://example.test/Test.Package.exe
12319 InstallerSha256: ABC123
12320"#;
12321
12322 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12323
12324 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12325 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12326 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12327 }
12328
12329 #[test]
12330 fn collapse_structured_documents_returns_plural_show_documents() {
12331 let documents = collapse_structured_documents(&[
12332 JsonValue::Array(vec![
12333 serde_json::json!({
12334 "PackageIdentifier": "Test.Package.One",
12335 "PackageVersion": "1.0.0",
12336 "DefaultLocale": "en-US",
12337 "ManifestType": "version",
12338 "ManifestVersion": "1.10.0"
12339 }),
12340 serde_json::json!({
12341 "PackageIdentifier": "Test.Package.One",
12342 "PackageVersion": "1.0.0",
12343 "PackageLocale": "en-US",
12344 "PackageName": "Test Package One",
12345 "ManifestType": "defaultLocale",
12346 "ManifestVersion": "1.10.0"
12347 }),
12348 serde_json::json!({
12349 "PackageIdentifier": "Test.Package.One",
12350 "PackageVersion": "1.0.0",
12351 "ManifestType": "installer",
12352 "ManifestVersion": "1.10.0",
12353 "Installers": [
12354 {
12355 "Architecture": "x64",
12356 "InstallerType": "exe",
12357 "InstallerUrl": "https://example.test/one.exe",
12358 "InstallerSha256": "ABC123"
12359 }
12360 ]
12361 }),
12362 ]),
12363 serde_json::json!({
12364 "PackageIdentifier": "Test.Package.Two",
12365 "PackageVersion": "2.0.0",
12366 "PackageLocale": "en-US",
12367 "PackageName": "Test Package Two",
12368 "ManifestType": "singleton",
12369 "ManifestVersion": "1.12.0"
12370 }),
12371 ]);
12372
12373 assert_eq!(documents.len(), 2);
12374 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12375 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12376 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12377 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12378 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12379 }
12380
12381 #[test]
12382 fn unsupported_action_result_marks_noop_and_warning() {
12383 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12384
12385 assert!(result.success);
12386 assert!(result.no_op);
12387 assert_eq!(result.exit_code, 0);
12388 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12389 }
12390
12391 #[test]
12392 fn create_install_no_op_result_honors_no_upgrade() {
12393 let mut request = InstallRequest::new(PackageQuery {
12394 id: Some("Contoso.App".to_owned()),
12395 ..PackageQuery::default()
12396 });
12397 request.no_upgrade = true;
12398
12399 let manifest = Manifest {
12400 id: "Contoso.App".to_owned(),
12401 name: "Contoso App".to_owned(),
12402 version: "2.0.0".to_owned(),
12403 channel: String::new(),
12404 publisher: None,
12405 description: None,
12406 moniker: None,
12407 package_url: None,
12408 publisher_url: None,
12409 publisher_support_url: None,
12410 license: None,
12411 license_url: None,
12412 privacy_url: None,
12413 author: None,
12414 copyright: None,
12415 copyright_url: None,
12416 release_notes: None,
12417 release_notes_url: None,
12418 tags: Vec::new(),
12419 agreements: Vec::new(),
12420 package_dependencies: Vec::new(),
12421 documentation: Vec::new(),
12422 icons: Vec::new(),
12423 installers: Vec::new(),
12424 require_explicit_upgrade: false,
12425 };
12426 let existing = ListMatch {
12427 name: "Contoso App".to_owned(),
12428 id: "Contoso.App".to_owned(),
12429 local_id: "Contoso.App".to_owned(),
12430 installed_version: "1.0.0".to_owned(),
12431 available_version: Some("2.0.0".to_owned()),
12432 source_name: Some("winget".to_owned()),
12433 publisher: None,
12434 scope: None,
12435 installer_category: None,
12436 install_location: None,
12437 package_family_names: Vec::new(),
12438 product_codes: Vec::new(),
12439 upgrade_codes: Vec::new(),
12440 };
12441
12442 let result =
12443 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12444
12445 assert!(result.success);
12446 assert!(result.no_op);
12447 assert_eq!(result.version, "1.0.0");
12448 assert_eq!(
12449 result.warnings,
12450 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12451 );
12452 }
12453
12454 #[test]
12455 fn create_install_no_op_result_skips_reinstall_when_current() {
12456 let request = InstallRequest::new(PackageQuery {
12457 id: Some("Contoso.App".to_owned()),
12458 ..PackageQuery::default()
12459 });
12460
12461 let manifest = Manifest {
12462 id: "Contoso.App".to_owned(),
12463 name: "Contoso App".to_owned(),
12464 version: "2.0.0".to_owned(),
12465 channel: String::new(),
12466 publisher: None,
12467 description: None,
12468 moniker: None,
12469 package_url: None,
12470 publisher_url: None,
12471 publisher_support_url: None,
12472 license: None,
12473 license_url: None,
12474 privacy_url: None,
12475 author: None,
12476 copyright: None,
12477 copyright_url: None,
12478 release_notes: None,
12479 release_notes_url: None,
12480 tags: Vec::new(),
12481 agreements: Vec::new(),
12482 package_dependencies: Vec::new(),
12483 documentation: Vec::new(),
12484 icons: Vec::new(),
12485 installers: Vec::new(),
12486 require_explicit_upgrade: false,
12487 };
12488 let existing = ListMatch {
12489 name: "Contoso App".to_owned(),
12490 id: "Contoso.App".to_owned(),
12491 local_id: "Contoso.App".to_owned(),
12492 installed_version: "2.0.0".to_owned(),
12493 available_version: Some("2.0.0".to_owned()),
12494 source_name: Some("winget".to_owned()),
12495 publisher: None,
12496 scope: None,
12497 installer_category: None,
12498 install_location: None,
12499 package_family_names: Vec::new(),
12500 product_codes: Vec::new(),
12501 upgrade_codes: Vec::new(),
12502 };
12503
12504 let result =
12505 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12506
12507 assert!(result.success);
12508 assert!(result.no_op);
12509 assert_eq!(result.version, "2.0.0");
12510 assert_eq!(
12511 result.warnings,
12512 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
12513 );
12514 }
12515
12516 #[test]
12517 fn collapse_structured_document_projects_merged_manifest() {
12518 let document = collapse_structured_document(&serde_json::json!({
12519 "PackageIdentifier": "Test.Package",
12520 "PackageVersion": "1.2.3",
12521 "PackageLocale": "en-US",
12522 "PackageName": "Test Package",
12523 "Publisher": "Example",
12524 "InstallerType": "exe",
12525 "Installers": [
12526 {
12527 "Architecture": "x64",
12528 "InstallerUrl": "https://example.test/Test.Package.exe",
12529 "InstallerSha256": "ABC123"
12530 }
12531 ],
12532 "ManifestType": "merged",
12533 "ManifestVersion": "1.10.0"
12534 }));
12535
12536 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12537 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
12538 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
12539 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
12540 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
12541 }
12542
12543 #[test]
12544 fn parses_fixture_manifest() {
12545 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
12546 .join("tests")
12547 .join("fixtures")
12548 .join("ManifestV1_28-Singleton.yaml");
12549 let bytes = fs::read(root).expect("fixture bytes");
12550 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
12551 assert_eq!(manifest.id, "microsoft.msixsdk");
12552 assert_eq!(manifest.version, "1.7.32");
12553 assert_eq!(manifest.name, "MSIX SDK");
12554 assert!(!manifest.installers.is_empty());
12555 }
12556
12557 #[test]
12558 fn parses_installer_switches_from_yaml_manifest() {
12559 let yaml = r#"
12560PackageIdentifier: Test.Package
12561PackageVersion: 1.2.3
12562PackageName: Test Package
12563InstallerSwitches:
12564 SilentWithProgress: /SILENT
12565Installers:
12566 - Architecture: x64
12567 InstallerType: inno
12568 InstallerUrl: https://example.test/Test.Package.exe
12569 InstallerSha256: ABC123
12570 InstallerSwitches:
12571 Silent: /VERYSILENT
12572 Interactive: /HELP
12573"#;
12574
12575 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12576 let installer = &manifest.installers[0];
12577
12578 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
12579 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
12580 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
12581 }
12582
12583 #[test]
12584 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
12585 let yaml = r#"
12586PackageIdentifier: Test.Package
12587PackageVersion: 1.2.3
12588PackageName: Test Package
12589Platform:
12590 - Windows.Desktop
12591MinimumOSVersion: 10.0.19041.0
12592Installers:
12593 - Architecture: x64
12594 InstallerType: exe
12595 InstallerUrl: https://example.test/Test.Package.exe
12596 InstallerSha256: ABC123
12597 - Architecture: x64
12598 InstallerType: msix
12599 Platform:
12600 - Windows.Universal
12601 MinimumOSVersion: 10.0.22621.0
12602 InstallerUrl: https://example.test/Test.Package.msix
12603 InstallerSha256: DEF456
12604"#;
12605
12606 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12607
12608 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
12609 assert_eq!(
12610 manifest.installers[0].minimum_os_version.as_deref(),
12611 Some("10.0.19041.0")
12612 );
12613 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
12614 assert_eq!(
12615 manifest.installers[1].minimum_os_version.as_deref(),
12616 Some("10.0.22621.0")
12617 );
12618 }
12619
12620 #[test]
12621 fn installer_switch_arguments_prefer_manifest_switches() {
12622 let installer = Installer {
12623 architecture: None,
12624 installer_type: Some("inno".to_owned()),
12625 url: None,
12626 sha256: None,
12627 product_code: None,
12628 locale: None,
12629 scope: None,
12630 release_date: None,
12631 package_family_name: None,
12632 upgrade_code: None,
12633 platforms: Vec::new(),
12634 minimum_os_version: None,
12635 switches: InstallerSwitches {
12636 silent: Some("/mysilent".to_owned()),
12637 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
12638 interactive: Some("/myinteractive".to_owned()),
12639 ..InstallerSwitches::default()
12640 },
12641 commands: Vec::new(),
12642 package_dependencies: Vec::new(),
12643 require_explicit_upgrade: false,
12644 nested_installer_type: None,
12645 nested_installer_files: Vec::new(),
12646 };
12647 let manifest = Manifest {
12648 id: "Test.Package".to_owned(),
12649 name: "Test Package".to_owned(),
12650 version: "1.0.0".to_owned(),
12651 channel: String::new(),
12652 publisher: None,
12653 description: None,
12654 moniker: None,
12655 package_url: None,
12656 publisher_url: None,
12657 publisher_support_url: None,
12658 license: None,
12659 license_url: None,
12660 privacy_url: None,
12661 author: None,
12662 copyright: None,
12663 copyright_url: None,
12664 release_notes: None,
12665 release_notes_url: None,
12666 tags: Vec::new(),
12667 agreements: Vec::new(),
12668 package_dependencies: Vec::new(),
12669 documentation: Vec::new(),
12670 icons: Vec::new(),
12671 installers: Vec::new(),
12672 require_explicit_upgrade: false,
12673 };
12674
12675 let mut silent_request = InstallRequest::new(PackageQuery::default());
12676 silent_request.mode = InstallerMode::Silent;
12677 let mut progress_request = InstallRequest::new(PackageQuery::default());
12678 progress_request.mode = InstallerMode::SilentWithProgress;
12679 let mut interactive_request = InstallRequest::new(PackageQuery::default());
12680 interactive_request.mode = InstallerMode::Interactive;
12681 assert_eq!(
12682 installer_command_arguments(
12683 "inno",
12684 &silent_request,
12685 &manifest,
12686 Path::new("installer.exe"),
12687 &installer
12688 ),
12689 vec!["/mysilent".to_owned()]
12690 );
12691 assert_eq!(
12692 installer_command_arguments(
12693 "inno",
12694 &progress_request,
12695 &manifest,
12696 Path::new("installer.exe"),
12697 &installer
12698 ),
12699 vec!["/mysilentwithprogress".to_owned()]
12700 );
12701 assert_eq!(
12702 installer_command_arguments(
12703 "inno",
12704 &interactive_request,
12705 &manifest,
12706 Path::new("installer.exe"),
12707 &installer
12708 ),
12709 vec!["/myinteractive".to_owned()]
12710 );
12711 }
12712
12713 #[test]
12714 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
12715 let installer = Installer {
12716 architecture: None,
12717 installer_type: Some("inno".to_owned()),
12718 url: None,
12719 sha256: None,
12720 product_code: None,
12721 locale: None,
12722 scope: None,
12723 release_date: None,
12724 package_family_name: None,
12725 upgrade_code: None,
12726 platforms: Vec::new(),
12727 minimum_os_version: None,
12728 switches: InstallerSwitches::default(),
12729 commands: Vec::new(),
12730 package_dependencies: Vec::new(),
12731 require_explicit_upgrade: false,
12732 nested_installer_type: None,
12733 nested_installer_files: Vec::new(),
12734 };
12735 let manifest = Manifest {
12736 id: "Test.Package".to_owned(),
12737 name: "Test Package".to_owned(),
12738 version: "1.0.0".to_owned(),
12739 channel: String::new(),
12740 publisher: None,
12741 description: None,
12742 moniker: None,
12743 package_url: None,
12744 publisher_url: None,
12745 publisher_support_url: None,
12746 license: None,
12747 license_url: None,
12748 privacy_url: None,
12749 author: None,
12750 copyright: None,
12751 copyright_url: None,
12752 release_notes: None,
12753 release_notes_url: None,
12754 tags: Vec::new(),
12755 agreements: Vec::new(),
12756 package_dependencies: Vec::new(),
12757 documentation: Vec::new(),
12758 icons: Vec::new(),
12759 installers: Vec::new(),
12760 require_explicit_upgrade: false,
12761 };
12762 let mut progress_request = InstallRequest::new(PackageQuery::default());
12763 progress_request.mode = InstallerMode::SilentWithProgress;
12764 let mut silent_request = InstallRequest::new(PackageQuery::default());
12765 silent_request.mode = InstallerMode::Silent;
12766
12767 assert_eq!(
12768 installer_command_arguments(
12769 "inno",
12770 &progress_request,
12771 &manifest,
12772 Path::new("installer.exe"),
12773 &installer
12774 ),
12775 vec![
12776 "/SP-".to_owned(),
12777 "/SILENT".to_owned(),
12778 "/SUPPRESSMSGBOXES".to_owned(),
12779 "/NORESTART".to_owned()
12780 ]
12781 );
12782 assert_eq!(
12783 installer_command_arguments(
12784 "inno",
12785 &silent_request,
12786 &manifest,
12787 Path::new("installer.exe"),
12788 &installer
12789 ),
12790 vec![
12791 "/SP-".to_owned(),
12792 "/VERYSILENT".to_owned(),
12793 "/SUPPRESSMSGBOXES".to_owned(),
12794 "/NORESTART".to_owned()
12795 ]
12796 );
12797 }
12798
12799 #[test]
12800 fn installer_command_arguments_append_manifest_and_cli_switches() {
12801 let installer = Installer {
12802 architecture: None,
12803 installer_type: Some("msi".to_owned()),
12804 url: None,
12805 sha256: None,
12806 product_code: None,
12807 locale: None,
12808 scope: None,
12809 release_date: None,
12810 package_family_name: None,
12811 upgrade_code: None,
12812 platforms: Vec::new(),
12813 minimum_os_version: None,
12814 switches: InstallerSwitches {
12815 custom: Some("ADDLOCAL=Core".to_owned()),
12816 log: Some("/log \"<LOGPATH>\"".to_owned()),
12817 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
12818 ..InstallerSwitches::default()
12819 },
12820 commands: Vec::new(),
12821 package_dependencies: Vec::new(),
12822 require_explicit_upgrade: false,
12823 nested_installer_type: None,
12824 nested_installer_files: Vec::new(),
12825 };
12826 let manifest = Manifest {
12827 id: "ShareX.ShareX".to_owned(),
12828 name: "ShareX".to_owned(),
12829 version: "19.0.2".to_owned(),
12830 channel: String::new(),
12831 publisher: None,
12832 description: None,
12833 moniker: None,
12834 package_url: None,
12835 publisher_url: None,
12836 publisher_support_url: None,
12837 license: None,
12838 license_url: None,
12839 privacy_url: None,
12840 author: None,
12841 copyright: None,
12842 copyright_url: None,
12843 release_notes: None,
12844 release_notes_url: None,
12845 tags: Vec::new(),
12846 agreements: Vec::new(),
12847 package_dependencies: Vec::new(),
12848 documentation: Vec::new(),
12849 icons: Vec::new(),
12850 installers: Vec::new(),
12851 require_explicit_upgrade: false,
12852 };
12853 let mut request = InstallRequest::new(PackageQuery::default());
12854 request.mode = InstallerMode::Silent;
12855 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
12856 request.custom = Some("REBOOT=ReallySuppress".to_owned());
12857 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
12858
12859 assert_eq!(
12860 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
12861 vec![
12862 "/i".to_owned(),
12863 r"C:\temp\ShareX.msi".to_owned(),
12864 "/quiet".to_owned(),
12865 "/norestart".to_owned(),
12866 "/log".to_owned(),
12867 r"C:\temp\winget.log".to_owned(),
12868 "ADDLOCAL=Core".to_owned(),
12869 "REBOOT=ReallySuppress".to_owned(),
12870 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
12871 ]
12872 );
12873 }
12874
12875 #[test]
12876 fn msi_shell_execute_arguments_include_msiexec_target() {
12877 let installer = Installer {
12878 architecture: None,
12879 installer_type: Some("wix".to_owned()),
12880 url: None,
12881 sha256: None,
12882 product_code: None,
12883 locale: None,
12884 scope: None,
12885 release_date: None,
12886 package_family_name: None,
12887 upgrade_code: None,
12888 platforms: Vec::new(),
12889 minimum_os_version: None,
12890 switches: InstallerSwitches {
12891 log: Some("/log \"<LOGPATH>\"".to_owned()),
12892 install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
12893 ..InstallerSwitches::default()
12894 },
12895 commands: Vec::new(),
12896 package_dependencies: Vec::new(),
12897 require_explicit_upgrade: false,
12898 nested_installer_type: None,
12899 nested_installer_files: Vec::new(),
12900 };
12901 let mut request = InstallRequest::new(PackageQuery::default());
12902 request.mode = InstallerMode::Silent;
12903 request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
12904 request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
12905
12906 assert_eq!(
12907 installer_shell_execute_arguments(
12908 "wix",
12909 &request,
12910 &Manifest {
12911 id: "OpenJS.NodeJS.22".to_owned(),
12912 name: "Node.js".to_owned(),
12913 version: "22.22.3".to_owned(),
12914 channel: String::new(),
12915 publisher: None,
12916 description: None,
12917 moniker: None,
12918 package_url: None,
12919 publisher_url: None,
12920 publisher_support_url: None,
12921 license: None,
12922 license_url: None,
12923 privacy_url: None,
12924 author: None,
12925 copyright: None,
12926 copyright_url: None,
12927 release_notes: None,
12928 release_notes_url: None,
12929 tags: Vec::new(),
12930 agreements: Vec::new(),
12931 package_dependencies: Vec::new(),
12932 documentation: Vec::new(),
12933 icons: Vec::new(),
12934 installers: Vec::new(),
12935 require_explicit_upgrade: false,
12936 },
12937 Path::new(r"C:\temp\node.msi"),
12938 &installer
12939 ),
12940 vec![
12941 "/i".to_owned(),
12942 r"C:\temp\node.msi".to_owned(),
12943 "/quiet".to_owned(),
12944 "/norestart".to_owned(),
12945 "/log".to_owned(),
12946 r"C:\temp\node.log".to_owned(),
12947 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
12948 ]
12949 );
12950 }
12951
12952 #[test]
12953 fn msi_direct_arguments_omit_msiexec_target() {
12954 let installer = Installer::default();
12955 let mut request = InstallRequest::new(PackageQuery::default());
12956 request.mode = InstallerMode::Silent;
12957
12958 assert_eq!(
12959 installer_direct_msi_arguments(
12960 "wix",
12961 &request,
12962 &Manifest {
12963 id: "Test.Package".to_owned(),
12964 name: "Test".to_owned(),
12965 version: "1.0.0".to_owned(),
12966 channel: String::new(),
12967 publisher: None,
12968 description: None,
12969 moniker: None,
12970 package_url: None,
12971 publisher_url: None,
12972 publisher_support_url: None,
12973 license: None,
12974 license_url: None,
12975 privacy_url: None,
12976 author: None,
12977 copyright: None,
12978 copyright_url: None,
12979 release_notes: None,
12980 release_notes_url: None,
12981 tags: Vec::new(),
12982 agreements: Vec::new(),
12983 package_dependencies: Vec::new(),
12984 documentation: Vec::new(),
12985 icons: Vec::new(),
12986 installers: Vec::new(),
12987 require_explicit_upgrade: false,
12988 },
12989 Path::new(r"C:\temp\test.msi"),
12990 &installer
12991 ),
12992 vec!["/quiet".to_owned(), "/norestart".to_owned()]
12993 );
12994 }
12995
12996 #[test]
12997 fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
12998 let args = vec![
12999 "/quiet".to_owned(),
13000 "/norestart".to_owned(),
13001 "/log".to_owned(),
13002 r"C:\temp\node.log".to_owned(),
13003 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13004 ];
13005
13006 let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13007
13008 assert_eq!(parsed.ui_level, 0x102);
13009 assert_eq!(
13010 parsed.properties,
13011 r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13012 );
13013 assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13014 }
13015
13016 #[cfg(windows)]
13017 #[test]
13018 fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13019 let mut request = InstallRequest::new(PackageQuery::default());
13020 request.mode = InstallerMode::Silent;
13021
13022 let installer = Installer {
13023 installer_type: Some("wix".to_owned()),
13024 scope: Some("machine".to_owned()),
13025 ..Installer::default()
13026 };
13027
13028 assert!(!should_run_msi_direct(&request, &installer));
13029 assert!(should_elevate_msi_shell_execute(&installer));
13030 }
13031
13032 #[test]
13033 fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13034 let strong = InstallerDownloadMetadata {
13035 url: "https://example.test/app.exe".to_owned(),
13036 etag: Some(r#""abc""#.to_owned()),
13037 last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13038 total_length: Some(100),
13039 offset: 50,
13040 };
13041 let weak = InstallerDownloadMetadata {
13042 etag: Some(r#"W/"abc""#.to_owned()),
13043 ..strong.clone()
13044 };
13045
13046 assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13047 assert_eq!(
13048 installer_download_if_range(&weak).as_deref(),
13049 Some("Wed, 03 Jun 2026 21:00:00 GMT")
13050 );
13051 }
13052
13053 #[test]
13054 fn parse_content_range_validates_resume_start_and_total() {
13055 let value = HeaderValue::from_static("bytes 50-99/100");
13056 let parsed = parse_content_range(Some(&value), 50).expect("content range");
13057
13058 assert_eq!(parsed.total_length, Some(100));
13059 assert!(parse_content_range(Some(&value), 49).is_err());
13060 }
13061
13062 #[test]
13063 fn installer_download_sidecar_paths_append_suffixes() {
13064 let path = Path::new(r"C:\temp\installer.msi");
13065
13066 assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13067 assert_eq!(
13068 sidecar_path(path, "part.json"),
13069 PathBuf::from(r"C:\temp\installer.msi.part.json")
13070 );
13071 }
13072
13073 #[test]
13074 fn shell_execute_argument_joining_quotes_spaces() {
13075 assert_eq!(
13076 join_windows_arguments(&[
13077 "/quiet".to_owned(),
13078 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13079 r#"VALUE=has "quotes""#.to_owned(),
13080 ]),
13081 r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13082 );
13083 }
13084
13085 #[test]
13086 fn decompresses_mszyml_payload() {
13087 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13088 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13089 encoder.write_all(payload.as_bytes()).expect("write payload");
13090 let compressed = encoder.finish().expect("finish payload");
13091 let mut mszip = b"CK".to_vec();
13092 mszip.extend_from_slice(&compressed);
13093
13094 let decompressed = decompress_mszyml(&mszip).expect("decompress");
13095 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13096 assert_eq!(parsed.versions[0].version, "1.2.3");
13097 }
13098
13099 #[test]
13100 fn decompresses_mszyml_payload_with_prefix() {
13101 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13102 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13103 encoder.write_all(payload.as_bytes()).expect("write payload");
13104 let compressed = encoder.finish().expect("finish payload");
13105 let mut prefixed = vec![0u8; 28];
13106 prefixed.extend_from_slice(b"CK");
13107 prefixed.extend_from_slice(&compressed);
13108
13109 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13110 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13111 assert_eq!(parsed.versions[0].version, "1.2.3");
13112 }
13113
13114 #[test]
13115 fn compares_versions_naturally() {
13116 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13117 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13118 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13119 assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13120 assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13121 }
13122
13123 #[test]
13124 fn installer_hash_verification_fails_on_mismatch_without_override() {
13125 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13126 assert!(result.is_err());
13127 assert!(
13128 result
13129 .expect_err("hash mismatch")
13130 .to_string()
13131 .contains("Installer hash mismatch")
13132 );
13133 }
13134
13135 #[test]
13136 fn installer_hash_verification_allows_mismatch_with_override() {
13137 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13138 verify_installer_hash(None, b"pinget", false).expect("missing hash");
13139 }
13140
13141 #[test]
13142 fn search_query_many_includes_tag_and_command_conditions() {
13143 let query = PackageQuery {
13144 query: Some("terminal".to_owned()),
13145 ..PackageQuery::default()
13146 };
13147
13148 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13149
13150 assert!(where_clause.contains("tags2"));
13151 assert!(where_clause.contains("commands2"));
13152 assert_eq!(params.len(), 5);
13153 }
13154
13155 #[test]
13156 fn tag_and_command_conditions_are_non_correlated() {
13157 let query = PackageQuery {
13164 query: Some("terminal".to_owned()),
13165 ..PackageQuery::default()
13166 };
13167
13168 let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13169
13170 assert!(where_clause.contains("packages.rowid IN (SELECT"));
13171 assert!(!where_clause.contains("EXISTS"));
13172 assert!(!where_clause.contains("= packages.rowid"));
13173 }
13174
13175 #[test]
13176 fn show_query_single_omits_tag_and_command_conditions() {
13177 let query = PackageQuery {
13178 query: Some("terminal".to_owned()),
13179 ..PackageQuery::default()
13180 };
13181
13182 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13183
13184 assert!(!where_clause.contains("tags2"));
13185 assert!(!where_clause.contains("commands2"));
13186 assert_eq!(params.len(), 3);
13187 }
13188
13189 #[test]
13190 fn explicit_tag_filter_uses_exact_match() {
13191 let query = PackageQuery {
13192 tag: Some("terminal".to_owned()),
13193 ..PackageQuery::default()
13194 };
13195
13196 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13197
13198 assert!(where_clause.contains("tags2"));
13199 assert_eq!(params, vec!["terminal".to_owned()]);
13200 }
13201
13202 #[test]
13203 fn rest_tag_filter_uses_exact_match() {
13204 let query = PackageQuery {
13205 tag: Some("terminal".to_owned()),
13206 ..PackageQuery::default()
13207 };
13208 let info = RestInformation {
13209 required_package_match_fields: Vec::new(),
13210 unsupported_package_match_fields: Vec::new(),
13211 required_query_parameters: Vec::new(),
13212 ..RestInformation::default()
13213 };
13214
13215 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13216 let filters = body["Filters"].as_array().expect("filters");
13217
13218 assert_eq!(filters.len(), 1);
13219 assert_eq!(filters[0]["PackageMatchField"], "Tag");
13220 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13221 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13222 }
13223
13224 #[test]
13225 fn selects_installer_using_requested_filters() {
13226 let installers = vec![
13227 Installer {
13228 architecture: Some("x86".to_owned()),
13229 installer_type: Some("zip".to_owned()),
13230 url: None,
13231 sha256: None,
13232 product_code: None,
13233 locale: Some("en-US".to_owned()),
13234 scope: Some("user".to_owned()),
13235 release_date: None,
13236 package_family_name: None,
13237 upgrade_code: None,
13238 platforms: Vec::new(),
13239 minimum_os_version: None,
13240 switches: InstallerSwitches::default(),
13241 commands: Vec::new(),
13242 package_dependencies: Vec::new(),
13243 require_explicit_upgrade: false,
13244 nested_installer_type: None,
13245 nested_installer_files: Vec::new(),
13246 },
13247 Installer {
13248 architecture: Some("x64".to_owned()),
13249 installer_type: Some("msix".to_owned()),
13250 url: None,
13251 sha256: None,
13252 product_code: None,
13253 locale: Some("en-US".to_owned()),
13254 scope: Some("user".to_owned()),
13255 release_date: None,
13256 package_family_name: None,
13257 upgrade_code: None,
13258 platforms: Vec::new(),
13259 minimum_os_version: None,
13260 switches: InstallerSwitches::default(),
13261 commands: vec!["demo".to_owned()],
13262 package_dependencies: Vec::new(),
13263 require_explicit_upgrade: false,
13264 nested_installer_type: None,
13265 nested_installer_files: Vec::new(),
13266 },
13267 ];
13268 let query = PackageQuery {
13269 installer_type: Some("msix".to_owned()),
13270 installer_architecture: Some("x64".to_owned()),
13271 install_scope: Some("user".to_owned()),
13272 locale: Some("en-US".to_owned()),
13273 ..PackageQuery::default()
13274 };
13275
13276 let selected = select_installer(&installers, &query).expect("selected installer");
13277 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13278 assert_eq!(selected.architecture.as_deref(), Some("x64"));
13279 }
13280
13281 #[test]
13282 fn selects_first_installer_when_rank_ties() {
13283 let installers = vec![
13284 Installer {
13285 architecture: Some("x64".to_owned()),
13286 installer_type: Some("exe".to_owned()),
13287 url: None,
13288 sha256: None,
13289 product_code: None,
13290 locale: Some("en-US".to_owned()),
13291 scope: Some("machine".to_owned()),
13292 release_date: None,
13293 package_family_name: None,
13294 upgrade_code: None,
13295 platforms: Vec::new(),
13296 minimum_os_version: None,
13297 switches: InstallerSwitches::default(),
13298 commands: Vec::new(),
13299 package_dependencies: Vec::new(),
13300 require_explicit_upgrade: false,
13301 nested_installer_type: None,
13302 nested_installer_files: Vec::new(),
13303 },
13304 Installer {
13305 architecture: Some("x64".to_owned()),
13306 installer_type: Some("exe".to_owned()),
13307 url: None,
13308 sha256: None,
13309 product_code: None,
13310 locale: Some("en-US".to_owned()),
13311 scope: Some("user".to_owned()),
13312 release_date: None,
13313 package_family_name: None,
13314 upgrade_code: None,
13315 platforms: Vec::new(),
13316 minimum_os_version: None,
13317 switches: InstallerSwitches::default(),
13318 commands: Vec::new(),
13319 package_dependencies: Vec::new(),
13320 require_explicit_upgrade: false,
13321 nested_installer_type: None,
13322 nested_installer_files: Vec::new(),
13323 },
13324 ];
13325
13326 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13327 assert_eq!(selected.scope.as_deref(), Some("machine"));
13328 }
13329
13330 #[test]
13331 fn selects_installer_using_requested_platform() {
13332 let installers = vec![
13333 Installer {
13334 architecture: Some("x64".to_owned()),
13335 installer_type: Some("exe".to_owned()),
13336 url: None,
13337 sha256: None,
13338 product_code: None,
13339 locale: Some("en-US".to_owned()),
13340 scope: Some("user".to_owned()),
13341 release_date: None,
13342 package_family_name: None,
13343 upgrade_code: None,
13344 platforms: vec!["Windows.Universal".to_owned()],
13345 minimum_os_version: None,
13346 switches: InstallerSwitches::default(),
13347 commands: Vec::new(),
13348 package_dependencies: Vec::new(),
13349 require_explicit_upgrade: false,
13350 nested_installer_type: None,
13351 nested_installer_files: Vec::new(),
13352 },
13353 Installer {
13354 architecture: Some("x64".to_owned()),
13355 installer_type: Some("exe".to_owned()),
13356 url: None,
13357 sha256: None,
13358 product_code: None,
13359 locale: Some("en-US".to_owned()),
13360 scope: Some("user".to_owned()),
13361 release_date: None,
13362 package_family_name: None,
13363 upgrade_code: None,
13364 platforms: vec!["Windows.Desktop".to_owned()],
13365 minimum_os_version: None,
13366 switches: InstallerSwitches::default(),
13367 commands: Vec::new(),
13368 package_dependencies: Vec::new(),
13369 require_explicit_upgrade: false,
13370 nested_installer_type: None,
13371 nested_installer_files: Vec::new(),
13372 },
13373 ];
13374 let query = PackageQuery {
13375 installer_type: Some("exe".to_owned()),
13376 platform: Some("Windows.Desktop".to_owned()),
13377 ..PackageQuery::default()
13378 };
13379
13380 let selected = select_installer(&installers, &query).expect("selected installer");
13381 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13382 }
13383
13384 #[test]
13385 fn selects_installer_using_requested_os_version() {
13386 let installers = vec![
13387 Installer {
13388 architecture: Some("x64".to_owned()),
13389 installer_type: Some("exe".to_owned()),
13390 url: None,
13391 sha256: None,
13392 product_code: None,
13393 locale: Some("en-US".to_owned()),
13394 scope: Some("user".to_owned()),
13395 release_date: None,
13396 package_family_name: None,
13397 upgrade_code: None,
13398 platforms: Vec::new(),
13399 minimum_os_version: Some("10.0.22621.0".to_owned()),
13400 switches: InstallerSwitches::default(),
13401 commands: Vec::new(),
13402 package_dependencies: Vec::new(),
13403 require_explicit_upgrade: false,
13404 nested_installer_type: None,
13405 nested_installer_files: Vec::new(),
13406 },
13407 Installer {
13408 architecture: Some("x64".to_owned()),
13409 installer_type: Some("exe".to_owned()),
13410 url: None,
13411 sha256: None,
13412 product_code: None,
13413 locale: Some("en-US".to_owned()),
13414 scope: Some("user".to_owned()),
13415 release_date: None,
13416 package_family_name: None,
13417 upgrade_code: None,
13418 platforms: Vec::new(),
13419 minimum_os_version: Some("10.0.19041.0".to_owned()),
13420 switches: InstallerSwitches::default(),
13421 commands: Vec::new(),
13422 package_dependencies: Vec::new(),
13423 require_explicit_upgrade: false,
13424 nested_installer_type: None,
13425 nested_installer_files: Vec::new(),
13426 },
13427 ];
13428 let query = PackageQuery {
13429 installer_type: Some("exe".to_owned()),
13430 os_version: Some("10.0.19045.0".to_owned()),
13431 ..PackageQuery::default()
13432 };
13433
13434 let selected = select_installer(&installers, &query).expect("selected installer");
13435 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13436 }
13437
13438 #[test]
13439 fn derives_correlation_name_candidates() {
13440 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13441 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13442 assert!(candidates.contains(&"PowerToys".to_owned()));
13443 }
13444
13445 #[test]
13446 fn correlates_installed_package_to_available_match() {
13447 let installed = InstalledPackage {
13448 name: "PowerToys (Preview) x64".to_owned(),
13449 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13450 installed_version: "0.98.1".to_owned(),
13451 publisher: None,
13452 scope: Some("Machine".to_owned()),
13453 installer_category: Some("exe".to_owned()),
13454 install_location: None,
13455 package_family_names: Vec::new(),
13456 product_codes: Vec::new(),
13457 upgrade_codes: Vec::new(),
13458 correlated: None,
13459 winget_package_identifier: None,
13460 winget_source_identifier: None,
13461 installed_version_canonical: false,
13462 correlated_requires_explicit_upgrade: false,
13463 correlated_lacks_compatible_installer: false,
13464 };
13465 let candidates = vec![SearchMatch {
13466 source_name: "winget".to_owned(),
13467 source_kind: SourceKind::PreIndexed,
13468 id: "Microsoft.PowerToys".to_owned(),
13469 name: "PowerToys".to_owned(),
13470 moniker: None,
13471 version: Some("0.98.1".to_owned()),
13472 channel: None,
13473 match_criteria: None,
13474 }];
13475
13476 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13477 assert_eq!(correlated.id, "Microsoft.PowerToys");
13478 }
13479
13480 #[test]
13481 fn list_query_uses_available_lookup_for_tag_filters() {
13482 let query = ListQuery {
13483 tag: Some("terminal".to_owned()),
13484 ..ListQuery::default()
13485 };
13486
13487 assert!(list_query_needs_available_lookup(&query));
13488 }
13489
13490 #[test]
13491 fn list_tag_filter_requires_correlated_match() {
13492 let package = InstalledPackage {
13493 name: "PowerToys (Preview) x64".to_owned(),
13494 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13495 installed_version: "0.98.1".to_owned(),
13496 publisher: None,
13497 scope: Some("Machine".to_owned()),
13498 installer_category: Some("exe".to_owned()),
13499 install_location: None,
13500 package_family_names: Vec::new(),
13501 product_codes: Vec::new(),
13502 upgrade_codes: Vec::new(),
13503 correlated: None,
13504 winget_package_identifier: None,
13505 winget_source_identifier: None,
13506 installed_version_canonical: false,
13507 correlated_requires_explicit_upgrade: false,
13508 correlated_lacks_compatible_installer: false,
13509 };
13510 let query = ListQuery {
13511 tag: Some("powertoys".to_owned()),
13512 ..ListQuery::default()
13513 };
13514
13515 assert!(!list_package_matches(&package, &query));
13516 }
13517
13518 #[test]
13519 fn list_lookup_ignores_display_count() {
13520 let query = ListQuery {
13521 query: Some("git".to_owned()),
13522 count: Some(5),
13523 ..ListQuery::default()
13524 };
13525
13526 let package_query = package_query_from_list_query(&query);
13527 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
13528 }
13529
13530 #[test]
13531 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
13532 let installed = InstalledPackage {
13537 name: "Notepad++ (ARM 64-bit)".to_owned(),
13538 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
13539 installed_version: "8.8.9".to_owned(),
13540 publisher: None,
13541 scope: Some("Machine".to_owned()),
13542 installer_category: Some("exe".to_owned()),
13543 install_location: None,
13544 package_family_names: Vec::new(),
13545 product_codes: Vec::new(),
13546 upgrade_codes: Vec::new(),
13547 correlated: None,
13548 winget_package_identifier: None,
13549 winget_source_identifier: None,
13550 installed_version_canonical: false,
13551 correlated_requires_explicit_upgrade: false,
13552 correlated_lacks_compatible_installer: false,
13553 };
13554 let candidates = vec![
13555 SearchMatch {
13556 source_name: "winget".to_owned(),
13557 source_kind: SourceKind::PreIndexed,
13558 id: "ndd.Notepad--".to_owned(),
13559 name: "Notepad--".to_owned(),
13560 moniker: None,
13561 version: Some("1.0.0".to_owned()),
13562 channel: None,
13563 match_criteria: None,
13564 },
13565 SearchMatch {
13566 source_name: "winget".to_owned(),
13567 source_kind: SourceKind::PreIndexed,
13568 id: "Notepad++.Notepad++".to_owned(),
13569 name: "Notepad++".to_owned(),
13570 moniker: None,
13571 version: Some("8.9.5".to_owned()),
13572 channel: None,
13573 match_criteria: None,
13574 },
13575 ];
13576
13577 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13578 assert_eq!(correlated.id, "Notepad++.Notepad++");
13579 }
13580
13581 #[test]
13582 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
13583 let installed = InstalledPackage {
13588 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
13589 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
13590 installed_version: "40.10.18029".to_owned(),
13591 publisher: None,
13592 scope: Some("Machine".to_owned()),
13593 installer_category: Some("msi".to_owned()),
13594 install_location: None,
13595 package_family_names: Vec::new(),
13596 product_codes: Vec::new(),
13597 upgrade_codes: Vec::new(),
13598 correlated: None,
13599 winget_package_identifier: None,
13600 winget_source_identifier: None,
13601 installed_version_canonical: false,
13602 correlated_requires_explicit_upgrade: false,
13603 correlated_lacks_compatible_installer: false,
13604 };
13605 let candidates = vec![
13606 SearchMatch {
13607 source_name: "winget".to_owned(),
13608 source_kind: SourceKind::PreIndexed,
13609 id: "ZeroBrane.Studio".to_owned(),
13610 name: "Studio".to_owned(),
13611 moniker: None,
13612 version: Some("1.0".to_owned()),
13613 channel: None,
13614 match_criteria: None,
13615 },
13616 SearchMatch {
13617 source_name: "winget".to_owned(),
13618 source_kind: SourceKind::PreIndexed,
13619 id: "BrickLink.Studio".to_owned(),
13620 name: "Studio".to_owned(),
13621 moniker: None,
13622 version: Some("1.0".to_owned()),
13623 channel: None,
13624 match_criteria: None,
13625 },
13626 SearchMatch {
13627 source_name: "winget".to_owned(),
13628 source_kind: SourceKind::PreIndexed,
13629 id: "Microsoft.DotNet.SDK.10".to_owned(),
13630 name: "Microsoft .NET SDK 10.0".to_owned(),
13631 moniker: None,
13632 version: Some("10.0.204".to_owned()),
13633 channel: None,
13634 match_criteria: None,
13635 },
13636 ];
13637
13638 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13639 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
13640 }
13641
13642 #[test]
13643 fn msix_packages_do_not_correlate_via_name() {
13644 let installed = InstalledPackage {
13652 name: "Microsoft Teams".to_owned(),
13653 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13654 installed_version: "25290.205.4069.4894".to_owned(),
13655 publisher: None,
13656 scope: Some("User".to_owned()),
13657 installer_category: Some("msix".to_owned()),
13658 install_location: None,
13659 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13660 product_codes: Vec::new(),
13661 upgrade_codes: Vec::new(),
13662 correlated: None,
13663 winget_package_identifier: None,
13664 winget_source_identifier: None,
13665 installed_version_canonical: false,
13666 correlated_requires_explicit_upgrade: false,
13667 correlated_lacks_compatible_installer: false,
13668 };
13669 let candidates = vec![SearchMatch {
13670 source_name: "winget".to_owned(),
13671 source_kind: SourceKind::PreIndexed,
13672 id: "Microsoft.Teams".to_owned(),
13673 name: "Microsoft Teams".to_owned(),
13674 moniker: None,
13675 version: Some("26106.1906.4665.7308".to_owned()),
13676 channel: None,
13677 match_criteria: None,
13678 }];
13679
13680 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13681 }
13682
13683 #[test]
13684 fn name_fallback_refuses_ambiguous_winners() {
13685 let installed = InstalledPackage {
13693 name: "Git".to_owned(),
13694 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
13695 installed_version: "2.53.0".to_owned(),
13696 publisher: Some("The Git Development Community".to_owned()),
13697 scope: Some("Machine".to_owned()),
13698 installer_category: Some("exe".to_owned()),
13699 install_location: None,
13700 package_family_names: Vec::new(),
13701 product_codes: Vec::new(),
13702 upgrade_codes: Vec::new(),
13703 correlated: None,
13704 winget_package_identifier: None,
13705 winget_source_identifier: None,
13706 installed_version_canonical: false,
13707 correlated_requires_explicit_upgrade: false,
13708 correlated_lacks_compatible_installer: false,
13709 };
13710 let candidates = vec![
13711 SearchMatch {
13712 source_name: "winget".to_owned(),
13713 source_kind: SourceKind::PreIndexed,
13714 id: "Git.Git".to_owned(),
13715 name: "Git".to_owned(),
13716 moniker: None,
13717 version: Some("2.54.0".to_owned()),
13718 channel: None,
13719 match_criteria: None,
13720 },
13721 SearchMatch {
13722 source_name: "winget".to_owned(),
13723 source_kind: SourceKind::PreIndexed,
13724 id: "Microsoft.Git".to_owned(),
13725 name: "Git".to_owned(),
13726 moniker: None,
13727 version: Some("2.53.0.0.7".to_owned()),
13728 channel: None,
13729 match_criteria: None,
13730 },
13731 ];
13732
13733 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13734 }
13735
13736 #[test]
13737 fn msix_with_resource_string_name_does_not_correlate() {
13738 let installed = InstalledPackage {
13742 name: "ms-resource:appDisplayName".to_owned(),
13743 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13744 installed_version: "1.28.239.0".to_owned(),
13745 publisher: None,
13746 scope: Some("User".to_owned()),
13747 installer_category: Some("msix".to_owned()),
13748 install_location: None,
13749 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13750 product_codes: Vec::new(),
13751 upgrade_codes: Vec::new(),
13752 correlated: None,
13753 winget_package_identifier: None,
13754 winget_source_identifier: None,
13755 installed_version_canonical: false,
13756 correlated_requires_explicit_upgrade: false,
13757 correlated_lacks_compatible_installer: false,
13758 };
13759 let candidates = vec![SearchMatch {
13760 source_name: "winget".to_owned(),
13761 source_kind: SourceKind::PreIndexed,
13762 id: "Microsoft.AppInstaller".to_owned(),
13763 name: "App Installer".to_owned(),
13764 moniker: None,
13765 version: Some("1.28.240.0".to_owned()),
13766 channel: None,
13767 match_criteria: None,
13768 }];
13769
13770 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13771 }
13772
13773 #[test]
13774 fn strict_list_correlation_avoids_short_substring_matches() {
13775 let installed = InstalledPackage {
13776 name: "GitHub".to_owned(),
13777 local_id: r"ARP\User\X64\GitHub".to_owned(),
13778 installed_version: "1.0.0".to_owned(),
13779 publisher: None,
13780 scope: Some("User".to_owned()),
13781 installer_category: Some("exe".to_owned()),
13782 install_location: None,
13783 package_family_names: Vec::new(),
13784 product_codes: Vec::new(),
13785 upgrade_codes: Vec::new(),
13786 correlated: None,
13787 winget_package_identifier: None,
13788 winget_source_identifier: None,
13789 installed_version_canonical: false,
13790 correlated_requires_explicit_upgrade: false,
13791 correlated_lacks_compatible_installer: false,
13792 };
13793 let candidates = vec![SearchMatch {
13794 source_name: "winget".to_owned(),
13795 source_kind: SourceKind::PreIndexed,
13796 id: "Git.Git.PreRelease".to_owned(),
13797 name: "Git".to_owned(),
13798 moniker: None,
13799 version: Some("2.54.0".to_owned()),
13800 channel: None,
13801 match_criteria: None,
13802 }];
13803
13804 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
13805 }
13806
13807 #[test]
13808 fn detects_available_upgrade_from_correlated_package() {
13809 let package = InstalledPackage {
13810 name: "AzCopy v10".to_owned(),
13811 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13812 installed_version: "10.32.2".to_owned(),
13813 publisher: None,
13814 scope: Some("Machine".to_owned()),
13815 installer_category: Some("exe".to_owned()),
13816 install_location: None,
13817 package_family_names: Vec::new(),
13818 product_codes: Vec::new(),
13819 upgrade_codes: Vec::new(),
13820 correlated: Some(SearchMatch {
13821 source_name: "winget".to_owned(),
13822 source_kind: SourceKind::PreIndexed,
13823 id: "Microsoft.Azure.AZCopy.10".to_owned(),
13824 name: "AzCopy v10".to_owned(),
13825 moniker: None,
13826 version: Some("10.32.3".to_owned()),
13827 channel: None,
13828 match_criteria: None,
13829 }),
13830 winget_package_identifier: None,
13831 winget_source_identifier: None,
13832 installed_version_canonical: false,
13833 correlated_requires_explicit_upgrade: false,
13834 correlated_lacks_compatible_installer: false,
13835 };
13836
13837 assert!(installed_package_has_upgrade(&package));
13838 }
13839
13840 #[test]
13841 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
13842 let package = InstalledPackage {
13843 name: "Example Tool".to_owned(),
13844 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
13845 installed_version: "Unknown".to_owned(),
13846 publisher: None,
13847 scope: Some("User".to_owned()),
13848 installer_category: Some("exe".to_owned()),
13849 install_location: None,
13850 package_family_names: Vec::new(),
13851 product_codes: Vec::new(),
13852 upgrade_codes: Vec::new(),
13853 correlated: Some(SearchMatch {
13854 source_name: "winget".to_owned(),
13855 source_kind: SourceKind::PreIndexed,
13856 id: "Contoso.ExampleTool".to_owned(),
13857 name: "Example Tool".to_owned(),
13858 moniker: None,
13859 version: Some("2.0.0".to_owned()),
13860 channel: None,
13861 match_criteria: None,
13862 }),
13863 winget_package_identifier: None,
13864 winget_source_identifier: None,
13865 installed_version_canonical: false,
13866 correlated_requires_explicit_upgrade: false,
13867 correlated_lacks_compatible_installer: false,
13868 };
13869 let query = ListQuery {
13870 upgrade_only: true,
13871 include_unknown: true,
13872 ..Default::default()
13873 };
13874
13875 assert!(installed_package_matches_upgrade_filter(&package, &query));
13876 assert_eq!(
13877 list_match_from_installed(package, &[]).available_version.as_deref(),
13878 Some("2.0.0")
13879 );
13880 }
13881
13882 #[test]
13883 fn create_repair_list_query_includes_installed_selectors() {
13884 let request = RepairRequest {
13885 query: PackageQuery {
13886 query: Some("powertoys".to_owned()),
13887 id: Some("Microsoft.PowerToys".to_owned()),
13888 name: Some("PowerToys".to_owned()),
13889 moniker: Some("powertoys".to_owned()),
13890 source: Some("winget".to_owned()),
13891 exact: true,
13892 version: Some("0.98.1".to_owned()),
13893 install_scope: Some("Machine".to_owned()),
13894 ..PackageQuery::default()
13895 },
13896 manifest_path: None,
13897 product_code: Some("{GUID}".to_owned()),
13898 mode: InstallerMode::Silent,
13899 log_path: Some(PathBuf::from("repair.log")),
13900 accept_package_agreements: true,
13901 force: true,
13902 ignore_security_hash: true,
13903 };
13904
13905 let query = Repository::create_repair_list_query(&request);
13906 assert_eq!(query.query.as_deref(), Some("powertoys"));
13907 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
13908 assert_eq!(query.name.as_deref(), Some("PowerToys"));
13909 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
13910 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
13911 assert_eq!(query.version.as_deref(), Some("0.98.1"));
13912 assert_eq!(query.source.as_deref(), Some("winget"));
13913 assert!(query.exact);
13914 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
13915 assert_eq!(query.count, Some(100));
13916 }
13917
13918 #[test]
13919 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
13920 let request = RepairRequest {
13921 query: PackageQuery {
13922 name: Some("PowerToys".to_owned()),
13923 source: Some("winget".to_owned()),
13924 locale: Some("en-US".to_owned()),
13925 installer_architecture: Some("x64".to_owned()),
13926 install_scope: Some("Machine".to_owned()),
13927 ..PackageQuery::default()
13928 },
13929 manifest_path: None,
13930 product_code: None,
13931 mode: InstallerMode::SilentWithProgress,
13932 log_path: Some(PathBuf::from("repair.log")),
13933 accept_package_agreements: true,
13934 force: false,
13935 ignore_security_hash: true,
13936 };
13937 let installed = ListMatch {
13938 name: "PowerToys".to_owned(),
13939 id: "Microsoft.PowerToys".to_owned(),
13940 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
13941 installed_version: "0.98.1".to_owned(),
13942 available_version: Some("0.98.2".to_owned()),
13943 source_name: Some("winget".to_owned()),
13944 publisher: None,
13945 scope: Some("Machine".to_owned()),
13946 installer_category: Some("exe".to_owned()),
13947 install_location: None,
13948 package_family_names: Vec::new(),
13949 product_codes: Vec::new(),
13950 upgrade_codes: Vec::new(),
13951 };
13952
13953 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
13954 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
13955 assert!(install_request.query.query.is_none());
13956 assert!(install_request.query.name.is_none());
13957 assert!(install_request.query.moniker.is_none());
13958 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
13959 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
13960 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
13961 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
13962 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
13963 assert!(install_request.query.exact);
13964 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
13965 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
13966 assert!(install_request.accept_package_agreements);
13967 assert!(install_request.force);
13968 assert!(install_request.ignore_security_hash);
13969 }
13970
13971 #[test]
13972 fn source_scoped_pin_operations_round_trip() {
13973 let app_root = temp_app_root("pins");
13974 let result = (|| -> Result<()> {
13975 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
13976 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
13977 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
13978 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
13979
13980 assert_eq!(repository.list_pins(None)?.len(), 3);
13981 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
13982 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
13983
13984 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
13985 assert_eq!(repository.list_pins(None)?.len(), 2);
13986 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
13987
13988 repository.reset_pins(Some("test"))?;
13989 let remaining = repository.list_pins(None)?;
13990 assert_eq!(remaining.len(), 1);
13991 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
13992
13993 repository.reset_pins(None)?;
13994 assert!(repository.list_pins(None)?.is_empty());
13995 Ok(())
13996 })();
13997
13998 let _ = fs::remove_dir_all(&app_root);
13999 result.expect("pin round trip");
14000 }
14001
14002 #[test]
14003 fn pin_operations_work_with_packaged_pin_schema() {
14004 let app_root = temp_app_root("pins_packaged_schema");
14005 let result = (|| -> Result<()> {
14006 let db_path = pins_db_path(&app_root);
14007 if let Some(parent) = db_path.parent() {
14008 fs::create_dir_all(parent)?;
14009 }
14010
14011 let conn = Repository::open_sqlite_connection(db_path.clone())?;
14012 execute_batch_sql(
14013 &conn,
14014 "CREATE TABLE pin (
14015 package_id TEXT NOT NULL,
14016 source_id TEXT NOT NULL,
14017 type INTEGER NOT NULL,
14018 version TEXT NOT NULL,
14019 PRIMARY KEY (package_id, source_id)
14020 )",
14021 )?;
14022 conn.cacheflush().context("failed to flush pin database")?;
14023 drop(conn);
14024
14025 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14026 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14027
14028 let pins = repository.list_pins(None)?;
14029 assert_eq!(pins.len(), 1);
14030 assert_eq!(pins[0].package_id, "Contoso.Tool");
14031 assert_eq!(pins[0].version, "1.2.*");
14032 assert_eq!(pins[0].source_id, "winget");
14033 assert_eq!(pins[0].pin_type, PinType::Gating);
14034
14035 let conn = Repository::open_sqlite_connection(db_path)?;
14036 let stored_pin = query_optional_value(
14037 &conn,
14038 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14039 vec![
14040 SqlValue::Text("Contoso.Tool".to_owned()),
14041 SqlValue::Text("winget".to_owned()),
14042 ],
14043 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14044 )?
14045 .ok_or_else(|| anyhow!("expected stored pin"))?;
14046 assert_eq!(stored_pin.0, 3);
14047 assert_eq!(stored_pin.1, "1.2.*");
14048
14049 Ok(())
14050 })();
14051
14052 let _ = fs::remove_dir_all(&app_root);
14053 result.expect("packaged pin schema round trip");
14054 }
14055
14056 #[test]
14057 fn version_pin_patterns_match_exact_and_prefix_values() {
14058 assert!(version_matches_pin_pattern("1.2.3", "*"));
14059 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14060 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14061 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14062 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14063 }
14064
14065 #[test]
14066 fn source_specific_pins_override_source_agnostic_pins() {
14067 let item = ListMatch {
14068 name: "Contoso Tool".to_owned(),
14069 id: "Contoso.Tool".to_owned(),
14070 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14071 installed_version: "1.0.0".to_owned(),
14072 available_version: Some("2.0.0".to_owned()),
14073 source_name: Some("winget".to_owned()),
14074 publisher: None,
14075 scope: None,
14076 installer_category: None,
14077 install_location: None,
14078 package_family_names: Vec::new(),
14079 product_codes: Vec::new(),
14080 upgrade_codes: Vec::new(),
14081 };
14082 let pins = vec![
14083 PinRecord {
14084 package_id: "Contoso.Tool".to_owned(),
14085 version: "*".to_owned(),
14086 source_id: String::new(),
14087 pin_type: PinType::Blocking,
14088 },
14089 PinRecord {
14090 package_id: "Contoso.Tool".to_owned(),
14091 version: "2.0.*".to_owned(),
14092 source_id: "winget".to_owned(),
14093 pin_type: PinType::Pinning,
14094 },
14095 ];
14096
14097 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14098 }
14099
14100 #[test]
14101 fn blocking_pin_blocks_upgrade() {
14102 let item = ListMatch {
14103 name: "Contoso Tool".to_owned(),
14104 id: "Contoso.Tool".to_owned(),
14105 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14106 installed_version: "1.0.0".to_owned(),
14107 available_version: Some("2.0.0".to_owned()),
14108 source_name: Some("winget".to_owned()),
14109 publisher: None,
14110 scope: None,
14111 installer_category: None,
14112 install_location: None,
14113 package_family_names: Vec::new(),
14114 product_codes: Vec::new(),
14115 upgrade_codes: Vec::new(),
14116 };
14117 let pins = vec![PinRecord {
14118 package_id: "Contoso.Tool".to_owned(),
14119 version: "*".to_owned(),
14120 source_id: "winget".to_owned(),
14121 pin_type: PinType::Blocking,
14122 }];
14123
14124 assert!(is_upgrade_blocked_by_pin(&item, &pins));
14125 }
14126
14127 #[cfg(windows)]
14128 #[test]
14129 fn parses_msix_package_full_name_into_version_and_family() {
14130 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14131 .expect("package metadata");
14132
14133 assert_eq!(parsed.version, "0.98.1.0");
14134 assert_eq!(parsed.resource_id, "");
14135 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14136 assert!(!is_msix_split_resource_package(&parsed.resource_id));
14137 }
14138
14139 #[cfg(windows)]
14140 #[test]
14141 fn classifies_msix_split_resource_package() {
14142 let parsed = parse_msix_package_full_name(
14143 "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14144 )
14145 .expect("package metadata");
14146
14147 assert_eq!(parsed.version, "1.97.17002.0");
14148 assert_eq!(parsed.resource_id, "split.scale-125");
14149 assert_eq!(
14150 parsed.family_name,
14151 "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14152 );
14153 assert!(is_msix_split_resource_package(&parsed.resource_id));
14154 }
14155
14156 #[cfg(windows)]
14157 #[test]
14158 fn recognizes_windows_system_paths() {
14159 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14160 assert!(!is_windows_system_path(
14161 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14162 ));
14163 }
14164
14165 #[test]
14166 fn search_ranking_prefers_exact_name_over_tag_match() {
14167 let query = PackageQuery {
14168 query: Some("PowerToys".to_owned()),
14169 ..Default::default()
14170 };
14171 let exact_name = SearchMatch {
14172 source_name: "winget".to_owned(),
14173 source_kind: SourceKind::PreIndexed,
14174 id: "Microsoft.PowerToys".to_owned(),
14175 name: "PowerToys".to_owned(),
14176 moniker: Some("powertoys".to_owned()),
14177 version: Some("0.98.1".to_owned()),
14178 channel: None,
14179 match_criteria: Some("Moniker: powertoys".to_owned()),
14180 };
14181 let tag_match = SearchMatch {
14182 source_name: "winget".to_owned(),
14183 source_kind: SourceKind::PreIndexed,
14184 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14185 name: "QR Codes for Command Palette".to_owned(),
14186 moniker: None,
14187 version: Some("0.4.0.0".to_owned()),
14188 channel: None,
14189 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14190 };
14191
14192 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14193 }
14194
14195 #[test]
14196 fn select_best_text_match_prefers_exact_tag_value() {
14197 let values = vec![
14198 "microsoft-powertoys".to_owned(),
14199 "powertoys-run".to_owned(),
14200 "powertoys".to_owned(),
14201 ];
14202
14203 assert_eq!(
14204 select_best_text_match(values, "PowerToys", false).as_deref(),
14205 Some("powertoys")
14206 );
14207 }
14208
14209 #[test]
14210 fn search_match_criteria_is_blank_for_name_match() {
14211 let query = PackageQuery {
14212 query: Some("PowerToys".to_owned()),
14213 ..Default::default()
14214 };
14215
14216 let result = infer_match_criteria(
14217 "Microsoft.PowerToys",
14218 "PowerToys",
14219 Some("powertoys"),
14220 &query,
14221 SearchSemantics::Many,
14222 |_| Ok(Some("powertoys".to_owned())),
14223 |_| Ok(None),
14224 )
14225 .expect("match criteria");
14226
14227 assert_eq!(result, None);
14228 }
14229
14230 #[test]
14231 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14232 let query = PackageQuery {
14233 query: Some("PowerToys".to_owned()),
14234 ..Default::default()
14235 };
14236 let unknown_version = SearchMatch {
14237 source_name: "msstore".to_owned(),
14238 source_kind: SourceKind::Rest,
14239 id: "XP89DCGQ3K6VLD".to_owned(),
14240 name: "Microsoft PowerToys".to_owned(),
14241 moniker: None,
14242 version: Some("Unknown".to_owned()),
14243 channel: None,
14244 match_criteria: None,
14245 };
14246 let tag_match = SearchMatch {
14247 source_name: "winget".to_owned(),
14248 source_kind: SourceKind::PreIndexed,
14249 id: "Riri.QRCodeforCmdPal".to_owned(),
14250 name: "QR Code for CmdPal".to_owned(),
14251 moniker: None,
14252 version: Some("0.0.1.0".to_owned()),
14253 channel: None,
14254 match_criteria: Some("Tag: powertoys".to_owned()),
14255 };
14256
14257 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14258 }
14259
14260 #[test]
14261 fn search_source_fetch_results_ignore_small_display_count() {
14262 let query = PackageQuery {
14263 count: Some(5),
14264 ..Default::default()
14265 };
14266
14267 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14268 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14269 }
14270
14271 #[test]
14272 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14273 let query = PackageQuery {
14274 query: Some("PowerToys".to_owned()),
14275 ..Default::default()
14276 };
14277 let prefix_name = SearchMatch {
14278 source_name: "winget".to_owned(),
14279 source_kind: SourceKind::PreIndexed,
14280 id: "advaith.CurrencyConverterPowerToys".to_owned(),
14281 name: "PowerToys-Run-Currency-Converter".to_owned(),
14282 moniker: None,
14283 version: Some("1.5.4".to_owned()),
14284 channel: None,
14285 match_criteria: None,
14286 };
14287 let exact_tag = SearchMatch {
14288 source_name: "winget".to_owned(),
14289 source_kind: SourceKind::PreIndexed,
14290 id: "Riri.QRCodeforCmdPal".to_owned(),
14291 name: "QR Code for CmdPal".to_owned(),
14292 moniker: None,
14293 version: Some("0.0.1.0".to_owned()),
14294 channel: None,
14295 match_criteria: Some("Tag: powertoys".to_owned()),
14296 };
14297
14298 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14299 }
14300
14301 #[test]
14302 fn list_sort_prefers_main_package_then_sparse_app() {
14303 let main = InstalledPackage {
14304 name: "PowerToys (Preview) x64".to_owned(),
14305 local_id: r"ARP\User\X64\PowerToys".to_owned(),
14306 installed_version: "0.98.1".to_owned(),
14307 publisher: None,
14308 scope: Some("User".to_owned()),
14309 installer_category: Some("exe".to_owned()),
14310 install_location: None,
14311 package_family_names: Vec::new(),
14312 product_codes: Vec::new(),
14313 upgrade_codes: Vec::new(),
14314 correlated: None,
14315 winget_package_identifier: None,
14316 winget_source_identifier: None,
14317 installed_version_canonical: false,
14318 correlated_requires_explicit_upgrade: false,
14319 correlated_lacks_compatible_installer: false,
14320 };
14321 let sparse = InstalledPackage {
14322 name: "PowerToys.SparseApp".to_owned(),
14323 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14324 installed_version: "0.98.1.0".to_owned(),
14325 publisher: None,
14326 scope: Some("User".to_owned()),
14327 installer_category: Some("msix".to_owned()),
14328 install_location: None,
14329 package_family_names: Vec::new(),
14330 product_codes: Vec::new(),
14331 upgrade_codes: Vec::new(),
14332 correlated: None,
14333 winget_package_identifier: None,
14334 winget_source_identifier: None,
14335 installed_version_canonical: false,
14336 correlated_requires_explicit_upgrade: false,
14337 correlated_lacks_compatible_installer: false,
14338 };
14339 let extension = InstalledPackage {
14340 name: "PowerToys FileLocksmith Context Menu".to_owned(),
14341 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14342 installed_version: "0.98.1.0".to_owned(),
14343 publisher: None,
14344 scope: Some("User".to_owned()),
14345 installer_category: Some("msix".to_owned()),
14346 install_location: None,
14347 package_family_names: Vec::new(),
14348 product_codes: Vec::new(),
14349 upgrade_codes: Vec::new(),
14350 correlated: None,
14351 winget_package_identifier: None,
14352 winget_source_identifier: None,
14353 installed_version_canonical: false,
14354 correlated_requires_explicit_upgrade: false,
14355 correlated_lacks_compatible_installer: false,
14356 };
14357
14358 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14359 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14360 }
14361
14362 #[test]
14363 #[cfg(windows)]
14364 fn arp_subkey_name_extracts_registry_subkey() {
14365 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14366 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14367 }
14368
14369 #[test]
14370 #[cfg(windows)]
14371 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14372 let installed = ListMatch {
14373 name: "ShareX".to_owned(),
14374 id: "ShareX.ShareX".to_owned(),
14375 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14376 installed_version: "19.0.2".to_owned(),
14377 available_version: None,
14378 source_name: Some("winget".to_owned()),
14379 publisher: Some("ShareX Team".to_owned()),
14380 scope: Some("Machine".to_owned()),
14381 installer_category: Some("exe".to_owned()),
14382 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14383 package_family_names: Vec::new(),
14384 product_codes: Vec::new(),
14385 upgrade_codes: Vec::new(),
14386 };
14387
14388 assert!(registry_entry_matches_installed_package(
14389 "ShareX", "ShareX", None, &installed
14390 ));
14391 assert!(!registry_entry_matches_installed_package(
14392 "ShareX.ShareX",
14393 "ShareX.ShareX",
14394 None,
14395 &installed
14396 ));
14397 }
14398
14399 #[test]
14400 #[cfg(windows)]
14401 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14402 assert_eq!(
14403 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14404 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14405 );
14406 assert_eq!(
14407 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14408 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14409 );
14410 assert_eq!(
14411 r#""C:\Program Files\ShareX\unins000.exe""#,
14412 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14413 );
14414 assert_eq!(
14415 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14416 build_uninstall_command(
14417 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14418 true,
14419 false,
14420 )
14421 );
14422 }
14423
14424 #[test]
14425 #[cfg(windows)]
14426 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14427 let installed = ListMatch {
14428 name: "lazygit".to_owned(),
14429 id: "JesseDuffield.lazygit".to_owned(),
14430 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14431 installed_version: "0.61.1".to_owned(),
14432 available_version: None,
14433 source_name: Some("winget".to_owned()),
14434 publisher: Some("Jesse Duffield".to_owned()),
14435 scope: Some("User".to_owned()),
14436 installer_category: Some("exe".to_owned()),
14437 install_location: Some(
14438 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14439 ),
14440 package_family_names: Vec::new(),
14441 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14442 upgrade_codes: Vec::new(),
14443 };
14444 let mut request = UninstallRequest::new(PackageQuery {
14445 id: Some("JesseDuffield.lazygit".to_owned()),
14446 install_scope: Some("user".to_owned()),
14447 ..PackageQuery::default()
14448 });
14449 request.mode = InstallerMode::Silent;
14450
14451 assert_eq!(
14452 Some(vec![
14453 "uninstall".to_owned(),
14454 "--id".to_owned(),
14455 "JesseDuffield.lazygit".to_owned(),
14456 "--exact".to_owned(),
14457 "--disable-interactivity".to_owned(),
14458 "--source".to_owned(),
14459 "winget".to_owned(),
14460 "--scope".to_owned(),
14461 "user".to_owned(),
14462 "--silent".to_owned(),
14463 ]),
14464 build_winget_uninstall_arguments(&installed, &request)
14465 );
14466
14467 assert_eq!(
14468 Some(vec![
14469 "uninstall".to_owned(),
14470 "--id".to_owned(),
14471 "JesseDuffield.lazygit".to_owned(),
14472 "--exact".to_owned(),
14473 "--disable-interactivity".to_owned(),
14474 "--source".to_owned(),
14475 "winget".to_owned(),
14476 "--silent".to_owned(),
14477 ]),
14478 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
14479 );
14480 }
14481
14482 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
14483 PackageVersionDataEntry {
14484 version: version.to_owned(),
14485 manifest_relative_path: format!("manifests/x/{version}"),
14486 manifest_hash: "00".repeat(32),
14487 arp_min_version: arp_min.map(str::to_owned),
14488 arp_max_version: arp_max.map(str::to_owned),
14489 }
14490 }
14491
14492 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
14493 V1VersionRow {
14494 version: version.to_owned(),
14495 channel: channel.to_owned(),
14496 pathpart_id: 0,
14497 manifest_hash: None,
14498 }
14499 }
14500
14501 #[derive(Clone)]
14502 struct TestHttpResponse {
14503 status: u16,
14504 body: Vec<u8>,
14505 source_version: Option<String>,
14506 etag: Option<String>,
14507 last_modified: Option<String>,
14508 }
14509
14510 struct TestHttpServer {
14511 addr: SocketAddr,
14512 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14513 counts: Arc<Mutex<HashMap<String, usize>>>,
14514 running: Arc<AtomicBool>,
14515 handle: Option<JoinHandle<()>>,
14516 }
14517
14518 impl TestHttpServer {
14519 fn start() -> Self {
14520 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
14521 let addr = listener.local_addr().expect("test HTTP server address");
14522 let routes = Arc::new(Mutex::new(HashMap::new()));
14523 let counts = Arc::new(Mutex::new(HashMap::new()));
14524 let running = Arc::new(AtomicBool::new(true));
14525 let thread_routes = Arc::clone(&routes);
14526 let thread_counts = Arc::clone(&counts);
14527 let thread_running = Arc::clone(&running);
14528 let handle = thread::spawn(move || {
14529 while thread_running.load(AtomicOrdering::SeqCst) {
14530 let Ok((stream, _)) = listener.accept() else {
14531 continue;
14532 };
14533 handle_test_http_request(stream, &thread_routes, &thread_counts);
14534 }
14535 });
14536
14537 Self {
14538 addr,
14539 routes,
14540 counts,
14541 running,
14542 handle: Some(handle),
14543 }
14544 }
14545
14546 fn url(&self) -> String {
14547 format!("http://{}", self.addr)
14548 }
14549
14550 fn set_bytes(&self, path: &str, body: Vec<u8>) {
14551 self.set_response(
14552 path,
14553 TestHttpResponse {
14554 status: 200,
14555 body,
14556 source_version: None,
14557 etag: None,
14558 last_modified: None,
14559 },
14560 );
14561 }
14562
14563 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
14564 self.set_source_package_with_validators(body, source_version, None, None);
14565 }
14566
14567 fn set_source_package_with_validators(
14568 &self,
14569 body: Vec<u8>,
14570 source_version: &str,
14571 etag: Option<&str>,
14572 last_modified: Option<&str>,
14573 ) {
14574 self.set_response(
14575 "/source.msix",
14576 TestHttpResponse {
14577 status: 200,
14578 body,
14579 source_version: Some(source_version.to_owned()),
14580 etag: etag.map(str::to_owned),
14581 last_modified: last_modified.map(str::to_owned),
14582 },
14583 );
14584 }
14585
14586 fn set_status(&self, path: &str, status: u16) {
14587 self.set_response(
14588 path,
14589 TestHttpResponse {
14590 status,
14591 body: Vec::new(),
14592 source_version: None,
14593 etag: None,
14594 last_modified: None,
14595 },
14596 );
14597 }
14598
14599 fn set_response(&self, path: &str, response: TestHttpResponse) {
14600 self.routes
14601 .lock()
14602 .expect("test HTTP routes")
14603 .insert(path.to_owned(), response);
14604 }
14605
14606 fn request_count(&self, path: &str) -> usize {
14607 self.counts
14608 .lock()
14609 .expect("test HTTP counts")
14610 .get(path)
14611 .copied()
14612 .unwrap_or_default()
14613 }
14614 }
14615
14616 impl Drop for TestHttpServer {
14617 fn drop(&mut self) {
14618 self.running.store(false, AtomicOrdering::SeqCst);
14619 let _ = TcpStream::connect(self.addr);
14620 if let Some(handle) = self.handle.take() {
14621 let _ = handle.join();
14622 }
14623 }
14624 }
14625
14626 fn handle_test_http_request(
14627 mut stream: TcpStream,
14628 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14629 counts: &Arc<Mutex<HashMap<String, usize>>>,
14630 ) {
14631 let mut buffer = [0u8; 2048];
14632 let read = stream.read(&mut buffer).unwrap_or(0);
14633 let request = String::from_utf8_lossy(&buffer[..read]);
14634 let path = request
14635 .lines()
14636 .next()
14637 .and_then(|line| line.split_whitespace().nth(1))
14638 .unwrap_or("/");
14639 *counts
14640 .lock()
14641 .expect("test HTTP counts")
14642 .entry(path.to_owned())
14643 .or_default() += 1;
14644 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
14645 let response = response.unwrap_or(TestHttpResponse {
14646 status: 404,
14647 body: Vec::new(),
14648 source_version: None,
14649 etag: None,
14650 last_modified: None,
14651 });
14652 let not_modified = response.status == 200
14653 && (response.etag.as_deref().is_some_and(|etag| {
14654 request_header_value(&request, "If-None-Match")
14655 .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
14656 }) || response.last_modified.as_deref().is_some_and(|last_modified| {
14657 request_header_value(&request, "If-Modified-Since")
14658 .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
14659 }));
14660 let status = if not_modified { 304 } else { response.status };
14661 let body = if not_modified {
14662 &[][..]
14663 } else {
14664 response.body.as_slice()
14665 };
14666 let reason = match status {
14667 200 => "OK",
14668 304 => "Not Modified",
14669 _ => "Not Found",
14670 };
14671 let mut headers = format!(
14672 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
14673 status,
14674 reason,
14675 body.len()
14676 );
14677 if let Some(source_version) = response.source_version {
14678 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
14679 }
14680 if let Some(etag) = response.etag {
14681 headers.push_str(&format!("ETag: {etag}\r\n"));
14682 }
14683 if let Some(last_modified) = response.last_modified {
14684 headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
14685 }
14686 headers.push_str("\r\n");
14687 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
14688 stream.write_all(body).expect("write test HTTP body");
14689 }
14690
14691 fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
14692 request.lines().find_map(|line| {
14693 let (header_name, value) = line.split_once(':')?;
14694 header_name.eq_ignore_ascii_case(name).then(|| value.trim())
14695 })
14696 }
14697
14698 fn test_source_record(source_url: String) -> SourceRecord {
14699 SourceRecord {
14700 name: "test-winget".to_owned(),
14701 kind: SourceKind::PreIndexed,
14702 arg: source_url,
14703 identifier: "Test.Source".to_owned(),
14704 trust_level: "Trusted".to_owned(),
14705 explicit: false,
14706 priority: 0,
14707 last_update: Some(Utc::now()),
14708 source_version: None,
14709 etag: None,
14710 last_modified: None,
14711 }
14712 }
14713
14714 fn open_test_preindexed_repository(
14715 app_root: &Path,
14716 source: &SourceRecord,
14717 interval: Option<Duration>,
14718 ) -> Repository {
14719 fs::create_dir_all(app_root).expect("create app root");
14720 save_store(
14721 app_root,
14722 &SourceStore {
14723 sources: vec![source.clone()],
14724 },
14725 )
14726 .expect("save source store");
14727 Repository::open_with_options(
14728 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
14729 )
14730 .expect("open repository")
14731 }
14732
14733 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
14734 let index_path = preindexed_index_path(app_root, source);
14735 if let Some(parent) = index_path.parent() {
14736 fs::create_dir_all(parent).expect("create source state directory");
14737 }
14738 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
14739 }
14740
14741 #[test]
14742 fn replace_file_failure_keeps_existing_target() {
14743 let root = temp_app_root("replace_file_failure");
14744 fs::create_dir_all(&root).expect("create replace root");
14745 let target = root.join("index.db");
14746 let missing_source = root.join("missing.tmp");
14747 fs::write(&target, b"old-index").expect("write existing target");
14748
14749 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
14750
14751 assert!(format!("{error:#}").contains("failed to replace"));
14752 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
14753 let _ = fs::remove_dir_all(root);
14754 }
14755
14756 #[cfg(windows)]
14757 #[test]
14758 fn replace_file_succeeds_while_target_is_open() {
14759 use std::os::windows::fs::OpenOptionsExt;
14760 const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
14764
14765 let root = temp_app_root("replace_file_open_target");
14766 fs::create_dir_all(&root).expect("create replace root");
14767 let target = root.join("index.db");
14768 let source = root.join("index.new.tmp");
14769 fs::write(&target, b"old-index").expect("write existing target");
14770 fs::write(&source, b"new-index").expect("write replacement");
14771
14772 let held = OpenOptions::new()
14773 .read(true)
14774 .share_mode(SHARE_ALL)
14775 .open(&target)
14776 .expect("open target with share-delete");
14777
14778 replace_file(&source, &target).expect("stage-aside replace should succeed");
14779
14780 assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
14781 drop(held);
14782 let _ = fs::remove_dir_all(root);
14783 }
14784
14785 fn make_source_package(versions: &[&str]) -> Vec<u8> {
14786 let cursor = Cursor::new(Vec::new());
14787 let mut archive = zip::ZipWriter::new(cursor);
14788 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
14789 archive
14790 .start_file("Public/index.db", options)
14791 .expect("start index entry");
14792 archive
14793 .write_all(&make_v1_index_bytes(versions))
14794 .expect("write index entry");
14795 archive.finish().expect("finish source package").into_inner()
14796 }
14797
14798 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
14799 let root = temp_app_root("preindexed_index_bytes");
14800 fs::create_dir_all(&root).expect("create index temp root");
14801 let db_path = root.join("index.db");
14802 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
14803 let mut sql = String::from(
14804 "CREATE TABLE ids (id TEXT);
14805 CREATE TABLE names (name TEXT);
14806 CREATE TABLE monikers (moniker TEXT);
14807 CREATE TABLE versions (version TEXT);
14808 CREATE TABLE channels (channel TEXT);
14809 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
14810 CREATE TABLE manifest (
14811 id INT64,
14812 name INT64,
14813 moniker INT64,
14814 version INT64,
14815 channel INT64,
14816 pathpart INT64
14817 );
14818 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
14819 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
14820 INSERT INTO channels(rowid, channel) VALUES (1, '');
14821 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
14822 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
14823 );
14824 for (index, version) in versions.iter().enumerate() {
14825 let rowid = index + 1;
14826 sql.push_str(&format!(
14827 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
14828 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
14829 ));
14830 }
14831 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
14832 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
14833 .expect("checkpoint preindexed V1 database");
14834 connection.cacheflush().expect("flush preindexed V1 database");
14835 drop(connection);
14836 let bytes = fs::read(&db_path).expect("read index bytes");
14837 let _ = fs::remove_dir_all(root);
14838 bytes
14839 }
14840
14841 fn test_manifest_yaml(version: &str) -> Vec<u8> {
14842 format!(
14843 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
14844 )
14845 .into_bytes()
14846 }
14847
14848 fn show_test_package(version: Option<&str>) -> PackageQuery {
14849 PackageQuery {
14850 id: Some("Test.Package".to_owned()),
14851 source: Some("test-winget".to_owned()),
14852 exact: true,
14853 version: version.map(str::to_owned),
14854 ..PackageQuery::default()
14855 }
14856 }
14857
14858 #[test]
14859 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
14860 let app_root = temp_app_root("preindexed_explicit_refresh");
14861 let server = TestHttpServer::start();
14862 server.set_status("/source2.msix", 404);
14863 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
14864 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14865 let source = test_source_record(server.url());
14866 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14867 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14868
14869 let result = repository
14870 .show(&show_test_package(Some("2.0.0")))
14871 .expect("show after refresh");
14872
14873 assert_eq!(result.manifest.version, "2.0.0");
14874 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
14875 let _ = fs::remove_dir_all(app_root);
14876 }
14877
14878 #[test]
14879 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
14880 let app_root = temp_app_root("preindexed_explicit_still_missing");
14881 let server = TestHttpServer::start();
14882 server.set_status("/source2.msix", 404);
14883 server.set_source_package(make_source_package(&["1.0.0"]), "same");
14884 let source = test_source_record(server.url());
14885 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14886 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14887
14888 let error = repository
14889 .show(&show_test_package(Some("2.0.0")))
14890 .expect_err("missing explicit version must fail");
14891
14892 assert!(format!("{error:#}").contains("2.0.0"));
14893 let _ = fs::remove_dir_all(app_root);
14894 }
14895
14896 #[test]
14897 fn preindexed_stale_index_refreshes_for_latest_requests() {
14898 let app_root = temp_app_root("preindexed_ttl_refresh");
14899 let server = TestHttpServer::start();
14900 server.set_status("/source2.msix", 404);
14901 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14902 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14903 let mut source = test_source_record(server.url());
14904 source.last_update = Some(Utc::now() - Duration::days(2));
14905 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14906 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14907
14908 let result = repository
14909 .show(&show_test_package(None))
14910 .expect("show latest after TTL refresh");
14911
14912 assert_eq!(result.manifest.version, "2.0.0");
14913 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
14914 let _ = fs::remove_dir_all(app_root);
14915 }
14916
14917 #[test]
14918 fn preindexed_stale_index_not_modified_keeps_local_index() {
14919 let app_root = temp_app_root("preindexed_ttl_not_modified");
14920 let server = TestHttpServer::start();
14921 server.set_status("/source2.msix", 404);
14922 server.set_source_package_with_validators(
14923 make_source_package(&["1.0.0", "2.0.0"]),
14924 "fresh",
14925 Some("\"same-index\""),
14926 Some("Wed, 03 Jun 2026 12:00:00 GMT"),
14927 );
14928 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14929 let mut source = test_source_record(server.url());
14930 source.last_update = Some(Utc::now() - Duration::days(2));
14931 source.etag = Some("\"same-index\"".to_owned());
14932 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
14933 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14934 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14935
14936 let result = repository
14937 .show(&show_test_package(None))
14938 .expect("show latest from not-modified stale index");
14939
14940 let refreshed_source = &repository.list_sources()[0];
14941 assert_eq!(result.manifest.version, "1.0.0");
14942 assert_eq!(server.request_count("/source.msix"), 1);
14943 assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
14944 assert_eq!(
14945 refreshed_source.last_modified.as_deref(),
14946 Some("Wed, 03 Jun 2026 12:00:00 GMT")
14947 );
14948 let _ = fs::remove_dir_all(app_root);
14949 }
14950
14951 #[test]
14952 fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
14953 let app_root = temp_app_root("preindexed_fresh_mtime");
14954 let server = TestHttpServer::start();
14955 server.set_status("/source2.msix", 404);
14956 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14957 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14958 let mut source = test_source_record(server.url());
14959 source.last_update = Some(Utc::now() - Duration::days(2));
14960 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14961 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
14962
14963 let result = repository
14964 .show(&show_test_package(None))
14965 .expect("show latest from fresh index mtime");
14966
14967 assert_eq!(result.manifest.version, "1.0.0");
14968 assert_eq!(server.request_count("/source.msix"), 0);
14969 let _ = fs::remove_dir_all(app_root);
14970 }
14971
14972 #[test]
14973 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
14974 let app_root = temp_app_root("preindexed_ttl_fallback");
14975 let server = TestHttpServer::start();
14976 server.set_status("/source2.msix", 404);
14977 server.set_status("/source.msix", 404);
14978 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14979 let mut source = test_source_record(server.url());
14980 source.last_update = Some(Utc::now() - Duration::days(2));
14981 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14982 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14983
14984 let result = repository
14985 .show(&show_test_package(None))
14986 .expect("show latest from stale cache after refresh failure");
14987
14988 assert_eq!(result.manifest.version, "1.0.0");
14989 let _ = fs::remove_dir_all(app_root);
14990 }
14991
14992 #[test]
14993 fn explicit_source_search_failure_is_not_reported_as_no_match() {
14994 let app_root = temp_app_root("preindexed_source_search_failure");
14995 let server = TestHttpServer::start();
14996 server.set_status("/source2.msix", 404);
14997 server.set_status("/source.msix", 404);
14998 let source = test_source_record(server.url());
14999 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15000
15001 let error = repository
15002 .show(&show_test_package(None))
15003 .expect_err("source failure must surface");
15004 let message = format!("{error:#}");
15005
15006 assert!(message.contains("failed to search source 'test-winget'"));
15007 assert!(message.contains("source.msix"));
15008 assert!(!message.contains("no package matched the supplied query"));
15009 let _ = fs::remove_dir_all(app_root);
15010 }
15011
15012 #[test]
15013 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15014 let app_root = temp_app_root("preindexed_no_double_refresh");
15015 let server = TestHttpServer::start();
15016 server.set_status("/source2.msix", 404);
15017 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15018 let mut source = test_source_record(server.url());
15019 source.last_update = Some(Utc::now() - Duration::days(2));
15020 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15021 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15022
15023 let error = repository
15024 .show(&show_test_package(Some("3.0.0")))
15025 .expect_err("still-missing explicit version must fail after one stale refresh");
15026
15027 assert!(format!("{error:#}").contains("3.0.0"));
15028 assert_eq!(server.request_count("/source.msix"), 1);
15029 let _ = fs::remove_dir_all(app_root);
15030 }
15031
15032 #[test]
15033 fn select_v2_version_without_explicit_filters_returns_latest() {
15034 let versions = vec![
15035 version_entry("1.0.0", None, None),
15036 version_entry("1.2.0", None, None),
15037 version_entry("1.1.0", None, None),
15038 ];
15039
15040 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15041
15042 assert_eq!(selected.version, "1.2.0");
15043 }
15044
15045 #[test]
15046 fn select_v2_version_with_missing_requested_version_throws() {
15047 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15048
15049 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15050 .expect_err("missing requested version");
15051
15052 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15053 assert!(error.to_string().contains("9.9.9"));
15054 }
15055
15056 #[test]
15057 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15058 let versions = vec![version_entry("1.2.0", None, None)];
15059
15060 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15061 .expect_err("unsupported channel request");
15062
15063 assert!(error.to_string().contains("beta"));
15064 assert!(error.to_string().contains("channel metadata"));
15065 }
15066
15067 #[test]
15068 fn select_v1_version_channel_only_returns_latest_matching_channel() {
15069 let versions = vec![
15070 v1_version("1.2.0", "stable"),
15071 v1_version("1.3.0", "stable"),
15072 v1_version("2.0.0", "preview"),
15073 ];
15074
15075 let selected =
15076 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15077
15078 assert_eq!(selected.version, "1.3.0");
15079 }
15080
15081 #[test]
15082 fn select_rest_version_channel_mismatch_throws() {
15083 let versions = vec![
15084 VersionKey {
15085 version: "1.2.0".to_owned(),
15086 channel: "stable".to_owned(),
15087 },
15088 VersionKey {
15089 version: "1.1.0".to_owned(),
15090 channel: "beta".to_owned(),
15091 },
15092 ];
15093
15094 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15095 .expect_err("missing version and channel pair");
15096
15097 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15098 }
15099
15100 #[test]
15101 fn map_arp_version_returns_catalog_version_inside_range() {
15102 let entries = vec![
15107 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15108 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15109 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15110 ];
15111
15112 assert_eq!(
15113 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15114 Some("10.0.108")
15115 );
15116 }
15117
15118 #[test]
15119 fn map_arp_version_returns_none_when_no_range_matches() {
15120 let entries = vec![
15125 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15126 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15127 ];
15128
15129 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15130 }
15131
15132 #[test]
15133 fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15134 let entries = vec![
15138 version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15139 version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15140 ];
15141
15142 assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15143 assert_eq!(
15144 less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15145 Some("< 25.4.1")
15146 );
15147 assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15148 }
15149
15150 #[test]
15151 fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15152 let entries = vec![
15156 version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15157 version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15158 ];
15159
15160 assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15161 assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15162 assert_eq!(
15163 greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15164 Some("> 1.5.8")
15165 );
15166 assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15167
15168 let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15169 assert!(
15170 greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15171 );
15172 }
15173
15174 #[test]
15175 fn map_arp_version_skips_entries_missing_arp_bounds() {
15176 let entries = vec![
15180 version_entry("9.0.300", None, None),
15181 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15182 ];
15183
15184 assert_eq!(
15185 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15186 Some("9.0.117")
15187 );
15188 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15189 }
15190
15191 #[test]
15192 fn map_arp_version_handles_inclusive_range_endpoints() {
15193 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15196
15197 assert_eq!(
15198 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15199 Some("3.14.5")
15200 );
15201 assert_eq!(
15202 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15203 Some("3.14.5")
15204 );
15205 assert_eq!(
15206 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15207 Some("3.14.5")
15208 );
15209 }
15210
15211 #[test]
15212 fn map_arp_version_ignores_unknown_marker() {
15213 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15217
15218 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15219 assert!(map_arp_version_to_catalog(&entries, "").is_none());
15220 }
15221
15222 #[test]
15223 fn latest_arp_anchored_skips_internal_versions() {
15224 let entries = vec![
15230 version_entry("8000.836.2153.0", None, None),
15231 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15232 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15233 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15234 ];
15235
15236 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15237 }
15238
15239 #[test]
15240 fn latest_arp_anchored_returns_none_without_bounds() {
15241 let entries = vec![
15245 version_entry("1.28.240.0", None, None),
15246 version_entry("1.27.470.0", None, None),
15247 ];
15248
15249 assert!(latest_arp_anchored_version(&entries).is_none());
15250 }
15251
15252 #[test]
15253 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15254 let package = InstalledPackage {
15255 name: "AzCopy v10".to_owned(),
15256 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15257 installed_version: "10.32.2".to_owned(),
15258 publisher: Some("Microsoft".to_owned()),
15259 scope: Some("Machine".to_owned()),
15260 installer_category: Some("exe".to_owned()),
15261 install_location: None,
15262 package_family_names: Vec::new(),
15263 product_codes: Vec::new(),
15264 upgrade_codes: Vec::new(),
15265 correlated: Some(SearchMatch {
15266 source_name: "winget".to_owned(),
15267 source_kind: SourceKind::PreIndexed,
15268 id: "Microsoft.Azure.AZCopy.10".to_owned(),
15269 name: "AzCopy v10".to_owned(),
15270 moniker: None,
15271 version: Some("10.32.3".to_owned()),
15272 channel: None,
15273 match_criteria: None,
15274 }),
15275 winget_package_identifier: None,
15276 winget_source_identifier: None,
15277 installed_version_canonical: false,
15278 correlated_requires_explicit_upgrade: false,
15279 correlated_lacks_compatible_installer: false,
15280 };
15281
15282 let item = list_match_from_installed(package, &[]);
15283
15284 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15285 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15286 assert_eq!(item.source_name.as_deref(), Some("winget"));
15287 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15288 }
15289
15290 #[test]
15291 fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15292 let rows = vec![
15293 ListMatch {
15294 name: "Example 1.0".to_owned(),
15295 id: "Example.Tool".to_owned(),
15296 local_id: r"ARP\Machine\X64\old".to_owned(),
15297 installed_version: "1.0.0".to_owned(),
15298 available_version: Some("3.0.0".to_owned()),
15299 source_name: Some("winget".to_owned()),
15300 publisher: None,
15301 scope: None,
15302 installer_category: None,
15303 install_location: None,
15304 package_family_names: Vec::new(),
15305 product_codes: Vec::new(),
15306 upgrade_codes: Vec::new(),
15307 },
15308 ListMatch {
15309 name: "Example 2.0".to_owned(),
15310 id: "Example.Tool".to_owned(),
15311 local_id: r"ARP\Machine\X64\new".to_owned(),
15312 installed_version: "2.0.0".to_owned(),
15313 available_version: Some("3.0.0".to_owned()),
15314 source_name: Some("winget".to_owned()),
15315 publisher: None,
15316 scope: None,
15317 installer_category: None,
15318 install_location: None,
15319 package_family_names: Vec::new(),
15320 product_codes: Vec::new(),
15321 upgrade_codes: Vec::new(),
15322 },
15323 ListMatch {
15324 name: "Other".to_owned(),
15325 id: "Other.Tool".to_owned(),
15326 local_id: r"ARP\Machine\X64\other".to_owned(),
15327 installed_version: "1.0.0".to_owned(),
15328 available_version: Some("2.0.0".to_owned()),
15329 source_name: Some("winget".to_owned()),
15330 publisher: None,
15331 scope: None,
15332 installer_category: None,
15333 install_location: None,
15334 package_family_names: Vec::new(),
15335 product_codes: Vec::new(),
15336 upgrade_codes: Vec::new(),
15337 },
15338 ];
15339
15340 let result = suppress_duplicate_available_versions(rows);
15341
15342 assert_eq!(result[0].available_version, None);
15343 assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15344 assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15345 }
15346
15347 #[test]
15348 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15349 let package = InstalledPackage {
15350 name: "Atlassian CLI".to_owned(),
15351 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15352 installed_version: "1.3.18-stable".to_owned(),
15353 publisher: Some("Atlassian".to_owned()),
15354 scope: Some("User".to_owned()),
15355 installer_category: Some("exe".to_owned()),
15356 install_location: None,
15357 package_family_names: Vec::new(),
15358 product_codes: Vec::new(),
15359 upgrade_codes: Vec::new(),
15360 correlated: Some(SearchMatch {
15361 source_name: "winget".to_owned(),
15362 source_kind: SourceKind::PreIndexed,
15363 id: "Atlassian.AtlassianCLI".to_owned(),
15364 name: "Atlassian CLI".to_owned(),
15365 moniker: None,
15366 version: Some("1.3.18-stable".to_owned()),
15367 channel: None,
15368 match_criteria: Some("ProductCode".to_owned()),
15369 }),
15370 winget_package_identifier: None,
15371 winget_source_identifier: None,
15372 installed_version_canonical: false,
15373 correlated_requires_explicit_upgrade: false,
15374 correlated_lacks_compatible_installer: false,
15375 };
15376
15377 let item = list_match_from_installed(package, &[]);
15378
15379 assert_eq!(item.id, "Atlassian.AtlassianCLI");
15380 assert_eq!(
15381 item.local_id,
15382 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15383 );
15384 assert_ne!(item.id, item.local_id);
15385 assert_eq!(item.source_name.as_deref(), Some("winget"));
15386 }
15387
15388 #[test]
15389 fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15390 let package = InstalledPackage {
15391 name: "FFmpeg".to_owned(),
15392 local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15393 installed_version: "8.0".to_owned(),
15394 publisher: Some("Gyan".to_owned()),
15395 scope: Some("User".to_owned()),
15396 installer_category: Some("portable".to_owned()),
15397 install_location: None,
15398 package_family_names: Vec::new(),
15399 product_codes: Vec::new(),
15400 upgrade_codes: Vec::new(),
15401 correlated: None,
15402 winget_package_identifier: None,
15403 winget_source_identifier: None,
15404 installed_version_canonical: false,
15405 correlated_requires_explicit_upgrade: false,
15406 correlated_lacks_compatible_installer: false,
15407 };
15408 let sources = vec![SourceRecord {
15409 name: "winget".to_owned(),
15410 kind: SourceKind::PreIndexed,
15411 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15412 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15413 trust_level: "Trusted".to_owned(),
15414 explicit: false,
15415 priority: 0,
15416 last_update: None,
15417 source_version: None,
15418 etag: None,
15419 last_modified: None,
15420 }];
15421
15422 let item = list_match_from_installed(package, &sources);
15423
15424 assert_eq!(item.id, "Gyan.FFmpeg");
15425 assert_eq!(
15426 item.local_id,
15427 r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15428 );
15429 assert_eq!(item.source_name.as_deref(), Some("winget"));
15430 }
15431
15432 #[test]
15433 fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15434 let package = InstalledPackage {
15435 name: "tessl".to_owned(),
15436 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15437 installed_version: "0.77.0".to_owned(),
15438 publisher: Some("tessl.io".to_owned()),
15439 scope: Some("User".to_owned()),
15440 installer_category: Some("portable".to_owned()),
15441 install_location: None,
15442 package_family_names: Vec::new(),
15443 product_codes: Vec::new(),
15444 upgrade_codes: Vec::new(),
15445 correlated: None,
15446 winget_package_identifier: Some("tessl.tessl".to_owned()),
15447 winget_source_identifier: Some("api.winget.pro".to_owned()),
15448 installed_version_canonical: false,
15449 correlated_requires_explicit_upgrade: false,
15450 correlated_lacks_compatible_installer: false,
15451 };
15452
15453 let item = list_match_from_installed(package, &[]);
15454
15455 assert_eq!(item.id, "tessl.tessl");
15456 assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15457 }
15458
15459 #[test]
15460 fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15461 let package = InstalledPackage {
15462 name: "tessl".to_owned(),
15463 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15464 installed_version: "0.77.0".to_owned(),
15465 publisher: Some("tessl.io".to_owned()),
15466 scope: Some("User".to_owned()),
15467 installer_category: Some("portable".to_owned()),
15468 install_location: None,
15469 package_family_names: Vec::new(),
15470 product_codes: Vec::new(),
15471 upgrade_codes: Vec::new(),
15472 correlated: None,
15473 winget_package_identifier: Some("tessl.tessl".to_owned()),
15474 winget_source_identifier: Some("api.winget.pro".to_owned()),
15475 installed_version_canonical: false,
15476 correlated_requires_explicit_upgrade: false,
15477 correlated_lacks_compatible_installer: false,
15478 };
15479 let sources = vec![SourceRecord {
15480 name: "winget.pro".to_owned(),
15481 kind: SourceKind::Rest,
15482 arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
15483 identifier: "api.winget.pro".to_owned(),
15484 trust_level: "Trusted".to_owned(),
15485 explicit: false,
15486 priority: 0,
15487 last_update: None,
15488 source_version: None,
15489 etag: None,
15490 last_modified: None,
15491 }];
15492
15493 let item = list_match_from_installed(package, &sources);
15494
15495 assert_eq!(item.id, "tessl.tessl");
15496 assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
15497 }
15498
15499 #[test]
15500 fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
15501 let package = InstalledPackage {
15502 name: "Example".to_owned(),
15503 local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
15504 installed_version: "1.0".to_owned(),
15505 publisher: Some("Vendor".to_owned()),
15506 scope: Some("User".to_owned()),
15507 installer_category: Some("portable".to_owned()),
15508 install_location: None,
15509 package_family_names: Vec::new(),
15510 product_codes: Vec::new(),
15511 upgrade_codes: Vec::new(),
15512 correlated: None,
15513 winget_package_identifier: None,
15514 winget_source_identifier: None,
15515 installed_version_canonical: false,
15516 correlated_requires_explicit_upgrade: false,
15517 correlated_lacks_compatible_installer: false,
15518 };
15519 let sources = vec![
15520 SourceRecord {
15521 name: "long".to_owned(),
15522 kind: SourceKind::PreIndexed,
15523 arg: "https://example.invalid/long".to_owned(),
15524 identifier: "identifier-longer-than-local-id".to_owned(),
15525 trust_level: "Trusted".to_owned(),
15526 explicit: false,
15527 priority: 0,
15528 last_update: None,
15529 source_version: None,
15530 etag: None,
15531 last_modified: None,
15532 },
15533 SourceRecord {
15534 name: "short".to_owned(),
15535 kind: SourceKind::PreIndexed,
15536 arg: "https://example.invalid/short".to_owned(),
15537 identifier: "short".to_owned(),
15538 trust_level: "Trusted".to_owned(),
15539 explicit: false,
15540 priority: 1,
15541 last_update: None,
15542 source_version: None,
15543 etag: None,
15544 last_modified: None,
15545 },
15546 ];
15547
15548 let item = list_match_from_installed(package, &sources);
15549
15550 assert_eq!(item.id, "Vendor.Example");
15551 assert_eq!(item.source_name.as_deref(), Some("short"));
15552 }
15553
15554 #[test]
15555 fn list_response_serialization_keeps_pascal_case_fields() {
15556 let response = ListResponse {
15557 matches: vec![ListMatch {
15558 name: "PowerToys".to_owned(),
15559 id: "Microsoft.PowerToys".to_owned(),
15560 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
15561 installed_version: "0.98.1".to_owned(),
15562 available_version: Some("0.99.0".to_owned()),
15563 source_name: Some("winget".to_owned()),
15564 publisher: Some("Microsoft".to_owned()),
15565 scope: Some("Machine".to_owned()),
15566 installer_category: Some("msi".to_owned()),
15567 install_location: None,
15568 package_family_names: Vec::new(),
15569 product_codes: Vec::new(),
15570 upgrade_codes: Vec::new(),
15571 }],
15572 warnings: Vec::new(),
15573 truncated: false,
15574 };
15575
15576 let value = serde_json::to_value(&response).expect("serialize list response");
15577 let match_value = &value["Matches"][0];
15578
15579 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
15580 assert_eq!(match_value["InstalledVersion"], "0.98.1");
15581 assert_eq!(match_value["AvailableVersion"], "0.99.0");
15582 assert_eq!(match_value["SourceName"], "winget");
15583 assert!(match_value.get("local_id").is_none());
15584 assert!(match_value.get("installed_version").is_none());
15585 assert!(match_value.get("available_version").is_none());
15586 assert!(match_value.get("source_name").is_none());
15587 }
15588
15589 #[test]
15590 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
15591 let response = SearchResponse {
15592 matches: vec![SearchMatch {
15593 source_name: "msstore".to_owned(),
15594 source_kind: SourceKind::Rest,
15595 id: "9WZDNCRFJBMP".to_owned(),
15596 name: "Microsoft To Do".to_owned(),
15597 moniker: None,
15598 version: Some("2.123.456.0".to_owned()),
15599 channel: None,
15600 match_criteria: None,
15601 }],
15602 warnings: Vec::new(),
15603 truncated: false,
15604 };
15605
15606 let value = serde_json::to_value(&response).expect("serialize search response");
15607 let match_value = &value["Matches"][0];
15608
15609 assert_eq!(match_value["Name"], "Microsoft To Do");
15610 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
15611 assert_eq!(match_value["Version"], "2.123.456.0");
15612 assert_eq!(match_value["SourceName"], "msstore");
15613 assert!(match_value.get("source_name").is_none());
15614 }
15615
15616 #[test]
15617 fn versions_result_serialization_keeps_pascal_case_fields() {
15618 let result = VersionsResult {
15619 package: SearchMatch {
15620 source_name: "winget".to_owned(),
15621 source_kind: SourceKind::PreIndexed,
15622 id: "Microsoft.PowerToys".to_owned(),
15623 name: "PowerToys".to_owned(),
15624 moniker: None,
15625 version: Some("0.99.0".to_owned()),
15626 channel: None,
15627 match_criteria: None,
15628 },
15629 versions: vec![
15630 VersionKey {
15631 version: "0.99.0".to_owned(),
15632 channel: "stable".to_owned(),
15633 },
15634 VersionKey {
15635 version: "0.100.0-preview".to_owned(),
15636 channel: "preview".to_owned(),
15637 },
15638 ],
15639 warnings: Vec::new(),
15640 };
15641
15642 let value = serde_json::to_value(&result).expect("serialize versions result");
15643
15644 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
15645 assert_eq!(value["Versions"][0]["Channel"], "stable");
15646 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
15647 assert_eq!(value["Versions"][1]["Channel"], "preview");
15648 assert!(value.get("versions").is_none());
15649 }
15650
15651 #[test]
15652 fn version_data_parses_arp_bounds_from_winget_payload() {
15653 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";
15657
15658 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
15659 assert_eq!(document.versions.len(), 2);
15660 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
15661 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
15662 assert!(document.versions[1].arp_min_version.is_none());
15663 assert!(document.versions[1].arp_max_version.is_none());
15664 }
15665
15666 #[test]
15667 fn msix_resource_string_name_resolves_to_catalog_name() {
15668 let mut package = InstalledPackage {
15673 name: "ms-resource:appDisplayName".to_owned(),
15674 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
15675 installed_version: "1.28.239.0".to_owned(),
15676 publisher: None,
15677 scope: Some("User".to_owned()),
15678 installer_category: Some("msix".to_owned()),
15679 install_location: None,
15680 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
15681 product_codes: Vec::new(),
15682 upgrade_codes: Vec::new(),
15683 correlated: Some(SearchMatch {
15684 source_name: "winget".to_owned(),
15685 source_kind: SourceKind::PreIndexed,
15686 id: "Microsoft.AppInstaller".to_owned(),
15687 name: "App Installer".to_owned(),
15688 moniker: None,
15689 version: Some("1.28.240.0".to_owned()),
15690 channel: None,
15691 match_criteria: Some("PackageFamilyName".to_owned()),
15692 }),
15693 winget_package_identifier: None,
15694 winget_source_identifier: None,
15695 installed_version_canonical: false,
15696 correlated_requires_explicit_upgrade: false,
15697 correlated_lacks_compatible_installer: false,
15698 };
15699 apply_msix_resource_string_name_fix(&mut package);
15700 assert_eq!(package.name, "App Installer");
15701 }
15702
15703 #[test]
15704 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
15705 let mut package = InstalledPackage {
15710 name: "ms-resource:appDisplayName".to_owned(),
15711 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
15712 installed_version: "1.0".to_owned(),
15713 publisher: None,
15714 scope: Some("Machine".to_owned()),
15715 installer_category: Some("msi".to_owned()),
15716 install_location: None,
15717 package_family_names: Vec::new(),
15718 product_codes: Vec::new(),
15719 upgrade_codes: Vec::new(),
15720 correlated: Some(SearchMatch {
15721 source_name: "winget".to_owned(),
15722 source_kind: SourceKind::PreIndexed,
15723 id: "Some.Package".to_owned(),
15724 name: "Should Not Apply".to_owned(),
15725 moniker: None,
15726 version: Some("1.0".to_owned()),
15727 channel: None,
15728 match_criteria: None,
15729 }),
15730 winget_package_identifier: None,
15731 winget_source_identifier: None,
15732 installed_version_canonical: false,
15733 correlated_requires_explicit_upgrade: false,
15734 correlated_lacks_compatible_installer: false,
15735 };
15736 apply_msix_resource_string_name_fix(&mut package);
15737 assert_eq!(package.name, "ms-resource:appDisplayName");
15738 }
15739
15740 #[test]
15741 fn msix_resource_string_fix_skips_resolved_names() {
15742 let mut package = InstalledPackage {
15747 name: "Microsoft Teams".to_owned(),
15748 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
15749 installed_version: "25290.205.4069.4894".to_owned(),
15750 publisher: None,
15751 scope: Some("User".to_owned()),
15752 installer_category: Some("msix".to_owned()),
15753 install_location: None,
15754 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
15755 product_codes: Vec::new(),
15756 upgrade_codes: Vec::new(),
15757 correlated: Some(SearchMatch {
15758 source_name: "winget".to_owned(),
15759 source_kind: SourceKind::PreIndexed,
15760 id: "Microsoft.Teams".to_owned(),
15761 name: "Microsoft Teams Catalog Name".to_owned(),
15762 moniker: None,
15763 version: Some("26106.1906.4665.7308".to_owned()),
15764 channel: None,
15765 match_criteria: Some("PackageFamilyName".to_owned()),
15766 }),
15767 winget_package_identifier: None,
15768 winget_source_identifier: None,
15769 installed_version_canonical: false,
15770 correlated_requires_explicit_upgrade: false,
15771 correlated_lacks_compatible_installer: false,
15772 };
15773 apply_msix_resource_string_name_fix(&mut package);
15774 assert_eq!(package.name, "Microsoft Teams");
15775 }
15776
15777 #[cfg(windows)]
15778 #[test]
15779 fn unflip_packed_guid_reverses_msi_installer_packing() {
15780 assert_eq!(
15787 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
15788 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
15789 );
15790 assert_eq!(
15793 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
15794 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
15795 );
15796 assert!(unflip_packed_guid("nothex").is_none());
15798 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
15800 }
15801
15802 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
15803 InstalledPackage {
15804 name: format!("{id} install"),
15805 local_id: format!(r"ARP\Machine\X64\{id}"),
15806 installed_version: installed_version.to_owned(),
15807 publisher: None,
15808 scope: Some("Machine".to_owned()),
15809 installer_category: Some("msi".to_owned()),
15810 install_location: None,
15811 package_family_names: Vec::new(),
15812 product_codes: Vec::new(),
15813 upgrade_codes: Vec::new(),
15814 correlated: Some(SearchMatch {
15815 source_name: "winget".to_owned(),
15816 source_kind: SourceKind::PreIndexed,
15817 id: id.to_owned(),
15818 name: id.to_owned(),
15819 moniker: None,
15820 version: Some("99.0.0".to_owned()),
15821 channel: None,
15822 match_criteria: None,
15823 }),
15824 winget_package_identifier: None,
15825 winget_source_identifier: None,
15826 installed_version_canonical: canonical,
15827 correlated_requires_explicit_upgrade: false,
15828 correlated_lacks_compatible_installer: false,
15829 }
15830 }
15831
15832 #[test]
15833 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
15834 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
15841 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
15842
15843 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
15844 assert_eq!(result.len(), 1);
15845 assert_eq!(result[0].installed_version, "10.0.108");
15846 assert!(result[0].installed_version_canonical);
15847 }
15848
15849 #[test]
15850 fn dedupe_keeps_highest_among_canonical_rows() {
15851 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
15852 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
15853
15854 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
15855 assert_eq!(result.len(), 1);
15856 assert_eq!(result[0].installed_version, "1.7.9");
15857 }
15858
15859 #[test]
15860 fn dedupe_leaves_uncorrelated_rows_alone() {
15861 let uncorrelated = InstalledPackage {
15865 name: "Foo".to_owned(),
15866 local_id: r"ARP\Machine\X64\Foo".to_owned(),
15867 installed_version: "1.0".to_owned(),
15868 publisher: None,
15869 scope: Some("Machine".to_owned()),
15870 installer_category: Some("exe".to_owned()),
15871 install_location: None,
15872 package_family_names: Vec::new(),
15873 product_codes: Vec::new(),
15874 upgrade_codes: Vec::new(),
15875 correlated: None,
15876 winget_package_identifier: None,
15877 winget_source_identifier: None,
15878 installed_version_canonical: false,
15879 correlated_requires_explicit_upgrade: false,
15880 correlated_lacks_compatible_installer: false,
15881 };
15882 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
15883 assert_eq!(result.len(), 1);
15884 }
15885}