1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs::{self, OpenOptions};
7use std::io::{Cursor, Read, Write};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::{OsStrExt, OsStringExt};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, OnceLock, RwLock};
14use std::time::{Duration as StdDuration, Instant};
15
16use anyhow::{Context, Result, anyhow, bail};
17use chrono::{DateTime, Duration, Utc};
18use reqwest::StatusCode;
19use reqwest::blocking::{Client, Response};
20use reqwest::header::{
21 ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE,
22 IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE,
23};
24use serde::{Deserialize, Serialize};
25use serde_json::{Map as JsonMap, Value as JsonValue};
26use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
27use sha2::{Digest, Sha256};
28use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
29#[cfg(windows)]
30use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
31#[cfg(windows)]
32use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
33#[cfg(windows)]
34use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
35#[cfg(windows)]
36use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
37#[cfg(windows)]
38use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
39#[cfg(windows)]
40use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
41#[cfg(windows)]
42use winreg::{RegKey, enums::*};
43use zip::ZipArchive;
44
45const DEFAULT_MARKET: &str = "US";
46const DEFAULT_MAX_RESULTS: usize = 50;
47const LIST_LOOKUP_MAX_RESULTS: usize = 500;
48const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
49const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
50const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
51const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
52const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json";
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 .header(CONTENT_TYPE, "application/json")
3125 .body(serde_json::to_vec(&body).context("failed to serialize REST search request")?)
3126 .send()
3127 .context("REST search request failed")?
3128 .error_for_status()
3129 .context("REST search request returned an error")?;
3130 let json =
3131 serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST search response")?)
3132 .context("failed to parse REST search response")?;
3133 let data = json
3134 .get("Data")
3135 .and_then(JsonValue::as_array)
3136 .cloned()
3137 .unwrap_or_default();
3138 let max_results = source_fetch_results(query, semantics);
3139
3140 let mut results = Vec::new();
3141 for item in data {
3142 let package_id = json_string(&item, "PackageIdentifier")
3143 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3144 let package_name =
3145 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3146 let mut versions = parse_rest_versions(&item)?;
3147 sort_versions_desc(&mut versions);
3148 let latest = versions
3149 .first()
3150 .cloned()
3151 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3152
3153 results.push(LocatedMatch {
3154 display: SearchMatch {
3155 source_name: source.name.clone(),
3156 source_kind: source.kind,
3157 id: package_id.clone(),
3158 name: package_name,
3159 moniker: json_string(&item, "Moniker"),
3160 version: Some(latest.version.clone()),
3161 channel: if latest.channel.is_empty() {
3162 None
3163 } else {
3164 Some(latest.channel.clone())
3165 },
3166 match_criteria: rest_match_criteria(&item, query, semantics),
3167 },
3168 source_index,
3169 locator: MatchLocator::Rest { package_id, versions },
3170 });
3171 }
3172
3173 Ok(SearchSourceMatches {
3174 truncated: results.len() >= max_results,
3175 matches: results,
3176 })
3177 }
3178
3179 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3180 Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3181 }
3182
3183 fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3184 let source = self.source_clone(source_index);
3185 let index_path = self.preindexed_index_path(&source);
3186 let mut refresh = PreindexedRefreshOutcome::default();
3187 if !index_path.exists() {
3188 let _ = self.refresh_preindexed_source(source_index, true)?;
3189 refresh.attempted = true;
3190 } else if self.is_preindexed_index_stale(&source, &index_path) {
3191 let lock_key = self.preindexed_refresh_lock_key(&source);
3192 if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3193 refresh.attempted = true;
3194 refresh.reused_recent_attempt = true;
3195 } else {
3196 refresh.attempted = true;
3197 if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3198 refresh.error = Some(format!("{error:#}"));
3199 }
3200 }
3201 }
3202
3203 Ok(PreindexedOpenConnection {
3204 connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3205 refresh,
3206 })
3207 }
3208
3209 fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3210 let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3211 return false;
3212 };
3213
3214 let index_modified = || {
3215 fs::metadata(index_path)
3216 .ok()
3217 .and_then(|metadata| metadata.modified().ok())
3218 .map(DateTime::<Utc>::from)
3219 };
3220
3221 let last_update = match (source.last_update, index_modified()) {
3222 (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3223 (Some(source_last_update), None) => Some(source_last_update),
3224 (None, Some(index_last_write)) => Some(index_last_write),
3225 (None, None) => None,
3226 };
3227
3228 match last_update {
3229 Some(last_update) => Utc::now() - last_update >= interval,
3230 None => true,
3231 }
3232 }
3233
3234 fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3235 format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3236 }
3237
3238 fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3239 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3240 Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3241 })
3242 }
3243
3244 fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3245 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3246 attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3247 })
3248 }
3249
3250 fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3251 self.preindexed_refresh_attempts.insert(
3252 lock_key,
3253 PreindexedRefreshAttempt {
3254 attempted_at: Utc::now(),
3255 succeeded,
3256 },
3257 );
3258 }
3259
3260 fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3261 {
3262 let locks = preindexed_refresh_locks()
3263 .read()
3264 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3265 if let Some(existing) = locks.get(lock_key) {
3266 return Ok(Arc::clone(existing));
3267 }
3268 }
3269
3270 let mut locks = preindexed_refresh_locks()
3271 .write()
3272 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3273 Ok(Arc::clone(
3274 locks
3275 .entry(lock_key.to_owned())
3276 .or_insert_with(|| Arc::new(Mutex::new(()))),
3277 ))
3278 }
3279
3280 fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3281 let source = self.source_clone(source_index);
3282 let index_path = self.preindexed_index_path(&source);
3283 let lock_key = self.preindexed_refresh_lock_key(&source);
3284 let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3285 let _guard = refresh_lock
3286 .lock()
3287 .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3288
3289 if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3290 return Ok("source already refreshed".to_owned());
3291 }
3292
3293 let result = self.update_preindexed(source_index);
3294 self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3295 let detail = result?;
3296 self.save_store()?;
3297 Ok(detail)
3298 }
3299
3300 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3301 let path_text = path.to_string_lossy().into_owned();
3302 let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3303 .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3304 database
3305 .connect()
3306 .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3307 }
3308
3309 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3310 let source_snapshot = self.store.sources[source_index].clone();
3311 let state_dir = self.source_state_dir(&source_snapshot);
3312 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3313 let index_path = self.preindexed_index_path(&source_snapshot);
3314 let has_local_index = index_path.exists();
3315
3316 let mut last_error = None;
3317 for candidate in PREINDEXED_CANDIDATES {
3318 let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3319 if has_local_index
3320 && let Some(detail) =
3321 self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3322 {
3323 return Ok(detail);
3324 }
3325
3326 let mut request = self.client.get(&url);
3327 if has_local_index {
3328 if let Some(etag) = &source_snapshot.etag
3329 && let Ok(value) = HeaderValue::from_str(etag)
3330 {
3331 request = request.header(IF_NONE_MATCH, value);
3332 }
3333 if let Some(last_modified) = &source_snapshot.last_modified
3334 && let Ok(value) = HeaderValue::from_str(last_modified)
3335 {
3336 request = request.header(IF_MODIFIED_SINCE, value);
3337 }
3338 }
3339
3340 match request.send() {
3341 Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3342 let source = &mut self.store.sources[source_index];
3343 update_preindexed_http_validators(source, response.headers());
3344 source.last_update = Some(Utc::now());
3345 return Ok(format!("already up to date from {}", candidate));
3346 }
3347 Ok(response) if response.status().is_success() => {
3348 let header_version = response
3349 .headers()
3350 .get("x-ms-meta-sourceversion")
3351 .and_then(|value| value.to_str().ok())
3352 .map(str::to_owned);
3353 let headers = response.headers().clone();
3354 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3355 let payload = bytes.to_vec();
3356 let index_bytes = extract_zip_member(&payload, "Public/index.db")
3357 .context("preindexed package did not contain Public/index.db")?;
3358
3359 fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3360 .context("failed to persist source package")?;
3361 let temp_index_path = state_dir.join(format!(
3362 "index.{}.tmp",
3363 Utc::now().timestamp_nanos_opt().unwrap_or_default()
3364 ));
3365 fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3366 if let Err(error) = replace_file(&temp_index_path, &index_path) {
3367 let _ = fs::remove_file(&temp_index_path);
3368 return Err(error).context("failed to persist source index");
3369 }
3370 remove_sqlite_sidecars(&index_path);
3375 let source = &mut self.store.sources[source_index];
3376 source.last_update = Some(Utc::now());
3377 source.source_version = header_version;
3378 update_preindexed_http_validators(source, &headers);
3379 return Ok(format!("downloaded {}", candidate));
3380 }
3381 Ok(response) => {
3382 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3383 }
3384 Err(error) => {
3385 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3386 }
3387 }
3388 }
3389
3390 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3391 }
3392
3393 fn try_skip_unchanged_preindexed_by_source_version(
3394 &mut self,
3395 source_index: usize,
3396 url: &str,
3397 candidate: &str,
3398 ) -> Option<String> {
3399 let current_version = self.store.sources[source_index].source_version.as_deref()?;
3400 let response = self.client.head(url).send().ok()?;
3401 if !response.status().is_success() {
3402 return None;
3403 }
3404
3405 let header_version = response
3406 .headers()
3407 .get("x-ms-meta-sourceversion")
3408 .and_then(|value| value.to_str().ok())?;
3409 if !header_version.eq_ignore_ascii_case(current_version) {
3410 return None;
3411 }
3412
3413 let source = &mut self.store.sources[source_index];
3414 update_preindexed_http_validators(source, response.headers());
3415 source.last_update = Some(Utc::now());
3416 Some(format!("already up to date from {} (v{})", candidate, header_version))
3417 }
3418
3419 fn update_rest(&mut self, source_index: usize) -> Result<String> {
3420 let info = self.fetch_rest_information(source_index, true)?;
3421 let source = &mut self.store.sources[source_index];
3422 if !info.source_identifier.is_empty() {
3423 source.identifier = info.source_identifier.clone();
3424 }
3425 source.last_update = Some(Utc::now());
3426 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3427 Ok("refreshed information cache".to_owned())
3428 }
3429
3430 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3431 let source = self.source_clone(source_index);
3432 let cache_path = self.rest_information_cache_path(&source);
3433
3434 if cache_path.exists() {
3435 let cache = serde_json::from_slice::<RestInfoCache>(
3436 &fs::read(&cache_path).context("failed to read REST information cache")?,
3437 )
3438 .context("failed to parse REST information cache")?;
3439 if cache.expires_at > Utc::now() {
3440 return Ok(cache.value);
3441 }
3442 }
3443
3444 self.fetch_rest_information(source_index, true)
3445 }
3446
3447 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3448 let source = self.source_clone(source_index);
3449 let url = format!("{}/information", source.arg.trim_end_matches('/'));
3450 let response = self
3451 .client
3452 .get(url)
3453 .send()
3454 .context("REST information request failed")?
3455 .error_for_status()
3456 .context("REST information request returned an error")?;
3457
3458 let max_age = cache_control_max_age(&response);
3459 let json =
3460 serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST information response")?)
3461 .context("failed to parse REST information response")?;
3462 let data = json
3463 .get("Data")
3464 .cloned()
3465 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3466 let info = serde_json::from_value::<RestInformation>(data)
3467 .context("failed to deserialize REST information payload")?;
3468
3469 if persist {
3470 let cache = RestInfoCache {
3471 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3472 value: info.clone(),
3473 };
3474 write_json(self.rest_information_cache_path(&source), &cache)?;
3475 }
3476
3477 Ok(info)
3478 }
3479
3480 fn load_v2_version_data(
3481 &self,
3482 source: &SourceRecord,
3483 package_rowid: i64,
3484 package_hash: &str,
3485 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3486 let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3487 .context("failed to reopen preindexed index for V2 version data")?;
3488 let package_hash = package_hash.to_ascii_lowercase();
3489 let package_id = query_optional_value(
3490 &connection,
3491 "SELECT id FROM packages WHERE rowid = ?1",
3492 vec![SqlValue::Integer(package_rowid)],
3493 |row| row_string(row, 0),
3494 )?
3495 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3496 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3497 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3498 let yaml = decompress_mszyml(&file.bytes)?;
3499 let document =
3500 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3501 Ok((document.versions, file.path))
3502 }
3503
3504 fn get_cached_source_file(
3505 &self,
3506 bucket: &str,
3507 source: &SourceRecord,
3508 relative_path: &str,
3509 expected_hash: Option<&str>,
3510 ) -> Result<CachedBytes> {
3511 let normalized_relative = relative_path.replace('\\', "/");
3512 let cache_path =
3513 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3514
3515 if cache_path.exists() {
3516 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3517 if hash_matches(expected_hash, &cached) {
3518 return Ok(CachedBytes {
3519 path: cache_path,
3520 bytes: cached,
3521 });
3522 }
3523 }
3524
3525 let url = format!(
3526 "{}/{}",
3527 source.arg.trim_end_matches('/'),
3528 normalized_relative.trim_start_matches('/')
3529 );
3530 let response = self
3531 .client
3532 .get(url)
3533 .send()
3534 .context("failed to fetch source file")?
3535 .error_for_status()
3536 .context("source file request returned an error")?;
3537 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3538
3539 if let Some(hash) = expected_hash {
3540 verify_hash(hash, &bytes)?;
3541 }
3542
3543 if let Some(parent) = cache_path.parent() {
3544 fs::create_dir_all(parent).context("failed to create cache directory")?;
3545 }
3546 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3547
3548 Ok(CachedBytes {
3549 path: cache_path,
3550 bytes,
3551 })
3552 }
3553
3554 fn get_or_fetch_rest_manifest(
3555 &mut self,
3556 source: &SourceRecord,
3557 package_id: &str,
3558 version: &str,
3559 channel: &str,
3560 ) -> Result<(Vec<u8>, PathBuf)> {
3561 let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3562 if cache_path.exists() {
3563 return Ok((
3564 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3565 cache_path,
3566 ));
3567 }
3568
3569 let info = self.load_rest_information_by_name(&source.name)?;
3570 let contract = choose_contract(&info.server_supported_versions)
3571 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3572 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3573
3574 let mut params = vec![("Version", version.to_owned())];
3575 if !channel.is_empty() {
3576 params.push(("Channel", channel.to_owned()));
3577 }
3578 if info
3579 .required_query_parameters
3580 .iter()
3581 .any(|value| value.eq_ignore_ascii_case("Market"))
3582 {
3583 params.push(("Market", default_market()));
3584 }
3585
3586 let response = self
3587 .client
3588 .get(url)
3589 .header("Version", contract)
3590 .query(¶ms)
3591 .send()
3592 .context("REST manifest request failed")?
3593 .error_for_status()
3594 .context("REST manifest request returned an error")?;
3595 let bytes = response
3596 .bytes()
3597 .context("failed to read REST manifest response")?
3598 .to_vec();
3599
3600 if let Some(parent) = cache_path.parent() {
3601 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3602 }
3603 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3604
3605 Ok((bytes, cache_path))
3606 }
3607
3608 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3609 let index = self
3610 .store
3611 .sources
3612 .iter()
3613 .position(|source| source.name.eq_ignore_ascii_case(name))
3614 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3615 self.load_rest_information(index)
3616 }
3617
3618 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3619 if let Some(name) = source_name {
3620 if let Some(index) = self
3621 .store
3622 .sources
3623 .iter()
3624 .position(|source| source.name.eq_ignore_ascii_case(name))
3625 {
3626 return Ok(vec![index]);
3627 }
3628
3629 let index = self
3630 .store
3631 .sources
3632 .iter()
3633 .position(|source| source.identifier.eq_ignore_ascii_case(name))
3634 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3635 return Ok(vec![index]);
3636 }
3637
3638 let mut indexes: Vec<_> = self
3639 .store
3640 .sources
3641 .iter()
3642 .enumerate()
3643 .filter(|(_, source)| !source.explicit)
3644 .collect();
3645 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3646 right_source
3647 .priority
3648 .cmp(&left_source.priority)
3649 .then_with(|| left_index.cmp(right_index))
3650 });
3651 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3652 }
3653
3654 fn source_clone(&self, index: usize) -> SourceRecord {
3655 self.store.sources[index].clone()
3656 }
3657
3658 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3659 self.store = match self.source_mode {
3660 EffectiveSourceMode::Private => self.store.clone(),
3661 EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3662 EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3663 };
3664 Ok(())
3665 }
3666
3667 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3668 self.refresh_system_winget_sources()?;
3669 let selected_names: Vec<_> = if source_name.is_none() {
3670 self.store.sources.iter().map(|source| source.name.clone()).collect()
3671 } else {
3672 self.resolve_source_indexes(source_name)?
3673 .into_iter()
3674 .map(|index| self.store.sources[index].name.clone())
3675 .collect()
3676 };
3677
3678 let detail = system_winget_update_sources(source_name)?;
3679 self.refresh_system_winget_sources()?;
3680
3681 Ok(selected_names
3682 .into_iter()
3683 .filter_map(|name| {
3684 self.store
3685 .sources
3686 .iter()
3687 .find(|source| source.name.eq_ignore_ascii_case(&name))
3688 .map(|source| SourceUpdateResult {
3689 name: source.name.clone(),
3690 kind: source.kind,
3691 detail: detail.clone(),
3692 })
3693 })
3694 .collect())
3695 }
3696}
3697
3698#[derive(Debug)]
3699struct CachedBytes {
3700 path: PathBuf,
3701 bytes: Vec<u8>,
3702}
3703
3704#[derive(Debug, Clone)]
3705struct V1SearchRow {
3706 manifest_rowid: i64,
3707 package_rowid: i64,
3708 version: String,
3709 channel: String,
3710 id: String,
3711 name: String,
3712 moniker: Option<String>,
3713 match_criteria: Option<String>,
3714}
3715
3716#[derive(Debug, Clone)]
3717struct V1VersionRow {
3718 version: String,
3719 channel: String,
3720 pathpart_id: i64,
3721 manifest_hash: Option<String>,
3722}
3723
3724#[derive(Debug, Clone)]
3725struct V2SearchRow {
3726 package_rowid: i64,
3727 package_hash: String,
3728 id: String,
3729 name: String,
3730 moniker: Option<String>,
3731 version: String,
3732 match_criteria: Option<String>,
3733}
3734
3735fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3736 query.query.is_some()
3737 || query.id.is_some()
3738 || query.name.is_some()
3739 || query.moniker.is_some()
3740 || query.tag.is_some()
3741 || query.command.is_some()
3742 || query.source.is_some()
3743}
3744
3745fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3746 PackageQuery {
3747 query: query.query.clone(),
3748 id: query.id.clone(),
3749 name: query.name.clone(),
3750 moniker: query.moniker.clone(),
3751 tag: query.tag.clone(),
3752 command: query.command.clone(),
3753 source: query.source.clone(),
3754 count: Some(LIST_LOOKUP_MAX_RESULTS),
3755 exact: query.exact,
3756 version: None,
3757 channel: None,
3758 locale: None,
3759 installer_type: None,
3760 installer_architecture: None,
3761 platform: None,
3762 os_version: None,
3763 install_scope: query.install_scope.clone(),
3764 }
3765}
3766
3767fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3768 query.query.is_some() || query.id.is_some() || query.name.is_some()
3769}
3770
3771fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3772 if is_greater_than_version_marker(&package.installed_version) {
3773 return false;
3774 }
3775 package
3776 .correlated
3777 .as_ref()
3778 .and_then(|candidate| candidate.version.as_deref())
3779 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3780}
3781
3782fn is_greater_than_version_marker(version: &str) -> bool {
3783 version.trim_start().starts_with('>')
3784}
3785
3786fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3787 package.installed_version.eq_ignore_ascii_case("Unknown")
3788}
3789
3790fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3791 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3794 return false;
3795 }
3796 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3799 return false;
3800 }
3801 installed_package_has_upgrade(package)
3802 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3803}
3804
3805fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3811 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3812 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3813
3814 for package in packages {
3815 match package.correlated.as_ref() {
3816 Some(candidate) => {
3817 let key = (
3818 candidate.id.to_ascii_lowercase(),
3819 candidate.source_name.to_ascii_lowercase(),
3820 );
3821 let keep_new = match by_id.get(&key) {
3822 Some(existing) => {
3823 match (
3829 package.installed_version_canonical,
3830 existing.installed_version_canonical,
3831 ) {
3832 (true, false) => true,
3833 (false, true) => false,
3834 _ => {
3835 compare_version(&package.installed_version, &existing.installed_version)
3836 == Ordering::Greater
3837 }
3838 }
3839 }
3840 None => true,
3841 };
3842 if keep_new {
3843 by_id.insert(key, package);
3844 }
3845 }
3846 None => uncorrelated.push(package),
3847 }
3848 }
3849
3850 let mut result = uncorrelated;
3851 result.extend(by_id.into_values());
3852 result
3853}
3854
3855fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3856 let mut source_specific = None;
3857 let mut source_agnostic = None;
3858
3859 for pin in pins {
3860 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3861 continue;
3862 }
3863
3864 if pin.source_id.is_empty() {
3865 if source_agnostic.is_none() {
3866 source_agnostic = Some(pin);
3867 }
3868 } else if item
3869 .source_name
3870 .as_deref()
3871 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3872 {
3873 source_specific = Some(pin);
3874 break;
3875 }
3876 }
3877
3878 source_specific.or(source_agnostic)
3879}
3880
3881fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3882 let Some(available_version) = item.available_version.as_deref() else {
3883 return false;
3884 };
3885 let Some(pin) = find_applicable_pin(item, pins) else {
3886 return false;
3887 };
3888
3889 match pin.pin_type {
3890 PinType::Blocking => true,
3891 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3892 }
3893}
3894
3895fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3896 if pattern.trim().is_empty() || pattern == "*" {
3897 return true;
3898 }
3899
3900 if !pattern.contains('*') {
3901 return version.eq_ignore_ascii_case(pattern);
3902 }
3903
3904 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3905 let prefix = &pattern[..pattern.len() - 1];
3906 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3907 }
3908
3909 version.eq_ignore_ascii_case(pattern)
3910}
3911
3912fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3913 let available_version = package.correlated.as_ref().and_then(|candidate| {
3914 candidate.version.as_ref().and_then(|candidate_version| {
3915 if !is_greater_than_version_marker(&package.installed_version)
3916 && (installed_package_has_unknown_version(&package)
3917 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3918 {
3919 Some(candidate_version.clone())
3920 } else {
3921 None
3922 }
3923 })
3924 });
3925 let mut source_name = package
3926 .correlated
3927 .as_ref()
3928 .map(|candidate| candidate.source_name.clone())
3929 .filter(|value| !value.is_empty());
3930 let mut id = package
3931 .correlated
3932 .as_ref()
3933 .map(|candidate| candidate.id.clone())
3934 .unwrap_or_else(|| package.local_id.clone());
3935
3936 if source_name.is_none()
3937 && let Some(package_id) = package
3938 .winget_package_identifier
3939 .as_deref()
3940 .filter(|value| !value.trim().is_empty())
3941 {
3942 id = package_id.to_owned();
3943 source_name = package
3944 .winget_source_identifier
3945 .as_deref()
3946 .filter(|value| !value.trim().is_empty())
3947 .map(|identifier| source_name_from_identifier(identifier, sources));
3948 }
3949
3950 if source_name.is_none()
3951 && let Some((local_package_id, local_source_name)) =
3952 winget_package_identity_from_local_id(&package.local_id, sources)
3953 {
3954 id = local_package_id;
3955 source_name = Some(local_source_name);
3956 }
3957
3958 ListMatch {
3959 name: package.name,
3960 id,
3961 local_id: package.local_id,
3962 installed_version: package.installed_version,
3963 available_version,
3964 source_name,
3965 publisher: package.publisher,
3966 scope: package.scope,
3967 installer_category: package.installer_category,
3968 install_location: package.install_location,
3969 package_family_names: package.package_family_names,
3970 product_codes: package.product_codes,
3971 upgrade_codes: package.upgrade_codes,
3972 }
3973}
3974
3975fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
3976 let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
3977 for (index, item) in matches.iter().enumerate() {
3978 let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
3979 continue;
3980 };
3981 grouped
3982 .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
3983 .or_default()
3984 .push(index);
3985 }
3986
3987 let mut keep = BTreeSet::new();
3988 for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
3989 let Some(best) = indexes
3990 .iter()
3991 .map(|index| matches[*index].installed_version.as_str())
3992 .max_by(|left, right| compare_version(left, right))
3993 else {
3994 continue;
3995 };
3996 for index in indexes {
3997 if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
3998 keep.insert(*index);
3999 }
4000 }
4001 }
4002
4003 matches
4004 .into_iter()
4005 .enumerate()
4006 .map(|(index, mut item)| {
4007 if item.available_version.is_some() && !keep.contains(&index) {
4008 let is_duplicate = item
4009 .source_name
4010 .as_ref()
4011 .map(|source_name| {
4012 grouped
4013 .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4014 .is_some_and(|indexes| indexes.len() > 1)
4015 })
4016 .unwrap_or(false);
4017 if is_duplicate {
4018 item.available_version = None;
4019 }
4020 }
4021 item
4022 })
4023 .collect()
4024}
4025
4026fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4027 sources
4028 .iter()
4029 .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4030 .map(|source| source.name.clone())
4031 .unwrap_or_else(|| identifier.to_owned())
4032}
4033
4034fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4035 let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4036 for source in sources {
4037 let source_suffix = format!("_{}", source.identifier);
4038 let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4039 continue;
4040 };
4041 if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4042 continue;
4043 }
4044 if source_identifier_start_index <= package_id_start_index {
4045 return None;
4046 }
4047 let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4048 if package_id.trim().is_empty() {
4049 return None;
4050 }
4051 return Some((package_id, source.name.clone()));
4052 }
4053 None
4054}
4055
4056fn list_sort_weight(package: &InstalledPackage) -> usize {
4057 if package.local_id.starts_with("ARP\\") {
4058 0
4059 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4060 1
4061 } else {
4062 2
4063 }
4064}
4065
4066fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4067 let correlated = package.correlated.as_ref();
4068
4069 if let Some(value) = &query.id {
4070 let local_match = matches_text(&package.local_id, value, query.exact);
4071 let correlated_match = correlated
4072 .map(|candidate| matches_text(&candidate.id, value, query.exact))
4073 .unwrap_or(false);
4074 if !local_match && !correlated_match {
4075 return false;
4076 }
4077 }
4078
4079 if let Some(value) = &query.name
4080 && !matches_text(&package.name, value, query.exact)
4081 {
4082 return false;
4083 }
4084
4085 if let Some(value) = &query.product_code
4086 && !package
4087 .product_codes
4088 .iter()
4089 .any(|code| code.eq_ignore_ascii_case(value))
4090 {
4091 return false;
4092 }
4093
4094 if let Some(value) = &query.version
4095 && !package.installed_version.eq_ignore_ascii_case(value)
4096 {
4097 return false;
4098 }
4099
4100 if let Some(value) = &query.query {
4101 let local_match =
4102 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4103 let correlated_match = correlated
4104 .map(|candidate| {
4105 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4106 })
4107 .unwrap_or(false);
4108 if !local_match && !correlated_match {
4109 return false;
4110 }
4111 }
4112
4113 if let Some(source) = &query.source
4114 && correlated
4115 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4116 .unwrap_or(true)
4117 {
4118 return false;
4119 }
4120
4121 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4122 return false;
4123 }
4124
4125 true
4126}
4127
4128fn correlate_installed_package(
4129 package: &InstalledPackage,
4130 candidates: &[SearchMatch],
4131 allow_loose_name_match: bool,
4132) -> Option<SearchMatch> {
4133 if package.local_id.starts_with("MSIX\\") {
4141 return None;
4142 }
4143
4144 let installed_name = normalize_correlation_name(&package.name);
4145 let installed_name_lower = package.name.to_ascii_lowercase();
4146 let candidate_names = correlation_name_candidates(&package.name);
4147
4148 let mut scored: Vec<(i32, SearchMatch)> = candidates
4149 .iter()
4150 .filter_map(|candidate| {
4151 let candidate_name = normalize_correlation_name(&candidate.name);
4152 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4153 1000
4154 } else if candidate_names.iter().any(|name| {
4155 let normalized = normalize_correlation_name(name);
4156 normalized == candidate_name
4157 }) {
4158 900
4159 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4160 700
4161 } else {
4162 0
4163 };
4164
4165 if base_score == 0 {
4166 return None;
4167 }
4168
4169 let candidate_lower = candidate.name.to_ascii_lowercase();
4182 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4183 100
4184 } else if installed_name_lower.contains(&candidate_lower) {
4185 50
4186 } else {
4187 0
4188 };
4189
4190 Some((base_score + prefix_bonus, candidate.clone()))
4191 })
4192 .collect();
4193
4194 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4195 let mut iter = scored.into_iter();
4196 let (top_score, top_match) = iter.next()?;
4197
4198 if let Some((next_score, _)) = iter.next()
4206 && next_score == top_score
4207 {
4208 return None;
4209 }
4210 Some(top_match)
4211}
4212
4213fn correlation_name_candidates(name: &str) -> Vec<String> {
4214 let mut candidates = Vec::new();
4215 candidates.push(name.trim().to_owned());
4216
4217 let mut trimmed = name.trim().to_owned();
4218 if let Some(index) = trimmed.find(" (") {
4219 trimmed.truncate(index);
4220 }
4221
4222 let mut words = Vec::new();
4223 for token in trimmed.split_whitespace() {
4224 let lower = token
4225 .trim_matches(|ch: char| ch == '(' || ch == ')')
4226 .to_ascii_lowercase();
4227 if !words.is_empty()
4228 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4229 {
4230 break;
4231 }
4232 words.push(token);
4233 }
4234
4235 if !words.is_empty() {
4236 candidates.push(words.join(" "));
4237 }
4238
4239 candidates.sort();
4240 candidates.dedup();
4241 candidates
4242}
4243
4244fn normalize_correlation_name(value: &str) -> String {
4245 value
4246 .chars()
4247 .filter(|ch| ch.is_ascii_alphanumeric())
4248 .flat_map(|ch| ch.to_lowercase())
4249 .collect()
4250}
4251
4252struct V2PackageMetadata {
4253 rowid: i64,
4254 id: String,
4255 name: String,
4256 moniker: Option<String>,
4257 latest_version: String,
4258 package_hash: String,
4259}
4260
4261fn lookup_identity_match_v2(
4267 connection: &Connection,
4268 package: &InstalledPackage,
4269) -> Result<Option<(i64, &'static str)>> {
4270 for pfn in &package.package_family_names {
4271 if let Some(rowid) = query_optional_value(
4272 connection,
4273 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4274 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4275 |row| row_i64(row, 0),
4276 )? {
4277 return Ok(Some((rowid, "PackageFamilyName")));
4278 }
4279 }
4280 for code in &package.upgrade_codes {
4289 if let Some(rowid) = query_optional_value(
4290 connection,
4291 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4292 vec![SqlValue::Text(code.to_ascii_lowercase())],
4293 |row| row_i64(row, 0),
4294 )? {
4295 return Ok(Some((rowid, "UpgradeCode")));
4296 }
4297 }
4298 for code in &package.product_codes {
4299 if let Some(rowid) = query_optional_value(
4300 connection,
4301 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4302 vec![SqlValue::Text(code.to_ascii_lowercase())],
4303 |row| row_i64(row, 0),
4304 )? {
4305 return Ok(Some((rowid, "ProductCode")));
4306 }
4307 }
4308 Ok(None)
4309}
4310
4311fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4312 Ok(table_exists(connection, "manifest")?
4313 && table_exists(connection, "ids")?
4314 && table_exists(connection, "names")?
4315 && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4316 || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4317 || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4318}
4319
4320fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4321 query_optional_value(
4322 connection,
4323 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4324 vec![SqlValue::Text(name.to_owned())],
4325 |row| row_i64(row, 0),
4326 )
4327 .map(|value| value.is_some())
4328}
4329
4330fn lookup_installed_db_identity_match(
4331 connection: &Connection,
4332 package: &InstalledPackage,
4333) -> Result<Option<(String, String, &'static str)>> {
4334 for pfn in &package.package_family_names {
4335 if let Some(hit) =
4336 lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4337 {
4338 return Ok(Some(hit));
4339 }
4340 }
4341 for code in &package.upgrade_codes {
4342 if let Some(hit) = lookup_installed_db_identity_match_value(
4343 connection,
4344 "upgradecodes",
4345 "upgradecodes_map",
4346 "upgradecode",
4347 code,
4348 "UpgradeCode",
4349 )? {
4350 return Ok(Some(hit));
4351 }
4352 }
4353 for code in &package.product_codes {
4354 if let Some(hit) = lookup_installed_db_identity_match_value(
4355 connection,
4356 "productcodes",
4357 "productcodes_map",
4358 "productcode",
4359 code,
4360 "ProductCode",
4361 )? {
4362 return Ok(Some(hit));
4363 }
4364 }
4365 Ok(None)
4366}
4367
4368fn lookup_installed_db_identity_match_value(
4369 connection: &Connection,
4370 value_table: &'static str,
4371 map_table: &'static str,
4372 value_column: &'static str,
4373 value: &str,
4374 matched_by: &'static str,
4375) -> Result<Option<(String, String, &'static str)>> {
4376 let sql = format!(
4377 "SELECT ids.id, names.name \
4378 FROM {map_table} map \
4379 JOIN {value_table} value ON map.{value_column} = value.rowid \
4380 JOIN manifest ON map.manifest = manifest.rowid \
4381 JOIN ids ON manifest.id = ids.rowid \
4382 JOIN names ON manifest.name = names.rowid \
4383 WHERE value.{value_column} = ?1 \
4384 LIMIT 1"
4385 );
4386 query_optional_value(
4387 connection,
4388 &sql,
4389 vec![SqlValue::Text(value.to_ascii_lowercase())],
4390 |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4391 )
4392}
4393
4394fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4398 query_optional_value(
4399 connection,
4400 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4401 vec![SqlValue::Text(package_id.to_owned())],
4402 |row| {
4403 Ok(V2PackageMetadata {
4404 rowid: row_i64(row, 0)?,
4405 id: row_string(row, 1)?,
4406 name: row_string(row, 2)?,
4407 moniker: row_opt_string(row, 3)?,
4408 latest_version: row_string(row, 4)?,
4409 package_hash: row_hex_string(row, 5)?,
4410 })
4411 },
4412 )
4413}
4414
4415fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4423 if !package.local_id.starts_with("MSIX\\") {
4424 return;
4425 }
4426 if !package.name.starts_with("ms-resource:") {
4427 return;
4428 }
4429 let Some(candidate) = package.correlated.as_ref() else {
4430 return;
4431 };
4432 if !candidate.name.is_empty() {
4433 package.name = candidate.name.clone();
4434 }
4435}
4436
4437fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4438 query_optional_value(
4439 connection,
4440 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4441 vec![SqlValue::Integer(package_rowid)],
4442 |row| {
4443 Ok(V2PackageMetadata {
4444 rowid: package_rowid,
4445 id: row_string(row, 0)?,
4446 name: row_string(row, 1)?,
4447 moniker: row_opt_string(row, 2)?,
4448 latest_version: row_string(row, 3)?,
4449 package_hash: row_hex_string(row, 4)?,
4450 })
4451 },
4452 )
4453}
4454
4455fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4456 query_optional_value(
4457 connection,
4458 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4459 Vec::new(),
4460 |row| row_i64(row, 0),
4461 )
4462 .ok()
4463 .flatten()
4464 .is_some()
4465}
4466
4467fn lookup_unique_normalized_identity(
4474 connection: &Connection,
4475 norm_name: &str,
4476 norm_publisher: &str,
4477) -> Result<Option<i64>> {
4478 let rows: Vec<i64> = query_rows(
4479 connection,
4480 "SELECT DISTINCT n.package FROM norm_names2 n \
4481 WHERE n.norm_name = ?1 \
4482 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4483 LIMIT 2",
4484 vec![
4485 SqlValue::Text(norm_name.to_owned()),
4486 SqlValue::Text(norm_publisher.to_owned()),
4487 ],
4488 |row| row_i64(row, 0),
4489 )?;
4490 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4491}
4492
4493fn v2_identity_tables_present(connection: &Connection) -> bool {
4494 query_optional_value(
4495 connection,
4496 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4497 Vec::new(),
4498 |row| row_i64(row, 0),
4499 )
4500 .ok()
4501 .flatten()
4502 .is_some()
4503}
4504
4505fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4512 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4513 return None;
4514 }
4515 for entry in entries {
4516 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4517 continue;
4518 };
4519 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4520 {
4521 return Some(entry.version.clone());
4522 }
4523 }
4524 None
4525}
4526
4527fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4533 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4534 return None;
4535 }
4536
4537 let latest = entries
4538 .iter()
4539 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4540 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4541 if compare_version(arp_version, &latest.version) != Ordering::Equal {
4542 return None;
4543 }
4544 let latest_min = latest.arp_min_version.as_deref()?;
4545 if compare_version(arp_version, latest_min) != Ordering::Less {
4546 return None;
4547 }
4548
4549 let previous_max = entries
4550 .iter()
4551 .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4552 .filter_map(|entry| entry.arp_max_version.as_deref())
4553 .max_by(|left, right| compare_version(left, right));
4554 if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4555 return None;
4556 }
4557
4558 Some(format!("< {}", latest.version))
4559}
4560
4561fn greater_than_latest_arp_anchored_version(
4566 entries: &[PackageVersionDataEntry],
4567 arp_version: &str,
4568 catalog_latest_version: &str,
4569) -> Option<String> {
4570 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4571 return None;
4572 }
4573
4574 let latest = entries
4575 .iter()
4576 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4577 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4578 let latest_max = latest.arp_max_version.as_deref()?;
4579 if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4580 return None;
4581 }
4582 if !has_different_leading_version_number(latest_max, &latest.version) {
4583 return None;
4584 }
4585 if compare_version(arp_version, latest_max) != Ordering::Greater {
4586 return None;
4587 }
4588
4589 Some(format!("> {}", latest.version))
4590}
4591
4592fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4593 let Some(left) = leading_version_number(left) else {
4594 return false;
4595 };
4596 let Some(right) = leading_version_number(right) else {
4597 return false;
4598 };
4599 !left.eq_ignore_ascii_case(right)
4600}
4601
4602fn leading_version_number(value: &str) -> Option<&str> {
4603 let trimmed = value.trim_start();
4604 let length = trimmed
4605 .chars()
4606 .take_while(|ch| ch.is_ascii_digit())
4607 .map(char::len_utf8)
4608 .sum();
4609 (length > 0).then_some(&trimmed[..length])
4610}
4611
4612fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4621 entries
4622 .iter()
4623 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4624 .max_by(|a, b| compare_version(&a.version, &b.version))
4625 .map(|entry| entry.version.clone())
4626}
4627
4628#[cfg(windows)]
4629fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4630 let mut packages = Vec::new();
4631 let mut seen = BTreeSet::new();
4632
4633 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4634 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4635
4636 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4644
4645 if machine {
4646 collect_uninstall_view(
4647 &mut packages,
4648 &mut seen,
4649 &upgrade_codes,
4650 RegKey::predef(HKEY_LOCAL_MACHINE),
4651 "Machine",
4652 "X64",
4653 KEY_READ | KEY_WOW64_64KEY,
4654 )?;
4655 collect_uninstall_view(
4656 &mut packages,
4657 &mut seen,
4658 &upgrade_codes,
4659 RegKey::predef(HKEY_LOCAL_MACHINE),
4660 "Machine",
4661 "X86",
4662 KEY_READ | KEY_WOW64_32KEY,
4663 )?;
4664 collect_appmodel_packages(
4665 &mut packages,
4666 &mut seen,
4667 RegKey::predef(HKEY_LOCAL_MACHINE),
4668 "Machine",
4669 KEY_READ | KEY_WOW64_64KEY,
4670 )?;
4671 }
4672
4673 if user {
4674 collect_uninstall_view(
4675 &mut packages,
4676 &mut seen,
4677 &upgrade_codes,
4678 RegKey::predef(HKEY_CURRENT_USER),
4679 "User",
4680 "X64",
4681 KEY_READ,
4682 )?;
4683 collect_appmodel_packages(
4684 &mut packages,
4685 &mut seen,
4686 RegKey::predef(HKEY_CURRENT_USER),
4687 "User",
4688 KEY_READ,
4689 )?;
4690 }
4691
4692 Ok(packages)
4693}
4694
4695#[cfg(windows)]
4701fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4702 let mut map = HashMap::new();
4703 if include_machine {
4704 collect_msi_upgrade_codes_from(
4705 RegKey::predef(HKEY_LOCAL_MACHINE),
4706 r"SOFTWARE\Classes\Installer\UpgradeCodes",
4707 KEY_READ | KEY_WOW64_64KEY,
4708 &mut map,
4709 );
4710 }
4711 if include_user {
4712 collect_msi_upgrade_codes_from(
4713 RegKey::predef(HKEY_CURRENT_USER),
4714 r"Software\Microsoft\Installer\UpgradeCodes",
4715 KEY_READ,
4716 &mut map,
4717 );
4718 }
4719 map
4720}
4721
4722#[cfg(windows)]
4723fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4724 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4725 return;
4726 };
4727 for key_name in upgrade_codes.enum_keys().flatten() {
4728 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4729 continue;
4730 };
4731 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4732 continue;
4733 };
4734 for (value_name, _) in subkey.enum_values().flatten() {
4735 if let Some(product_code) = unflip_packed_guid(&value_name) {
4736 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4740 }
4741 }
4742 }
4743}
4744
4745#[cfg(windows)]
4750fn unflip_packed_guid(packed: &str) -> Option<String> {
4751 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4752 return None;
4753 }
4754 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4755 let bytes = packed.as_bytes();
4756 let mut reversed = String::with_capacity(32);
4757 let mut offset = 0;
4758 for size in CHUNKS {
4759 let chunk = &bytes[offset..offset + size];
4760 for &c in chunk.iter().rev() {
4761 reversed.push(c as char);
4762 }
4763 offset += size;
4764 }
4765 let r = reversed.to_ascii_lowercase();
4766 Some(format!(
4767 "{{{}-{}-{}-{}-{}}}",
4768 &r[0..8],
4769 &r[8..12],
4770 &r[12..16],
4771 &r[16..20],
4772 &r[20..32]
4773 ))
4774}
4775
4776#[cfg(not(windows))]
4777fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4778 Ok(Vec::new())
4779}
4780
4781#[cfg(windows)]
4782fn collect_uninstall_view(
4783 packages: &mut Vec<InstalledPackage>,
4784 seen: &mut BTreeSet<String>,
4785 upgrade_code_map: &HashMap<String, String>,
4786 root: RegKey,
4787 scope: &str,
4788 arch: &str,
4789 flags: u32,
4790) -> Result<()> {
4791 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4792
4793 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4794 Ok(key) => key,
4795 Err(_) => return Ok(()),
4796 };
4797
4798 for key_name in uninstall.enum_keys().flatten() {
4799 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4800 Ok(key) => key,
4801 Err(_) => continue,
4802 };
4803 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4804 {
4805 continue;
4806 }
4807
4808 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4809 continue;
4810 };
4811
4812 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4813 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4814 let publisher = read_reg_string(&subkey, "Publisher");
4815 let install_location = read_reg_string(&subkey, "InstallLocation");
4816 let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4817 let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4818 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4819 .into_iter()
4820 .collect::<Vec<_>>();
4821 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4822 if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4823 product_codes.push(key_name.to_ascii_lowercase());
4824 }
4825 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4826 if upgrade_codes.is_empty() {
4827 for code in &product_codes {
4831 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4832 upgrade_codes.push(uc.clone());
4833 break;
4834 }
4835 }
4836 }
4837 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4838 .as_deref()
4839 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4840 {
4841 Some("portable".to_owned())
4847 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4848 Some("msi".to_owned())
4849 } else if key_name.starts_with("MSIX\\") {
4850 Some("msix".to_owned())
4851 } else {
4852 Some("exe".to_owned())
4853 };
4854
4855 let dedupe_key = format!(
4856 "{}|{}|{}|{}",
4857 local_id,
4858 name.to_ascii_lowercase(),
4859 installed_version.to_ascii_lowercase(),
4860 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4861 );
4862 if !seen.insert(dedupe_key) {
4863 continue;
4864 }
4865
4866 packages.push(InstalledPackage {
4867 name,
4868 local_id,
4869 installed_version,
4870 publisher,
4871 scope: Some(scope.to_owned()),
4872 installer_category,
4873 install_location,
4874 winget_package_identifier,
4875 winget_source_identifier,
4876 package_family_names,
4877 product_codes,
4878 upgrade_codes,
4879 correlated: None,
4880 installed_version_canonical: false,
4881 correlated_requires_explicit_upgrade: false,
4882 correlated_lacks_compatible_installer: false,
4883 });
4884 }
4885
4886 Ok(())
4887}
4888
4889#[cfg(windows)]
4890fn collect_appmodel_packages(
4891 packages: &mut Vec<InstalledPackage>,
4892 seen: &mut BTreeSet<String>,
4893 root: RegKey,
4894 scope: &str,
4895 flags: u32,
4896) -> Result<()> {
4897 const APPMODEL_PACKAGES_PATH: &str =
4898 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4899
4900 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4901 Ok(key) => key,
4902 Err(_) => return Ok(()),
4903 };
4904
4905 for key_name in appmodel.enum_keys().flatten() {
4906 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4907 Ok(key) => key,
4908 Err(_) => continue,
4909 };
4910
4911 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4912 if install_location.as_deref().is_some_and(is_windows_system_path) {
4913 continue;
4914 }
4915
4916 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4917 continue;
4918 };
4919 if is_msix_split_resource_package(&metadata.resource_id) {
4920 continue;
4921 }
4922
4923 let Some(name) = read_reg_string(&subkey, "DisplayName")
4924 .or_else(|| read_appmodel_display_name(&subkey))
4925 .filter(|value| !value.is_empty())
4926 else {
4927 continue;
4928 };
4929
4930 let local_id = format!(r"MSIX\{key_name}");
4931 let dedupe_key = format!(
4932 "{}|{}|{}",
4933 local_id,
4934 name.to_ascii_lowercase(),
4935 metadata.version.to_ascii_lowercase()
4936 );
4937 if !seen.insert(dedupe_key) {
4938 continue;
4939 }
4940
4941 packages.push(InstalledPackage {
4942 name,
4943 local_id,
4944 installed_version: metadata.version,
4945 publisher: None,
4946 scope: Some(scope.to_owned()),
4947 installer_category: Some("msix".to_owned()),
4948 install_location,
4949 winget_package_identifier: None,
4950 winget_source_identifier: None,
4951 package_family_names: vec![metadata.family_name],
4952 product_codes: Vec::new(),
4953 upgrade_codes: Vec::new(),
4954 correlated: None,
4955 installed_version_canonical: false,
4956 correlated_requires_explicit_upgrade: false,
4957 correlated_lacks_compatible_installer: false,
4958 });
4959 }
4960
4961 Ok(())
4962}
4963
4964#[cfg(windows)]
4965struct ParsedMsixPackageFullName {
4966 version: String,
4967 resource_id: String,
4968 family_name: String,
4969}
4970
4971#[cfg(windows)]
4972fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4973 let segments = value.split('_').collect::<Vec<_>>();
4974 if segments.len() < 5 {
4975 return None;
4976 }
4977
4978 let name = segments[..segments.len() - 4].join("_");
4979 if name.is_empty() {
4980 return None;
4981 }
4982
4983 let version = segments[segments.len() - 4].trim();
4984 let resource_id = segments[segments.len() - 2].trim();
4985 let publisher_id = segments[segments.len() - 1].trim();
4986 if version.is_empty() || publisher_id.is_empty() {
4987 return None;
4988 }
4989
4990 let family_name = if resource_id.is_empty() {
4991 format!("{name}_{publisher_id}")
4992 } else {
4993 format!("{name}_{resource_id}_{publisher_id}")
4994 };
4995
4996 Some(ParsedMsixPackageFullName {
4997 version: version.to_owned(),
4998 resource_id: resource_id.to_owned(),
4999 family_name,
5000 })
5001}
5002
5003#[cfg(windows)]
5004fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5005 let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5006 read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5007}
5008
5009#[cfg(windows)]
5010fn is_msix_split_resource_package(resource_id: &str) -> bool {
5011 resource_id.trim().to_ascii_lowercase().starts_with("split.")
5012}
5013
5014#[cfg(windows)]
5015fn is_windows_system_path(path: &str) -> bool {
5016 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5017}
5018
5019#[cfg(windows)]
5020fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5021 key.get_value::<String, _>(value_name)
5022 .ok()
5023 .map(|value| value.trim().to_owned())
5024 .filter(|value| !value.is_empty())
5025}
5026
5027#[cfg(windows)]
5028fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5029 key.get_value::<u32, _>(value_name).ok()
5030}
5031
5032fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5033 fs::create_dir_all(app_root).context("failed to create app root directory")?;
5034 if uses_packaged_layout(app_root) {
5035 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5036 let user_sources_path = packaged_user_sources_path(app_root)?;
5037 if let Some(parent) = user_sources_path.parent() {
5038 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5039 }
5040 } else {
5041 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5042 }
5043 Ok(())
5044}
5045
5046fn default_app_root() -> Result<PathBuf> {
5047 if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
5048 return Ok(PathBuf::from(app_root));
5049 }
5050
5051 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5052
5053 #[cfg(windows)]
5054 {
5055 if has_current_package_identity() {
5056 return Ok(local_app_data
5057 .join("Packages")
5058 .join(PACKAGED_FAMILY_NAME)
5059 .join("LocalState"));
5060 }
5061 Ok(local_app_data.join("Devolutions").join("Pinget"))
5062 }
5063
5064 #[cfg(not(windows))]
5065 {
5066 Ok(local_app_data.join("pinget"))
5067 }
5068}
5069
5070#[cfg(windows)]
5071fn has_current_package_identity() -> bool {
5072 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5075
5076 let mut length: u32 = 0;
5077 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5080 rc != APPMODEL_ERROR_NO_PACKAGE
5081}
5082
5083fn store_path(app_root: &Path) -> PathBuf {
5084 app_root.join("sources.json")
5085}
5086
5087fn user_settings_path(app_root: &Path) -> PathBuf {
5088 if uses_packaged_layout(app_root) {
5089 app_root.join("settings.json")
5090 } else {
5091 app_root.join("user-settings.json")
5092 }
5093}
5094
5095fn admin_settings_path(app_root: &Path) -> PathBuf {
5096 app_root.join("admin-settings.json")
5097}
5098
5099#[cfg(test)]
5100fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5101 source_state_dir_with_keying(app_root, source, false)
5102}
5103
5104fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5105 if uses_packaged_layout(app_root) {
5106 return app_root
5107 .join(packaged_source_type(source.kind))
5108 .join(sanitize_path_segment(&source.identifier));
5109 }
5110
5111 if identifier_keyed {
5112 return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5113 }
5114
5115 app_root.join("sources").join(sanitize_path_segment(&source.name))
5116}
5117
5118fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5119 if uses_packaged_layout(app_root) {
5120 return app_root
5121 .join(sanitize_path_segment(&source.identifier))
5122 .join("installed.db");
5123 }
5124
5125 source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5126}
5127
5128#[cfg(test)]
5129fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5130 source_state_dir(app_root, source).join("index.db")
5131}
5132
5133fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5134 if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5135 source.etag = Some(value.to_owned());
5136 }
5137
5138 if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5139 source.last_modified = Some(value.to_owned());
5140 }
5141}
5142
5143fn remove_sqlite_sidecars(db_path: &Path) {
5147 for suffix in ["-wal", "-shm"] {
5148 let mut sidecar = db_path.as_os_str().to_owned();
5149 sidecar.push(suffix);
5150 let _ = fs::remove_file(PathBuf::from(sidecar));
5151 }
5152}
5153
5154#[cfg(windows)]
5155const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5156
5157#[cfg(windows)]
5158fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5159 let source_wide = source
5160 .as_os_str()
5161 .encode_wide()
5162 .chain(std::iter::once(0))
5163 .collect::<Vec<_>>();
5164 let target_wide = target
5165 .as_os_str()
5166 .encode_wide()
5167 .chain(std::iter::once(0))
5168 .collect::<Vec<_>>();
5169 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5170 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5173 if ok == 0 {
5174 return Err(std::io::Error::last_os_error());
5175 }
5176 Ok(())
5177}
5178
5179#[cfg(windows)]
5180fn replace_file(source: &Path, target: &Path) -> Result<()> {
5181 let to_err = |error: std::io::Error| -> anyhow::Error {
5182 anyhow::Error::new(error).context(format!(
5183 "failed to replace {} with {}",
5184 target.display(),
5185 source.display()
5186 ))
5187 };
5188
5189 let first = match move_replace_existing(source, target) {
5191 Ok(()) => return Ok(()),
5192 Err(error) => error,
5193 };
5194
5195 if !source.exists() || !target.exists() {
5198 return Err(to_err(first));
5199 }
5200
5201 let mut last = first;
5206 for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5207 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5208 match move_replace_existing(source, target) {
5209 Ok(()) => return Ok(()),
5210 Err(error) => last = error,
5211 }
5212 }
5213
5214 let staged = target.with_extension("old.tmp");
5220 if move_replace_existing(target, &staged).is_err() {
5221 return Err(to_err(last));
5222 }
5223 match move_replace_existing(source, target) {
5224 Ok(()) => {
5225 let _ = fs::remove_file(&staged);
5226 Ok(())
5227 }
5228 Err(error) => {
5229 let _ = move_replace_existing(&staged, target);
5230 Err(to_err(error))
5231 }
5232 }
5233}
5234
5235#[cfg(not(windows))]
5236fn replace_file(source: &Path, target: &Path) -> Result<()> {
5237 fs::rename(source, target)
5238 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5239}
5240
5241fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5242 cache_root(app_root)
5243 .join(bucket)
5244 .join(sanitize_path_segment(identifier))
5245}
5246
5247fn load_store(app_root: &Path) -> Result<SourceStore> {
5248 if uses_packaged_layout(app_root)
5249 && let Some(store) = load_packaged_store(app_root)?
5250 {
5251 return Ok(store);
5252 }
5253
5254 let path = store_path(app_root);
5255 if !path.exists() {
5256 let store = SourceStore::default();
5257 save_store(app_root, &store)?;
5258 return Ok(store);
5259 }
5260
5261 let bytes = fs::read(path).context("failed to read source store")?;
5262 serde_json::from_slice(&bytes).context("failed to parse source store")
5263}
5264
5265fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5266 if uses_system_winget_source_commands(app_root) {
5267 return Ok((
5268 EffectiveSourceMode::SystemWingetDirect,
5269 load_system_winget_source_store()?,
5270 ));
5271 }
5272
5273 match source_mode {
5274 SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5275 SourceMode::SystemWingetMirror => {
5276 let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5277 Ok((EffectiveSourceMode::SystemWingetMirror, store))
5278 }
5279 SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5280 Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5281 Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5282 },
5283 }
5284}
5285
5286fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5287 app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5288}
5289
5290fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5291 let path = system_winget_mirror_store_path(app_root);
5292 if !path.exists() {
5293 return Ok(None);
5294 }
5295
5296 let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5297 serde_json::from_slice(&bytes)
5298 .context("failed to parse system WinGet mirror source store")
5299 .map(Some)
5300}
5301
5302fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5303 write_json(system_winget_mirror_store_path(app_root), store)
5304}
5305
5306fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5307 if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5308 return Ok(store);
5309 }
5310
5311 refresh_system_winget_mirror_store(app_root)
5312}
5313
5314fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5315 let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5316 let mut exported = load_system_winget_source_store()?;
5317 merge_source_cache_metadata(&mut exported, &prior);
5318 save_system_winget_mirror_store(app_root, &exported)?;
5319 Ok(exported)
5320}
5321
5322fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5323 let prior_by_identifier: HashMap<_, _> = prior
5324 .sources
5325 .iter()
5326 .map(|source| (source.identifier.to_ascii_lowercase(), source))
5327 .collect();
5328
5329 for source in &mut target.sources {
5330 let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5331 continue;
5332 };
5333 source.last_update = previous.last_update;
5334 source.source_version = previous.source_version.clone();
5335 source.etag = previous.etag.clone();
5336 source.last_modified = previous.last_modified.clone();
5337 }
5338}
5339
5340fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5341 if uses_system_winget_source_commands(app_root) {
5342 bail!("System WinGet source settings must be modified through winget.");
5343 }
5344
5345 if uses_packaged_layout(app_root) {
5346 return save_packaged_store(app_root, store);
5347 }
5348
5349 write_json(store_path(app_root), store)
5350}
5351
5352fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5353 let user_sources_path = packaged_user_sources_path(app_root)?;
5354 let metadata_path = app_root.join("sources_metadata");
5355 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5356 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5357 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5358}
5359
5360fn load_packaged_store_from_streams(
5361 user_sources_yaml: Option<&str>,
5362 metadata_yaml: Option<&str>,
5363) -> Result<Option<SourceStore>> {
5364 if is_secure_settings_stub(user_sources_yaml) {
5365 return load_system_winget_source_store().map(Some);
5366 }
5367
5368 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5369}
5370
5371fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5372 let user_sources_path = packaged_user_sources_path(app_root)?;
5373 if let Some(parent) = user_sources_path.parent() {
5374 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5375 }
5376
5377 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5378 .context("failed to write packaged user_sources stream")?;
5379 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5380 .context("failed to write packaged source metadata")?;
5381 Ok(())
5382}
5383
5384fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5385 if !uses_packaged_layout(app_root) {
5386 return false;
5387 }
5388
5389 #[cfg(windows)]
5390 {
5391 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5392 return false;
5393 };
5394
5395 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5396 is_secure_settings_stub(user_sources_yaml.as_deref())
5397 }
5398
5399 #[cfg(not(windows))]
5400 {
5401 false
5402 }
5403}
5404
5405fn is_secure_settings_stub(value: Option<&str>) -> bool {
5406 value
5407 .map(str::trim_start)
5408 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5409}
5410
5411fn load_system_winget_source_store() -> Result<SourceStore> {
5412 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5413 Ok(SourceStore {
5414 sources: parse_system_winget_source_export(&result.stdout)?,
5415 })
5416}
5417
5418fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5419 run_checked_winget_source_command(
5420 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5421 &format!("add WinGet source '{name}'"),
5422 )?;
5423 Ok(())
5424}
5425
5426fn system_winget_remove_source(name: &str) -> Result<()> {
5427 run_checked_winget_source_command(
5428 vec![
5429 "source".to_owned(),
5430 "remove".to_owned(),
5431 "--name".to_owned(),
5432 name.to_owned(),
5433 "--disable-interactivity".to_owned(),
5434 ],
5435 &format!("remove WinGet source '{name}'"),
5436 )?;
5437 Ok(())
5438}
5439
5440fn system_winget_reset_source(name: &str) -> Result<()> {
5441 run_checked_winget_source_command(
5442 vec![
5443 "source".to_owned(),
5444 "reset".to_owned(),
5445 "--name".to_owned(),
5446 name.to_owned(),
5447 "--force".to_owned(),
5448 "--disable-interactivity".to_owned(),
5449 ],
5450 &format!("reset WinGet source '{name}'"),
5451 )?;
5452 Ok(())
5453}
5454
5455fn system_winget_reset_sources() -> Result<()> {
5456 run_checked_winget_source_command(
5457 vec![
5458 "source".to_owned(),
5459 "reset".to_owned(),
5460 "--force".to_owned(),
5461 "--disable-interactivity".to_owned(),
5462 ],
5463 "reset WinGet sources",
5464 )?;
5465 Ok(())
5466}
5467
5468fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5469 let mut args = vec!["source".to_owned(), "update".to_owned()];
5470 if let Some(name) = name {
5471 args.push("--name".to_owned());
5472 args.push(name.to_owned());
5473 }
5474
5475 args.push("--disable-interactivity".to_owned());
5476 let result = run_checked_winget_source_command(
5477 args,
5478 if name.is_some() {
5479 "update WinGet source"
5480 } else {
5481 "update WinGet sources"
5482 },
5483 )?;
5484 let detail = result.stdout.trim();
5485 Ok(if detail.is_empty() {
5486 "Done".to_owned()
5487 } else {
5488 detail.to_owned()
5489 })
5490}
5491
5492fn build_winget_source_export_arguments() -> Vec<String> {
5493 vec![
5494 "source".to_owned(),
5495 "export".to_owned(),
5496 "--disable-interactivity".to_owned(),
5497 ]
5498}
5499
5500fn build_winget_source_add_arguments(
5501 name: &str,
5502 arg: &str,
5503 kind: SourceKind,
5504 trust_level: &str,
5505 explicit: bool,
5506) -> Vec<String> {
5507 let mut args = vec![
5508 "source".to_owned(),
5509 "add".to_owned(),
5510 "--name".to_owned(),
5511 name.to_owned(),
5512 "--arg".to_owned(),
5513 arg.to_owned(),
5514 "--type".to_owned(),
5515 packaged_source_type(kind).to_owned(),
5516 "--disable-interactivity".to_owned(),
5517 ];
5518
5519 if !trust_level.trim().is_empty() {
5520 args.push("--trust-level".to_owned());
5521 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5522 "trusted".to_owned()
5523 } else {
5524 "none".to_owned()
5525 });
5526 }
5527
5528 if explicit {
5529 args.push("--explicit".to_owned());
5530 }
5531
5532 args
5533}
5534
5535#[derive(Debug)]
5536struct WingetSourceCommandResult {
5537 exit_code: i32,
5538 stdout: String,
5539 stderr: String,
5540}
5541
5542type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5543
5544static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5545 RwLock::new(run_winget_source_command_process);
5546
5547fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5548 let result = run_winget_source_command(&args)?;
5549 if result.exit_code == 0 {
5550 return Ok(result);
5551 }
5552
5553 let detail = [result.stderr.trim(), result.stdout.trim()]
5554 .into_iter()
5555 .filter(|value| !value.is_empty())
5556 .collect::<Vec<_>>()
5557 .join("\n");
5558 if detail.is_empty() {
5559 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5560 }
5561
5562 bail!(
5563 "Failed to {action}; winget exited with code {}:\n{}",
5564 result.exit_code,
5565 detail
5566 );
5567}
5568
5569fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5570 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5571 .read()
5572 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5573 runner(args)
5574}
5575
5576fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5577 use std::process::Command;
5578
5579 let output = Command::new("winget")
5580 .args(args)
5581 .output()
5582 .context("failed to run winget source command")?;
5583
5584 Ok(WingetSourceCommandResult {
5585 exit_code: output.status.code().unwrap_or(-1),
5586 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5587 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5588 })
5589}
5590
5591fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5592 let trimmed = output.trim();
5593 if trimmed.is_empty() {
5594 return Ok(Vec::new());
5595 }
5596
5597 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5598 && let Some(sources) = parse_system_winget_source_export_value(&value)?
5599 {
5600 return Ok(sources);
5601 }
5602
5603 let mut sources = Vec::new();
5604 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5605 if line.is_empty() {
5606 continue;
5607 }
5608
5609 let value: JsonValue = serde_json::from_str(line)
5610 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5611 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5612 bail!("WinGet source export returned a non-source line: {line}");
5613 };
5614 sources.append(&mut parsed);
5615 }
5616
5617 Ok(sources)
5618}
5619
5620fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5621 match value {
5622 JsonValue::Array(items) => items
5623 .iter()
5624 .map(parse_system_winget_source_record)
5625 .collect::<Result<Vec<_>>>()
5626 .map(Some),
5627 JsonValue::Object(object) => {
5628 if let Some(JsonValue::Array(items)) = object.get("Sources") {
5629 items
5630 .iter()
5631 .map(parse_system_winget_source_record)
5632 .collect::<Result<Vec<_>>>()
5633 .map(Some)
5634 } else if object.contains_key("Name") {
5635 Ok(Some(vec![parse_system_winget_source_record(value)?]))
5636 } else {
5637 Ok(None)
5638 }
5639 }
5640 _ => Ok(None),
5641 }
5642}
5643
5644fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5645 let object = value
5646 .as_object()
5647 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5648 let name = required_json_string(object, "Name")?;
5649 let source_type = required_json_string(object, "Type")?;
5650 let arg = required_json_string(object, "Arg")?;
5651 let kind = parse_system_winget_source_kind(&source_type)?;
5652 let identifier = optional_json_string(object, "Identifier")
5653 .or_else(|| optional_json_string(object, "Data"))
5654 .unwrap_or_else(|| name.clone());
5655
5656 Ok(SourceRecord {
5657 name,
5658 kind,
5659 arg,
5660 identifier,
5661 trust_level: parse_system_winget_trust_level(object),
5662 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5663 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5664 last_update: None,
5665 source_version: None,
5666 etag: None,
5667 last_modified: None,
5668 })
5669}
5670
5671fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5672 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5673}
5674
5675fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5676 match object.get(key)? {
5677 JsonValue::String(value) => Some(value.clone()),
5678 JsonValue::Number(value) => Some(value.to_string()),
5679 _ => None,
5680 }
5681}
5682
5683fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5684 match object.get(key)? {
5685 JsonValue::Bool(value) => Some(*value),
5686 JsonValue::String(value) => value.parse().ok(),
5687 _ => None,
5688 }
5689}
5690
5691fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5692 match object.get(key)? {
5693 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5694 JsonValue::String(value) => value.parse().ok(),
5695 _ => None,
5696 }
5697}
5698
5699fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5700 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5701 Ok(SourceKind::PreIndexed)
5702 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5703 Ok(SourceKind::Rest)
5704 } else {
5705 bail!("Unsupported WinGet source type '{value}'.")
5706 }
5707}
5708
5709fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5710 let Some(value) = object.get("TrustLevel") else {
5711 return "None".to_owned();
5712 };
5713
5714 match value {
5715 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5716 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5717 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5718 _ => "None".to_owned(),
5719 }
5720}
5721
5722fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5723 value
5724 .as_str()
5725 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5726}
5727
5728fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5729 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5730 return None;
5731 }
5732
5733 let mut sources = SourceStore::default().sources;
5734 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5735 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5736 continue;
5737 };
5738
5739 if yaml_bool(entry.get("IsTombstone")) {
5740 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5741 continue;
5742 }
5743
5744 let Some(mapped) = map_packaged_source_entry(&entry) else {
5745 continue;
5746 };
5747
5748 if let Some(existing) = sources
5749 .iter_mut()
5750 .find(|source| source.name.eq_ignore_ascii_case(&name))
5751 {
5752 *existing = mapped;
5753 } else {
5754 sources.push(mapped);
5755 }
5756 }
5757
5758 for entry in parse_packaged_yaml_entries(metadata_yaml) {
5759 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5760 continue;
5761 };
5762
5763 let Some(source) = sources
5764 .iter_mut()
5765 .find(|source| source.name.eq_ignore_ascii_case(&name))
5766 else {
5767 continue;
5768 };
5769
5770 if let Some(last_update) = entry
5771 .get("LastUpdate")
5772 .and_then(|value| value.as_deref())
5773 .and_then(parse_packaged_datetime)
5774 {
5775 source.last_update = Some(last_update);
5776 }
5777
5778 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
5779 && !source_version.is_empty()
5780 {
5781 source.source_version = Some(source_version);
5782 }
5783
5784 if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
5785 && !etag.is_empty()
5786 {
5787 source.etag = Some(etag);
5788 }
5789
5790 if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
5791 && !last_modified.is_empty()
5792 {
5793 source.last_modified = Some(last_modified);
5794 }
5795 }
5796
5797 Some(SourceStore { sources })
5798}
5799
5800fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5801 let Some(yaml) = yaml else {
5802 return Vec::new();
5803 };
5804
5805 let mut entries = Vec::new();
5806 let mut current: Option<BTreeMap<String, Option<String>>> = None;
5807 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5808 let line = raw_line.trim_end();
5809 let trimmed = line.trim();
5810 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5811 continue;
5812 }
5813
5814 if let Some(remainder) = trimmed.strip_prefix("- ") {
5815 if let Some(entry) = current.take() {
5816 entries.push(entry);
5817 }
5818
5819 let mut entry = BTreeMap::new();
5820 add_packaged_yaml_key_value(&mut entry, remainder);
5821 current = Some(entry);
5822 continue;
5823 }
5824
5825 if let Some(entry) = current.as_mut() {
5826 add_packaged_yaml_key_value(entry, trimmed);
5827 }
5828 }
5829
5830 if let Some(entry) = current {
5831 entries.push(entry);
5832 }
5833
5834 entries
5835}
5836
5837fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5838 let Some((key, value)) = line.split_once(':') else {
5839 return;
5840 };
5841
5842 entry.insert(
5843 key.trim().to_owned(),
5844 Some(unquote_yaml_scalar(value.trim()).to_owned()),
5845 );
5846}
5847
5848fn unquote_yaml_scalar(value: &str) -> &str {
5849 if value.len() >= 2 {
5850 let bytes = value.as_bytes();
5851 let first = bytes[0] as char;
5852 let last = bytes[value.len() - 1] as char;
5853 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5854 return &value[1..value.len() - 1];
5855 }
5856 }
5857
5858 value
5859}
5860
5861fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5862 let name = entry.get("Name").and_then(|value| value.clone())?;
5863 let source_type = entry.get("Type").and_then(|value| value.clone())?;
5864 let arg = entry.get("Arg").and_then(|value| value.clone())?;
5865 let kind = parse_packaged_source_kind(&source_type)?;
5866
5867 Some(SourceRecord {
5868 name: name.clone(),
5869 kind,
5870 arg,
5871 identifier: entry
5872 .get("Identifier")
5873 .and_then(|value| value.clone())
5874 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5875 .unwrap_or(name),
5876 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5877 explicit: yaml_bool(entry.get("Explicit")),
5878 priority: entry
5879 .get("Priority")
5880 .and_then(|value| value.as_deref())
5881 .and_then(|value| value.parse::<i32>().ok())
5882 .unwrap_or_default(),
5883 last_update: None,
5884 source_version: None,
5885 etag: None,
5886 last_modified: None,
5887 })
5888}
5889
5890fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5891 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5892 Some(SourceKind::PreIndexed)
5893 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5894 Some(SourceKind::Rest)
5895 } else {
5896 None
5897 }
5898}
5899
5900fn parse_packaged_trust_level(value: Option<&str>) -> String {
5901 match value {
5902 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5903 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5904 _ => "None".to_owned(),
5905 }
5906}
5907
5908fn yaml_bool(value: Option<&Option<String>>) -> bool {
5909 value
5910 .and_then(|raw| raw.as_deref())
5911 .and_then(|raw| raw.parse::<bool>().ok())
5912 .unwrap_or(false)
5913}
5914
5915fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5916 if let Ok(unix_seconds) = value.parse::<i64>() {
5917 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5918 }
5919
5920 DateTime::parse_from_rfc3339(value)
5921 .ok()
5922 .map(|value| value.with_timezone(&Utc))
5923}
5924
5925fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5926 let mut lines = vec!["Sources:".to_owned()];
5927 for source in &store.sources {
5928 lines.push(format!(" - Name: {}", source.name));
5929 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
5930 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
5931 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
5932 lines.push(format!(
5933 " TrustLevel: {}",
5934 if source.trust_level.eq_ignore_ascii_case("Trusted") {
5935 1
5936 } else {
5937 0
5938 }
5939 ));
5940 lines.push(format!(" Explicit: {}", source.explicit));
5941 lines.push(format!(" Priority: {}", source.priority));
5942 lines.push(" IsTombstone: false".to_owned());
5943 }
5944 lines.push(String::new());
5945 lines.join("\n")
5946}
5947
5948fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5949 let mut lines = vec!["Sources:".to_owned()];
5950 for source in &store.sources {
5951 if source.last_update.is_none()
5952 && source.source_version.is_none()
5953 && source.etag.is_none()
5954 && source.last_modified.is_none()
5955 {
5956 continue;
5957 }
5958
5959 lines.push(format!(" - Name: {}", source.name));
5960 if let Some(last_update) = source.last_update {
5961 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
5962 }
5963 if let Some(source_version) = &source.source_version {
5964 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
5965 }
5966 if let Some(etag) = &source.etag {
5967 lines.push(format!(" ETag: {}", yaml_scalar(etag)));
5968 }
5969 if let Some(last_modified) = &source.last_modified {
5970 lines.push(format!(" LastModified: {}", yaml_scalar(last_modified)));
5971 }
5972 }
5973 lines.push(String::new());
5974 lines.join("\n")
5975}
5976
5977fn yaml_scalar(value: &str) -> String {
5978 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5979 format!("\"{}\"", value.replace('"', "\\\""))
5980 } else {
5981 value.to_owned()
5982 }
5983}
5984
5985fn uses_packaged_layout(app_root: &Path) -> bool {
5986 #[cfg(windows)]
5987 {
5988 try_get_packaged_local_state_root(app_root).is_some()
5989 }
5990
5991 #[cfg(not(windows))]
5992 {
5993 let _ = app_root;
5994 false
5995 }
5996}
5997
5998#[cfg(windows)]
5999fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
6000 let local_state = app_root.file_name()?.to_string_lossy();
6001 if !local_state.eq_ignore_ascii_case("LocalState") {
6002 return None;
6003 }
6004
6005 let family = app_root.parent()?.file_name()?.to_string_lossy();
6006 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6007 return None;
6008 }
6009
6010 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6011 if !packages.eq_ignore_ascii_case("Packages") {
6012 return None;
6013 }
6014
6015 Some(app_root.to_path_buf())
6016}
6017
6018#[cfg(windows)]
6019fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6020 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6021 Ok(packaged_secure_settings_root()?.join("user_sources"))
6022}
6023
6024#[cfg(not(windows))]
6025fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6026 bail!("packaged WinGet source streams are only available on Windows")
6027}
6028
6029#[cfg(windows)]
6030fn packaged_secure_settings_root() -> Result<PathBuf> {
6031 let program_data = std::env::var_os("PROGRAMDATA")
6032 .map(PathBuf::from)
6033 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6034 Ok(program_data
6035 .join("Microsoft")
6036 .join("WinGet")
6037 .join(current_user_sid()?)
6038 .join("settings")
6039 .join("pkg")
6040 .join(PACKAGED_NAME))
6041}
6042
6043#[cfg(windows)]
6044#[allow(clippy::multiple_unsafe_ops_per_block)]
6045fn current_user_sid() -> Result<String> {
6046 fn close_token(token: HANDLE) {
6047 if !token.is_null() {
6048 unsafe {
6050 CloseHandle(token);
6051 }
6052 }
6053 }
6054
6055 let mut token: HANDLE = std::ptr::null_mut();
6056 let current_process = unsafe { GetCurrentProcess() };
6058 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6060 bail!("failed to open current process token")
6061 }
6062
6063 let mut length = 0;
6064 unsafe {
6066 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6067 }
6068
6069 let element_size = size_of::<TOKEN_USER>();
6070 let element_count = (length as usize).div_ceil(element_size);
6071 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6072 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6074 close_token(token);
6075 bail!("failed to get current user token information")
6076 }
6077
6078 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6080 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6081 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6083 close_token(token);
6084 bail!("failed to convert current user SID")
6085 }
6086
6087 let mut len = 0usize;
6088 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6090 len += 1;
6091 }
6092
6093 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6095 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6096 unsafe {
6098 LocalFree(sid_ptr.cast());
6099 }
6100 close_token(token);
6101 Ok(sid)
6102}
6103
6104fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6105 app_root.join("Microsoft").join("Windows Package Manager")
6106}
6107
6108fn cache_root(app_root: &Path) -> PathBuf {
6109 if uses_packaged_layout(app_root) {
6110 packaged_file_cache_root(app_root)
6111 } else {
6112 default_cache_root_fallback()
6113 }
6114}
6115
6116fn default_cache_root_fallback() -> PathBuf {
6117 std::env::temp_dir().join("cache")
6118}
6119
6120fn packaged_source_type(kind: SourceKind) -> &'static str {
6121 match kind {
6122 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6123 SourceKind::Rest => "Microsoft.Rest",
6124 }
6125}
6126
6127fn sanitize_path_segment(value: &str) -> String {
6128 value
6129 .chars()
6130 .map(|ch| match ch {
6131 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6132 _ => ch,
6133 })
6134 .collect()
6135}
6136
6137fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6138 if !path.exists() {
6139 return Ok(JsonMap::new());
6140 }
6141
6142 let bytes = fs::read(path).context("failed to read JSON file")?;
6143 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6144 Ok(match value {
6145 JsonValue::Object(object) => object,
6146 _ => JsonMap::new(),
6147 })
6148}
6149
6150fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6151 write_json(path, value)
6152}
6153
6154fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6155 if let Some(parent) = path.parent() {
6156 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6157 }
6158
6159 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6160 fs::write(path, bytes).context("failed to write JSON file")
6161}
6162
6163fn merge_json_objects(
6164 current: &JsonMap<String, JsonValue>,
6165 update: &JsonMap<String, JsonValue>,
6166) -> JsonMap<String, JsonValue> {
6167 let mut merged = current.clone();
6168 for (key, value) in update {
6169 match (merged.get(key), value) {
6170 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6171 merged.insert(
6172 key.clone(),
6173 JsonValue::Object(merge_json_objects(current_object, update_object)),
6174 );
6175 }
6176 _ => {
6177 merged.insert(key.clone(), value.clone());
6178 }
6179 }
6180 }
6181
6182 merged
6183}
6184
6185fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6186 match expected {
6187 JsonValue::Object(expected_object) => match current {
6188 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6189 current_object
6190 .get(key)
6191 .is_some_and(|current_child| json_contains(current_child, expected_child))
6192 }),
6193 _ => false,
6194 },
6195 JsonValue::Array(expected_array) => match current {
6196 JsonValue::Array(current_array) => {
6197 current_array.len() == expected_array.len()
6198 && current_array
6199 .iter()
6200 .zip(expected_array.iter())
6201 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6202 }
6203 _ => false,
6204 },
6205 _ => current == expected,
6206 }
6207}
6208
6209fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6210 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6211 "none" | "default" => Ok("None".to_owned()),
6212 "trusted" => Ok("Trusted".to_owned()),
6213 other => bail!("unsupported source trust level: {other}"),
6214 }
6215}
6216
6217fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6218 SUPPORTED_ADMIN_SETTINGS
6219 .iter()
6220 .copied()
6221 .find(|setting| setting.eq_ignore_ascii_case(name))
6222 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6223}
6224
6225fn default_source_trust_level() -> String {
6226 "None".to_owned()
6227}
6228
6229fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6230where
6231 F: FnMut(&SqlRow) -> Result<T>,
6232{
6233 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6234 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6235 let mut result = Vec::new();
6236
6237 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6238 result.push(map(&row)?);
6239 }
6240
6241 Ok(result)
6242}
6243
6244fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6245where
6246 F: FnOnce(&SqlRow) -> Result<T>,
6247{
6248 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6249 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6250
6251 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6252 Some(row) => Ok(Some(map(&row)?)),
6253 None => Ok(None),
6254 }
6255}
6256
6257fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6258 pollster::block_on(connection.execute(sql, params_from_iter(params)))
6259 .with_context(|| format!("failed to execute SQL statement: {sql}"))
6260}
6261
6262fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6263 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6264}
6265
6266fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6267 row.get_value(index)
6268 .with_context(|| format!("failed to read SQL column {index}"))
6269}
6270
6271fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6272 match row_value(row, index)? {
6273 SqlValue::Text(value) => Ok(value),
6274 SqlValue::Integer(value) => Ok(value.to_string()),
6275 SqlValue::Real(value) => Ok(value.to_string()),
6276 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6277 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6278 }
6279}
6280
6281fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6282 match row_value(row, index)? {
6283 SqlValue::Null => Ok(None),
6284 SqlValue::Text(value) => Ok(Some(value)),
6285 SqlValue::Integer(value) => Ok(Some(value.to_string())),
6286 SqlValue::Real(value) => Ok(Some(value.to_string())),
6287 SqlValue::Blob(value) => Ok(Some(
6288 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6289 )),
6290 }
6291}
6292
6293fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6294 match row_value(row, index)? {
6295 SqlValue::Integer(value) => Ok(value),
6296 SqlValue::Text(value) => value
6297 .parse::<i64>()
6298 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6299 SqlValue::Real(value) => f64_to_i64_checked(value, index),
6300 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6301 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6302 }
6303}
6304
6305fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6306 match row_value(row, index)? {
6307 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6308 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6309 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6310 SqlValue::Integer(value) => Ok(format!("{value:x}")),
6311 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6312 }
6313}
6314
6315fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6316 match row_value(row, index)? {
6317 SqlValue::Null => Ok(None),
6318 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6319 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6320 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6321 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6322 }
6323}
6324
6325fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6326 if !value.is_finite() {
6327 bail!("SQL column {index} contained a non-finite floating-point value");
6328 }
6329
6330 if value.fract() != 0.0 {
6331 bail!("SQL column {index} contained a non-integer floating-point value");
6332 }
6333
6334 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6335 bail!("SQL column {index} contained a floating-point value outside the i64 range");
6336 }
6337
6338 format!("{value:.0}")
6339 .parse::<i64>()
6340 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6341}
6342
6343fn bytes_to_hex(bytes: &[u8]) -> String {
6344 let mut result = String::with_capacity(bytes.len() * 2);
6345 for byte in bytes {
6346 result.push_str(&format!("{byte:02x}"));
6347 }
6348 result
6349}
6350
6351fn query_v2_matches(
6352 connection: &Connection,
6353 query: &PackageQuery,
6354 semantics: SearchSemantics,
6355) -> Result<(Vec<V2SearchRow>, bool)> {
6356 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6357 let limit = source_fetch_results(query, semantics);
6358 let sql = format!(
6359 "SELECT rowid, id, name, moniker, latest_version, hash \
6360 FROM packages WHERE {where_clause} LIMIT {}",
6361 limit + 1
6362 );
6363 let mut rows = query_rows(
6364 connection,
6365 &sql,
6366 params.into_iter().map(SqlValue::Text).collect(),
6367 |row| {
6368 Ok(V2SearchRow {
6369 package_rowid: row_i64(row, 0)?,
6370 id: row_string(row, 1)?,
6371 name: row_string(row, 2)?,
6372 moniker: row_opt_string(row, 3)?,
6373 version: row_string(row, 4)?,
6374 package_hash: row_hex_string(row, 5)?,
6375 match_criteria: None,
6376 })
6377 },
6378 )?;
6379 for row in &mut rows {
6380 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6381 }
6382 let truncated = rows.len() > limit;
6383 rows.truncate(limit);
6384 Ok((rows, truncated))
6385}
6386
6387fn query_v1_matches(
6388 connection: &Connection,
6389 query: &PackageQuery,
6390 semantics: SearchSemantics,
6391) -> Result<(Vec<V1SearchRow>, bool)> {
6392 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6393 let limit = source_fetch_results(query, semantics);
6394 let sql = format!(
6395 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6396 FROM manifest \
6397 JOIN ids ON manifest.id = ids.rowid \
6398 JOIN names ON manifest.name = names.rowid \
6399 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6400 JOIN versions ON manifest.version = versions.rowid \
6401 JOIN channels ON manifest.channel = channels.rowid \
6402 WHERE {where_clause} LIMIT {}",
6403 limit + 1
6404 );
6405 let mut rows = query_rows(
6406 connection,
6407 &sql,
6408 params.into_iter().map(SqlValue::Text).collect(),
6409 |row| {
6410 Ok(V1SearchRow {
6411 manifest_rowid: row_i64(row, 0)?,
6412 package_rowid: row_i64(row, 1)?,
6413 version: row_string(row, 2)?,
6414 channel: row_string(row, 3)?,
6415 id: row_string(row, 4)?,
6416 name: row_string(row, 5)?,
6417 moniker: row_opt_string(row, 6)?,
6418 match_criteria: None,
6419 })
6420 },
6421 )?;
6422 for row in &mut rows {
6423 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6424 }
6425 let truncated = rows.len() > limit;
6426 rows.truncate(limit);
6427 Ok((rows, truncated))
6428}
6429
6430fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6431 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6432
6433 for row in rows {
6434 match grouped.get(&row.package_rowid) {
6435 Some(existing)
6436 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6437 != Ordering::Less => {}
6438 _ => {
6439 grouped.insert(row.package_rowid, row);
6440 }
6441 }
6442 }
6443
6444 grouped.into_values().collect()
6445}
6446
6447fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6448 let has_hash = table_has_column(connection, "manifest", "hash")?;
6449 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6450 let sql = format!(
6451 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6452 FROM manifest \
6453 JOIN versions ON manifest.version = versions.rowid \
6454 JOIN channels ON manifest.channel = channels.rowid \
6455 WHERE manifest.id = ?1"
6456 );
6457 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6458 Ok(V1VersionRow {
6459 version: row_string(row, 0)?,
6460 channel: row_string(row, 1)?,
6461 pathpart_id: row_i64(row, 2)?,
6462 manifest_hash: row_opt_hex_string(row, 3)?,
6463 })
6464 })
6465}
6466
6467fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6468 let mut parts = Vec::new();
6469 let mut current = Some(pathpart_id);
6470
6471 while let Some(id) = current {
6472 let (parent, pathpart) = query_optional_value(
6473 connection,
6474 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6475 vec![SqlValue::Integer(id)],
6476 |row| {
6477 Ok((
6478 row_opt_string(row, 0)?
6479 .and_then(|value| value.parse::<i64>().ok())
6480 .filter(|value| *value != 0),
6481 row_string(row, 1)?,
6482 ))
6483 },
6484 )?
6485 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6486 parts.push(pathpart);
6487 current = parent;
6488 }
6489
6490 parts.reverse();
6491 Ok(parts.join("/"))
6492}
6493
6494fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6495 format!(
6496 "packages/{}/{}/versionData.mszyml",
6497 package_id,
6498 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6499 )
6500}
6501
6502fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6503 let message = format!("{error:#}").to_ascii_lowercase();
6504 message.contains("no such table") && message.contains("packages")
6505}
6506
6507fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6508 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6509 row_string(row, 1)
6510 })? {
6511 if value.eq_ignore_ascii_case(column) {
6512 return Ok(true);
6513 }
6514 }
6515
6516 Ok(false)
6517}
6518
6519fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6520 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6521 let id_column = if v2 { "id" } else { "ids.id" };
6522 let name_column = if v2 { "name" } else { "names.name" };
6523 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6524 let exact_match = query.exact || semantics == SearchSemantics::Single;
6525 let mut params = Vec::new();
6526
6527 if let Some(value) = &query.id {
6528 return (
6529 single_field_condition(id_column, value, exact_match, &mut params),
6530 params,
6531 );
6532 }
6533 if let Some(value) = &query.name {
6534 return (
6535 single_field_condition(name_column, value, exact_match, &mut params),
6536 params,
6537 );
6538 }
6539 if let Some(value) = &query.moniker {
6540 return (
6541 single_field_condition(moniker_column, value, exact_match, &mut params),
6542 params,
6543 );
6544 }
6545 if let Some(value) = &query.tag {
6546 return (
6547 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6548 params,
6549 );
6550 }
6551 if let Some(value) = &query.command {
6552 return (
6553 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6554 params,
6555 );
6556 }
6557 if let Some(value) = &query.query {
6558 if exact_match {
6559 let conditions = [
6560 single_field_condition(id_column, value, true, &mut params),
6561 single_field_condition(name_column, value, true, &mut params),
6562 single_field_condition(moniker_column, value, true, &mut params),
6563 ];
6564 return (format!("({})", conditions.join(" OR ")), params);
6565 }
6566
6567 let mut conditions = vec![
6568 single_field_condition(id_column, value, false, &mut params),
6569 single_field_condition(name_column, value, false, &mut params),
6570 single_field_condition(moniker_column, value, false, &mut params),
6571 ];
6572 if semantics == SearchSemantics::Many {
6573 conditions.push(mapped_field_condition(
6574 v2,
6575 "tag",
6576 value,
6577 rowid_column,
6578 false,
6579 &mut params,
6580 ));
6581 conditions.push(mapped_field_condition(
6582 v2,
6583 "command",
6584 value,
6585 rowid_column,
6586 false,
6587 &mut params,
6588 ));
6589 }
6590 return (format!("({})", conditions.join(" OR ")), params);
6591 }
6592
6593 ("1 = 1".to_owned(), Vec::new())
6594}
6595
6596fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6597 params.push(match_parameter(value, exact));
6598 format!("{column} LIKE ?{}", params.len())
6599}
6600
6601fn mapped_field_condition(
6602 v2: bool,
6603 value_name: &str,
6604 value: &str,
6605 rowid_column: &str,
6606 exact: bool,
6607 params: &mut Vec<String>,
6608) -> String {
6609 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6610 (
6611 format!("{value_name}s2"),
6612 format!("{value_name}s2_map"),
6613 value_name.to_owned(),
6614 "package".to_owned(),
6615 )
6616 } else {
6617 (
6618 format!("{value_name}s"),
6619 format!("{value_name}s_map"),
6620 value_name.to_owned(),
6621 "manifest".to_owned(),
6622 )
6623 };
6624 params.push(match_parameter(value, exact));
6625 let parameter = params.len();
6626 format!(
6633 "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6634 FROM {map_table_name} JOIN {table_name} ON \
6635 {map_table_name}.{value_name} = {table_name}.rowid \
6636 WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6637 )
6638}
6639
6640fn match_parameter(value: &str, exact: bool) -> String {
6641 if exact { value.to_owned() } else { format!("%{value}%") }
6642}
6643
6644fn build_rest_search_body(
6645 query: &PackageQuery,
6646 info: &RestInformation,
6647 semantics: SearchSemantics,
6648) -> Result<JsonValue> {
6649 let mut root = serde_json::Map::new();
6650 root.insert(
6651 "MaximumResults".to_owned(),
6652 JsonValue::from(source_fetch_results(query, semantics) as u64),
6653 );
6654 let exact_match = query.exact || semantics == SearchSemantics::Single;
6655
6656 if let Some(value) = &query.query {
6657 if semantics == SearchSemantics::Single {
6658 let mut filters = vec![
6659 rest_filter("PackageIdentifier", value, true),
6660 rest_filter("PackageName", value, true),
6661 rest_filter("Moniker", value, true),
6662 ];
6663 append_required_rest_filters(&mut filters, info);
6664 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6665 return Ok(JsonValue::Object(root));
6666 }
6667
6668 root.insert(
6669 "Query".to_owned(),
6670 serde_json::json!({
6671 "KeyWord": value,
6672 "MatchType": if exact_match { "Exact" } else { "Substring" },
6673 }),
6674 );
6675 }
6676
6677 let mut filters = Vec::new();
6678 if let Some(value) = &query.id {
6679 filters.push(rest_filter("PackageIdentifier", value, exact_match));
6680 }
6681 if let Some(value) = &query.name {
6682 filters.push(rest_filter("PackageName", value, exact_match));
6683 }
6684 if let Some(value) = &query.moniker {
6685 filters.push(rest_filter("Moniker", value, exact_match));
6686 }
6687 if let Some(value) = &query.tag {
6688 filters.push(rest_filter("Tag", value, true));
6689 }
6690 if let Some(value) = &query.command {
6691 filters.push(rest_filter("Command", value, true));
6692 }
6693 append_required_rest_filters(&mut filters, info);
6694
6695 if !filters.is_empty() {
6696 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6697 }
6698
6699 Ok(JsonValue::Object(root))
6700}
6701
6702fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6703 if info
6704 .required_package_match_fields
6705 .iter()
6706 .any(|field| field.eq_ignore_ascii_case("Market"))
6707 {
6708 filters.push(rest_filter("Market", &default_market(), true));
6709 }
6710}
6711
6712fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6713 serde_json::json!({
6714 "PackageMatchField": field,
6715 "RequestMatch": {
6716 "KeyWord": value,
6717 "MatchType": if exact { "Exact" } else { "Substring" },
6718 }
6719 })
6720}
6721
6722fn max_results(query: &PackageQuery) -> usize {
6723 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6724}
6725
6726fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6727 match semantics {
6728 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6729 SearchSemantics::Single => max_results(query),
6730 }
6731}
6732
6733fn infer_preindexed_match_criteria_v2(
6734 connection: &Connection,
6735 row: &V2SearchRow,
6736 query: &PackageQuery,
6737 semantics: SearchSemantics,
6738) -> Result<Option<String>> {
6739 infer_match_criteria(
6740 &row.id,
6741 &row.name,
6742 row.moniker.as_deref(),
6743 query,
6744 semantics,
6745 |value| {
6746 find_mapped_value_v2(
6747 connection,
6748 "tags2",
6749 "tags2_map",
6750 "tag",
6751 row.package_rowid,
6752 value,
6753 query.exact,
6754 )
6755 },
6756 |value| {
6757 find_mapped_value_v2(
6758 connection,
6759 "commands2",
6760 "commands2_map",
6761 "command",
6762 row.package_rowid,
6763 value,
6764 query.exact,
6765 )
6766 },
6767 )
6768}
6769
6770fn infer_preindexed_match_criteria_v1(
6771 connection: &Connection,
6772 row: &V1SearchRow,
6773 query: &PackageQuery,
6774 semantics: SearchSemantics,
6775) -> Result<Option<String>> {
6776 infer_match_criteria(
6777 &row.id,
6778 &row.name,
6779 row.moniker.as_deref(),
6780 query,
6781 semantics,
6782 |value| {
6783 find_mapped_value_v1(
6784 connection,
6785 "tags",
6786 "tags_map",
6787 "tag",
6788 row.manifest_rowid,
6789 value,
6790 query.exact,
6791 )
6792 },
6793 |value| {
6794 find_mapped_value_v1(
6795 connection,
6796 "commands",
6797 "commands_map",
6798 "command",
6799 row.manifest_rowid,
6800 value,
6801 query.exact,
6802 )
6803 },
6804 )
6805}
6806
6807fn infer_match_criteria<FTag, FCommand>(
6808 id: &str,
6809 name: &str,
6810 moniker: Option<&str>,
6811 query: &PackageQuery,
6812 semantics: SearchSemantics,
6813 find_tag: FTag,
6814 find_command: FCommand,
6815) -> Result<Option<String>>
6816where
6817 FTag: FnOnce(&str) -> Result<Option<String>>,
6818 FCommand: FnOnce(&str) -> Result<Option<String>>,
6819{
6820 if let Some(value) = &query.tag {
6821 return Ok(Some(format_match_criteria("Tag", value)));
6822 }
6823 if let Some(value) = &query.command {
6824 return Ok(Some(format_match_criteria("Command", value)));
6825 }
6826 if let Some(value) = &query.moniker {
6827 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6828 }
6829 if semantics == SearchSemantics::Single {
6830 return Ok(None);
6831 }
6832 if let Some(value) = &query.query {
6833 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6834 return Ok(None);
6835 }
6836 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6837 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6838 }
6839 if let Some(tag) = find_tag(value)? {
6840 return Ok(Some(format_match_criteria("Tag", &tag)));
6841 }
6842 if let Some(command) = find_command(value)? {
6843 return Ok(Some(format_match_criteria("Command", &command)));
6844 }
6845 }
6846 Ok(None)
6847}
6848
6849fn find_mapped_value_v2(
6850 connection: &Connection,
6851 table_name: &str,
6852 map_table_name: &str,
6853 value_name: &str,
6854 package_rowid: i64,
6855 query: &str,
6856 exact: bool,
6857) -> Result<Option<String>> {
6858 let sql = format!(
6859 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6860 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6861 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6862 );
6863 let values = query_rows(
6864 connection,
6865 &sql,
6866 vec![
6867 SqlValue::Integer(package_rowid),
6868 SqlValue::Text(match_parameter(query, exact)),
6869 ],
6870 |row| row_string(row, 0),
6871 )?;
6872 Ok(select_best_text_match(values, query, exact))
6873}
6874
6875fn find_mapped_value_v1(
6876 connection: &Connection,
6877 table_name: &str,
6878 map_table_name: &str,
6879 value_name: &str,
6880 manifest_rowid: i64,
6881 query: &str,
6882 exact: bool,
6883) -> Result<Option<String>> {
6884 let sql = format!(
6885 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6886 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6887 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6888 );
6889 let values = query_rows(
6890 connection,
6891 &sql,
6892 vec![
6893 SqlValue::Integer(manifest_rowid),
6894 SqlValue::Text(match_parameter(query, exact)),
6895 ],
6896 |row| row_string(row, 0),
6897 )?;
6898 Ok(select_best_text_match(values, query, exact))
6899}
6900
6901fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6902 if let Some(value) = &query.tag {
6903 return Some(format_match_criteria("Tag", value));
6904 }
6905 if let Some(value) = &query.command {
6906 return Some(format_match_criteria("Command", value));
6907 }
6908 if let Some(value) = &query.moniker {
6909 return Some(format_match_criteria("Moniker", value));
6910 }
6911 if semantics == SearchSemantics::Single {
6912 return None;
6913 }
6914 let value = query.query.as_deref()?;
6915 if json_string(item, "PackageIdentifier")
6916 .as_deref()
6917 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6918 || json_string(item, "PackageName")
6919 .as_deref()
6920 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6921 {
6922 return None;
6923 }
6924 json_string(item, "Moniker")
6925 .filter(|candidate| matches_text(candidate, value, query.exact))
6926 .map(|candidate| format_match_criteria("Moniker", &candidate))
6927}
6928
6929fn format_match_criteria(field: &str, value: &str) -> String {
6930 format!("{field}: {value}")
6931}
6932
6933fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6934 if exact {
6935 candidate.eq_ignore_ascii_case(query)
6936 } else {
6937 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6938 }
6939}
6940
6941fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6942 let mut score = 0;
6943
6944 if let Some(value) = &query.query {
6945 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6946 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6947 if let Some(moniker) = candidate.moniker.as_deref() {
6948 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6949 }
6950 }
6951
6952 if let Some(value) = &query.id {
6953 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6954 }
6955 if let Some(value) = &query.name {
6956 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6957 }
6958 if let Some(value) = &query.moniker
6959 && let Some(moniker) = candidate.moniker.as_deref()
6960 {
6961 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6962 }
6963 if let Some(value) = &query.tag
6964 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6965 {
6966 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6967 }
6968 if let Some(value) = &query.command
6969 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6970 {
6971 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6972 }
6973
6974 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6975 score += 5;
6976 }
6977 if search_match_has_unknown_version(candidate) {
6978 score = score.saturating_sub(10);
6979 }
6980
6981 score
6982}
6983
6984fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6985 candidate
6986 .version
6987 .as_deref()
6988 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6989}
6990
6991fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
7000 let mut document = match documents {
7001 JsonValue::Object(map) => map.clone(),
7002 _ => serde_json::Map::new(),
7003 };
7004
7005 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7006 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7007 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7008 if !manifest.channel.is_empty() {
7009 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7010 } else {
7011 document.remove("Channel");
7012 }
7013
7014 let mut single_installer = serde_json::Map::new();
7017 if let Some(arch) = installer.architecture.as_ref() {
7018 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7019 }
7020 if let Some(installer_type) = installer.installer_type.as_ref() {
7021 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7022 }
7023 if let Some(url) = installer.url.as_ref() {
7024 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7025 }
7026 if let Some(sha) = installer.sha256.as_ref() {
7027 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7028 }
7029 if let Some(scope) = installer.scope.as_ref() {
7030 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7031 }
7032 if let Some(locale) = installer.locale.as_ref() {
7033 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7034 }
7035 if let Some(product_code) = installer.product_code.as_ref() {
7036 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7037 }
7038 if let Some(release_date) = installer.release_date.as_ref() {
7039 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7040 }
7041
7042 document.insert(
7043 "Installers".to_owned(),
7044 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7045 );
7046 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7047
7048 let value = JsonValue::Object(document);
7049 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7050}
7051
7052fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7057 let mut filename = derive_download_filename_stem(manifest, installer);
7058 filename.push_str(".yaml");
7059 filename
7060}
7061
7062fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7063 let mut stem = sanitize_filename_component(&manifest.name);
7064 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7065 stem.push('_');
7066 stem.push_str(&sanitize_filename_component(&manifest.version));
7067 }
7068 if let Some(scope) = installer.scope.as_deref() {
7069 stem.push('_');
7070 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7071 }
7072 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7073 stem.push('_');
7074 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7075
7076 let installer_type = installer.installer_type.as_deref().unwrap_or("");
7077 if !installer_type.is_empty() {
7078 stem.push('_');
7079 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7080 }
7081 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7082 stem.push('_');
7083 stem.push_str(&sanitize_filename_component(locale));
7084 }
7085
7086 stem
7087}
7088
7089fn capitalize_first(value: &str) -> String {
7090 let mut chars = value.chars();
7091 match chars.next() {
7092 Some(first) => {
7093 let mut out = first.to_ascii_uppercase().to_string();
7094 out.push_str(&chars.as_str().to_ascii_lowercase());
7095 out
7096 }
7097 None => String::new(),
7098 }
7099}
7100
7101fn format_architecture(value: &str) -> String {
7102 match value.to_ascii_lowercase().as_str() {
7103 "x64" => "X64".to_owned(),
7104 "x86" => "X86".to_owned(),
7105 "arm" => "Arm".to_owned(),
7106 "arm64" => "Arm64".to_owned(),
7107 "neutral" => "Neutral".to_owned(),
7108 other => capitalize_first(other),
7109 }
7110}
7111
7112fn sanitize_filename_component(value: &str) -> String {
7113 value
7114 .chars()
7115 .map(|ch| match ch {
7116 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7117 ch if ch.is_control() => '_',
7118 ch => ch,
7119 })
7120 .collect()
7121}
7122
7123fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7124 if manifest_path.is_file() {
7125 return Ok(manifest_path.to_path_buf());
7126 }
7127
7128 if !manifest_path.is_dir() {
7129 bail!("Manifest path not found: {}", manifest_path.display());
7130 }
7131
7132 let candidate = fs::read_dir(manifest_path)?
7133 .filter_map(|entry| entry.ok())
7134 .map(|entry| entry.path())
7135 .filter(|path| {
7136 path.extension()
7137 .and_then(|value| value.to_str())
7138 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7139 })
7140 .min();
7141
7142 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7143}
7144
7145fn score_text_match(
7146 candidate: &str,
7147 query: &str,
7148 exact: bool,
7149 exact_score: usize,
7150 prefix_score: usize,
7151 substring_score: usize,
7152) -> usize {
7153 if candidate.eq_ignore_ascii_case(query) {
7154 return exact_score;
7155 }
7156 if exact {
7157 return 0;
7158 }
7159
7160 let candidate_lower = candidate.to_ascii_lowercase();
7161 let query_lower = query.to_ascii_lowercase();
7162 if candidate_lower.starts_with(&query_lower) {
7163 prefix_score
7164 } else if candidate_lower.contains(&query_lower) {
7165 substring_score
7166 } else {
7167 0
7168 }
7169}
7170
7171fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7172where
7173 I: IntoIterator<Item = String>,
7174{
7175 values
7176 .into_iter()
7177 .filter_map(|candidate| {
7178 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7179 (score > 0).then_some((score, candidate))
7180 })
7181 .max_by(|(left_score, left_value), (right_score, right_value)| {
7182 left_score
7183 .cmp(right_score)
7184 .then_with(|| right_value.len().cmp(&left_value.len()))
7185 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7186 })
7187 .map(|(_, candidate)| candidate)
7188}
7189
7190fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7191 let (field, value) = criteria.split_once(": ")?;
7192 Some((field, value))
7193}
7194
7195fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7196 let versions = item
7197 .get("Versions")
7198 .and_then(JsonValue::as_array)
7199 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7200 let mut result = Vec::new();
7201 for version in versions {
7202 let value = json_string(version, "PackageVersion")
7203 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7204 let channel = json_string(version, "Channel").unwrap_or_default();
7205 result.push(VersionKey {
7206 version: value,
7207 channel,
7208 });
7209 }
7210 Ok(result)
7211}
7212
7213fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7214 let mut merged = YamlMapping::new();
7215 let mut documents = Vec::new();
7216 for document in serde_yaml::Deserializer::from_slice(bytes) {
7217 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7218 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7219 if let Some(mapping) = value.as_mapping() {
7220 merge_yaml_mapping(&mut merged, mapping);
7221 }
7222 }
7223
7224 let id = yaml_string_from_root(&merged, "PackageIdentifier")
7225 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7226 let version =
7227 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7228 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7229
7230 let installers = parse_yaml_installers(&merged);
7231 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7232 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7233
7234 Ok((
7235 Manifest {
7236 id,
7237 name,
7238 version,
7239 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7240 publisher: yaml_localized_string(&merged, "Publisher"),
7241 description: yaml_localized_string(&merged, "Description")
7242 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7243 moniker: yaml_string_from_root(&merged, "Moniker"),
7244 package_url: yaml_localized_string(&merged, "PackageUrl"),
7245 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7246 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7247 license: yaml_localized_string(&merged, "License"),
7248 license_url: yaml_localized_string(&merged, "LicenseUrl"),
7249 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7250 author: yaml_localized_string(&merged, "Author"),
7251 copyright: yaml_localized_string(&merged, "Copyright"),
7252 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7253 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7254 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7255 tags: yaml_string_list(&merged, "Tags"),
7256 agreements: yaml_agreement_list(&merged),
7257 package_dependencies: yaml_package_dependencies(&merged),
7258 documentation: yaml_documentation_list(&merged),
7259 icons: yaml_icon_list(&merged),
7260 installers,
7261 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7262 },
7263 collapse_structured_document(&JsonValue::Array(documents)),
7264 ))
7265}
7266
7267fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7268 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7269}
7270
7271fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7272 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7273 let data = root
7274 .get("Data")
7275 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7276 let versions = data
7277 .get("Versions")
7278 .and_then(JsonValue::as_array)
7279 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7280 let selected = versions
7281 .iter()
7282 .find(|item| {
7283 json_string(item, "PackageVersion").as_deref() == Some(version)
7284 && json_string(item, "Channel").unwrap_or_default() == channel
7285 })
7286 .or_else(|| versions.first())
7287 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7288
7289 let default_locale = selected
7290 .get("DefaultLocale")
7291 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7292 let package_default_locale = data.get("DefaultLocale");
7293 let name = json_string(default_locale, "PackageName")
7294 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7295 let installer_switch_defaults = json_installer_switches(selected);
7296 let top_platforms = json_string_list(selected, "Platform");
7297 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7298 let top_installer_type = json_string(selected, "InstallerType");
7299 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7300 let top_nested_installer_files = json_nested_installer_files(selected);
7301
7302 let installers = selected
7303 .get("Installers")
7304 .and_then(JsonValue::as_array)
7305 .map(|items| {
7306 items
7307 .iter()
7308 .map(|item| Installer {
7309 platforms: {
7310 let platforms = json_string_list(item, "Platform");
7311 if platforms.is_empty() {
7312 top_platforms.clone()
7313 } else {
7314 platforms
7315 }
7316 },
7317 minimum_os_version: json_string(item, "MinimumOSVersion")
7318 .or_else(|| top_minimum_os_version.clone()),
7319 architecture: json_string(item, "Architecture"),
7320 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7321 nested_installer_type: json_string(item, "NestedInstallerType")
7322 .or_else(|| top_nested_installer_type.clone()),
7323 nested_installer_files: {
7324 let files = json_nested_installer_files(item);
7325 if files.is_empty() {
7326 top_nested_installer_files.clone()
7327 } else {
7328 files
7329 }
7330 },
7331 url: json_string(item, "InstallerUrl"),
7332 sha256: json_string(item, "InstallerSha256"),
7333 product_code: json_string(item, "ProductCode"),
7334 locale: json_string(item, "InstallerLocale"),
7335 scope: json_string(item, "Scope"),
7336 release_date: json_string(item, "ReleaseDate"),
7337 package_family_name: json_string(item, "PackageFamilyName"),
7338 upgrade_code: json_string(item, "UpgradeCode"),
7339 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7340 commands: json_string_list(item, "Commands"),
7341 package_dependencies: json_package_dependencies(item),
7342 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7343 })
7344 .collect::<Vec<_>>()
7345 })
7346 .unwrap_or_default();
7347 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7348 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7349 let mut icons = json_icon_list(default_locale);
7350 if icons.is_empty() {
7351 icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7352 }
7353
7354 let manifest = Manifest {
7355 id: package_id.to_owned(),
7356 name,
7357 version: version.to_owned(),
7358 channel: channel.to_owned(),
7359 publisher: json_string(default_locale, "Publisher"),
7360 description: json_string(default_locale, "Description")
7361 .or_else(|| json_string(default_locale, "ShortDescription")),
7362 moniker: json_string(default_locale, "Moniker"),
7363 package_url: json_string(default_locale, "PackageUrl"),
7364 publisher_url: json_string(default_locale, "PublisherUrl"),
7365 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7366 license: json_string(default_locale, "License"),
7367 license_url: json_string(default_locale, "LicenseUrl"),
7368 privacy_url: json_string(default_locale, "PrivacyUrl"),
7369 author: json_string(default_locale, "Author"),
7370 copyright: json_string(default_locale, "Copyright"),
7371 copyright_url: json_string(default_locale, "CopyrightUrl"),
7372 release_notes: json_string(default_locale, "ReleaseNotes"),
7373 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7374 tags: json_string_list(default_locale, "Tags"),
7375 agreements: json_agreement_list(default_locale),
7376 package_dependencies: json_package_dependencies(selected),
7377 documentation: json_documentation_list(default_locale),
7378 icons,
7379 installers,
7380 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7381 };
7382
7383 Ok((
7384 manifest,
7385 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7386 ))
7387}
7388
7389fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7390 match document {
7391 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7392 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7393 }
7394 JsonValue::Array(documents) => merge_manifest_documents(documents),
7395 JsonValue::Object(_) if is_merged_manifest_document(document) => {
7396 merge_manifest_documents(&split_merged_manifest_document(document))
7397 }
7398 _ => document.clone(),
7399 }
7400}
7401
7402fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7403 documents.iter().map(collapse_structured_document).collect()
7404}
7405
7406fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7407 let Some(root) = document.as_object_mut() else {
7408 return document;
7409 };
7410
7411 root.insert(
7412 "IconUrl".to_owned(),
7413 manifest
7414 .icon_url()
7415 .map(|url| JsonValue::String(url.to_owned()))
7416 .unwrap_or(JsonValue::Null),
7417 );
7418 root.insert(
7419 "Icon".to_owned(),
7420 manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7421 );
7422 root.entry("Icons".to_owned())
7423 .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7424
7425 document
7426}
7427
7428fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7429 serde_json::to_value(icon).expect("package icon serialization cannot fail")
7430}
7431
7432fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7433 let version = documents.iter().find(|document| {
7434 document
7435 .get("ManifestType")
7436 .and_then(JsonValue::as_str)
7437 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7438 });
7439 let default_locale = documents.iter().find(|document| {
7440 document
7441 .get("ManifestType")
7442 .and_then(JsonValue::as_str)
7443 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7444 });
7445 let installer = documents.iter().find(|document| {
7446 document
7447 .get("ManifestType")
7448 .and_then(JsonValue::as_str)
7449 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7450 });
7451
7452 let mut singleton = JsonMap::new();
7453 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7454 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7455 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7456
7457 if !singleton.contains_key("PackageLocale")
7458 && let Some(package_locale) = default_locale
7459 .and_then(|document| json_string(document, "PackageLocale"))
7460 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7461 {
7462 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7463 }
7464
7465 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7466 singleton.insert(
7467 "ManifestVersion".to_owned(),
7468 JsonValue::String(
7469 installer
7470 .and_then(|document| json_string(document, "ManifestVersion"))
7471 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7472 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7473 .unwrap_or_else(|| "1.10.0".to_owned()),
7474 ),
7475 );
7476 singleton.remove("DefaultLocale");
7477
7478 JsonValue::Object(singleton)
7479}
7480
7481fn is_merged_manifest_document(document: &JsonValue) -> bool {
7482 document
7483 .get("ManifestType")
7484 .and_then(JsonValue::as_str)
7485 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7486}
7487
7488fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7489 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7490 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7491 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7492 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7493
7494 let version_document = serde_json::json!({
7495 "PackageIdentifier": package_identifier,
7496 "PackageVersion": package_version,
7497 "DefaultLocale": package_locale,
7498 "ManifestType": "version",
7499 "ManifestVersion": manifest_version,
7500 });
7501
7502 let mut default_locale_document = project_manifest_document(
7503 merged,
7504 &[
7505 "PackageIdentifier",
7506 "PackageVersion",
7507 "PackageLocale",
7508 "Publisher",
7509 "PublisherUrl",
7510 "PublisherSupportUrl",
7511 "PrivacyUrl",
7512 "Author",
7513 "PackageName",
7514 "PackageUrl",
7515 "License",
7516 "LicenseUrl",
7517 "Copyright",
7518 "CopyrightUrl",
7519 "ShortDescription",
7520 "Description",
7521 "Moniker",
7522 "Tags",
7523 "Agreements",
7524 "ReleaseNotes",
7525 "ReleaseNotesUrl",
7526 "PurchaseUrl",
7527 "InstallationNotes",
7528 "Documentations",
7529 "Icons",
7530 ],
7531 );
7532 default_locale_document.insert(
7533 "PackageIdentifier".to_owned(),
7534 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7535 );
7536 default_locale_document.insert(
7537 "PackageVersion".to_owned(),
7538 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7539 );
7540 default_locale_document.insert(
7541 "PackageLocale".to_owned(),
7542 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7543 );
7544 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7545 default_locale_document.insert(
7546 "ManifestVersion".to_owned(),
7547 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7548 );
7549
7550 let mut installer_document = project_manifest_document(
7551 merged,
7552 &[
7553 "PackageIdentifier",
7554 "PackageVersion",
7555 "Channel",
7556 "InstallerLocale",
7557 "Platform",
7558 "MinimumOSVersion",
7559 "InstallerType",
7560 "NestedInstallerType",
7561 "NestedInstallerFiles",
7562 "Scope",
7563 "InstallModes",
7564 "InstallerSwitches",
7565 "InstallerSuccessCodes",
7566 "ExpectedReturnCodes",
7567 "UpgradeBehavior",
7568 "Commands",
7569 "Protocols",
7570 "FileExtensions",
7571 "Dependencies",
7572 "PackageFamilyName",
7573 "ProductCode",
7574 "Capabilities",
7575 "RestrictedCapabilities",
7576 "Markets",
7577 "InstallerAbortsTerminal",
7578 "ReleaseDate",
7579 "InstallLocationRequired",
7580 "RequireExplicitUpgrade",
7581 "DisplayInstallWarnings",
7582 "UnsupportedOSArchitectures",
7583 "UnsupportedArguments",
7584 "AppsAndFeaturesEntries",
7585 "ElevationRequirement",
7586 "InstallationMetadata",
7587 "DownloadCommandProhibited",
7588 "RepairBehavior",
7589 "ArchiveBinariesDependOnPath",
7590 "Authentication",
7591 "Installers",
7592 ],
7593 );
7594 installer_document.insert(
7595 "PackageIdentifier".to_owned(),
7596 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7597 );
7598 installer_document.insert(
7599 "PackageVersion".to_owned(),
7600 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7601 );
7602 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7603 installer_document.insert(
7604 "ManifestVersion".to_owned(),
7605 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7606 );
7607
7608 vec![
7609 version_document,
7610 JsonValue::Object(default_locale_document),
7611 JsonValue::Object(installer_document),
7612 ]
7613}
7614
7615fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7616 let mut result = JsonMap::new();
7617 if let Some(source) = source.as_object() {
7618 for key in keys {
7619 if let Some(value) = source.get(*key) {
7620 result.insert((*key).to_owned(), value.clone());
7621 }
7622 }
7623 }
7624
7625 result
7626}
7627
7628fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7629 if let Some(source) = source.and_then(JsonValue::as_object) {
7630 for key in keys {
7631 if let Some(value) = source.get(*key) {
7632 target.insert((*key).to_owned(), value.clone());
7633 }
7634 }
7635 }
7636}
7637
7638fn copy_all_manifest_keys_except(
7639 target: &mut JsonMap<String, JsonValue>,
7640 source: Option<&JsonValue>,
7641 excluded_keys: &[&str],
7642) {
7643 if let Some(source) = source.and_then(JsonValue::as_object) {
7644 for (key, value) in source {
7645 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7646 target.insert(key.clone(), value.clone());
7647 }
7648 }
7649 }
7650}
7651
7652fn build_rest_manifest_documents(
7653 data: &JsonValue,
7654 installer_source: &JsonValue,
7655 default_locale: &JsonValue,
7656 package_id: &str,
7657 version: &str,
7658 channel: &str,
7659) -> JsonValue {
7660 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7661 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7662 let manifest_version = json_string(installer_source, "ManifestVersion")
7663 .or_else(|| json_string(default_locale, "ManifestVersion"))
7664 .or_else(|| json_string(data, "ManifestVersion"))
7665 .unwrap_or_else(|| "1.10.0".to_owned());
7666
7667 let mut version_document = JsonMap::new();
7668 version_document.insert(
7669 "PackageIdentifier".to_owned(),
7670 JsonValue::String(package_identifier.clone()),
7671 );
7672 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7673 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7674 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7675 version_document.insert(
7676 "ManifestVersion".to_owned(),
7677 JsonValue::String(manifest_version.clone()),
7678 );
7679
7680 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7681 default_locale_document.insert(
7682 "PackageIdentifier".to_owned(),
7683 JsonValue::String(package_identifier.clone()),
7684 );
7685 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7686 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7687 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7688 default_locale_document.insert(
7689 "ManifestVersion".to_owned(),
7690 JsonValue::String(manifest_version.clone()),
7691 );
7692
7693 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7694 installer_document.remove("DefaultLocale");
7695 installer_document.remove("Locales");
7696 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7697 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7698 if !channel.is_empty() {
7699 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7700 }
7701 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7702 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7703
7704 let documents = vec![
7705 JsonValue::Object(version_document),
7706 JsonValue::Object(default_locale_document),
7707 JsonValue::Object(installer_document),
7708 ];
7709
7710 merge_manifest_documents(&documents)
7711}
7712
7713fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7714 let base = installer_defaults(root);
7715 let base_switches = yaml_installer_switches(root);
7716 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7717 let installers = items
7718 .iter()
7719 .filter_map(YamlValue::as_mapping)
7720 .map(|item| {
7721 let mut merged = base.clone();
7722 merge_yaml_mapping(&mut merged, item);
7723 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7724 installer_from_yaml(&merged, switches)
7725 })
7726 .collect::<Vec<_>>();
7727 if !installers.is_empty() {
7728 return installers;
7729 }
7730 }
7731
7732 if base.is_empty() {
7733 Vec::new()
7734 } else {
7735 vec![installer_from_yaml(&base, base_switches)]
7736 }
7737}
7738
7739fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7740 let keys = [
7741 "Architecture",
7742 "InstallerType",
7743 "NestedInstallerType",
7744 "NestedInstallerFiles",
7745 "InstallerUrl",
7746 "InstallerSha256",
7747 "ProductCode",
7748 "InstallerLocale",
7749 "Platform",
7750 "MinimumOSVersion",
7751 "Scope",
7752 "ReleaseDate",
7753 "PackageFamilyName",
7754 "UpgradeCode",
7755 "Commands",
7756 ];
7757 let mut defaults = YamlMapping::new();
7758 for key in keys {
7759 if let Some(value) = root.get(YamlValue::from(key)) {
7760 defaults.insert(YamlValue::from(key), value.clone());
7761 }
7762 }
7763 defaults
7764}
7765
7766fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7767 Installer {
7768 architecture: yaml_string(root, "Architecture"),
7769 installer_type: yaml_string(root, "InstallerType"),
7770 nested_installer_type: yaml_string(root, "NestedInstallerType"),
7771 nested_installer_files: yaml_nested_installer_files(root),
7772 url: yaml_string(root, "InstallerUrl"),
7773 sha256: yaml_string(root, "InstallerSha256"),
7774 product_code: yaml_string(root, "ProductCode"),
7775 locale: yaml_string(root, "InstallerLocale"),
7776 scope: yaml_string(root, "Scope"),
7777 release_date: yaml_string(root, "ReleaseDate"),
7778 package_family_name: yaml_string(root, "PackageFamilyName"),
7779 upgrade_code: yaml_string(root, "UpgradeCode"),
7780 platforms: yaml_string_list(root, "Platform"),
7781 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
7782 switches,
7783 commands: yaml_string_list(root, "Commands"),
7784 package_dependencies: yaml_package_dependencies(root),
7785 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
7786 }
7787}
7788
7789fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
7792 let Some(value) = root.get(YamlValue::from(key)) else {
7793 return false;
7794 };
7795 match value {
7796 YamlValue::Bool(b) => *b,
7797 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
7798 _ => false,
7799 }
7800}
7801
7802fn json_bool(value: &JsonValue, key: &str) -> bool {
7803 match value.get(key) {
7804 Some(JsonValue::Bool(b)) => *b,
7805 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
7806 _ => false,
7807 }
7808}
7809
7810fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7811 let mapping = root
7812 .get(YamlValue::from("InstallerSwitches"))
7813 .or_else(|| root.get(YamlValue::from("Switches")))
7814 .and_then(YamlValue::as_mapping);
7815 yaml_installer_switches_from_mapping(mapping)
7816}
7817
7818fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7819 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7820
7821 InstallerSwitches {
7822 silent: string("Silent"),
7823 silent_with_progress: string("SilentWithProgress"),
7824 interactive: string("Interactive"),
7825 custom: string("Custom"),
7826 log: string("Log"),
7827 install_location: string("InstallLocation"),
7828 }
7829}
7830
7831fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7832 yaml_string(root, key).or_else(|| {
7833 root.get(YamlValue::from("DefaultLocale"))
7834 .and_then(YamlValue::as_mapping)
7835 .and_then(|mapping| yaml_string(mapping, key))
7836 })
7837}
7838
7839fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7840 yaml_string(root, key)
7841}
7842
7843fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7844 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7845}
7846
7847fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7848 match value {
7849 YamlValue::Null => None,
7850 YamlValue::Bool(value) => Some(value.to_string()),
7851 YamlValue::Number(value) => Some(value.to_string()),
7852 YamlValue::String(value) => Some(value.clone()),
7853 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7854 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7855 }
7856}
7857
7858fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7859 root.get(YamlValue::from(key))
7860 .and_then(YamlValue::as_sequence)
7861 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7862 .unwrap_or_default()
7863}
7864
7865fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7866 root.get(YamlValue::from("Documentations"))
7867 .and_then(YamlValue::as_sequence)
7868 .map(|items| {
7869 items
7870 .iter()
7871 .filter_map(YamlValue::as_mapping)
7872 .filter_map(|item| {
7873 let url = yaml_string(item, "DocumentUrl")?;
7874 Some(Documentation {
7875 label: yaml_string(item, "DocumentLabel"),
7876 url,
7877 })
7878 })
7879 .collect()
7880 })
7881 .unwrap_or_default()
7882}
7883
7884fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7885 root.get(YamlValue::from("Icons"))
7886 .and_then(YamlValue::as_sequence)
7887 .map(|items| {
7888 items
7889 .iter()
7890 .filter_map(YamlValue::as_mapping)
7891 .filter_map(|item| {
7892 let icon = PackageIcon {
7893 icon_url: yaml_string(item, "IconUrl"),
7894 icon_file_type: yaml_string(item, "IconFileType"),
7895 icon_resolution: yaml_string(item, "IconResolution"),
7896 icon_theme: yaml_string(item, "IconTheme"),
7897 icon_sha256: yaml_string(item, "IconSha256"),
7898 };
7899 (!icon.is_empty()).then_some(icon)
7900 })
7901 .collect()
7902 })
7903 .unwrap_or_default()
7904}
7905
7906fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7907 root.get(YamlValue::from("Agreements"))
7908 .and_then(YamlValue::as_sequence)
7909 .map(|items| {
7910 items
7911 .iter()
7912 .filter_map(YamlValue::as_mapping)
7913 .map(|item| PackageAgreement {
7914 label: yaml_string(item, "AgreementLabel"),
7915 text: yaml_string(item, "Agreement"),
7916 url: yaml_string(item, "AgreementUrl"),
7917 })
7918 .collect()
7919 })
7920 .unwrap_or_default()
7921}
7922
7923fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7924 root.get(YamlValue::from("Dependencies"))
7925 .and_then(YamlValue::as_mapping)
7926 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7927 .and_then(YamlValue::as_sequence)
7928 .map(|items| {
7929 items
7930 .iter()
7931 .filter_map(YamlValue::as_mapping)
7932 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7933 .collect()
7934 })
7935 .unwrap_or_default()
7936}
7937
7938fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7939 root.get(YamlValue::from("NestedInstallerFiles"))
7940 .and_then(YamlValue::as_sequence)
7941 .map(|items| {
7942 items
7943 .iter()
7944 .filter_map(YamlValue::as_mapping)
7945 .filter_map(|item| {
7946 let relative = yaml_string(item, "RelativeFilePath")?;
7947 Some(NestedInstallerFile {
7948 relative_file_path: relative,
7949 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7950 })
7951 })
7952 .collect()
7953 })
7954 .unwrap_or_default()
7955}
7956
7957fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7958 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7959}
7960
7961fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7962 value
7963 .get(key)
7964 .and_then(JsonValue::as_array)
7965 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7966 .unwrap_or_default()
7967}
7968
7969fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7970 value
7971 .get("Documentations")
7972 .and_then(JsonValue::as_array)
7973 .map(|items| {
7974 items
7975 .iter()
7976 .filter_map(|item| {
7977 let url = json_string(item, "DocumentUrl")?;
7978 Some(Documentation {
7979 label: json_string(item, "DocumentLabel"),
7980 url,
7981 })
7982 })
7983 .collect()
7984 })
7985 .unwrap_or_default()
7986}
7987
7988fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
7989 value
7990 .get("Icons")
7991 .and_then(JsonValue::as_array)
7992 .map(|items| {
7993 items
7994 .iter()
7995 .filter_map(|item| {
7996 let icon = PackageIcon {
7997 icon_url: json_string(item, "IconUrl"),
7998 icon_file_type: json_string(item, "IconFileType"),
7999 icon_resolution: json_string(item, "IconResolution"),
8000 icon_theme: json_string(item, "IconTheme"),
8001 icon_sha256: json_string(item, "IconSha256"),
8002 };
8003 (!icon.is_empty()).then_some(icon)
8004 })
8005 .collect()
8006 })
8007 .unwrap_or_default()
8008}
8009
8010fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8011 value
8012 .get("Agreements")
8013 .and_then(JsonValue::as_array)
8014 .map(|items| {
8015 items
8016 .iter()
8017 .map(|item| PackageAgreement {
8018 label: json_string(item, "AgreementLabel"),
8019 text: json_string(item, "Agreement"),
8020 url: json_string(item, "AgreementUrl"),
8021 })
8022 .collect()
8023 })
8024 .unwrap_or_default()
8025}
8026
8027fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8028 value
8029 .get("Dependencies")
8030 .and_then(JsonValue::as_object)
8031 .and_then(|deps| deps.get("PackageDependencies"))
8032 .and_then(JsonValue::as_array)
8033 .map(|items| {
8034 items
8035 .iter()
8036 .filter_map(|item| json_string(item, "PackageIdentifier"))
8037 .collect()
8038 })
8039 .unwrap_or_default()
8040}
8041
8042fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8043 value
8044 .get("NestedInstallerFiles")
8045 .and_then(JsonValue::as_array)
8046 .map(|items| {
8047 items
8048 .iter()
8049 .filter_map(|item| {
8050 let relative = json_string(item, "RelativeFilePath")?;
8051 Some(NestedInstallerFile {
8052 relative_file_path: relative,
8053 portable_command_alias: json_string(item, "PortableCommandAlias"),
8054 })
8055 })
8056 .collect()
8057 })
8058 .unwrap_or_default()
8059}
8060
8061fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8062 let switches = value
8063 .get("InstallerSwitches")
8064 .or_else(|| value.get("Switches"))
8065 .and_then(JsonValue::as_object);
8066
8067 let string = |key: &str| {
8068 switches
8069 .and_then(|switches| switches.get(key))
8070 .and_then(JsonValue::as_str)
8071 .map(str::to_owned)
8072 };
8073
8074 InstallerSwitches {
8075 silent: string("Silent"),
8076 silent_with_progress: string("SilentWithProgress"),
8077 interactive: string("Interactive"),
8078 custom: string("Custom"),
8079 log: string("Log"),
8080 install_location: string("InstallLocation"),
8081 }
8082}
8083
8084fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8085 for (key, value) in source {
8086 target.insert(key.clone(), value.clone());
8087 }
8088}
8089
8090fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8091 let reader = Cursor::new(bytes.to_vec());
8092 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8093 let mut file = archive
8094 .by_name(member_name)
8095 .with_context(|| format!("zip payload missing {member_name}"))?;
8096 let mut output = Vec::new();
8097 file.read_to_end(&mut output)
8098 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8099 Ok(output)
8100}
8101
8102fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8103 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8104 return Ok(output);
8105 }
8106
8107 if !bytes.starts_with(b"CK")
8108 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8109 {
8110 return decompress_mszyml_payload(&bytes[offset + 2..]);
8111 }
8112
8113 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8114}
8115
8116fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8117 let mut decoder = flate2::read::DeflateDecoder::new(payload);
8118 let mut output = String::new();
8119 decoder.read_to_string(&mut output)?;
8120 Ok(output)
8121}
8122
8123fn cache_control_max_age(response: &Response) -> u64 {
8124 response
8125 .headers()
8126 .get(reqwest::header::CACHE_CONTROL)
8127 .and_then(|value| value.to_str().ok())
8128 .and_then(|header| {
8129 header.split(',').find_map(|part| {
8130 let trimmed = part.trim();
8131 trimmed
8132 .strip_prefix("max-age=")
8133 .and_then(|value| value.parse::<u64>().ok())
8134 })
8135 })
8136 .unwrap_or(60)
8137}
8138
8139fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8140 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8141 let (major, minor) = major_minor(candidate);
8142 server_versions.iter().any(|version| {
8143 let (server_major, server_minor) = major_minor(version);
8144 server_major == major && server_minor == minor
8145 })
8146 })
8147}
8148
8149fn major_minor(version: &str) -> (String, String) {
8150 let mut parts = version.split('.');
8151 (
8152 parts.next().unwrap_or_default().to_owned(),
8153 parts.next().unwrap_or_default().to_owned(),
8154 )
8155}
8156
8157fn default_market() -> String {
8158 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8159}
8160
8161fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8162 let requested_locale = query.locale.as_deref();
8163 let requested_architecture = query.installer_architecture.as_deref();
8164 let requested_type = query.installer_type.as_deref();
8165 let requested_scope = query.install_scope.as_deref();
8166 let requested_platform = query.platform.as_deref();
8167 let requested_os_version = query.os_version.as_deref();
8168 let system_architecture = current_architecture();
8169 let system_platform = current_platform();
8170 let current_os_version = current_os_version();
8171
8172 installers
8173 .iter()
8174 .enumerate()
8175 .filter(|(_, installer)| {
8176 installer_matches_requested(
8177 installer,
8178 requested_type,
8179 requested_scope,
8180 requested_platform,
8181 requested_os_version,
8182 system_platform,
8183 current_os_version.as_deref(),
8184 )
8185 })
8186 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8187 .max_by_key(|(index, installer)| {
8188 (
8189 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8190 std::cmp::Reverse(*index),
8191 )
8192 })
8193 .map(|(_, installer)| installer)
8194 .cloned()
8195}
8196
8197fn installer_matches_requested(
8198 installer: &Installer,
8199 requested_type: Option<&str>,
8200 requested_scope: Option<&str>,
8201 requested_platform: Option<&str>,
8202 requested_os_version: Option<&str>,
8203 system_platform: Option<&str>,
8204 current_os_version: Option<&str>,
8205) -> bool {
8206 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8207 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8208 && installer_matches_platform(installer, requested_platform, system_platform)
8209 && installer_matches_os_version(installer, requested_os_version, current_os_version)
8210}
8211
8212fn installer_matches_platform(
8213 installer: &Installer,
8214 requested_platform: Option<&str>,
8215 system_platform: Option<&str>,
8216) -> bool {
8217 if installer.platforms.is_empty() {
8218 return true;
8219 }
8220
8221 let Some(effective_platform) = requested_platform.or(system_platform) else {
8222 return true;
8223 };
8224
8225 installer
8226 .platforms
8227 .iter()
8228 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8229}
8230
8231fn installer_matches_os_version(
8232 installer: &Installer,
8233 requested_os_version: Option<&str>,
8234 current_os_version: Option<&str>,
8235) -> bool {
8236 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8237 return true;
8238 };
8239
8240 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8241 return true;
8242 };
8243
8244 match (
8245 parse_dotted_version(minimum_os_version),
8246 parse_dotted_version(actual_os_version),
8247 ) {
8248 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8249 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8250 }
8251}
8252
8253fn installer_matches_architecture(
8254 installer: &Installer,
8255 requested_architecture: Option<&str>,
8256 system_architecture: &str,
8257) -> bool {
8258 let Some(architecture) = installer.architecture.as_deref() else {
8259 return true;
8260 };
8261
8262 let architecture = architecture.to_ascii_lowercase();
8263 if let Some(requested) = requested_architecture {
8264 return architecture.eq_ignore_ascii_case(requested);
8265 }
8266
8267 preferred_architectures(system_architecture)
8268 .iter()
8269 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8270}
8271
8272fn installer_rank(
8273 installer: &Installer,
8274 requested_locale: Option<&str>,
8275 requested_architecture: Option<&str>,
8276 system_architecture: &str,
8277) -> (i32, i32, i32) {
8278 let architecture_rank = architecture_rank(
8279 installer.architecture.as_deref(),
8280 requested_architecture.unwrap_or(system_architecture),
8281 requested_architecture.is_some(),
8282 system_architecture,
8283 );
8284 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8285 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8286 (architecture_rank, locale_rank, command_rank)
8287}
8288
8289fn architecture_rank(
8290 installer_architecture: Option<&str>,
8291 preferred_architecture: &str,
8292 strict: bool,
8293 system_architecture: &str,
8294) -> i32 {
8295 let Some(value) = installer_architecture else {
8296 return 0;
8297 };
8298 if value.eq_ignore_ascii_case(preferred_architecture) {
8299 return 5;
8300 }
8301 if value.eq_ignore_ascii_case("neutral") {
8302 return 4;
8303 }
8304 if strict {
8305 return -1;
8306 }
8307
8308 preferred_architectures(system_architecture)
8309 .iter()
8310 .rev()
8311 .position(|candidate| value.eq_ignore_ascii_case(candidate))
8312 .and_then(|index| i32::try_from(index).ok())
8313 .and_then(|index| index.checked_add(1))
8314 .unwrap_or(-1)
8315}
8316
8317fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8321 if manifest.installers.is_empty() {
8322 return true;
8323 }
8324 let allowed = preferred_architectures(current_architecture());
8325 manifest.installers.iter().any(|installer| {
8326 let arch = installer.architecture.as_deref().unwrap_or("neutral");
8327 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8328 })
8329}
8330
8331fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8332 match system_architecture {
8333 "arm64" => &["arm64", "neutral", "x64", "x86"],
8334 "x64" => &["x64", "neutral", "x86"],
8335 "x86" => &["x86", "neutral"],
8336 _ => &["neutral"],
8337 }
8338}
8339
8340fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8341 match (installer_locale, requested_locale) {
8342 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8343 (Some(installer), Some(requested))
8344 if installer
8345 .split('-')
8346 .next()
8347 .zip(requested.split('-').next())
8348 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8349 {
8350 2
8351 }
8352 (None, Some(_)) => 1,
8353 (Some(_), None) => 1,
8354 (None, None) => 0,
8355 _ => -1,
8356 }
8357}
8358
8359fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8360 match requested {
8361 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8362 None => true,
8363 }
8364}
8365
8366#[cfg(windows)]
8367fn current_architecture() -> &'static str {
8368 use std::sync::OnceLock;
8369 static CELL: OnceLock<&'static str> = OnceLock::new();
8370 CELL.get_or_init(|| {
8371 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8376 let mut process_machine: u16 = 0;
8377 let mut native_machine: u16 = 0;
8378 let handle = unsafe { GetCurrentProcess() };
8381 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8383 if ok != 0 {
8384 match native_machine {
8385 0xAA64 => return "arm64",
8386 0x8664 => return "x64",
8387 0x014C => return "x86",
8388 _ => {}
8389 }
8390 }
8391 compiled_architecture()
8392 })
8393}
8394
8395#[cfg(not(windows))]
8396fn current_architecture() -> &'static str {
8397 compiled_architecture()
8398}
8399
8400fn compiled_architecture() -> &'static str {
8401 match std::env::consts::ARCH {
8402 "x86_64" => "x64",
8403 "x86" => "x86",
8404 "aarch64" => "arm64",
8405 _ => "neutral",
8406 }
8407}
8408
8409fn current_platform() -> Option<&'static str> {
8410 if cfg!(windows) { Some("Windows.Desktop") } else { None }
8411}
8412
8413#[cfg(windows)]
8414fn current_os_version() -> Option<String> {
8415 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8416 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8417 let build: String = key.get_value("CurrentBuildNumber").ok()?;
8418 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8419 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8420 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8421 Some(format!("{major}.{minor}.{build}.{ubr}"))
8422}
8423
8424#[cfg(not(windows))]
8425fn current_os_version() -> Option<String> {
8426 None
8427}
8428
8429fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8430 let mut parsed = Vec::new();
8431 for component in version.split('.') {
8432 if component.is_empty() {
8433 continue;
8434 }
8435 parsed.push(component.parse().ok()?);
8436 }
8437
8438 if parsed.is_empty() { None } else { Some(parsed) }
8439}
8440
8441fn select_v1_version(
8442 versions: &[V1VersionRow],
8443 requested_version: Option<&str>,
8444 requested_channel: Option<&str>,
8445 package_id: &str,
8446 source_name: &str,
8447) -> Result<V1VersionRow> {
8448 let mut ordered = versions.to_vec();
8449 ordered.sort_by(|left, right| {
8450 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8451 });
8452
8453 if let Some(version) = requested_version {
8454 let channel = requested_channel.unwrap_or_default();
8455 return ordered
8456 .iter()
8457 .find(|candidate| {
8458 candidate.version.eq_ignore_ascii_case(version)
8459 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8460 })
8461 .cloned()
8462 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8463 }
8464
8465 if let Some(channel) = requested_channel {
8466 return ordered
8467 .iter()
8468 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8469 .cloned()
8470 .ok_or_else(|| {
8471 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8472 });
8473 }
8474
8475 ordered
8476 .into_iter()
8477 .next()
8478 .ok_or_else(|| anyhow!("package had no versions"))
8479}
8480
8481fn select_v2_version(
8482 versions: &[PackageVersionDataEntry],
8483 requested_version: Option<&str>,
8484 requested_channel: Option<&str>,
8485 package_id: &str,
8486 source_name: &str,
8487) -> Result<PackageVersionDataEntry> {
8488 let mut ordered = versions.to_vec();
8489 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8490
8491 if let Some(channel) = requested_channel {
8492 bail!(
8493 "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."
8494 );
8495 }
8496
8497 if let Some(version) = requested_version {
8498 return ordered
8499 .iter()
8500 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8501 .cloned()
8502 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8503 }
8504
8505 ordered
8506 .into_iter()
8507 .next()
8508 .ok_or_else(|| anyhow!("package had no V2 versions"))
8509}
8510
8511fn missing_package_version(
8512 package_id: &str,
8513 source_name: &str,
8514 requested_version: &str,
8515 requested_channel: Option<&str>,
8516) -> anyhow::Error {
8517 anyhow!(MissingPackageVersionError {
8518 package_id: package_id.to_owned(),
8519 source_name: source_name.to_owned(),
8520 requested_version: requested_version.to_owned(),
8521 requested_channel: requested_channel.map(str::to_owned),
8522 })
8523}
8524
8525fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8526 if let Some(refresh_error) = refresh.error.as_deref() {
8527 let missing_error = error.to_string();
8528 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8529 }
8530
8531 Err(error)
8532}
8533
8534fn select_rest_version<'a>(
8535 versions: &'a [VersionKey],
8536 requested_version: Option<&str>,
8537 requested_channel: Option<&str>,
8538 package_id: &str,
8539 source_name: &str,
8540) -> Result<&'a VersionKey> {
8541 if let Some(version) = requested_version {
8542 let channel = requested_channel.unwrap_or_default();
8543 return versions
8544 .iter()
8545 .find(|candidate| {
8546 candidate.version.eq_ignore_ascii_case(version)
8547 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8548 })
8549 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8550 }
8551
8552 if let Some(channel) = requested_channel {
8553 return versions
8554 .iter()
8555 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8556 .ok_or_else(|| {
8557 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8558 });
8559 }
8560
8561 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8562}
8563
8564fn sort_versions_desc(versions: &mut [VersionKey]) {
8565 versions.sort_by(|left, right| {
8566 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8567 });
8568}
8569
8570fn compare_version_and_channel(
8571 left_version: &str,
8572 left_channel: &str,
8573 right_version: &str,
8574 right_channel: &str,
8575) -> Ordering {
8576 compare_version(left_version, right_version).then_with(|| {
8577 left_channel
8578 .to_ascii_lowercase()
8579 .cmp(&right_channel.to_ascii_lowercase())
8580 })
8581}
8582
8583fn compare_version(left: &str, right: &str) -> Ordering {
8584 let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8585 let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8586 match (left_less_than, right_less_than) {
8587 (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8588 (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8589 (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8590 _ => {}
8591 }
8592
8593 let left_parts = tokenize_version(left);
8594 let right_parts = tokenize_version(right);
8595 let max_len = left_parts.len().max(right_parts.len());
8596
8597 for index in 0..max_len {
8598 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8599 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8600 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8601
8602 let ordering = if let Some((left_number, right_number)) = numeric {
8603 left_number.cmp(&right_number)
8604 } else {
8605 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8606 };
8607
8608 if ordering != Ordering::Equal {
8609 return ordering;
8610 }
8611 }
8612
8613 Ordering::Equal
8614}
8615
8616fn tokenize_version(value: &str) -> Vec<String> {
8617 let mut parts = Vec::new();
8618 let mut current = String::new();
8619 let mut current_is_digit = None;
8620
8621 for ch in value.chars() {
8622 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8623 if !current.is_empty() {
8624 parts.push(std::mem::take(&mut current));
8625 }
8626 current_is_digit = None;
8627 continue;
8628 }
8629
8630 let is_digit = ch.is_ascii_digit();
8631 match current_is_digit {
8632 Some(previous) if previous != is_digit => {
8633 parts.push(std::mem::take(&mut current));
8634 current.push(ch);
8635 current_is_digit = Some(is_digit);
8636 }
8637 Some(_) => current.push(ch),
8638 None => {
8639 current.push(ch);
8640 current_is_digit = Some(is_digit);
8641 }
8642 }
8643 }
8644
8645 if !current.is_empty() {
8646 parts.push(current);
8647 }
8648
8649 parts
8650}
8651
8652fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8653 if !hash_matches(Some(expected_hash), bytes) {
8654 bail!("downloaded file hash did not match expected SHA256");
8655 }
8656 Ok(())
8657}
8658
8659#[cfg(test)]
8660fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8661 let Some(expected_hash) = expected_hash else {
8662 return Ok(());
8663 };
8664 if ignore_security_hash {
8665 return Ok(());
8666 }
8667
8668 if !hash_matches(Some(expected_hash), bytes) {
8669 let actual = sha256_hex(bytes);
8670 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8671 }
8672 Ok(())
8673}
8674
8675fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8676 let Some(expected_hash) = expected_hash else {
8677 return Ok(());
8678 };
8679 if ignore_security_hash {
8680 return Ok(());
8681 }
8682
8683 if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8684 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8685 }
8686 Ok(())
8687}
8688
8689const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8690const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8691const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8692const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8693const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8694
8695#[derive(Debug, Clone, Serialize, Deserialize)]
8696struct InstallerDownloadMetadata {
8697 url: String,
8698 etag: Option<String>,
8699 last_modified: Option<String>,
8700 total_length: Option<u64>,
8701 offset: u64,
8702}
8703
8704#[derive(Debug)]
8705struct DownloadAttempt {
8706 total_length: Option<u64>,
8707}
8708
8709fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8710 let runtime = tokio::runtime::Builder::new_current_thread()
8711 .enable_all()
8712 .build()
8713 .context("failed to create installer download runtime")?;
8714 runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8715}
8716
8717async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8718 let part_path = sidecar_path(dest, "part");
8719 let metadata_path = sidecar_path(dest, "part.json");
8720 let started_at = Instant::now();
8721 let mut last_error = None;
8722
8723 for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8724 if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8725 break;
8726 }
8727
8728 let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8729 .checked_sub(started_at.elapsed())
8730 .unwrap_or_else(|| StdDuration::from_secs(1));
8731 match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8732 Ok(()) => {
8733 let actual_hash = sha256_file_hex(&part_path)?;
8734 replace_file(&part_path, dest)?;
8735 let _ = fs::remove_file(&metadata_path);
8736 return Ok(actual_hash);
8737 }
8738 Err(error) => {
8739 last_error = Some(error);
8740 }
8741 }
8742 }
8743
8744 Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8745}
8746
8747async fn try_download_installer_to_part(
8748 user_agent: &str,
8749 url: &str,
8750 part_path: &Path,
8751 metadata_path: &Path,
8752 remaining: StdDuration,
8753) -> Result<()> {
8754 let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8755 let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8756 let mut request = reqwest::Client::builder()
8757 .user_agent(user_agent)
8758 .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8759 .timeout(remaining)
8760 .build()
8761 .context("failed to build installer download client")?
8762 .get(url)
8763 .header(ACCEPT_ENCODING, "identity");
8764
8765 if offset > 0 {
8766 request = request.header(RANGE, format!("bytes={offset}-"));
8767 if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8768 request = request.header(IF_RANGE, if_range);
8769 }
8770 }
8771
8772 let mut response = request.send().await.context("failed to download installer")?;
8773 let headers = response.headers().clone();
8774 let status = response.status();
8775 let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
8776 parse_content_range(headers.get(CONTENT_RANGE), offset)?
8777 } else if status == StatusCode::OK {
8778 offset = 0;
8779 DownloadAttempt {
8780 total_length: content_length(&headers),
8781 }
8782 } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
8783 let _ = fs::remove_file(part_path);
8784 let _ = fs::remove_file(metadata_path);
8785 bail!("installer partial download could not be resumed; retrying from the beginning");
8786 } else if status.is_success() {
8787 bail!("unexpected installer download HTTP status {status}");
8788 } else {
8789 bail!("Download failed: HTTP {status}");
8790 };
8791
8792 if offset == 0 {
8793 let _ = fs::remove_file(part_path);
8794 }
8795
8796 let mut file = if offset == 0 {
8797 fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
8798 } else {
8799 OpenOptions::new()
8800 .append(true)
8801 .open(part_path)
8802 .with_context(|| format!("failed to open {}", part_path.display()))?
8803 };
8804
8805 let mut metadata = InstallerDownloadMetadata {
8806 url: url.to_owned(),
8807 etag: strong_etag(&headers),
8808 last_modified: header_string(&headers, LAST_MODIFIED),
8809 total_length: attempt.total_length,
8810 offset,
8811 };
8812 write_installer_download_metadata(metadata_path, &metadata)?;
8813
8814 let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8815 while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
8816 .await
8817 .context("installer download stalled while waiting for data")?
8818 .context("failed to read installer response body")?
8819 {
8820 file.write_all(&chunk)
8821 .with_context(|| format!("failed to write {}", part_path.display()))?;
8822 offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
8823
8824 if offset >= next_metadata_offset {
8825 file.flush()
8826 .with_context(|| format!("failed to flush {}", part_path.display()))?;
8827 metadata.offset = offset;
8828 write_installer_download_metadata(metadata_path, &metadata)?;
8829 next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
8830 }
8831 }
8832
8833 file.flush()
8834 .with_context(|| format!("failed to flush {}", part_path.display()))?;
8835 file.sync_all()
8836 .with_context(|| format!("failed to sync {}", part_path.display()))?;
8837
8838 if let Some(total_length) = attempt.total_length
8839 && offset != total_length
8840 {
8841 bail!("installer download ended early: received {offset} of {total_length} bytes");
8842 }
8843
8844 metadata.offset = offset;
8845 metadata.total_length = attempt.total_length;
8846 write_installer_download_metadata(metadata_path, &metadata)?;
8847 Ok(())
8848}
8849
8850fn read_installer_download_metadata(
8851 metadata_path: &Path,
8852 part_path: &Path,
8853 url: &str,
8854) -> Result<Option<InstallerDownloadMetadata>> {
8855 let Some(mut metadata) = fs::read(metadata_path)
8856 .ok()
8857 .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
8858 else {
8859 let _ = fs::remove_file(part_path);
8860 let _ = fs::remove_file(metadata_path);
8861 return Ok(None);
8862 };
8863
8864 if metadata.url != url || metadata.offset == 0 {
8865 let _ = fs::remove_file(part_path);
8866 let _ = fs::remove_file(metadata_path);
8867 return Ok(None);
8868 }
8869
8870 let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
8871 metadata.offset = metadata.offset.min(part_len);
8872 if metadata.offset == 0 {
8873 let _ = fs::remove_file(part_path);
8874 let _ = fs::remove_file(metadata_path);
8875 return Ok(None);
8876 }
8877
8878 OpenOptions::new()
8879 .write(true)
8880 .open(part_path)
8881 .with_context(|| format!("failed to open {}", part_path.display()))?
8882 .set_len(metadata.offset)
8883 .with_context(|| format!("failed to truncate {}", part_path.display()))?;
8884
8885 Ok(Some(metadata))
8886}
8887
8888fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
8889 let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
8890 fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
8891}
8892
8893fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
8894 metadata
8895 .etag
8896 .as_ref()
8897 .filter(|etag| !etag.starts_with("W/"))
8898 .cloned()
8899 .or_else(|| metadata.last_modified.clone())
8900}
8901
8902fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
8903 let value = value
8904 .and_then(|value| value.to_str().ok())
8905 .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
8906 let range = value
8907 .strip_prefix("bytes ")
8908 .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
8909 let (span, total) = range
8910 .split_once('/')
8911 .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
8912 let (start, _) = span
8913 .split_once('-')
8914 .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
8915 let start = start
8916 .parse::<u64>()
8917 .with_context(|| format!("invalid Content-Range start: {value}"))?;
8918 if start != expected_start {
8919 bail!("partial installer response started at {start}, expected {expected_start}");
8920 }
8921 let total_length = if total == "*" {
8922 None
8923 } else {
8924 Some(
8925 total
8926 .parse::<u64>()
8927 .with_context(|| format!("invalid Content-Range total: {value}"))?,
8928 )
8929 };
8930 Ok(DownloadAttempt { total_length })
8931}
8932
8933fn content_length(headers: &HeaderMap) -> Option<u64> {
8934 headers
8935 .get(CONTENT_LENGTH)
8936 .and_then(|value| value.to_str().ok())
8937 .and_then(|value| value.parse::<u64>().ok())
8938}
8939
8940fn strong_etag(headers: &HeaderMap) -> Option<String> {
8941 header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
8942}
8943
8944fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
8945 headers
8946 .get(name)
8947 .and_then(|value| value.to_str().ok())
8948 .map(str::to_owned)
8949}
8950
8951fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
8952 let mut value = path.as_os_str().to_os_string();
8953 value.push(".");
8954 value.push(suffix);
8955 PathBuf::from(value)
8956}
8957
8958fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
8959 expected_hash
8960 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
8961 .unwrap_or(true)
8962}
8963
8964fn sha256_hex(bytes: &[u8]) -> String {
8965 let digest = Sha256::digest(bytes);
8966 sha256_digest_hex(digest)
8967}
8968
8969fn sha256_file_hex(path: &Path) -> Result<String> {
8970 let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
8971 let mut hasher = Sha256::new();
8972 let mut buffer = [0u8; 128 * 1024];
8973 loop {
8974 let read = file
8975 .read(&mut buffer)
8976 .with_context(|| format!("failed to read {}", path.display()))?;
8977 if read == 0 {
8978 break;
8979 }
8980 hasher.update(&buffer[..read]);
8981 }
8982 Ok(sha256_digest_hex(hasher.finalize()))
8983}
8984
8985fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
8986 let digest = digest.as_ref();
8987 let mut output = String::with_capacity(digest.len() * 2);
8988 for byte in digest {
8989 output.push_str(&format!("{byte:02X}"));
8990 }
8991 output
8992}
8993
8994fn pins_db_path(app_root: &Path) -> PathBuf {
8995 if uses_packaged_layout(app_root) {
8996 app_root.join("pinning.db")
8997 } else {
8998 app_root.join("pins.db")
8999 }
9000}
9001
9002fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9003 let mut has_current_column = false;
9004 let mut has_legacy_column = false;
9005
9006 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9007 match column_name.as_str() {
9008 "type" => has_current_column = true,
9009 "pin_type" => has_legacy_column = true,
9010 _ => {}
9011 }
9012 }
9013
9014 Ok(if has_current_column {
9015 Some("type")
9016 } else if has_legacy_column {
9017 Some("pin_type")
9018 } else {
9019 None
9020 })
9021}
9022
9023fn decode_pin_type(pin_type_int: i64) -> PinType {
9024 match pin_type_int {
9025 4 => PinType::Blocking,
9026 3 => PinType::Gating,
9027 _ => PinType::Pinning,
9028 }
9029}
9030
9031#[cfg(windows)]
9032fn dispatch_installer(
9033 installer_path: &Path,
9034 installer_type: &str,
9035 request: &InstallRequest,
9036 manifest: &Manifest,
9037 installer: &Installer,
9038) -> Result<i32> {
9039 match installer_type {
9040 "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9041 installer_path,
9042 &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9043 ),
9044 "msi" | "wix" => shell_execute_installer(
9045 Path::new("msiexec.exe"),
9046 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9047 should_elevate_msi_shell_execute(installer),
9048 ),
9049 "msix" | "appx" => {
9050 use std::process::Command;
9051
9052 let mut cmd = Command::new("powershell");
9053 cmd.arg("-NoProfile")
9054 .arg("-Command")
9055 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9056 let status = cmd.status().context("failed to run Add-AppxPackage")?;
9057 Ok(status.code().unwrap_or(-1))
9058 }
9059 "zip" => {
9060 if installer
9066 .nested_installer_type
9067 .as_deref()
9068 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9069 {
9070 return install_portable(installer_path, request, manifest, installer);
9071 }
9072 let target = dirs::data_local_dir()
9073 .unwrap_or_else(|| PathBuf::from("."))
9074 .join("Programs");
9075 fs::create_dir_all(&target)?;
9076 let file = fs::File::open(installer_path)?;
9077 let mut archive = ZipArchive::new(file)?;
9078 archive.extract(&target)?;
9079 Ok(0)
9080 }
9081 "portable" => install_portable(installer_path, request, manifest, installer),
9082 _ => shell_execute_installer(
9085 installer_path,
9086 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9087 false,
9088 ),
9089 }
9090}
9091
9092#[cfg(windows)]
9093fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9094 request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9095}
9096
9097#[cfg(windows)]
9098fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9099 installer
9100 .scope
9101 .as_deref()
9102 .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9103}
9104
9105#[cfg(windows)]
9106fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9107 use std::ffi::OsStr;
9108
9109 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9110 use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9111 use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9112 use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9113
9114 let file = wide_null(installer_path.as_os_str());
9115 let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9116 let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9117
9118 let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9121 exec_info.cbSize =
9122 u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9123 exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9124 exec_info.lpFile = file.as_ptr();
9125 exec_info.lpParameters = parameters.as_ptr();
9126 if let Some(verb) = verb.as_ref() {
9127 exec_info.lpVerb = verb.as_ptr();
9128 }
9129 exec_info.nShow = SW_SHOW;
9130
9131 if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9134 bail!(
9135 "failed to launch installer through ShellExecute (Win32 error {})",
9136 unsafe { GetLastError() }
9138 );
9139 }
9140
9141 if exec_info.hProcess.is_null() {
9142 bail!("ShellExecute did not return an installer process handle");
9143 }
9144
9145 let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9148 if wait_result != WAIT_OBJECT_0 {
9149 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9151 bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9152 }
9153
9154 let mut exit_code = 0u32;
9155 let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9157 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9159 if got_exit_code == 0 {
9160 let error = unsafe { GetLastError() };
9162 bail!("failed to retrieve installer exit code (Win32 error {error})");
9163 }
9164
9165 Ok(exit_code.cast_signed())
9166}
9167
9168#[cfg(windows)]
9169fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9170 value.encode_wide().chain(std::iter::once(0)).collect()
9171}
9172
9173#[cfg(windows)]
9174fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9175 use std::ffi::OsStr;
9176 use std::ptr::{null, null_mut};
9177
9178 use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9179 INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9180 };
9181
9182 let parsed = parse_msi_direct_arguments(args)?;
9183 let package_path = wide_null(installer_path.as_os_str());
9184 let properties = wide_null(OsStr::new(&parsed.properties));
9185 let property_ptr = if parsed.properties.is_empty() {
9186 null()
9187 } else {
9188 properties.as_ptr()
9189 };
9190 let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9191
9192 if let Some(path) = log_path.as_ref() {
9193 let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9196 if result != 0 {
9197 bail!("failed to enable MSI logging (Win32 error {result})");
9198 }
9199 } else {
9200 let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9202 }
9203
9204 let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9205 (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9206 } else {
9207 parsed.ui_level
9208 };
9209 unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9212 let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9215 Ok(result.cast_signed())
9216}
9217
9218#[cfg(any(windows, test))]
9219#[derive(Debug, PartialEq, Eq)]
9220struct MsiDirectArguments {
9221 ui_level: u32,
9222 properties: String,
9223 log_mode: u32,
9224 log_path: Option<String>,
9225 log_attributes: u32,
9226}
9227
9228#[cfg(any(windows, test))]
9229fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9230 const INSTALLUILEVEL_DEFAULT: u32 = 1;
9231 const INSTALLUILEVEL_NONE: u32 = 2;
9232 const INSTALLUILEVEL_BASIC: u32 = 3;
9233 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9234 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9235 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9236 const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9237 const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9238 const LOGMODE_VERBOSE: u32 = 4096;
9239 const LOGMODE_EXTRADEBUG: u32 = 8192;
9240 const LOGATTR_APPEND: u32 = 1;
9241 const LOGATTR_FLUSH: u32 = 2;
9242
9243 let mut parsed = MsiDirectArguments {
9244 ui_level: INSTALLUILEVEL_DEFAULT,
9245 properties: String::new(),
9246 log_mode: 0,
9247 log_path: None,
9248 log_attributes: 0,
9249 };
9250
9251 let mut index = 0;
9252 while index < args.len() {
9253 let token = &args[index];
9254 if is_msi_switch(token) {
9255 let option = &token[1..];
9256 if option.eq_ignore_ascii_case("quiet") {
9257 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9258 } else if option.eq_ignore_ascii_case("passive") {
9259 parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9260 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9261 } else if option.eq_ignore_ascii_case("norestart") {
9262 append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9263 } else if option.eq_ignore_ascii_case("forcerestart") {
9264 append_msi_property(&mut parsed.properties, "REBOOT=Force");
9265 } else if option.eq_ignore_ascii_case("promptrestart") {
9266 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9267 } else if option.eq_ignore_ascii_case("log") {
9268 index += 1;
9269 let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9270 parsed.log_mode = LOGMODE_ALL;
9271 parsed.log_path = Some(path.clone());
9272 } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9273 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9274 } else if option.starts_with(['q', 'Q']) {
9275 parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9276 } else if option.starts_with(['l', 'L']) {
9277 index += 1;
9278 let path = args
9279 .get(index)
9280 .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9281 let (mode, attributes) = parse_msi_log_mode(
9282 &option[1..],
9283 LOGMODE_DEFAULT,
9284 LOGMODE_ALL,
9285 LOGMODE_VERBOSE,
9286 LOGMODE_EXTRADEBUG,
9287 LOGATTR_APPEND,
9288 LOGATTR_FLUSH,
9289 )?;
9290 parsed.log_mode = mode;
9291 parsed.log_attributes = attributes;
9292 parsed.log_path = Some(path.clone());
9293 } else {
9294 bail!("unsupported MSI switch for direct MSI execution: {token}");
9295 }
9296 } else {
9297 append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9298 }
9299 index += 1;
9300 }
9301
9302 Ok(parsed)
9303}
9304
9305#[cfg(any(windows, test))]
9306fn is_msi_switch(token: &str) -> bool {
9307 token.starts_with('/') || token.starts_with('-')
9308}
9309
9310#[cfg(any(windows, test))]
9311fn append_msi_property(properties: &mut String, property: &str) {
9312 if !properties.is_empty() {
9313 properties.push(' ');
9314 }
9315 properties.push_str(property);
9316}
9317
9318#[cfg(any(windows, test))]
9319fn format_msi_property(token: &str) -> Result<String> {
9320 let Some((name, value)) = token.split_once('=') else {
9321 bail!("invalid MSI property argument: {token}");
9322 };
9323 if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9324 bail!("invalid MSI property name: {name}");
9325 }
9326 if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9327 Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9328 } else {
9329 Ok(token.to_owned())
9330 }
9331}
9332
9333#[cfg(any(windows, test))]
9334fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9335 const INSTALLUILEVEL_NONE: u32 = 2;
9336 const INSTALLUILEVEL_BASIC: u32 = 3;
9337 const INSTALLUILEVEL_REDUCED: u32 = 4;
9338 const INSTALLUILEVEL_FULL: u32 = 5;
9339 const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9340 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9341 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9342 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9343
9344 let modifier = if modifier.is_empty() { "n" } else { modifier };
9345 let mut chars = modifier.chars();
9346 let Some(first) = chars.next() else {
9347 return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9348 };
9349 let mut ui_level = match first.to_ascii_lowercase() {
9350 'f' => INSTALLUILEVEL_FULL,
9351 'r' => INSTALLUILEVEL_REDUCED,
9352 'b' => INSTALLUILEVEL_BASIC,
9353 'n' => INSTALLUILEVEL_NONE,
9354 '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9355 _ => bail!("invalid MSI /q modifier: {modifier}"),
9356 };
9357
9358 for ch in chars {
9359 match ch {
9360 '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9361 '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9362 '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9363 '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9364 _ => bail!("invalid MSI /q modifier: {modifier}"),
9365 }
9366 }
9367
9368 if ui_level & 0xF == INSTALLUILEVEL_NONE {
9369 ui_level |= INSTALLUILEVEL_UACONLY;
9370 }
9371 Ok(ui_level)
9372}
9373
9374#[cfg(any(windows, test))]
9375#[allow(clippy::too_many_arguments)]
9376fn parse_msi_log_mode(
9377 modifier: &str,
9378 default_mode: u32,
9379 all_mode: u32,
9380 verbose: u32,
9381 extra_debug: u32,
9382 append: u32,
9383 flush: u32,
9384) -> Result<(u32, u32)> {
9385 let mut mode = 0;
9386 let mut attributes = 0;
9387 for ch in modifier.chars() {
9388 match ch.to_ascii_lowercase() {
9389 '*' => mode |= all_mode,
9390 'm' => mode |= 1,
9391 'e' => mode |= 2,
9392 'w' => mode |= 4,
9393 'u' => mode |= 8,
9394 'i' => mode |= 16,
9395 'o' => mode |= 128,
9396 'a' => mode |= 256,
9397 'r' => mode |= 512,
9398 'p' => mode |= 1024,
9399 'c' => mode |= 2048,
9400 'v' => mode |= verbose,
9401 'x' => mode |= extra_debug,
9402 '+' => attributes |= append,
9403 '!' => attributes |= flush,
9404 _ => bail!("invalid MSI /l modifier: {modifier}"),
9405 }
9406 }
9407 if mode == 0 {
9408 mode = default_mode;
9409 }
9410 Ok((mode, attributes))
9411}
9412
9413#[cfg(not(windows))]
9414fn dispatch_installer(
9415 _installer_path: &Path,
9416 _installer_type: &str,
9417 _request: &InstallRequest,
9418 _manifest: &Manifest,
9419 _installer: &Installer,
9420) -> Result<i32> {
9421 bail!("Installing packages is only supported on Windows")
9422}
9423
9424#[cfg(test)]
9425fn installer_command_arguments(
9426 installer_type: &str,
9427 request: &InstallRequest,
9428 _manifest: &Manifest,
9429 installer_path: &Path,
9430 installer: &Installer,
9431) -> Vec<String> {
9432 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9433}
9434
9435#[cfg(any(windows, test))]
9436fn installer_direct_msi_arguments(
9437 installer_type: &str,
9438 request: &InstallRequest,
9439 _manifest: &Manifest,
9440 installer_path: &Path,
9441 installer: &Installer,
9442) -> Vec<String> {
9443 installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9444}
9445
9446#[cfg(any(windows, test))]
9447fn installer_shell_execute_arguments(
9448 installer_type: &str,
9449 request: &InstallRequest,
9450 _manifest: &Manifest,
9451 installer_path: &Path,
9452 installer: &Installer,
9453) -> Vec<String> {
9454 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9455}
9456
9457#[cfg(any(windows, test))]
9458fn installer_command_arguments_with_options(
9459 installer_type: &str,
9460 request: &InstallRequest,
9461 installer_path: &Path,
9462 installer: &Installer,
9463 include_msi_install_target: bool,
9464) -> Vec<String> {
9465 if let Some(override_args) = request.override_args.as_deref() {
9466 return split_installer_switches(override_args);
9467 }
9468
9469 let mut args = Vec::new();
9470 match installer_type {
9471 "msi" | "wix" if include_msi_install_target => {
9472 args.push("/i".to_owned());
9473 args.push(installer_path.display().to_string());
9474 }
9475 "msix" | "appx" | "zip" => return Vec::new(),
9476 _ => {}
9477 }
9478
9479 let configured = match request.mode {
9480 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9481 InstallerMode::SilentWithProgress => installer
9482 .switches
9483 .silent_with_progress
9484 .as_deref()
9485 .or(installer.switches.silent.as_deref()),
9486 InstallerMode::Silent => installer
9487 .switches
9488 .silent
9489 .as_deref()
9490 .or(installer.switches.silent_with_progress.as_deref()),
9491 };
9492
9493 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9494 args.extend(split_installer_switches(value));
9495 } else {
9496 args.extend(default_installer_arguments(installer_type, request.mode));
9497 }
9498
9499 append_switches(
9500 &mut args,
9501 resolve_template_switch(
9502 installer.switches.log.as_deref(),
9503 default_log_switch(installer_type),
9504 request.log_path.as_ref().map(|path| path.display().to_string()),
9505 "<LOGPATH>",
9506 ),
9507 );
9508 append_switches(&mut args, installer.switches.custom.clone());
9509 append_switches(&mut args, request.custom.clone());
9510 append_switches(
9511 &mut args,
9512 resolve_template_switch(
9513 installer.switches.install_location.as_deref(),
9514 default_install_location_switch(installer_type),
9515 request.install_location.clone(),
9516 "<INSTALLPATH>",
9517 ),
9518 );
9519 args
9520}
9521
9522#[cfg(any(windows, test))]
9523fn join_windows_arguments(args: &[String]) -> String {
9524 args.iter()
9525 .map(|arg| quote_windows_argument(arg))
9526 .collect::<Vec<_>>()
9527 .join(" ")
9528}
9529
9530#[cfg(any(windows, test))]
9531fn quote_windows_argument(arg: &str) -> String {
9532 if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9533 return arg.to_owned();
9534 }
9535
9536 let mut quoted = String::from("\"");
9537 let mut backslashes = 0usize;
9538 for ch in arg.chars() {
9539 match ch {
9540 '\\' => backslashes += 1,
9541 '"' => {
9542 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9543 quoted.push('"');
9544 backslashes = 0;
9545 }
9546 _ => {
9547 quoted.push_str(&"\\".repeat(backslashes));
9548 backslashes = 0;
9549 quoted.push(ch);
9550 }
9551 }
9552 }
9553 quoted.push_str(&"\\".repeat(backslashes * 2));
9554 quoted.push('"');
9555 quoted
9556}
9557
9558#[cfg(any(windows, test))]
9559fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9560 match mode {
9561 InstallerMode::Interactive => Vec::new(),
9562 InstallerMode::SilentWithProgress => match installer_type {
9563 "inno" => vec![
9564 "/SP-".to_owned(),
9565 "/SILENT".to_owned(),
9566 "/SUPPRESSMSGBOXES".to_owned(),
9567 "/NORESTART".to_owned(),
9568 ],
9569 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9570 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9571 _ => vec!["/SILENT".to_owned()],
9572 },
9573 InstallerMode::Silent => match installer_type {
9574 "inno" => vec![
9575 "/SP-".to_owned(),
9576 "/VERYSILENT".to_owned(),
9577 "/SUPPRESSMSGBOXES".to_owned(),
9578 "/NORESTART".to_owned(),
9579 ],
9580 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9581 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9582 _ => vec!["/S".to_owned()],
9583 },
9584 }
9585}
9586
9587#[cfg(any(windows, test))]
9588fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9589 match installer_type {
9590 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9591 "inno" => Some("/LOG=\"<LOGPATH>\""),
9592 _ => None,
9593 }
9594}
9595
9596#[cfg(any(windows, test))]
9597fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9598 match installer_type {
9599 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9600 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9601 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9602 _ => None,
9603 }
9604}
9605
9606#[cfg(any(windows, test))]
9607fn resolve_template_switch(
9608 manifest_value: Option<&str>,
9609 fallback: Option<&str>,
9610 replacement: Option<String>,
9611 token: &str,
9612) -> Option<String> {
9613 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9614 let replacement = replacement?;
9615 Some(template.replace(token, &replacement))
9616}
9617
9618#[cfg(any(windows, test))]
9619fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9620 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9621 args.extend(split_installer_switches(&value));
9622 }
9623}
9624
9625#[cfg(any(windows, test))]
9626fn split_installer_switches(value: &str) -> Vec<String> {
9627 let mut args = Vec::new();
9628 let mut current = String::new();
9629 let mut in_quotes = false;
9630
9631 for ch in value.chars() {
9632 match ch {
9633 '"' => in_quotes = !in_quotes,
9634 c if c.is_whitespace() && !in_quotes => {
9635 if !current.is_empty() {
9636 args.push(std::mem::take(&mut current));
9637 }
9638 }
9639 _ => current.push(ch),
9640 }
9641 }
9642
9643 if !current.is_empty() {
9644 args.push(current);
9645 }
9646
9647 args
9648}
9649
9650#[cfg(windows)]
9651fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9652 if request.purge && request.preserve {
9653 bail!("--purge and --preserve cannot be used together.");
9654 }
9655
9656 if installed
9657 .installer_category
9658 .as_deref()
9659 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9660 {
9661 return uninstall_portable(installed, request);
9662 }
9663
9664 if (request.purge || request.preserve) && !request.force {
9665 bail!("--purge and --preserve are currently only supported for portable packages.");
9666 }
9667
9668 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9669 return Ok(exit_code);
9670 }
9671
9672 if let Some(exit_code) = try_uninstall_msix(installed)? {
9673 return Ok(exit_code);
9674 }
9675
9676 bail!(
9677 "No uninstall command found for installed package '{}' ({})",
9678 installed.name,
9679 installed.local_id
9680 );
9681}
9682
9683#[cfg(windows)]
9684fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9685 use std::os::windows::process::CommandExt;
9686 use std::process::Command;
9687
9688 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9689 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9690
9691 let arp_paths = [
9692 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9693 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9694 ];
9695
9696 for root in [&hklm, &hkcu] {
9697 for arp_path in &arp_paths {
9698 if let Ok(key) = root.open_subkey(arp_path) {
9699 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9700 if let Ok(subkey) = key.open_subkey(&subkey_name) {
9701 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9702 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9703 if !registry_entry_matches_installed_package(
9704 &subkey_name,
9705 &display_name,
9706 product_code.as_deref(),
9707 installed,
9708 ) {
9709 continue;
9710 }
9711
9712 if let Some(exit_code) =
9713 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9714 {
9715 return Ok(Some(exit_code));
9716 }
9717
9718 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9719 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9720 subkey.get_value("UninstallString").ok()
9721 } else {
9722 quiet_uninstall_cmd
9723 .clone()
9724 .or_else(|| subkey.get_value("UninstallString").ok())
9725 }
9726 .context("No uninstall command found in registry")?;
9727
9728 if is_winget_uninstall_command(&uninstall_cmd)
9729 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9730 {
9731 return Ok(Some(exit_code));
9732 }
9733
9734 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9735 let uninstall_line = build_uninstall_command_with_mode(
9736 &uninstall_cmd,
9737 request.mode,
9738 quiet_uninstall_cmd.is_some(),
9739 log_path.as_deref(),
9740 );
9741 let mut cmd = Command::new("cmd");
9750 cmd.raw_arg("/S /C");
9751 cmd.raw_arg(format!("\"{uninstall_line}\""));
9752 let status = cmd.status().context("failed to run uninstaller")?;
9753 return Ok(Some(status.code().unwrap_or(-1)));
9754 }
9755 }
9756 }
9757 }
9758 }
9759
9760 Ok(None)
9761}
9762
9763#[cfg(windows)]
9764fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9765 use std::process::Command;
9766
9767 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9768 return Ok(None);
9769 }
9770
9771 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9772 let mut cmd = Command::new("powershell");
9773 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9774 package_full_name,
9775 &installed.package_family_names,
9776 ));
9777 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
9778 Ok(Some(status.code().unwrap_or(-1)))
9779}
9780
9781#[cfg(windows)]
9782fn arp_subkey_name(local_id: &str) -> Option<&str> {
9783 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
9784}
9785
9786#[cfg(windows)]
9787fn registry_entry_matches_installed_package(
9788 subkey_name: &str,
9789 display_name: &str,
9790 product_code: Option<&str>,
9791 installed: &ListMatch,
9792) -> bool {
9793 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
9794 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
9795 {
9796 return true;
9797 }
9798
9799 if installed.product_codes.iter().any(|code| {
9800 code.eq_ignore_ascii_case(subkey_name)
9801 || product_code
9802 .map(|value| code.eq_ignore_ascii_case(value))
9803 .unwrap_or(false)
9804 }) {
9805 return true;
9806 }
9807
9808 display_name.eq_ignore_ascii_case(&installed.name)
9809}
9810
9811#[cfg(all(windows, test))]
9812fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
9813 build_uninstall_command_with_mode(
9814 uninstall_cmd,
9815 if silent {
9816 InstallerMode::Silent
9817 } else {
9818 InstallerMode::Interactive
9819 },
9820 has_quiet_command,
9821 None,
9822 )
9823}
9824
9825#[cfg(windows)]
9826fn build_uninstall_command_with_mode(
9827 uninstall_cmd: &str,
9828 mode: InstallerMode,
9829 has_quiet_command: bool,
9830 log_path: Option<&str>,
9831) -> String {
9832 let uninstall_cmd = log_path
9833 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
9834 .unwrap_or_else(|| uninstall_cmd.to_owned());
9835 if mode == InstallerMode::Interactive || has_quiet_command {
9836 return uninstall_cmd;
9837 }
9838
9839 let lower = uninstall_cmd.to_ascii_lowercase();
9840 if is_winget_uninstall_command_lower(&lower) {
9841 return uninstall_cmd;
9842 }
9843 if lower.contains("/quiet")
9844 || lower.contains("/passive")
9845 || lower.contains("/verysilent")
9846 || lower.contains("/silent")
9847 || lower.contains(" /s")
9848 {
9849 return uninstall_cmd;
9850 }
9851
9852 format!("{uninstall_cmd} /S")
9853}
9854
9855#[cfg(windows)]
9856fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9857 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
9858 return Ok(None);
9859 };
9860
9861 let exit_code = run_winget_uninstall(&args)?;
9862 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
9863 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
9864 return Ok(Some(exit_code));
9865 };
9866
9867 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
9868 }
9869
9870 Ok(Some(exit_code))
9871}
9872
9873#[cfg(windows)]
9874fn run_winget_uninstall(args: &[String]) -> Result<i32> {
9875 use std::process::Command;
9876
9877 let status = Command::new("winget")
9878 .args(args)
9879 .status()
9880 .context("failed to run delegated winget uninstall")?;
9881 Ok(status.code().unwrap_or(-1))
9882}
9883
9884#[cfg(windows)]
9885fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
9886 build_winget_uninstall_arguments_with_scope(installed, request, true)
9887}
9888
9889#[cfg(windows)]
9890fn build_winget_uninstall_arguments_with_scope(
9891 installed: &ListMatch,
9892 request: &UninstallRequest,
9893 include_scope: bool,
9894) -> Option<Vec<String>> {
9895 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
9896 return None;
9897 }
9898
9899 let mut args = vec![
9900 "uninstall".to_owned(),
9901 "--id".to_owned(),
9902 installed.id.clone(),
9903 "--exact".to_owned(),
9904 "--disable-interactivity".to_owned(),
9905 ];
9906
9907 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
9908 args.push("--source".to_owned());
9909 args.push(source_name.to_owned());
9910 }
9911
9912 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
9913 args.push("--scope".to_owned());
9914 args.push(scope.to_owned());
9915 }
9916
9917 if request.mode == InstallerMode::Silent {
9918 args.push("--silent".to_owned());
9919 }
9920
9921 Some(args)
9922}
9923
9924#[cfg(windows)]
9925fn is_winget_uninstall_command(command: &str) -> bool {
9926 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
9927}
9928
9929#[cfg(windows)]
9930fn is_winget_uninstall_command_lower(command: &str) -> bool {
9931 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
9932}
9933
9934#[cfg(windows)]
9935fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
9936 let full_name_literal = package_full_name
9937 .map(|value| format!("'{}'", value.replace('\'', "''")))
9938 .unwrap_or_else(|| "$null".to_owned());
9939 let family_names_literal = if package_family_names.is_empty() {
9940 "@()".to_owned()
9941 } else {
9942 format!(
9943 "@({})",
9944 package_family_names
9945 .iter()
9946 .map(|value| format!("'{}'", value.replace('\'', "''")))
9947 .collect::<Vec<_>>()
9948 .join(", ")
9949 )
9950 };
9951
9952 format!(
9953 "$fullName = {full_name_literal}; \
9954 $familyNames = {family_names_literal}; \
9955 $targets = Get-AppxPackage | Where-Object {{ \
9956 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
9957 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
9958 if (-not $targets) {{ exit 1 }}; \
9959 $targets | Remove-AppxPackage"
9960 )
9961}
9962
9963#[cfg(windows)]
9964fn try_run_msi_uninstall(
9965 installed: &ListMatch,
9966 subkey_name: &str,
9967 product_code: Option<&str>,
9968 request: &UninstallRequest,
9969) -> Result<Option<i32>> {
9970 use std::process::Command;
9971
9972 let uninstall_code = installed
9973 .product_codes
9974 .iter()
9975 .map(String::as_str)
9976 .chain(product_code)
9977 .chain(Some(subkey_name))
9978 .find(|value| is_product_code_like(value));
9979
9980 let Some(uninstall_code) = uninstall_code else {
9981 return Ok(None);
9982 };
9983
9984 let mut cmd = Command::new("msiexec");
9985 cmd.arg("/x").arg(uninstall_code);
9986 match request.mode {
9987 InstallerMode::Interactive => {}
9988 InstallerMode::SilentWithProgress => {
9989 cmd.arg("/passive").arg("/norestart");
9990 }
9991 InstallerMode::Silent => {
9992 cmd.arg("/quiet").arg("/norestart");
9993 }
9994 }
9995 if let Some(log_path) = &request.log_path {
9996 cmd.arg("/log").arg(log_path);
9997 }
9998
9999 let status = cmd.status().context("failed to run msiexec uninstall")?;
10000 Ok(Some(status.code().unwrap_or(-1)))
10001}
10002
10003#[cfg(windows)]
10004fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10005 let known_symlink = read_portable_symlink_full_path(installed);
10011
10012 let Some(location) = installed.install_location.as_deref() else {
10013 if request.force {
10014 try_remove_portable_symlinks(installed, &known_symlink, None);
10015 try_remove_portable_registry_entry(installed);
10016 return Ok(0);
10017 }
10018 bail!(
10019 "Portable package '{}' does not expose an install location.",
10020 installed.name
10021 );
10022 };
10023
10024 if request.preserve {
10025 return Ok(0);
10026 }
10027
10028 let path = Path::new(location);
10029 let removed = if path.is_dir() {
10030 fs::remove_dir_all(path)?;
10031 true
10032 } else if path.is_file() {
10033 fs::remove_file(path)?;
10034 true
10035 } else {
10036 false
10037 };
10038
10039 if removed || request.force {
10040 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10043 try_remove_portable_registry_entry(installed);
10044 return Ok(0);
10045 }
10046
10047 bail!("Portable package location not found: {location}")
10048}
10049
10050#[cfg(windows)]
10056fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10057 const ARP_PREFIX: &str = r"ARP\";
10058 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10059 let mut parts = rest.splitn(3, '\\');
10060 let scope = parts.next().unwrap_or("");
10061 let _arch = parts.next();
10062 let subkey_name = parts.next().unwrap_or("");
10063 if subkey_name.is_empty() {
10064 return None;
10065 }
10066 let hive = if scope.eq_ignore_ascii_case("User") {
10067 RegKey::predef(HKEY_CURRENT_USER)
10068 } else {
10069 RegKey::predef(HKEY_LOCAL_MACHINE)
10070 };
10071 let subkey = hive
10072 .open_subkey(format!(
10073 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10074 ))
10075 .ok()?;
10076 return read_reg_string(&subkey, "PortableSymlinkFullPath");
10077 }
10078 None
10079}
10080
10081#[cfg(windows)]
10087fn try_remove_portable_symlinks(
10088 _installed: &ListMatch,
10089 known_symlink: &Option<String>,
10090 install_location: Option<&Path>,
10091) {
10092 if let Some(path) = known_symlink.as_deref() {
10093 let _ = fs::remove_file(path);
10094 }
10095
10096 let Some(install_dir) = install_location else { return };
10101
10102 let Some(local) = dirs::data_local_dir() else { return };
10103 let links_root = local.join("Microsoft").join("WinGet").join("Links");
10104 let Ok(entries) = fs::read_dir(&links_root) else { return };
10105 for entry in entries.flatten() {
10106 let path = entry.path();
10107 let Ok(target) = fs::read_link(&path) else { continue };
10108 if path_starts_with_case_insensitive(&target, install_dir) {
10109 let _ = fs::remove_file(&path);
10110 }
10111 }
10112}
10113
10114#[cfg(windows)]
10121fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10122 let lower_components = |p: &Path| -> Vec<String> {
10123 p.components()
10124 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10125 .collect()
10126 };
10127 let parent_components = lower_components(parent);
10128 let child_components = lower_components(child);
10129 if parent_components.is_empty() || parent_components.len() > child_components.len() {
10130 return false;
10131 }
10132 parent_components
10133 .iter()
10134 .zip(child_components.iter())
10135 .all(|(a, b)| a == b)
10136}
10137
10138#[cfg(windows)]
10145fn try_remove_portable_registry_entry(installed: &ListMatch) {
10146 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10149 let mut parts = rest.splitn(3, '\\');
10151 let scope = parts.next().unwrap_or("");
10152 let _arch = parts.next();
10153 let subkey_name = parts.next().unwrap_or("");
10154 if subkey_name.is_empty() {
10155 return;
10156 }
10157 let is_hkcu = scope.eq_ignore_ascii_case("User");
10158 vec![(
10159 is_hkcu,
10160 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10161 )]
10162 } else {
10163 let mut found = Vec::new();
10165 for (is_hkcu, hive) in [
10166 (true, RegKey::predef(HKEY_CURRENT_USER)),
10167 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10168 ] {
10169 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10170 continue;
10171 };
10172 for name in uninstall.enum_keys().flatten() {
10173 let Ok(subkey) = uninstall.open_subkey(&name) else {
10174 continue;
10175 };
10176 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10177 found.push((
10178 is_hkcu,
10179 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10180 ));
10181 }
10182 }
10183 }
10184 found
10185 };
10186
10187 for (is_hkcu, path) in candidates {
10188 let hive = if is_hkcu {
10189 RegKey::predef(HKEY_CURRENT_USER)
10190 } else {
10191 RegKey::predef(HKEY_LOCAL_MACHINE)
10192 };
10193 let _ = hive.delete_subkey_all(&path);
10194 }
10195}
10196
10197#[cfg(windows)]
10198fn is_product_code_like(value: &str) -> bool {
10199 value.starts_with('{') && value.ends_with('}')
10200}
10201
10202#[cfg(not(windows))]
10203fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10204 bail!("Uninstalling packages is only supported on Windows")
10205}
10206
10207#[cfg(windows)]
10229fn install_portable(
10230 installer_path: &Path,
10231 request: &InstallRequest,
10232 manifest: &Manifest,
10233 installer: &Installer,
10234) -> Result<i32> {
10235 let source_identifier = portable_source_identifier(&request.query);
10236 let existing_entry = read_existing_portable_entry(&manifest.id);
10237 let subkey_name = existing_entry
10240 .as_ref()
10241 .map(|entry| entry.subkey_name.clone())
10242 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10243
10244 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10245
10246 let prev_dir_created = existing_entry
10251 .as_ref()
10252 .and_then(|entry| entry.install_directory_created)
10253 == Some(1);
10254 let dir_existed = target_dir.is_dir();
10255 if prev_dir_created && dir_existed {
10256 clean_directory_contents(&target_dir).with_context(|| {
10257 format!(
10258 "failed to clean existing portable install directory: {}",
10259 target_dir.display()
10260 )
10261 })?;
10262 }
10263
10264 fs::create_dir_all(&target_dir)
10265 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10266 let install_directory_created = !dir_existed || prev_dir_created;
10270
10271 let installer_type = installer.installer_type.as_deref().unwrap_or("");
10272 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10273 let file = fs::File::open(installer_path)
10274 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10275 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10276 archive
10277 .extract(&target_dir)
10278 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10279 installer
10283 .nested_installer_files
10284 .first()
10285 .map(|file| target_dir.join(&file.relative_file_path))
10286 .unwrap_or_else(|| target_dir.clone())
10287 } else {
10288 let basename = installer_path
10292 .file_name()
10293 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10294 let copied = target_dir.join(basename);
10295 fs::copy(installer_path, &copied)
10296 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10297 copied
10298 };
10299
10300 let alias = determine_portable_alias(installer);
10305 let symlink_full_path = match alias.as_deref() {
10306 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10307 _ => None,
10308 };
10309
10310 let added_to_path = if symlink_full_path.is_some() {
10314 try_add_links_dir_to_user_path()
10315 } else {
10316 false
10317 };
10318
10319 write_portable_arp_entry(&PortableArpEntry {
10320 subkey_name: &subkey_name,
10321 install_location: &target_dir,
10322 portable_target_full_path: &portable_target_full_path,
10323 portable_symlink_full_path: symlink_full_path.as_deref(),
10324 install_directory_created,
10325 added_to_path,
10326 source_identifier: &source_identifier,
10327 manifest,
10328 })?;
10329
10330 Ok(0)
10331}
10332
10333#[cfg(windows)]
10334struct PortableArpEntry<'a> {
10335 subkey_name: &'a str,
10336 install_location: &'a Path,
10337 portable_target_full_path: &'a Path,
10338 portable_symlink_full_path: Option<&'a Path>,
10339 install_directory_created: bool,
10340 added_to_path: bool,
10341 source_identifier: &'a str,
10342 manifest: &'a Manifest,
10343}
10344
10345#[cfg(windows)]
10351fn determine_portable_alias(installer: &Installer) -> Option<String> {
10352 installer
10353 .nested_installer_files
10354 .iter()
10355 .find_map(|file| file.portable_command_alias.clone())
10356 .or_else(|| installer.commands.first().cloned())
10357}
10358
10359#[cfg(windows)]
10360fn winget_links_dir() -> PathBuf {
10361 dirs::data_local_dir()
10362 .unwrap_or_else(|| PathBuf::from("."))
10363 .join("Microsoft")
10364 .join("WinGet")
10365 .join("Links")
10366}
10367
10368#[cfg(windows)]
10373fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10374 use std::os::windows::fs::symlink_file;
10375 let links_dir = winget_links_dir();
10376 if fs::create_dir_all(&links_dir).is_err() {
10377 return None;
10378 }
10379 let link_path = links_dir.join(format!("{alias}.exe"));
10380
10381 if link_path.symlink_metadata().is_ok() {
10384 let _ = fs::remove_file(&link_path);
10385 }
10386
10387 symlink_file(target, &link_path).ok().map(|()| link_path)
10392}
10393
10394#[cfg(windows)]
10400fn try_add_links_dir_to_user_path() -> bool {
10401 let links_dir = winget_links_dir();
10402 let links_str = links_dir.to_string_lossy().to_string();
10403 let normalized_links = links_str.to_ascii_lowercase();
10404
10405 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10406 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10407 return false;
10408 };
10409 let existing: String = env_key.get_value("Path").unwrap_or_default();
10410 let already_present = existing
10411 .split(';')
10412 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10413 if already_present {
10414 return false;
10415 }
10416
10417 let new_path = if existing.is_empty() {
10418 links_str
10419 } else if existing.ends_with(';') {
10420 format!("{existing}{links_str}")
10421 } else {
10422 format!("{existing};{links_str}")
10423 };
10424
10425 if env_key.set_value("Path", &new_path).is_err() {
10426 return false;
10427 }
10428
10429 broadcast_environment_change();
10430 true
10431}
10432
10433#[cfg(windows)]
10434fn broadcast_environment_change() {
10435 use std::ffi::OsStr;
10436 use std::os::windows::ffi::OsStrExt;
10437
10438 use windows_sys::Win32::UI::WindowsAndMessaging::{
10439 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10440 };
10441
10442 let param: Vec<u16> = OsStr::new("Environment")
10443 .encode_wide()
10444 .chain(std::iter::once(0))
10445 .collect();
10446 unsafe {
10450 let mut result: usize = 0;
10451 SendMessageTimeoutW(
10452 HWND_BROADCAST,
10453 WM_SETTINGCHANGE,
10454 0usize,
10455 param.as_ptr() as isize,
10456 SMTO_ABORTIFHUNG,
10457 5000,
10458 &mut result,
10459 );
10460 }
10461}
10462
10463#[cfg(windows)]
10468fn portable_source_identifier(query: &PackageQuery) -> String {
10469 match query.source.as_deref() {
10470 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10471 Some(name) if !name.is_empty() => name.to_owned(),
10472 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10473 }
10474}
10475
10476#[cfg(windows)]
10477fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10478 format!("{package_id}_{source_identifier}")
10479}
10480
10481#[cfg(windows)]
10482struct ExistingPortableEntry {
10483 install_location: Option<PathBuf>,
10484 install_directory_created: Option<u32>,
10485 subkey_name: String,
10486}
10487
10488#[cfg(windows)]
10489fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10490 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10491 let uninstall = hkcu
10492 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10493 .ok()?;
10494 for name in uninstall.enum_keys().flatten() {
10495 let Ok(subkey) = uninstall.open_subkey(&name) else {
10496 continue;
10497 };
10498 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10499 continue;
10500 }
10501 if !read_reg_string(&subkey, "WinGetInstallerType")
10502 .as_deref()
10503 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10504 {
10505 continue;
10506 }
10507 return Some(ExistingPortableEntry {
10508 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10509 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10510 subkey_name: name,
10511 });
10512 }
10513 None
10514}
10515
10516#[cfg(windows)]
10517fn resolve_portable_install_location(
10518 request: &InstallRequest,
10519 existing: &Option<ExistingPortableEntry>,
10520 subkey_name: &str,
10521) -> PathBuf {
10522 resolve_portable_install_location_pure(
10523 request.install_location.as_deref(),
10524 existing
10525 .as_ref()
10526 .and_then(|e| e.install_location.as_deref())
10527 .and_then(Path::to_str),
10528 subkey_name,
10529 &default_user_portable_root(),
10530 )
10531}
10532
10533#[cfg(windows)]
10538fn resolve_portable_install_location_pure(
10539 request_location: Option<&str>,
10540 existing_location: Option<&str>,
10541 subkey_name: &str,
10542 default_user_portable_root: &Path,
10543) -> PathBuf {
10544 if let Some(custom) = request_location
10545 && !custom.is_empty()
10546 {
10547 return PathBuf::from(custom);
10548 }
10549 if let Some(loc) = existing_location
10550 && !loc.is_empty()
10551 {
10552 return PathBuf::from(loc);
10553 }
10554 default_user_portable_root.join(subkey_name)
10555}
10556
10557#[cfg(windows)]
10558fn default_user_portable_root() -> PathBuf {
10559 dirs::data_local_dir()
10560 .unwrap_or_else(|| PathBuf::from("."))
10561 .join("Microsoft")
10562 .join("WinGet")
10563 .join("Packages")
10564}
10565
10566#[cfg(windows)]
10567fn clean_directory_contents(dir: &Path) -> Result<()> {
10568 if !dir.is_dir() {
10569 return Ok(());
10570 }
10571 for entry in fs::read_dir(dir)? {
10572 let entry = entry?;
10573 let path = entry.path();
10574 if path.is_dir() {
10575 fs::remove_dir_all(&path)?;
10576 } else {
10577 fs::remove_file(&path)?;
10578 }
10579 }
10580 Ok(())
10581}
10582
10583#[cfg(windows)]
10587fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10588 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10589 let uninstall_path = format!(
10590 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10591 entry.subkey_name
10592 );
10593 let (subkey, _) = hkcu
10594 .create_subkey(&uninstall_path)
10595 .context("failed to create portable ARP registry subkey")?;
10596
10597 let manifest = entry.manifest;
10598 let install_location_str = entry.install_location.to_string_lossy().to_string();
10599 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10600 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10601 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10602 subkey.set_value("InstallLocation", &install_location_str)?;
10603 subkey.set_value(
10604 "PortableTargetFullPath",
10605 &entry.portable_target_full_path.to_string_lossy().to_string(),
10606 )?;
10607 if let Some(symlink_path) = entry.portable_symlink_full_path {
10608 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10609 }
10610 subkey.set_value(
10611 "InstallDirectoryAddedToPath",
10612 &(if entry.added_to_path { 1u32 } else { 0u32 }),
10613 )?;
10614 subkey.set_value(
10615 "InstallDirectoryCreated",
10616 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10617 )?;
10618 subkey.set_value(
10619 "DisplayName",
10620 &if manifest.name.is_empty() {
10621 manifest.id.clone()
10622 } else {
10623 manifest.name.clone()
10624 },
10625 )?;
10626 subkey.set_value("DisplayVersion", &manifest.version)?;
10627 if let Some(publisher) = manifest.publisher.as_ref() {
10628 subkey.set_value("Publisher", publisher)?;
10629 }
10630 subkey.set_value(
10631 "UninstallString",
10632 &format!("winget uninstall --product-code {}", entry.subkey_name),
10633 )?;
10634 let today = Utc::now().format("%Y%m%d").to_string();
10635 subkey.set_value("InstallDate", &today)?;
10636 if let Some(url) = manifest.package_url.as_ref() {
10637 subkey.set_value("URLInfoAbout", url)?;
10638 }
10639
10640 Ok(())
10641}
10642
10643#[cfg(test)]
10644mod tests {
10645 use std::io::{Read as _, Write};
10646 use std::net::{SocketAddr, TcpListener, TcpStream};
10647 use std::path::{Path, PathBuf};
10648 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10649 use std::thread::{self, JoinHandle};
10650
10651 use flate2::Compression;
10652 use flate2::write::DeflateEncoder;
10653 use zip::write::SimpleFileOptions;
10654
10655 use super::*;
10656
10657 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10658 Manifest {
10659 id: id.to_owned(),
10660 name: name.to_owned(),
10661 version: version.to_owned(),
10662 channel: String::new(),
10663 publisher: Some("Acme".to_owned()),
10664 description: Some("desc".to_owned()),
10665 moniker: None,
10666 package_url: None,
10667 publisher_url: None,
10668 publisher_support_url: None,
10669 license: None,
10670 license_url: None,
10671 privacy_url: None,
10672 author: None,
10673 copyright: None,
10674 copyright_url: None,
10675 release_notes: None,
10676 release_notes_url: None,
10677 tags: Vec::new(),
10678 agreements: Vec::new(),
10679 package_dependencies: Vec::new(),
10680 documentation: Vec::new(),
10681 icons: Vec::new(),
10682 installers: Vec::new(),
10683 require_explicit_upgrade: false,
10684 }
10685 }
10686
10687 #[test]
10688 fn derive_download_filename_matches_winget_pattern() {
10689 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10690 let installer = Installer {
10691 architecture: Some("arm64".to_owned()),
10692 installer_type: Some("exe".to_owned()),
10693 scope: Some("user".to_owned()),
10694 locale: Some("en-US".to_owned()),
10695 ..Installer::default()
10696 };
10697 assert_eq!(
10698 derive_download_manifest_filename(&manifest, &installer),
10699 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10700 );
10701 }
10702
10703 #[test]
10704 fn derive_download_filename_omits_optional_components() {
10705 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10706 let installer = Installer {
10707 architecture: Some("x64".to_owned()),
10708 installer_type: Some("exe".to_owned()),
10709 ..Installer::default()
10710 };
10711 assert_eq!(
10713 derive_download_manifest_filename(&manifest, &installer),
10714 "7-Zip_26.01_X64_exe.yaml"
10715 );
10716 }
10717
10718 #[test]
10719 fn derive_download_filename_skips_unknown_version() {
10720 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10721 let installer = Installer {
10722 architecture: Some("neutral".to_owned()),
10723 installer_type: Some("msi".to_owned()),
10724 ..Installer::default()
10725 };
10726 assert_eq!(
10727 derive_download_manifest_filename(&manifest, &installer),
10728 "Test_Neutral_msi.yaml"
10729 );
10730 }
10731
10732 #[test]
10733 fn derive_download_filename_sanitizes_illegal_chars() {
10734 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10735 let installer = Installer {
10736 architecture: Some("x64".to_owned()),
10737 installer_type: Some("exe".to_owned()),
10738 ..Installer::default()
10739 };
10740 let filename = derive_download_manifest_filename(&manifest, &installer);
10742 assert!(!filename.contains('/') && !filename.contains(':'));
10743 assert!(filename.starts_with("Some_Bad_Name_1.0"));
10744 }
10745
10746 #[test]
10747 fn build_download_manifest_yaml_overrides_identity_fields() {
10748 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10749 let documents = serde_json::json!({
10752 "PackageIdentifier": "stale-id",
10753 "PackageVersion": "",
10754 "PackageName": "Old Name",
10755 "Channel": "preview",
10756 "Installers": [
10757 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10758 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10759 ],
10760 });
10761 let installer = Installer {
10762 architecture: Some("x64".to_owned()),
10763 installer_type: Some("exe".to_owned()),
10764 url: Some("https://example.com/a".to_owned()),
10765 ..Installer::default()
10766 };
10767 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10768 assert!(yaml.contains("PackageIdentifier: App.Id"));
10769 assert!(yaml.contains("PackageVersion: 2.0.0"));
10770 assert!(yaml.contains("PackageName: App Name"));
10771 assert!(yaml.contains("ManifestType: merged"));
10772 assert!(!yaml.contains("Channel: preview"));
10775 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
10777 assert!(yaml.contains("https://example.com/a"));
10778 assert!(!yaml.contains("https://example.com/b"));
10779 }
10780
10781 #[test]
10782 fn build_download_manifest_yaml_preserves_typed_channel() {
10783 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10784 manifest.channel = "beta".to_owned();
10785 let documents = serde_json::json!({
10786 "PackageIdentifier": "App.Id",
10787 "PackageVersion": "2.0.0",
10788 "Installers": [],
10789 });
10790 let installer = Installer::default();
10791 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10792 assert!(yaml.contains("Channel: beta"));
10793 }
10794
10795 fn temp_app_root(label: &str) -> PathBuf {
10796 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
10797 std::env::temp_dir().join(format!(
10798 "pinget-rs-tests-{label}-{}-{}",
10799 std::process::id(),
10800 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
10801 ))
10802 }
10803
10804 #[cfg(windows)]
10805 #[test]
10806 fn path_prefix_check_rejects_string_prefix_collisions() {
10807 assert!(!path_starts_with_case_insensitive(
10811 Path::new(r"C:\Foobar\bin.exe"),
10812 Path::new(r"C:\Foo"),
10813 ));
10814 assert!(!path_starts_with_case_insensitive(
10815 Path::new(r"C:\Foo\bin.exe.bak"),
10816 Path::new(r"C:\Foo\bin.exe"),
10817 ));
10818 }
10819
10820 #[cfg(windows)]
10821 #[test]
10822 fn path_prefix_check_accepts_real_children_and_self() {
10823 assert!(path_starts_with_case_insensitive(
10825 Path::new(r"C:\Foo\sub\bin.exe"),
10826 Path::new(r"C:\Foo"),
10827 ));
10828 assert!(path_starts_with_case_insensitive(
10830 Path::new(r"C:\Foo\bin.exe"),
10831 Path::new(r"C:\Foo\bin.exe"),
10832 ));
10833 }
10834
10835 #[cfg(windows)]
10836 #[test]
10837 fn path_prefix_check_is_case_insensitive() {
10838 assert!(path_starts_with_case_insensitive(
10841 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
10842 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
10843 ));
10844 }
10845
10846 #[cfg(windows)]
10847 #[test]
10848 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
10849 let query = PackageQuery {
10850 source: Some("winget".to_owned()),
10851 ..PackageQuery::default()
10852 };
10853 assert_eq!(
10854 portable_source_identifier(&query),
10855 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10856 );
10857 let query = PackageQuery {
10859 source: Some("WINGET".to_owned()),
10860 ..PackageQuery::default()
10861 };
10862 assert_eq!(
10863 portable_source_identifier(&query),
10864 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10865 );
10866 }
10867
10868 #[cfg(windows)]
10869 #[test]
10870 fn portable_source_identifier_passes_through_custom_sources() {
10871 let query = PackageQuery {
10872 source: Some("internal-feed".to_owned()),
10873 ..PackageQuery::default()
10874 };
10875 assert_eq!(portable_source_identifier(&query), "internal-feed");
10876 }
10877
10878 #[cfg(windows)]
10879 #[test]
10880 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
10881 let query = PackageQuery {
10882 source: None,
10883 ..PackageQuery::default()
10884 };
10885 assert_eq!(
10886 portable_source_identifier(&query),
10887 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10888 );
10889 let query = PackageQuery {
10890 source: Some(String::new()),
10891 ..PackageQuery::default()
10892 };
10893 assert_eq!(
10894 portable_source_identifier(&query),
10895 "Microsoft.Winget.Source_8wekyb3d8bbwe"
10896 );
10897 }
10898
10899 #[cfg(windows)]
10900 #[test]
10901 fn portable_subkey_name_matches_winget_format() {
10902 assert_eq!(
10903 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
10904 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
10905 );
10906 }
10907
10908 #[cfg(windows)]
10909 #[test]
10910 fn determine_portable_alias_prefers_nested_installer_alias() {
10911 let installer = Installer {
10912 nested_installer_files: vec![NestedInstallerFile {
10913 relative_file_path: "ripgrep/rg.exe".to_owned(),
10914 portable_command_alias: Some("rg".to_owned()),
10915 }],
10916 commands: vec!["other-name".to_owned()],
10917 ..Installer::default()
10918 };
10919 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
10920 }
10921
10922 #[cfg(windows)]
10923 #[test]
10924 fn determine_portable_alias_falls_back_to_commands() {
10925 let installer = Installer {
10926 commands: vec!["nuget".to_owned()],
10928 ..Installer::default()
10929 };
10930 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
10931
10932 let installer = Installer {
10934 nested_installer_files: vec![NestedInstallerFile {
10935 relative_file_path: "subdir/bin.exe".to_owned(),
10936 portable_command_alias: None,
10937 }],
10938 commands: vec!["bin".to_owned()],
10939 ..Installer::default()
10940 };
10941 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
10942 }
10943
10944 #[cfg(windows)]
10945 #[test]
10946 fn determine_portable_alias_returns_none_without_alias_or_commands() {
10947 let installer = Installer::default();
10948 assert!(determine_portable_alias(&installer).is_none());
10949 }
10950
10951 #[cfg(windows)]
10952 #[test]
10953 fn resolve_portable_install_location_prefers_request_override() {
10954 let result = resolve_portable_install_location_pure(
10955 Some(r"D:\Tools\rg"),
10956 Some(r"C:\Software\rg-test"),
10957 "Sub_Source",
10958 Path::new(r"C:\default"),
10959 );
10960 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
10961 }
10962
10963 #[cfg(windows)]
10964 #[test]
10965 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
10966 let result = resolve_portable_install_location_pure(
10970 None,
10971 Some(r"C:\Software\rg-test"),
10972 "Sub_Source",
10973 Path::new(r"C:\default"),
10974 );
10975 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
10976 }
10977
10978 #[cfg(windows)]
10979 #[test]
10980 fn resolve_portable_install_location_falls_back_to_default_root() {
10981 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
10982 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10983 }
10984
10985 #[cfg(windows)]
10986 #[test]
10987 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
10988 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
10991 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
10992 }
10993
10994 #[cfg(windows)]
10995 #[test]
10996 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
10997 let root = std::env::temp_dir().join(format!(
10998 "pinget-clean-test-{}",
10999 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11000 ));
11001 fs::create_dir_all(&root).expect("create temp dir");
11002 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11003 fs::create_dir_all(root.join("nested")).expect("create nested dir");
11004 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11005
11006 clean_directory_contents(&root).expect("clean root contents");
11007
11008 assert!(root.is_dir(), "root dir itself must survive");
11009 assert_eq!(
11010 fs::read_dir(&root).expect("read root").count(),
11011 0,
11012 "root must be empty after clean"
11013 );
11014 let _ = fs::remove_dir_all(&root);
11015 }
11016
11017 #[cfg(windows)]
11018 #[test]
11019 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11020 let bogus = std::env::temp_dir().join(format!(
11021 "pinget-clean-test-bogus-{}",
11022 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11023 ));
11024 assert!(!bogus.exists());
11025 clean_directory_contents(&bogus).expect("clean nonexistent dir");
11026 assert!(!bogus.exists());
11028 }
11029
11030 #[cfg(windows)]
11031 #[test]
11032 fn path_prefix_check_handles_mixed_separators() {
11033 assert!(path_starts_with_case_insensitive(
11036 Path::new(r"C:\Foo\sub/bin.exe"),
11037 Path::new(r"C:\Foo"),
11038 ));
11039 }
11040
11041 #[test]
11042 fn repository_options_capture_custom_host_settings() {
11043 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11044 .with_user_agent("pinget-rs-tests/1.0")
11045 .with_pre_indexed_source_auto_update_interval(None);
11046
11047 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11048 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11049 assert!(options.pre_indexed_source_auto_update_interval.is_none());
11050 }
11051
11052 #[test]
11053 fn dependency_install_requests_are_silent_and_not_forced() {
11054 let mut parent = InstallRequest::new(PackageQuery {
11055 source: Some("winget".to_owned()),
11056 ..PackageQuery::default()
11057 });
11058 parent.mode = InstallerMode::SilentWithProgress;
11059 parent.force = true;
11060 parent.accept_package_agreements = true;
11061 parent.ignore_security_hash = true;
11062 parent.dependency_source = Some("dependencies".to_owned());
11063
11064 let dependency =
11065 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11066
11067 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11068 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11069 assert_eq!(dependency.mode, InstallerMode::Silent);
11070 assert!(!dependency.force);
11071 assert!(dependency.no_upgrade);
11072 assert!(dependency.accept_package_agreements);
11073 assert!(dependency.ignore_security_hash);
11074 }
11075
11076 #[test]
11077 fn storage_paths_use_configured_app_root() {
11078 let app_root = PathBuf::from(r"C:\temp\pinget-test");
11079 let source = SourceRecord {
11080 name: "winget/test".to_owned(),
11081 kind: SourceKind::PreIndexed,
11082 arg: "https://example.com/cache".to_owned(),
11083 identifier: "Test.Source".to_owned(),
11084 trust_level: "None".to_owned(),
11085 explicit: false,
11086 priority: 0,
11087 last_update: None,
11088 source_version: None,
11089 etag: None,
11090 last_modified: None,
11091 };
11092
11093 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11094 assert_eq!(
11095 source_state_dir(&app_root, &source),
11096 app_root.join("sources").join("winget_test")
11097 );
11098 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11099 }
11100
11101 #[cfg(windows)]
11102 #[test]
11103 fn packaged_layout_paths_resolve_from_packaged_app_root() {
11104 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11105 let app_root = local_app_data
11106 .join("Packages")
11107 .join(PACKAGED_FAMILY_NAME)
11108 .join("LocalState");
11109 let source = SourceStore::default()
11110 .sources
11111 .into_iter()
11112 .find(|source| source.name == "winget")
11113 .expect("winget source");
11114 assert!(uses_packaged_layout(&app_root));
11115 assert!(
11116 user_settings_path(&app_root)
11117 .display()
11118 .to_string()
11119 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11120 );
11121 assert!(
11122 packaged_file_cache_root(&app_root)
11123 .display()
11124 .to_string()
11125 .ends_with(&format!(
11126 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11127 PACKAGED_FAMILY_NAME
11128 ))
11129 );
11130 assert!(
11131 source_state_dir(&app_root, &source)
11132 .display()
11133 .to_string()
11134 .ends_with(&format!(
11135 r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11136 PACKAGED_FAMILY_NAME
11137 ))
11138 );
11139 assert!(
11140 source_installed_db_path_with_keying(&app_root, &source, false)
11141 .display()
11142 .to_string()
11143 .ends_with(&format!(
11144 r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11145 PACKAGED_FAMILY_NAME
11146 ))
11147 );
11148 }
11149
11150 #[cfg(windows)]
11151 #[test]
11152 fn default_app_root_outside_package_avoids_packaged_layout() {
11153 let prior = std::env::var_os("PINGET_APPROOT");
11157 unsafe { std::env::remove_var("PINGET_APPROOT") };
11159
11160 let result = default_app_root();
11161
11162 if let Some(prior) = prior {
11163 unsafe { std::env::set_var("PINGET_APPROOT", prior) };
11165 }
11166
11167 let app_root = result.expect("default app root");
11168 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11169 assert!(
11170 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11171 "unexpected fallback app root: {app_root:?}"
11172 );
11173 }
11174
11175 #[test]
11176 fn packaged_source_stream_overlays_defaults_and_metadata() {
11177 let store = parse_packaged_source_store(
11178 Some(
11179 "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",
11180 ),
11181 Some(
11182 "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",
11183 ),
11184 )
11185 .expect("packaged store");
11186
11187 assert!(!store.sources.iter().any(|source| source.name == "winget"));
11188 let corp = store
11189 .sources
11190 .iter()
11191 .find(|source| source.name == "corp")
11192 .expect("corp source");
11193 assert_eq!(corp.kind, SourceKind::Rest);
11194 assert_eq!(corp.identifier, "Contoso.Rest");
11195 assert_eq!(corp.trust_level, "Trusted");
11196 assert!(corp.explicit);
11197 assert_eq!(corp.priority, 7);
11198 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11199 assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11200 assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11201 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11202 }
11203
11204 #[test]
11205 fn system_winget_export_parses_json_lines() {
11206 let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11207{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11208{"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"}
11209{"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"}
11210"#;
11211
11212 let sources = parse_system_winget_source_export(output).expect("source export");
11213
11214 assert_eq!(
11215 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11216 vec!["winget.pro", "msstore", "winget", "winget-font"]
11217 );
11218 assert_eq!(sources[0].kind, SourceKind::Rest);
11219 assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11220 assert_eq!(sources[0].identifier, "api.winget.pro");
11221 assert_eq!(sources[0].trust_level, "Trusted");
11222 assert!(!sources[0].explicit);
11223 assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11224 assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11225 assert!(!sources[2].explicit);
11226 }
11227
11228 #[test]
11229 fn system_winget_export_parses_wrapped_sources() {
11230 let output = r#"{
11231 "Sources": [
11232 {
11233 "Name": "corp",
11234 "Type": "Microsoft.Rest",
11235 "Arg": "https://packages.contoso.test/api",
11236 "Identifier": "Contoso.Rest",
11237 "TrustLevel": "Trusted",
11238 "Explicit": true,
11239 "Priority": 9
11240 }
11241 ]
11242}"#;
11243
11244 let sources = parse_system_winget_source_export(output).expect("source export");
11245 let source = sources.first().expect("source");
11246 assert_eq!(sources.len(), 1);
11247 assert_eq!(source.name, "corp");
11248 assert_eq!(source.kind, SourceKind::Rest);
11249 assert_eq!(source.identifier, "Contoso.Rest");
11250 assert_eq!(source.trust_level, "Trusted");
11251 assert!(source.explicit);
11252 assert_eq!(source.priority, 9);
11253 }
11254
11255 #[test]
11256 fn system_winget_source_store_detects_secure_settings_stub() {
11257 assert!(is_secure_settings_stub(Some(
11258 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11259 )));
11260 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
11261 }
11262
11263 #[test]
11264 fn system_winget_source_store_builds_add_arguments() {
11265 let args = build_winget_source_add_arguments(
11266 "corp",
11267 "https://packages.contoso.test/api",
11268 SourceKind::Rest,
11269 "Trusted",
11270 true,
11271 );
11272
11273 assert_eq!(
11274 args,
11275 vec![
11276 "source",
11277 "add",
11278 "--name",
11279 "corp",
11280 "--arg",
11281 "https://packages.contoso.test/api",
11282 "--type",
11283 "Microsoft.Rest",
11284 "--disable-interactivity",
11285 "--trust-level",
11286 "trusted",
11287 "--explicit",
11288 ]
11289 );
11290 }
11291
11292 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11293 Ok(WingetSourceCommandResult {
11294 exit_code: 0,
11295 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11296"#
11297 .to_owned(),
11298 stderr: String::new(),
11299 })
11300 }
11301
11302 #[test]
11303 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11304 let original_runner = {
11305 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11306 let original = *runner;
11307 *runner = fake_system_winget_source_export;
11308 original
11309 };
11310
11311 let result = load_packaged_store_from_streams(
11312 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11313 None,
11314 );
11315
11316 {
11317 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11318 *runner = original_runner;
11319 }
11320
11321 let store = result.expect("packaged store").expect("packaged store");
11322 let source = store.sources.first().expect("source");
11323 assert_eq!(store.sources.len(), 1);
11324 assert_eq!(source.name, "contoso");
11325 assert_eq!(source.kind, SourceKind::Rest);
11326 }
11327
11328 #[test]
11329 fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11330 let original_runner = {
11331 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11332 let original = *runner;
11333 *runner = fake_system_winget_source_export;
11334 original
11335 };
11336
11337 let app_root = temp_app_root("system-winget-mirror");
11338 let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11339
11340 {
11341 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11342 *runner = original_runner;
11343 }
11344
11345 let (mode, mut store) = result.expect("mirror store");
11346 assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11347 assert!(system_winget_mirror_store_path(&app_root).exists());
11348 assert!(
11349 !store_path(&app_root).exists(),
11350 "mirror mode must not write private sources.json"
11351 );
11352
11353 let source = store.sources.first_mut().expect("source");
11354 source.last_update = Some(Utc::now());
11355 source.source_version = Some("cached-contract".to_owned());
11356 source.etag = Some("\"etag\"".to_owned());
11357 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11358 save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11359
11360 let original_runner = {
11361 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11362 let original = *runner;
11363 *runner = fake_system_winget_source_export;
11364 original
11365 };
11366 let refreshed = refresh_system_winget_mirror_store(&app_root);
11367 {
11368 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11369 *runner = original_runner;
11370 }
11371
11372 let refreshed = refreshed.expect("refresh mirror");
11373 let source = refreshed.sources.first().expect("source");
11374 assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11375 assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11376 assert_eq!(
11377 source_state_dir_with_keying(&app_root, source, true),
11378 app_root.join("sources").join("api.contoso.test")
11379 );
11380 assert_eq!(
11381 source_state_dir_with_keying(&app_root, source, false),
11382 app_root.join("sources").join("contoso")
11383 );
11384
11385 let _ = fs::remove_dir_all(&app_root);
11386 }
11387
11388 #[test]
11389 fn source_metadata_and_named_reset_round_trip() {
11390 let app_root = temp_app_root("source-reset");
11391 let result = (|| -> Result<()> {
11392 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11393 repository.add_source_with_metadata(
11394 "test",
11395 "https://example.com/test",
11396 SourceKind::Rest,
11397 Some("trusted"),
11398 true,
11399 4,
11400 )?;
11401
11402 repository.edit_source("test", Some(false), None)?;
11403 let edited = repository
11404 .list_sources()
11405 .into_iter()
11406 .find(|source| source.name == "test")
11407 .expect("source");
11408 assert_eq!(edited.trust_level, "Trusted");
11409 assert!(!edited.explicit);
11410 assert_eq!(edited.priority, 4);
11411
11412 repository.reset_source("test")?;
11413 let reset = repository
11414 .list_sources()
11415 .into_iter()
11416 .find(|source| source.name == "test")
11417 .expect("source");
11418 assert_eq!(reset.trust_level, "Trusted");
11419 assert!(!reset.explicit);
11420 assert_eq!(reset.priority, 4);
11421 assert!(reset.last_update.is_none());
11422 assert!(reset.source_version.is_none());
11423 Ok(())
11424 })();
11425
11426 let _ = fs::remove_dir_all(&app_root);
11427 result.expect("source metadata");
11428 }
11429
11430 #[test]
11431 fn explicit_sources_are_skipped_by_default_search_resolution() {
11432 let app_root = temp_app_root("source-resolution");
11433 let result = (|| -> Result<()> {
11434 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11435 repository.add_source_with_metadata(
11436 "explicit-test",
11437 "https://example.com/explicit",
11438 SourceKind::Rest,
11439 Some("trusted"),
11440 true,
11441 10,
11442 )?;
11443
11444 let indexes = repository.resolve_source_indexes(None)?;
11445 let names: Vec<_> = indexes
11446 .into_iter()
11447 .map(|index| repository.list_sources()[index].name.clone())
11448 .collect();
11449 assert!(!names.iter().any(|name| name == "explicit-test"));
11450 Ok(())
11451 })();
11452
11453 let _ = fs::remove_dir_all(&app_root);
11454 result.expect("source resolution");
11455 }
11456
11457 #[test]
11458 fn source_resolution_accepts_source_identifier() {
11459 let app_root = temp_app_root("source-identifier-resolution");
11460 let result = (|| -> Result<()> {
11461 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11462 repository.add_source_with_metadata(
11463 "winget.pro",
11464 "https://api.winget.pro/feed",
11465 SourceKind::Rest,
11466 Some("trusted"),
11467 false,
11468 0,
11469 )?;
11470 repository.store.sources[0].identifier = "api.winget.pro".to_owned();
11471
11472 let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
11473 assert_eq!(indexes, vec![0]);
11474 Ok(())
11475 })();
11476
11477 let _ = fs::remove_dir_all(&app_root);
11478 result.expect("source identifier resolution");
11479 }
11480
11481 #[test]
11482 fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
11483 let query = PackageQuery {
11484 id: Some("tessl.tessl".to_owned()),
11485 source: Some("winget".to_owned()),
11486 exact: true,
11487 ..PackageQuery::default()
11488 };
11489 let clean_miss = SearchLocatedResult {
11490 matches: Vec::new(),
11491 warnings: Vec::new(),
11492 failures: Vec::new(),
11493 truncated: false,
11494 };
11495 assert!(should_retry_show_across_system_winget_sources(
11496 true,
11497 &query,
11498 &clean_miss
11499 ));
11500
11501 let private_mode = false;
11502 assert!(!should_retry_show_across_system_winget_sources(
11503 private_mode,
11504 &query,
11505 &clean_miss
11506 ));
11507
11508 let explicit_custom_source = PackageQuery {
11509 source: Some("winget.pro".to_owned()),
11510 ..query.clone()
11511 };
11512 assert!(!should_retry_show_across_system_winget_sources(
11513 true,
11514 &explicit_custom_source,
11515 &clean_miss
11516 ));
11517
11518 let failed_source = SearchLocatedResult {
11519 failures: vec![SourceSearchFailure {
11520 source_name: "winget".to_owned(),
11521 message: "network failure".to_owned(),
11522 }],
11523 ..clean_miss
11524 };
11525 assert!(!should_retry_show_across_system_winget_sources(
11526 true,
11527 &query,
11528 &failed_source
11529 ));
11530 }
11531
11532 #[test]
11533 fn user_and_admin_settings_round_trip() {
11534 let app_root = temp_app_root("settings");
11535 let result = (|| -> Result<()> {
11536 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11537 repository.set_user_settings(
11538 &serde_json::json!({
11539 "visual": {
11540 "progressBar": "retro"
11541 }
11542 }),
11543 false,
11544 )?;
11545 repository.set_user_settings(
11546 &serde_json::json!({
11547 "experimentalFeatures": {
11548 "directMSI": true
11549 }
11550 }),
11551 true,
11552 )?;
11553
11554 let user_settings = repository.get_user_settings()?;
11555 assert_eq!(
11556 user_settings["visual"]["progressBar"],
11557 serde_json::Value::String("retro".to_owned())
11558 );
11559 assert_eq!(
11560 user_settings["experimentalFeatures"]["directMSI"],
11561 serde_json::Value::Bool(true)
11562 );
11563 assert!(repository.test_user_settings(
11564 &serde_json::json!({
11565 "experimentalFeatures": {
11566 "directMSI": true
11567 }
11568 }),
11569 true
11570 )?);
11571
11572 repository.set_admin_setting("LocalManifestFiles", true)?;
11573 repository.set_admin_setting("InstallerHashOverride", true)?;
11574 let admin_settings = repository.get_admin_settings()?;
11575 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
11576 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
11577
11578 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
11579 let reset_one = repository.get_admin_settings()?;
11580 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
11581
11582 repository.reset_admin_setting(None, true)?;
11583 let reset_all = repository.get_admin_settings()?;
11584 for name in Repository::supported_admin_settings() {
11585 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
11586 }
11587 Ok(())
11588 })();
11589
11590 let _ = fs::remove_dir_all(&app_root);
11591 result.expect("settings");
11592 }
11593
11594 #[test]
11595 fn show_result_structured_document_is_manifest_oriented() {
11596 let result = ShowResult {
11597 package: SearchMatch {
11598 source_name: "winget".to_owned(),
11599 source_kind: SourceKind::PreIndexed,
11600 id: "Test.Package".to_owned(),
11601 name: "Test Package".to_owned(),
11602 moniker: Some("testpkg".to_owned()),
11603 version: Some("1.2.3".to_owned()),
11604 channel: Some("stable".to_owned()),
11605 match_criteria: Some("Id".to_owned()),
11606 },
11607 manifest: Manifest {
11608 id: "Test.Package".to_owned(),
11609 name: "Test Package".to_owned(),
11610 version: "1.2.3".to_owned(),
11611 channel: "stable".to_owned(),
11612 publisher: Some("Contoso".to_owned()),
11613 description: Some("Structured output".to_owned()),
11614 moniker: Some("testpkg".to_owned()),
11615 package_url: None,
11616 publisher_url: None,
11617 publisher_support_url: None,
11618 license: None,
11619 license_url: None,
11620 privacy_url: None,
11621 author: None,
11622 copyright: None,
11623 copyright_url: None,
11624 release_notes: None,
11625 release_notes_url: None,
11626 tags: vec!["utility".to_owned()],
11627 agreements: Vec::new(),
11628 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
11629 documentation: vec![Documentation {
11630 label: Some("Docs".to_owned()),
11631 url: "https://example.test/docs".to_owned(),
11632 }],
11633 icons: vec![PackageIcon {
11634 icon_url: Some("https://example.test/default-256.ico".to_owned()),
11635 icon_file_type: Some("ico".to_owned()),
11636 icon_resolution: Some("256x256".to_owned()),
11637 icon_theme: Some("default".to_owned()),
11638 icon_sha256: Some("DEF456".to_owned()),
11639 }],
11640 installers: vec![Installer {
11641 architecture: Some("x64".to_owned()),
11642 installer_type: Some("msix".to_owned()),
11643 url: Some("https://example.test/Test.Package.msix".to_owned()),
11644 sha256: Some("ABC123".to_owned()),
11645 product_code: None,
11646 locale: Some("en-US".to_owned()),
11647 scope: Some("machine".to_owned()),
11648 release_date: None,
11649 package_family_name: None,
11650 upgrade_code: None,
11651 platforms: Vec::new(),
11652 minimum_os_version: None,
11653 switches: InstallerSwitches {
11654 silent: Some("/quiet".to_owned()),
11655 ..InstallerSwitches::default()
11656 },
11657 commands: vec!["testpkg".to_owned()],
11658 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11659 require_explicit_upgrade: false,
11660 nested_installer_type: None,
11661 nested_installer_files: Vec::new(),
11662 }],
11663 require_explicit_upgrade: false,
11664 },
11665 selected_installer: Some(Installer {
11666 architecture: Some("x64".to_owned()),
11667 installer_type: Some("msix".to_owned()),
11668 url: Some("https://example.test/Test.Package.msix".to_owned()),
11669 sha256: Some("ABC123".to_owned()),
11670 product_code: None,
11671 locale: Some("en-US".to_owned()),
11672 scope: Some("machine".to_owned()),
11673 release_date: None,
11674 package_family_name: None,
11675 upgrade_code: None,
11676 platforms: Vec::new(),
11677 minimum_os_version: None,
11678 switches: InstallerSwitches {
11679 silent: Some("/quiet".to_owned()),
11680 ..InstallerSwitches::default()
11681 },
11682 commands: vec!["testpkg".to_owned()],
11683 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
11684 require_explicit_upgrade: false,
11685 nested_installer_type: None,
11686 nested_installer_files: Vec::new(),
11687 }),
11688 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
11689 warnings: vec!["cache warmed".to_owned()],
11690 manifest_documents: JsonValue::Array(vec![
11691 serde_json::json!({
11692 "PackageIdentifier": "Test.Package",
11693 "PackageVersion": "1.2.3",
11694 "DefaultLocale": "en-US",
11695 "ManifestType": "version",
11696 "ManifestVersion": "1.10.0"
11697 }),
11698 serde_json::json!({
11699 "PackageIdentifier": "Test.Package",
11700 "PackageVersion": "1.2.3",
11701 "PackageLocale": "en-US",
11702 "PackageName": "Test Package",
11703 "Publisher": "Example",
11704 "License": "MIT",
11705 "ShortDescription": "Structured output",
11706 "Icons": [
11707 {
11708 "IconUrl": "https://example.test/default-256.ico",
11709 "IconFileType": "ico",
11710 "IconResolution": "256x256",
11711 "IconTheme": "default",
11712 "IconSha256": "DEF456"
11713 }
11714 ],
11715 "ManifestType": "defaultLocale",
11716 "ManifestVersion": "1.10.0"
11717 }),
11718 serde_json::json!({
11719 "PackageIdentifier": "Test.Package",
11720 "PackageVersion": "1.2.3",
11721 "ManifestType": "installer",
11722 "ManifestVersion": "1.10.0",
11723 "Installers": [
11724 {
11725 "Architecture": "x64",
11726 "InstallerType": "msix",
11727 "InstallerUrl": "https://example.test/Test.Package.msix",
11728 "InstallerSha256": "ABC123",
11729 "Commands": ["testpkg"],
11730 "InstallerSwitches": { "Silent": "/quiet" },
11731 "Dependencies": {
11732 "PackageDependencies": [
11733 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
11734 ]
11735 }
11736 }
11737 ]
11738 }),
11739 ]),
11740 };
11741
11742 let document = result.structured_document();
11743
11744 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
11745 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
11746 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
11747 assert_eq!(
11748 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
11749 Some("Microsoft.VCRedist.2015+.x64")
11750 );
11751 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
11752 assert_eq!(
11753 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
11754 Some("/quiet")
11755 );
11756 assert_eq!(
11757 document["IconUrl"].as_str(),
11758 Some("https://example.test/default-256.ico")
11759 );
11760 assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
11761 assert_eq!(
11762 document["Icons"][0]["IconUrl"].as_str(),
11763 Some("https://example.test/default-256.ico")
11764 );
11765 }
11766
11767 #[test]
11768 fn manifest_parses_require_explicit_upgrade_at_top_level() {
11769 let yaml = r#"
11774PackageIdentifier: Test.Package
11775PackageVersion: 1.2.3
11776DefaultLocale: en-US
11777ManifestType: singleton
11778ManifestVersion: 1.10.0
11779PackageLocale: en-US
11780PackageName: Test Package
11781Publisher: Example
11782License: MIT
11783ShortDescription: explicit-upgrade fixture
11784RequireExplicitUpgrade: true
11785Installers:
11786 - Architecture: x64
11787 InstallerType: exe
11788 InstallerUrl: https://example.test/Test.Package.exe
11789 InstallerSha256: ABC123
11790"#;
11791 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11792 assert!(manifest.require_explicit_upgrade);
11793 }
11794
11795 #[test]
11796 fn manifest_parses_require_explicit_upgrade_on_installer() {
11797 let yaml = r#"
11801PackageIdentifier: Test.Package
11802PackageVersion: 1.2.3
11803DefaultLocale: en-US
11804ManifestType: singleton
11805ManifestVersion: 1.10.0
11806PackageLocale: en-US
11807PackageName: Test Package
11808Publisher: Example
11809License: MIT
11810ShortDescription: explicit-upgrade fixture
11811Installers:
11812 - Architecture: x64
11813 InstallerType: exe
11814 InstallerUrl: https://example.test/Test.Package.x64.exe
11815 InstallerSha256: ABC123
11816 - Architecture: arm64
11817 InstallerType: exe
11818 InstallerUrl: https://example.test/Test.Package.arm64.exe
11819 InstallerSha256: DEF456
11820 RequireExplicitUpgrade: true
11821"#;
11822 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11823 assert!(manifest.require_explicit_upgrade);
11824 assert!(!manifest.installers[0].require_explicit_upgrade);
11825 assert!(manifest.installers[1].require_explicit_upgrade);
11826 }
11827
11828 #[test]
11829 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
11830 let yaml = r#"
11831PackageIdentifier: Test.Package
11832PackageVersion: 1.2.3
11833DefaultLocale: en-US
11834ManifestType: singleton
11835ManifestVersion: 1.10.0
11836PackageLocale: en-US
11837PackageName: Test Package
11838Publisher: Example
11839License: MIT
11840ShortDescription: baseline fixture
11841Installers:
11842 - Architecture: x64
11843 InstallerType: exe
11844 InstallerUrl: https://example.test/Test.Package.exe
11845 InstallerSha256: ABC123
11846"#;
11847 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11848 assert!(!manifest.require_explicit_upgrade);
11849 }
11850
11851 #[test]
11852 fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
11853 let yaml = r#"
11854PackageIdentifier: Test.Package
11855PackageVersion: 1.2.3
11856DefaultLocale: en-US
11857ManifestType: singleton
11858ManifestVersion: 1.10.0
11859PackageLocale: en-US
11860PackageName: Test Package
11861Publisher: Example
11862License: MIT
11863ShortDescription: icon fixture
11864Icons:
11865 - IconUrl: https://example.test/dark-512.png
11866 IconFileType: png
11867 IconResolution: 512x512
11868 IconTheme: dark
11869 IconSha256: DARK
11870 - IconUrl: https://example.test/default-64.png
11871 IconFileType: png
11872 IconResolution: 64x64
11873 IconTheme: default
11874 - IconUrl: https://example.test/default-256.ico
11875 IconFileType: ico
11876 IconResolution: 256x256
11877 IconTheme: default
11878 - IconFileType: png
11879 IconResolution: 1024x1024
11880Installers:
11881 - Architecture: x64
11882 InstallerType: exe
11883 InstallerUrl: https://example.test/Test.Package.exe
11884 InstallerSha256: ABC123
11885"#;
11886 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11887
11888 assert_eq!(manifest.icons.len(), 4);
11889 assert_eq!(
11890 manifest.icons[0].icon_url.as_deref(),
11891 Some("https://example.test/dark-512.png")
11892 );
11893 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11894 let selected = manifest.icon().expect("selected icon");
11895 assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
11896 assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
11897 }
11898
11899 #[test]
11900 fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
11901 let yaml = r#"
11902PackageIdentifier: Test.Package
11903PackageVersion: 1.2.3
11904DefaultLocale: en-US
11905ManifestType: singleton
11906ManifestVersion: 1.10.0
11907PackageLocale: en-US
11908PackageName: Test Package
11909Publisher: Example
11910License: MIT
11911ShortDescription: no-icon fixture
11912Installers:
11913 - Architecture: x64
11914 InstallerType: exe
11915 InstallerUrl: https://example.test/Test.Package.exe
11916 InstallerSha256: ABC123
11917"#;
11918 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
11919
11920 assert!(manifest.icons.is_empty());
11921 assert!(manifest.icon().is_none());
11922 assert!(manifest.icon_url().is_none());
11923 }
11924
11925 #[test]
11926 fn rest_manifest_parses_icons_from_default_locale() {
11927 let response = serde_json::json!({
11928 "Data": {
11929 "PackageIdentifier": "Test.Package",
11930 "Versions": [
11931 {
11932 "PackageVersion": "1.2.3",
11933 "DefaultLocale": {
11934 "PackageName": "Test Package",
11935 "Publisher": "Example",
11936 "ShortDescription": "REST icon fixture",
11937 "Icons": [
11938 {
11939 "IconUrl": "https://example.test/default-128.png",
11940 "IconFileType": "png",
11941 "IconResolution": "128x128",
11942 "IconTheme": "default"
11943 },
11944 {
11945 "IconUrl": "https://example.test/default-256.ico",
11946 "IconFileType": "ico",
11947 "IconResolution": "256x256"
11948 }
11949 ]
11950 },
11951 "Installers": [
11952 {
11953 "Architecture": "x64",
11954 "InstallerType": "exe",
11955 "InstallerUrl": "https://example.test/Test.Package.exe",
11956 "InstallerSha256": "ABC123"
11957 }
11958 ],
11959 "ManifestVersion": "1.10.0"
11960 }
11961 ],
11962 "ManifestVersion": "1.10.0"
11963 }
11964 });
11965 let bytes = serde_json::to_vec(&response).expect("json");
11966
11967 let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
11968
11969 assert_eq!(manifest.icons.len(), 2);
11970 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
11971 assert_eq!(
11972 document["Icons"][0]["IconUrl"].as_str(),
11973 Some("https://example.test/default-128.png")
11974 );
11975 }
11976
11977 #[test]
11978 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
11979 let mut pkg = InstalledPackage {
11983 name: "Edge".to_owned(),
11984 local_id: r"ARP\Machine\X64\Edge".to_owned(),
11985 installed_version: "100.0".to_owned(),
11986 publisher: None,
11987 scope: Some("Machine".to_owned()),
11988 installer_category: Some("exe".to_owned()),
11989 install_location: None,
11990 package_family_names: Vec::new(),
11991 product_codes: Vec::new(),
11992 upgrade_codes: Vec::new(),
11993 correlated: Some(SearchMatch {
11994 source_name: "winget".to_owned(),
11995 source_kind: SourceKind::PreIndexed,
11996 id: "Microsoft.Edge".to_owned(),
11997 name: "Microsoft Edge".to_owned(),
11998 moniker: None,
11999 version: Some("110.0".to_owned()),
12000 channel: None,
12001 match_criteria: None,
12002 }),
12003 winget_package_identifier: None,
12004 winget_source_identifier: None,
12005 installed_version_canonical: false,
12006 correlated_requires_explicit_upgrade: true,
12007 correlated_lacks_compatible_installer: false,
12008 };
12009 let bulk_query = ListQuery {
12010 upgrade_only: true,
12011 ..ListQuery::default()
12012 };
12013 assert!(
12014 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12015 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12016 );
12017
12018 let filtered_query = ListQuery {
12021 upgrade_only: true,
12022 id: Some("Microsoft.Edge".to_owned()),
12023 ..ListQuery::default()
12024 };
12025 assert!(
12026 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12027 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12028 );
12029
12030 pkg.correlated_requires_explicit_upgrade = false;
12032 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12033 }
12034
12035 #[test]
12036 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12037 let mut pkg = InstalledPackage {
12038 name: "Foo".to_owned(),
12039 local_id: r"ARP\Machine\X64\Foo".to_owned(),
12040 installed_version: "1.0".to_owned(),
12041 publisher: None,
12042 scope: Some("Machine".to_owned()),
12043 installer_category: Some("exe".to_owned()),
12044 install_location: None,
12045 package_family_names: Vec::new(),
12046 product_codes: Vec::new(),
12047 upgrade_codes: Vec::new(),
12048 correlated: Some(SearchMatch {
12049 source_name: "winget".to_owned(),
12050 source_kind: SourceKind::PreIndexed,
12051 id: "Test.Foo".to_owned(),
12052 name: "Foo".to_owned(),
12053 moniker: None,
12054 version: Some("2.0".to_owned()),
12055 channel: None,
12056 match_criteria: None,
12057 }),
12058 winget_package_identifier: None,
12059 winget_source_identifier: None,
12060 installed_version_canonical: false,
12061 correlated_requires_explicit_upgrade: false,
12062 correlated_lacks_compatible_installer: true,
12063 };
12064 let bulk_query = ListQuery {
12065 upgrade_only: true,
12066 ..ListQuery::default()
12067 };
12068 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12069
12070 let filtered_query = ListQuery {
12072 upgrade_only: true,
12073 id: Some("Test.Foo".to_owned()),
12074 ..ListQuery::default()
12075 };
12076 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12077
12078 pkg.correlated_lacks_compatible_installer = false;
12080 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12081 }
12082
12083 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12084 let installers = arches
12085 .iter()
12086 .map(|a| Installer {
12087 architecture: Some((*a).to_owned()),
12088 installer_type: None,
12089 url: None,
12090 sha256: None,
12091 product_code: None,
12092 locale: None,
12093 scope: None,
12094 release_date: None,
12095 package_family_name: None,
12096 upgrade_code: None,
12097 platforms: Vec::new(),
12098 minimum_os_version: None,
12099 switches: InstallerSwitches::default(),
12100 commands: Vec::new(),
12101 package_dependencies: Vec::new(),
12102 require_explicit_upgrade: false,
12103 nested_installer_type: None,
12104 nested_installer_files: Vec::new(),
12105 })
12106 .collect();
12107 Manifest {
12108 id: "Test".to_owned(),
12109 name: "Test".to_owned(),
12110 version: "1.0".to_owned(),
12111 channel: String::new(),
12112 publisher: None,
12113 description: None,
12114 moniker: None,
12115 package_url: None,
12116 publisher_url: None,
12117 publisher_support_url: None,
12118 license: None,
12119 license_url: None,
12120 privacy_url: None,
12121 author: None,
12122 copyright: None,
12123 copyright_url: None,
12124 release_notes: None,
12125 release_notes_url: None,
12126 tags: Vec::new(),
12127 agreements: Vec::new(),
12128 package_dependencies: Vec::new(),
12129 documentation: Vec::new(),
12130 icons: Vec::new(),
12131 installers,
12132 require_explicit_upgrade: false,
12133 }
12134 }
12135
12136 #[test]
12137 fn manifest_compatible_installer_neutral_always_matches() {
12138 assert!(manifest_has_compatible_installer(
12140 &synthetic_manifest_with_installer_arches(&["neutral"])
12141 ));
12142 }
12143
12144 #[test]
12145 fn manifest_compatible_installer_empty_installers_pass_through() {
12146 assert!(manifest_has_compatible_installer(
12149 &synthetic_manifest_with_installer_arches(&[])
12150 ));
12151 }
12152
12153 #[test]
12154 fn manifest_compatible_installer_rejects_alien_arch_only() {
12155 assert!(!manifest_has_compatible_installer(
12157 &synthetic_manifest_with_installer_arches(&["ppc"])
12158 ));
12159 }
12160
12161 #[test]
12162 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12163 assert!(manifest_has_compatible_installer(
12165 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12166 ));
12167 }
12168
12169 #[test]
12170 fn lookup_unique_normalized_identity_returns_unique_match() {
12171 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12173 execute_batch_sql(
12174 &connection,
12175 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12176 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12177 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12178 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12179 )
12180 .expect("seed schema");
12181
12182 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12183 .expect("query")
12184 .expect("unique match");
12185 assert_eq!(rowid, 100);
12186 }
12187
12188 #[test]
12189 fn lookup_installed_db_identity_match_returns_product_code_match() {
12190 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12191 execute_batch_sql(
12192 &connection,
12193 "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12194 CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12195 CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12196 CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12197 CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12198 INSERT INTO ids VALUES (1, 'Git.Git');\n\
12199 INSERT INTO names VALUES (1, 'Git');\n\
12200 INSERT INTO manifest VALUES (10, 1, 1);\n\
12201 INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12202 INSERT INTO productcodes_map VALUES (10, 20);",
12203 )
12204 .expect("seed schema");
12205 let package = InstalledPackage {
12206 name: "Git".to_owned(),
12207 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12208 installed_version: "2.54.0".to_owned(),
12209 publisher: None,
12210 scope: None,
12211 installer_category: None,
12212 install_location: None,
12213 package_family_names: Vec::new(),
12214 product_codes: vec!["git_is1".to_owned()],
12215 upgrade_codes: Vec::new(),
12216 correlated: None,
12217 winget_package_identifier: None,
12218 winget_source_identifier: None,
12219 installed_version_canonical: false,
12220 correlated_requires_explicit_upgrade: false,
12221 correlated_lacks_compatible_installer: false,
12222 };
12223
12224 let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12225 .expect("query")
12226 .expect("product code match");
12227
12228 assert_eq!(id, "Git.Git");
12229 assert_eq!(name, "Git");
12230 assert_eq!(matched_by, "ProductCode");
12231 }
12232
12233 #[test]
12234 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12235 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12240 execute_batch_sql(
12241 &connection,
12242 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12243 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12244 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12245 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12246 )
12247 .expect("seed schema");
12248
12249 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12250 assert!(rowid.is_none(), "ambiguous match must not correlate");
12251 }
12252
12253 #[test]
12254 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12255 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12259 execute_batch_sql(
12260 &connection,
12261 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12262 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12263 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12264 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12265 )
12266 .expect("seed schema");
12267
12268 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12269 assert_eq!(rowid, Some(100));
12270 }
12271
12272 #[test]
12273 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12274 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12277 execute_batch_sql(
12278 &connection,
12279 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12280 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12281 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12282 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12283 )
12284 .expect("seed schema");
12285
12286 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12287 assert!(
12288 rowid.is_none(),
12289 "name matches package 100 but publisher matches 200 — no intersection"
12290 );
12291 }
12292
12293 #[test]
12294 fn parse_yaml_manifest_bundle_returns_singleton_document() {
12295 let yaml = r#"
12296PackageIdentifier: Test.Package
12297PackageVersion: 1.2.3
12298DefaultLocale: en-US
12299ManifestType: version
12300ManifestVersion: 1.10.0
12301---
12302PackageIdentifier: Test.Package
12303PackageVersion: 1.2.3
12304PackageLocale: en-US
12305PackageName: Test Package
12306Publisher: Example
12307License: MIT
12308ShortDescription: Structured output
12309ManifestType: defaultLocale
12310ManifestVersion: 1.10.0
12311---
12312PackageIdentifier: Test.Package
12313PackageVersion: 1.2.3
12314ManifestType: installer
12315ManifestVersion: 1.10.0
12316Installers:
12317 - Architecture: x64
12318 InstallerType: exe
12319 InstallerUrl: https://example.test/Test.Package.exe
12320 InstallerSha256: ABC123
12321"#;
12322
12323 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12324
12325 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12326 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12327 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12328 }
12329
12330 #[test]
12331 fn collapse_structured_documents_returns_plural_show_documents() {
12332 let documents = collapse_structured_documents(&[
12333 JsonValue::Array(vec![
12334 serde_json::json!({
12335 "PackageIdentifier": "Test.Package.One",
12336 "PackageVersion": "1.0.0",
12337 "DefaultLocale": "en-US",
12338 "ManifestType": "version",
12339 "ManifestVersion": "1.10.0"
12340 }),
12341 serde_json::json!({
12342 "PackageIdentifier": "Test.Package.One",
12343 "PackageVersion": "1.0.0",
12344 "PackageLocale": "en-US",
12345 "PackageName": "Test Package One",
12346 "ManifestType": "defaultLocale",
12347 "ManifestVersion": "1.10.0"
12348 }),
12349 serde_json::json!({
12350 "PackageIdentifier": "Test.Package.One",
12351 "PackageVersion": "1.0.0",
12352 "ManifestType": "installer",
12353 "ManifestVersion": "1.10.0",
12354 "Installers": [
12355 {
12356 "Architecture": "x64",
12357 "InstallerType": "exe",
12358 "InstallerUrl": "https://example.test/one.exe",
12359 "InstallerSha256": "ABC123"
12360 }
12361 ]
12362 }),
12363 ]),
12364 serde_json::json!({
12365 "PackageIdentifier": "Test.Package.Two",
12366 "PackageVersion": "2.0.0",
12367 "PackageLocale": "en-US",
12368 "PackageName": "Test Package Two",
12369 "ManifestType": "singleton",
12370 "ManifestVersion": "1.12.0"
12371 }),
12372 ]);
12373
12374 assert_eq!(documents.len(), 2);
12375 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12376 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12377 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12378 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12379 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12380 }
12381
12382 #[test]
12383 fn unsupported_action_result_marks_noop_and_warning() {
12384 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12385
12386 assert!(result.success);
12387 assert!(result.no_op);
12388 assert_eq!(result.exit_code, 0);
12389 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12390 }
12391
12392 #[test]
12393 fn create_install_no_op_result_honors_no_upgrade() {
12394 let mut request = InstallRequest::new(PackageQuery {
12395 id: Some("Contoso.App".to_owned()),
12396 ..PackageQuery::default()
12397 });
12398 request.no_upgrade = true;
12399
12400 let manifest = Manifest {
12401 id: "Contoso.App".to_owned(),
12402 name: "Contoso App".to_owned(),
12403 version: "2.0.0".to_owned(),
12404 channel: String::new(),
12405 publisher: None,
12406 description: None,
12407 moniker: None,
12408 package_url: None,
12409 publisher_url: None,
12410 publisher_support_url: None,
12411 license: None,
12412 license_url: None,
12413 privacy_url: None,
12414 author: None,
12415 copyright: None,
12416 copyright_url: None,
12417 release_notes: None,
12418 release_notes_url: None,
12419 tags: Vec::new(),
12420 agreements: Vec::new(),
12421 package_dependencies: Vec::new(),
12422 documentation: Vec::new(),
12423 icons: Vec::new(),
12424 installers: Vec::new(),
12425 require_explicit_upgrade: false,
12426 };
12427 let existing = ListMatch {
12428 name: "Contoso App".to_owned(),
12429 id: "Contoso.App".to_owned(),
12430 local_id: "Contoso.App".to_owned(),
12431 installed_version: "1.0.0".to_owned(),
12432 available_version: Some("2.0.0".to_owned()),
12433 source_name: Some("winget".to_owned()),
12434 publisher: None,
12435 scope: None,
12436 installer_category: None,
12437 install_location: None,
12438 package_family_names: Vec::new(),
12439 product_codes: Vec::new(),
12440 upgrade_codes: Vec::new(),
12441 };
12442
12443 let result =
12444 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12445
12446 assert!(result.success);
12447 assert!(result.no_op);
12448 assert_eq!(result.version, "1.0.0");
12449 assert_eq!(
12450 result.warnings,
12451 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12452 );
12453 }
12454
12455 #[test]
12456 fn create_install_no_op_result_skips_reinstall_when_current() {
12457 let request = InstallRequest::new(PackageQuery {
12458 id: Some("Contoso.App".to_owned()),
12459 ..PackageQuery::default()
12460 });
12461
12462 let manifest = Manifest {
12463 id: "Contoso.App".to_owned(),
12464 name: "Contoso App".to_owned(),
12465 version: "2.0.0".to_owned(),
12466 channel: String::new(),
12467 publisher: None,
12468 description: None,
12469 moniker: None,
12470 package_url: None,
12471 publisher_url: None,
12472 publisher_support_url: None,
12473 license: None,
12474 license_url: None,
12475 privacy_url: None,
12476 author: None,
12477 copyright: None,
12478 copyright_url: None,
12479 release_notes: None,
12480 release_notes_url: None,
12481 tags: Vec::new(),
12482 agreements: Vec::new(),
12483 package_dependencies: Vec::new(),
12484 documentation: Vec::new(),
12485 icons: Vec::new(),
12486 installers: Vec::new(),
12487 require_explicit_upgrade: false,
12488 };
12489 let existing = ListMatch {
12490 name: "Contoso App".to_owned(),
12491 id: "Contoso.App".to_owned(),
12492 local_id: "Contoso.App".to_owned(),
12493 installed_version: "2.0.0".to_owned(),
12494 available_version: Some("2.0.0".to_owned()),
12495 source_name: Some("winget".to_owned()),
12496 publisher: None,
12497 scope: None,
12498 installer_category: None,
12499 install_location: None,
12500 package_family_names: Vec::new(),
12501 product_codes: Vec::new(),
12502 upgrade_codes: Vec::new(),
12503 };
12504
12505 let result =
12506 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12507
12508 assert!(result.success);
12509 assert!(result.no_op);
12510 assert_eq!(result.version, "2.0.0");
12511 assert_eq!(
12512 result.warnings,
12513 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
12514 );
12515 }
12516
12517 #[test]
12518 fn collapse_structured_document_projects_merged_manifest() {
12519 let document = collapse_structured_document(&serde_json::json!({
12520 "PackageIdentifier": "Test.Package",
12521 "PackageVersion": "1.2.3",
12522 "PackageLocale": "en-US",
12523 "PackageName": "Test Package",
12524 "Publisher": "Example",
12525 "InstallerType": "exe",
12526 "Installers": [
12527 {
12528 "Architecture": "x64",
12529 "InstallerUrl": "https://example.test/Test.Package.exe",
12530 "InstallerSha256": "ABC123"
12531 }
12532 ],
12533 "ManifestType": "merged",
12534 "ManifestVersion": "1.10.0"
12535 }));
12536
12537 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12538 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
12539 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
12540 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
12541 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
12542 }
12543
12544 #[test]
12545 fn parses_fixture_manifest() {
12546 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
12547 .join("tests")
12548 .join("fixtures")
12549 .join("ManifestV1_28-Singleton.yaml");
12550 let bytes = fs::read(root).expect("fixture bytes");
12551 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
12552 assert_eq!(manifest.id, "microsoft.msixsdk");
12553 assert_eq!(manifest.version, "1.7.32");
12554 assert_eq!(manifest.name, "MSIX SDK");
12555 assert!(!manifest.installers.is_empty());
12556 }
12557
12558 #[test]
12559 fn parses_installer_switches_from_yaml_manifest() {
12560 let yaml = r#"
12561PackageIdentifier: Test.Package
12562PackageVersion: 1.2.3
12563PackageName: Test Package
12564InstallerSwitches:
12565 SilentWithProgress: /SILENT
12566Installers:
12567 - Architecture: x64
12568 InstallerType: inno
12569 InstallerUrl: https://example.test/Test.Package.exe
12570 InstallerSha256: ABC123
12571 InstallerSwitches:
12572 Silent: /VERYSILENT
12573 Interactive: /HELP
12574"#;
12575
12576 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12577 let installer = &manifest.installers[0];
12578
12579 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
12580 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
12581 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
12582 }
12583
12584 #[test]
12585 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
12586 let yaml = r#"
12587PackageIdentifier: Test.Package
12588PackageVersion: 1.2.3
12589PackageName: Test Package
12590Platform:
12591 - Windows.Desktop
12592MinimumOSVersion: 10.0.19041.0
12593Installers:
12594 - Architecture: x64
12595 InstallerType: exe
12596 InstallerUrl: https://example.test/Test.Package.exe
12597 InstallerSha256: ABC123
12598 - Architecture: x64
12599 InstallerType: msix
12600 Platform:
12601 - Windows.Universal
12602 MinimumOSVersion: 10.0.22621.0
12603 InstallerUrl: https://example.test/Test.Package.msix
12604 InstallerSha256: DEF456
12605"#;
12606
12607 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
12608
12609 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
12610 assert_eq!(
12611 manifest.installers[0].minimum_os_version.as_deref(),
12612 Some("10.0.19041.0")
12613 );
12614 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
12615 assert_eq!(
12616 manifest.installers[1].minimum_os_version.as_deref(),
12617 Some("10.0.22621.0")
12618 );
12619 }
12620
12621 #[test]
12622 fn installer_switch_arguments_prefer_manifest_switches() {
12623 let installer = Installer {
12624 architecture: None,
12625 installer_type: Some("inno".to_owned()),
12626 url: None,
12627 sha256: None,
12628 product_code: None,
12629 locale: None,
12630 scope: None,
12631 release_date: None,
12632 package_family_name: None,
12633 upgrade_code: None,
12634 platforms: Vec::new(),
12635 minimum_os_version: None,
12636 switches: InstallerSwitches {
12637 silent: Some("/mysilent".to_owned()),
12638 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
12639 interactive: Some("/myinteractive".to_owned()),
12640 ..InstallerSwitches::default()
12641 },
12642 commands: Vec::new(),
12643 package_dependencies: Vec::new(),
12644 require_explicit_upgrade: false,
12645 nested_installer_type: None,
12646 nested_installer_files: Vec::new(),
12647 };
12648 let manifest = Manifest {
12649 id: "Test.Package".to_owned(),
12650 name: "Test Package".to_owned(),
12651 version: "1.0.0".to_owned(),
12652 channel: String::new(),
12653 publisher: None,
12654 description: None,
12655 moniker: None,
12656 package_url: None,
12657 publisher_url: None,
12658 publisher_support_url: None,
12659 license: None,
12660 license_url: None,
12661 privacy_url: None,
12662 author: None,
12663 copyright: None,
12664 copyright_url: None,
12665 release_notes: None,
12666 release_notes_url: None,
12667 tags: Vec::new(),
12668 agreements: Vec::new(),
12669 package_dependencies: Vec::new(),
12670 documentation: Vec::new(),
12671 icons: Vec::new(),
12672 installers: Vec::new(),
12673 require_explicit_upgrade: false,
12674 };
12675
12676 let mut silent_request = InstallRequest::new(PackageQuery::default());
12677 silent_request.mode = InstallerMode::Silent;
12678 let mut progress_request = InstallRequest::new(PackageQuery::default());
12679 progress_request.mode = InstallerMode::SilentWithProgress;
12680 let mut interactive_request = InstallRequest::new(PackageQuery::default());
12681 interactive_request.mode = InstallerMode::Interactive;
12682 assert_eq!(
12683 installer_command_arguments(
12684 "inno",
12685 &silent_request,
12686 &manifest,
12687 Path::new("installer.exe"),
12688 &installer
12689 ),
12690 vec!["/mysilent".to_owned()]
12691 );
12692 assert_eq!(
12693 installer_command_arguments(
12694 "inno",
12695 &progress_request,
12696 &manifest,
12697 Path::new("installer.exe"),
12698 &installer
12699 ),
12700 vec!["/mysilentwithprogress".to_owned()]
12701 );
12702 assert_eq!(
12703 installer_command_arguments(
12704 "inno",
12705 &interactive_request,
12706 &manifest,
12707 Path::new("installer.exe"),
12708 &installer
12709 ),
12710 vec!["/myinteractive".to_owned()]
12711 );
12712 }
12713
12714 #[test]
12715 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
12716 let installer = Installer {
12717 architecture: None,
12718 installer_type: Some("inno".to_owned()),
12719 url: None,
12720 sha256: None,
12721 product_code: None,
12722 locale: None,
12723 scope: None,
12724 release_date: None,
12725 package_family_name: None,
12726 upgrade_code: None,
12727 platforms: Vec::new(),
12728 minimum_os_version: None,
12729 switches: InstallerSwitches::default(),
12730 commands: Vec::new(),
12731 package_dependencies: Vec::new(),
12732 require_explicit_upgrade: false,
12733 nested_installer_type: None,
12734 nested_installer_files: Vec::new(),
12735 };
12736 let manifest = Manifest {
12737 id: "Test.Package".to_owned(),
12738 name: "Test Package".to_owned(),
12739 version: "1.0.0".to_owned(),
12740 channel: String::new(),
12741 publisher: None,
12742 description: None,
12743 moniker: None,
12744 package_url: None,
12745 publisher_url: None,
12746 publisher_support_url: None,
12747 license: None,
12748 license_url: None,
12749 privacy_url: None,
12750 author: None,
12751 copyright: None,
12752 copyright_url: None,
12753 release_notes: None,
12754 release_notes_url: None,
12755 tags: Vec::new(),
12756 agreements: Vec::new(),
12757 package_dependencies: Vec::new(),
12758 documentation: Vec::new(),
12759 icons: Vec::new(),
12760 installers: Vec::new(),
12761 require_explicit_upgrade: false,
12762 };
12763 let mut progress_request = InstallRequest::new(PackageQuery::default());
12764 progress_request.mode = InstallerMode::SilentWithProgress;
12765 let mut silent_request = InstallRequest::new(PackageQuery::default());
12766 silent_request.mode = InstallerMode::Silent;
12767
12768 assert_eq!(
12769 installer_command_arguments(
12770 "inno",
12771 &progress_request,
12772 &manifest,
12773 Path::new("installer.exe"),
12774 &installer
12775 ),
12776 vec![
12777 "/SP-".to_owned(),
12778 "/SILENT".to_owned(),
12779 "/SUPPRESSMSGBOXES".to_owned(),
12780 "/NORESTART".to_owned()
12781 ]
12782 );
12783 assert_eq!(
12784 installer_command_arguments(
12785 "inno",
12786 &silent_request,
12787 &manifest,
12788 Path::new("installer.exe"),
12789 &installer
12790 ),
12791 vec![
12792 "/SP-".to_owned(),
12793 "/VERYSILENT".to_owned(),
12794 "/SUPPRESSMSGBOXES".to_owned(),
12795 "/NORESTART".to_owned()
12796 ]
12797 );
12798 }
12799
12800 #[test]
12801 fn installer_command_arguments_append_manifest_and_cli_switches() {
12802 let installer = Installer {
12803 architecture: None,
12804 installer_type: Some("msi".to_owned()),
12805 url: None,
12806 sha256: None,
12807 product_code: None,
12808 locale: None,
12809 scope: None,
12810 release_date: None,
12811 package_family_name: None,
12812 upgrade_code: None,
12813 platforms: Vec::new(),
12814 minimum_os_version: None,
12815 switches: InstallerSwitches {
12816 custom: Some("ADDLOCAL=Core".to_owned()),
12817 log: Some("/log \"<LOGPATH>\"".to_owned()),
12818 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
12819 ..InstallerSwitches::default()
12820 },
12821 commands: Vec::new(),
12822 package_dependencies: Vec::new(),
12823 require_explicit_upgrade: false,
12824 nested_installer_type: None,
12825 nested_installer_files: Vec::new(),
12826 };
12827 let manifest = Manifest {
12828 id: "ShareX.ShareX".to_owned(),
12829 name: "ShareX".to_owned(),
12830 version: "19.0.2".to_owned(),
12831 channel: String::new(),
12832 publisher: None,
12833 description: None,
12834 moniker: None,
12835 package_url: None,
12836 publisher_url: None,
12837 publisher_support_url: None,
12838 license: None,
12839 license_url: None,
12840 privacy_url: None,
12841 author: None,
12842 copyright: None,
12843 copyright_url: None,
12844 release_notes: None,
12845 release_notes_url: None,
12846 tags: Vec::new(),
12847 agreements: Vec::new(),
12848 package_dependencies: Vec::new(),
12849 documentation: Vec::new(),
12850 icons: Vec::new(),
12851 installers: Vec::new(),
12852 require_explicit_upgrade: false,
12853 };
12854 let mut request = InstallRequest::new(PackageQuery::default());
12855 request.mode = InstallerMode::Silent;
12856 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
12857 request.custom = Some("REBOOT=ReallySuppress".to_owned());
12858 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
12859
12860 assert_eq!(
12861 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
12862 vec![
12863 "/i".to_owned(),
12864 r"C:\temp\ShareX.msi".to_owned(),
12865 "/quiet".to_owned(),
12866 "/norestart".to_owned(),
12867 "/log".to_owned(),
12868 r"C:\temp\winget.log".to_owned(),
12869 "ADDLOCAL=Core".to_owned(),
12870 "REBOOT=ReallySuppress".to_owned(),
12871 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
12872 ]
12873 );
12874 }
12875
12876 #[test]
12877 fn msi_shell_execute_arguments_include_msiexec_target() {
12878 let installer = Installer {
12879 architecture: None,
12880 installer_type: Some("wix".to_owned()),
12881 url: None,
12882 sha256: None,
12883 product_code: None,
12884 locale: None,
12885 scope: None,
12886 release_date: None,
12887 package_family_name: None,
12888 upgrade_code: None,
12889 platforms: Vec::new(),
12890 minimum_os_version: None,
12891 switches: InstallerSwitches {
12892 log: Some("/log \"<LOGPATH>\"".to_owned()),
12893 install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
12894 ..InstallerSwitches::default()
12895 },
12896 commands: Vec::new(),
12897 package_dependencies: Vec::new(),
12898 require_explicit_upgrade: false,
12899 nested_installer_type: None,
12900 nested_installer_files: Vec::new(),
12901 };
12902 let mut request = InstallRequest::new(PackageQuery::default());
12903 request.mode = InstallerMode::Silent;
12904 request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
12905 request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
12906
12907 assert_eq!(
12908 installer_shell_execute_arguments(
12909 "wix",
12910 &request,
12911 &Manifest {
12912 id: "OpenJS.NodeJS.22".to_owned(),
12913 name: "Node.js".to_owned(),
12914 version: "22.22.3".to_owned(),
12915 channel: String::new(),
12916 publisher: None,
12917 description: None,
12918 moniker: None,
12919 package_url: None,
12920 publisher_url: None,
12921 publisher_support_url: None,
12922 license: None,
12923 license_url: None,
12924 privacy_url: None,
12925 author: None,
12926 copyright: None,
12927 copyright_url: None,
12928 release_notes: None,
12929 release_notes_url: None,
12930 tags: Vec::new(),
12931 agreements: Vec::new(),
12932 package_dependencies: Vec::new(),
12933 documentation: Vec::new(),
12934 icons: Vec::new(),
12935 installers: Vec::new(),
12936 require_explicit_upgrade: false,
12937 },
12938 Path::new(r"C:\temp\node.msi"),
12939 &installer
12940 ),
12941 vec![
12942 "/i".to_owned(),
12943 r"C:\temp\node.msi".to_owned(),
12944 "/quiet".to_owned(),
12945 "/norestart".to_owned(),
12946 "/log".to_owned(),
12947 r"C:\temp\node.log".to_owned(),
12948 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
12949 ]
12950 );
12951 }
12952
12953 #[test]
12954 fn msi_direct_arguments_omit_msiexec_target() {
12955 let installer = Installer::default();
12956 let mut request = InstallRequest::new(PackageQuery::default());
12957 request.mode = InstallerMode::Silent;
12958
12959 assert_eq!(
12960 installer_direct_msi_arguments(
12961 "wix",
12962 &request,
12963 &Manifest {
12964 id: "Test.Package".to_owned(),
12965 name: "Test".to_owned(),
12966 version: "1.0.0".to_owned(),
12967 channel: String::new(),
12968 publisher: None,
12969 description: None,
12970 moniker: None,
12971 package_url: None,
12972 publisher_url: None,
12973 publisher_support_url: None,
12974 license: None,
12975 license_url: None,
12976 privacy_url: None,
12977 author: None,
12978 copyright: None,
12979 copyright_url: None,
12980 release_notes: None,
12981 release_notes_url: None,
12982 tags: Vec::new(),
12983 agreements: Vec::new(),
12984 package_dependencies: Vec::new(),
12985 documentation: Vec::new(),
12986 icons: Vec::new(),
12987 installers: Vec::new(),
12988 require_explicit_upgrade: false,
12989 },
12990 Path::new(r"C:\temp\test.msi"),
12991 &installer
12992 ),
12993 vec!["/quiet".to_owned(), "/norestart".to_owned()]
12994 );
12995 }
12996
12997 #[test]
12998 fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
12999 let args = vec![
13000 "/quiet".to_owned(),
13001 "/norestart".to_owned(),
13002 "/log".to_owned(),
13003 r"C:\temp\node.log".to_owned(),
13004 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13005 ];
13006
13007 let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13008
13009 assert_eq!(parsed.ui_level, 0x102);
13010 assert_eq!(
13011 parsed.properties,
13012 r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13013 );
13014 assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13015 }
13016
13017 #[cfg(windows)]
13018 #[test]
13019 fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13020 let mut request = InstallRequest::new(PackageQuery::default());
13021 request.mode = InstallerMode::Silent;
13022
13023 let installer = Installer {
13024 installer_type: Some("wix".to_owned()),
13025 scope: Some("machine".to_owned()),
13026 ..Installer::default()
13027 };
13028
13029 assert!(!should_run_msi_direct(&request, &installer));
13030 assert!(should_elevate_msi_shell_execute(&installer));
13031 }
13032
13033 #[test]
13034 fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13035 let strong = InstallerDownloadMetadata {
13036 url: "https://example.test/app.exe".to_owned(),
13037 etag: Some(r#""abc""#.to_owned()),
13038 last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13039 total_length: Some(100),
13040 offset: 50,
13041 };
13042 let weak = InstallerDownloadMetadata {
13043 etag: Some(r#"W/"abc""#.to_owned()),
13044 ..strong.clone()
13045 };
13046
13047 assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13048 assert_eq!(
13049 installer_download_if_range(&weak).as_deref(),
13050 Some("Wed, 03 Jun 2026 21:00:00 GMT")
13051 );
13052 }
13053
13054 #[test]
13055 fn parse_content_range_validates_resume_start_and_total() {
13056 let value = HeaderValue::from_static("bytes 50-99/100");
13057 let parsed = parse_content_range(Some(&value), 50).expect("content range");
13058
13059 assert_eq!(parsed.total_length, Some(100));
13060 assert!(parse_content_range(Some(&value), 49).is_err());
13061 }
13062
13063 #[test]
13064 fn installer_download_sidecar_paths_append_suffixes() {
13065 let path = Path::new(r"C:\temp\installer.msi");
13066
13067 assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13068 assert_eq!(
13069 sidecar_path(path, "part.json"),
13070 PathBuf::from(r"C:\temp\installer.msi.part.json")
13071 );
13072 }
13073
13074 #[test]
13075 fn shell_execute_argument_joining_quotes_spaces() {
13076 assert_eq!(
13077 join_windows_arguments(&[
13078 "/quiet".to_owned(),
13079 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13080 r#"VALUE=has "quotes""#.to_owned(),
13081 ]),
13082 r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13083 );
13084 }
13085
13086 #[test]
13087 fn decompresses_mszyml_payload() {
13088 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13089 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13090 encoder.write_all(payload.as_bytes()).expect("write payload");
13091 let compressed = encoder.finish().expect("finish payload");
13092 let mut mszip = b"CK".to_vec();
13093 mszip.extend_from_slice(&compressed);
13094
13095 let decompressed = decompress_mszyml(&mszip).expect("decompress");
13096 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13097 assert_eq!(parsed.versions[0].version, "1.2.3");
13098 }
13099
13100 #[test]
13101 fn decompresses_mszyml_payload_with_prefix() {
13102 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13103 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13104 encoder.write_all(payload.as_bytes()).expect("write payload");
13105 let compressed = encoder.finish().expect("finish payload");
13106 let mut prefixed = vec![0u8; 28];
13107 prefixed.extend_from_slice(b"CK");
13108 prefixed.extend_from_slice(&compressed);
13109
13110 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13111 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13112 assert_eq!(parsed.versions[0].version, "1.2.3");
13113 }
13114
13115 #[test]
13116 fn compares_versions_naturally() {
13117 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13118 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13119 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13120 assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13121 assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13122 }
13123
13124 #[test]
13125 fn installer_hash_verification_fails_on_mismatch_without_override() {
13126 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13127 assert!(result.is_err());
13128 assert!(
13129 result
13130 .expect_err("hash mismatch")
13131 .to_string()
13132 .contains("Installer hash mismatch")
13133 );
13134 }
13135
13136 #[test]
13137 fn installer_hash_verification_allows_mismatch_with_override() {
13138 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13139 verify_installer_hash(None, b"pinget", false).expect("missing hash");
13140 }
13141
13142 #[test]
13143 fn search_query_many_includes_tag_and_command_conditions() {
13144 let query = PackageQuery {
13145 query: Some("terminal".to_owned()),
13146 ..PackageQuery::default()
13147 };
13148
13149 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13150
13151 assert!(where_clause.contains("tags2"));
13152 assert!(where_clause.contains("commands2"));
13153 assert_eq!(params.len(), 5);
13154 }
13155
13156 #[test]
13157 fn tag_and_command_conditions_are_non_correlated() {
13158 let query = PackageQuery {
13165 query: Some("terminal".to_owned()),
13166 ..PackageQuery::default()
13167 };
13168
13169 let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13170
13171 assert!(where_clause.contains("packages.rowid IN (SELECT"));
13172 assert!(!where_clause.contains("EXISTS"));
13173 assert!(!where_clause.contains("= packages.rowid"));
13174 }
13175
13176 #[test]
13177 fn show_query_single_omits_tag_and_command_conditions() {
13178 let query = PackageQuery {
13179 query: Some("terminal".to_owned()),
13180 ..PackageQuery::default()
13181 };
13182
13183 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13184
13185 assert!(!where_clause.contains("tags2"));
13186 assert!(!where_clause.contains("commands2"));
13187 assert_eq!(params.len(), 3);
13188 }
13189
13190 #[test]
13191 fn explicit_tag_filter_uses_exact_match() {
13192 let query = PackageQuery {
13193 tag: Some("terminal".to_owned()),
13194 ..PackageQuery::default()
13195 };
13196
13197 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13198
13199 assert!(where_clause.contains("tags2"));
13200 assert_eq!(params, vec!["terminal".to_owned()]);
13201 }
13202
13203 #[test]
13204 fn rest_tag_filter_uses_exact_match() {
13205 let query = PackageQuery {
13206 tag: Some("terminal".to_owned()),
13207 ..PackageQuery::default()
13208 };
13209 let info = RestInformation {
13210 required_package_match_fields: Vec::new(),
13211 unsupported_package_match_fields: Vec::new(),
13212 required_query_parameters: Vec::new(),
13213 ..RestInformation::default()
13214 };
13215
13216 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13217 let filters = body["Filters"].as_array().expect("filters");
13218
13219 assert_eq!(filters.len(), 1);
13220 assert_eq!(filters[0]["PackageMatchField"], "Tag");
13221 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13222 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13223 }
13224
13225 #[test]
13226 fn selects_installer_using_requested_filters() {
13227 let installers = vec![
13228 Installer {
13229 architecture: Some("x86".to_owned()),
13230 installer_type: Some("zip".to_owned()),
13231 url: None,
13232 sha256: None,
13233 product_code: None,
13234 locale: Some("en-US".to_owned()),
13235 scope: Some("user".to_owned()),
13236 release_date: None,
13237 package_family_name: None,
13238 upgrade_code: None,
13239 platforms: Vec::new(),
13240 minimum_os_version: None,
13241 switches: InstallerSwitches::default(),
13242 commands: Vec::new(),
13243 package_dependencies: Vec::new(),
13244 require_explicit_upgrade: false,
13245 nested_installer_type: None,
13246 nested_installer_files: Vec::new(),
13247 },
13248 Installer {
13249 architecture: Some("x64".to_owned()),
13250 installer_type: Some("msix".to_owned()),
13251 url: None,
13252 sha256: None,
13253 product_code: None,
13254 locale: Some("en-US".to_owned()),
13255 scope: Some("user".to_owned()),
13256 release_date: None,
13257 package_family_name: None,
13258 upgrade_code: None,
13259 platforms: Vec::new(),
13260 minimum_os_version: None,
13261 switches: InstallerSwitches::default(),
13262 commands: vec!["demo".to_owned()],
13263 package_dependencies: Vec::new(),
13264 require_explicit_upgrade: false,
13265 nested_installer_type: None,
13266 nested_installer_files: Vec::new(),
13267 },
13268 ];
13269 let query = PackageQuery {
13270 installer_type: Some("msix".to_owned()),
13271 installer_architecture: Some("x64".to_owned()),
13272 install_scope: Some("user".to_owned()),
13273 locale: Some("en-US".to_owned()),
13274 ..PackageQuery::default()
13275 };
13276
13277 let selected = select_installer(&installers, &query).expect("selected installer");
13278 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13279 assert_eq!(selected.architecture.as_deref(), Some("x64"));
13280 }
13281
13282 #[test]
13283 fn selects_first_installer_when_rank_ties() {
13284 let installers = vec![
13285 Installer {
13286 architecture: Some("x64".to_owned()),
13287 installer_type: Some("exe".to_owned()),
13288 url: None,
13289 sha256: None,
13290 product_code: None,
13291 locale: Some("en-US".to_owned()),
13292 scope: Some("machine".to_owned()),
13293 release_date: None,
13294 package_family_name: None,
13295 upgrade_code: None,
13296 platforms: Vec::new(),
13297 minimum_os_version: None,
13298 switches: InstallerSwitches::default(),
13299 commands: Vec::new(),
13300 package_dependencies: Vec::new(),
13301 require_explicit_upgrade: false,
13302 nested_installer_type: None,
13303 nested_installer_files: Vec::new(),
13304 },
13305 Installer {
13306 architecture: Some("x64".to_owned()),
13307 installer_type: Some("exe".to_owned()),
13308 url: None,
13309 sha256: None,
13310 product_code: None,
13311 locale: Some("en-US".to_owned()),
13312 scope: Some("user".to_owned()),
13313 release_date: None,
13314 package_family_name: None,
13315 upgrade_code: None,
13316 platforms: Vec::new(),
13317 minimum_os_version: None,
13318 switches: InstallerSwitches::default(),
13319 commands: Vec::new(),
13320 package_dependencies: Vec::new(),
13321 require_explicit_upgrade: false,
13322 nested_installer_type: None,
13323 nested_installer_files: Vec::new(),
13324 },
13325 ];
13326
13327 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13328 assert_eq!(selected.scope.as_deref(), Some("machine"));
13329 }
13330
13331 #[test]
13332 fn selects_installer_using_requested_platform() {
13333 let installers = vec![
13334 Installer {
13335 architecture: Some("x64".to_owned()),
13336 installer_type: Some("exe".to_owned()),
13337 url: None,
13338 sha256: None,
13339 product_code: None,
13340 locale: Some("en-US".to_owned()),
13341 scope: Some("user".to_owned()),
13342 release_date: None,
13343 package_family_name: None,
13344 upgrade_code: None,
13345 platforms: vec!["Windows.Universal".to_owned()],
13346 minimum_os_version: None,
13347 switches: InstallerSwitches::default(),
13348 commands: Vec::new(),
13349 package_dependencies: Vec::new(),
13350 require_explicit_upgrade: false,
13351 nested_installer_type: None,
13352 nested_installer_files: Vec::new(),
13353 },
13354 Installer {
13355 architecture: Some("x64".to_owned()),
13356 installer_type: Some("exe".to_owned()),
13357 url: None,
13358 sha256: None,
13359 product_code: None,
13360 locale: Some("en-US".to_owned()),
13361 scope: Some("user".to_owned()),
13362 release_date: None,
13363 package_family_name: None,
13364 upgrade_code: None,
13365 platforms: vec!["Windows.Desktop".to_owned()],
13366 minimum_os_version: None,
13367 switches: InstallerSwitches::default(),
13368 commands: Vec::new(),
13369 package_dependencies: Vec::new(),
13370 require_explicit_upgrade: false,
13371 nested_installer_type: None,
13372 nested_installer_files: Vec::new(),
13373 },
13374 ];
13375 let query = PackageQuery {
13376 installer_type: Some("exe".to_owned()),
13377 platform: Some("Windows.Desktop".to_owned()),
13378 ..PackageQuery::default()
13379 };
13380
13381 let selected = select_installer(&installers, &query).expect("selected installer");
13382 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13383 }
13384
13385 #[test]
13386 fn selects_installer_using_requested_os_version() {
13387 let installers = vec![
13388 Installer {
13389 architecture: Some("x64".to_owned()),
13390 installer_type: Some("exe".to_owned()),
13391 url: None,
13392 sha256: None,
13393 product_code: None,
13394 locale: Some("en-US".to_owned()),
13395 scope: Some("user".to_owned()),
13396 release_date: None,
13397 package_family_name: None,
13398 upgrade_code: None,
13399 platforms: Vec::new(),
13400 minimum_os_version: Some("10.0.22621.0".to_owned()),
13401 switches: InstallerSwitches::default(),
13402 commands: Vec::new(),
13403 package_dependencies: Vec::new(),
13404 require_explicit_upgrade: false,
13405 nested_installer_type: None,
13406 nested_installer_files: Vec::new(),
13407 },
13408 Installer {
13409 architecture: Some("x64".to_owned()),
13410 installer_type: Some("exe".to_owned()),
13411 url: None,
13412 sha256: None,
13413 product_code: None,
13414 locale: Some("en-US".to_owned()),
13415 scope: Some("user".to_owned()),
13416 release_date: None,
13417 package_family_name: None,
13418 upgrade_code: None,
13419 platforms: Vec::new(),
13420 minimum_os_version: Some("10.0.19041.0".to_owned()),
13421 switches: InstallerSwitches::default(),
13422 commands: Vec::new(),
13423 package_dependencies: Vec::new(),
13424 require_explicit_upgrade: false,
13425 nested_installer_type: None,
13426 nested_installer_files: Vec::new(),
13427 },
13428 ];
13429 let query = PackageQuery {
13430 installer_type: Some("exe".to_owned()),
13431 os_version: Some("10.0.19045.0".to_owned()),
13432 ..PackageQuery::default()
13433 };
13434
13435 let selected = select_installer(&installers, &query).expect("selected installer");
13436 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13437 }
13438
13439 #[test]
13440 fn derives_correlation_name_candidates() {
13441 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13442 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13443 assert!(candidates.contains(&"PowerToys".to_owned()));
13444 }
13445
13446 #[test]
13447 fn correlates_installed_package_to_available_match() {
13448 let installed = InstalledPackage {
13449 name: "PowerToys (Preview) x64".to_owned(),
13450 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13451 installed_version: "0.98.1".to_owned(),
13452 publisher: None,
13453 scope: Some("Machine".to_owned()),
13454 installer_category: Some("exe".to_owned()),
13455 install_location: None,
13456 package_family_names: Vec::new(),
13457 product_codes: Vec::new(),
13458 upgrade_codes: Vec::new(),
13459 correlated: None,
13460 winget_package_identifier: None,
13461 winget_source_identifier: None,
13462 installed_version_canonical: false,
13463 correlated_requires_explicit_upgrade: false,
13464 correlated_lacks_compatible_installer: false,
13465 };
13466 let candidates = vec![SearchMatch {
13467 source_name: "winget".to_owned(),
13468 source_kind: SourceKind::PreIndexed,
13469 id: "Microsoft.PowerToys".to_owned(),
13470 name: "PowerToys".to_owned(),
13471 moniker: None,
13472 version: Some("0.98.1".to_owned()),
13473 channel: None,
13474 match_criteria: None,
13475 }];
13476
13477 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13478 assert_eq!(correlated.id, "Microsoft.PowerToys");
13479 }
13480
13481 #[test]
13482 fn list_query_uses_available_lookup_for_tag_filters() {
13483 let query = ListQuery {
13484 tag: Some("terminal".to_owned()),
13485 ..ListQuery::default()
13486 };
13487
13488 assert!(list_query_needs_available_lookup(&query));
13489 }
13490
13491 #[test]
13492 fn list_tag_filter_requires_correlated_match() {
13493 let package = InstalledPackage {
13494 name: "PowerToys (Preview) x64".to_owned(),
13495 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13496 installed_version: "0.98.1".to_owned(),
13497 publisher: None,
13498 scope: Some("Machine".to_owned()),
13499 installer_category: Some("exe".to_owned()),
13500 install_location: None,
13501 package_family_names: Vec::new(),
13502 product_codes: Vec::new(),
13503 upgrade_codes: Vec::new(),
13504 correlated: None,
13505 winget_package_identifier: None,
13506 winget_source_identifier: None,
13507 installed_version_canonical: false,
13508 correlated_requires_explicit_upgrade: false,
13509 correlated_lacks_compatible_installer: false,
13510 };
13511 let query = ListQuery {
13512 tag: Some("powertoys".to_owned()),
13513 ..ListQuery::default()
13514 };
13515
13516 assert!(!list_package_matches(&package, &query));
13517 }
13518
13519 #[test]
13520 fn list_lookup_ignores_display_count() {
13521 let query = ListQuery {
13522 query: Some("git".to_owned()),
13523 count: Some(5),
13524 ..ListQuery::default()
13525 };
13526
13527 let package_query = package_query_from_list_query(&query);
13528 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
13529 }
13530
13531 #[test]
13532 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
13533 let installed = InstalledPackage {
13538 name: "Notepad++ (ARM 64-bit)".to_owned(),
13539 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
13540 installed_version: "8.8.9".to_owned(),
13541 publisher: None,
13542 scope: Some("Machine".to_owned()),
13543 installer_category: Some("exe".to_owned()),
13544 install_location: None,
13545 package_family_names: Vec::new(),
13546 product_codes: Vec::new(),
13547 upgrade_codes: Vec::new(),
13548 correlated: None,
13549 winget_package_identifier: None,
13550 winget_source_identifier: None,
13551 installed_version_canonical: false,
13552 correlated_requires_explicit_upgrade: false,
13553 correlated_lacks_compatible_installer: false,
13554 };
13555 let candidates = vec![
13556 SearchMatch {
13557 source_name: "winget".to_owned(),
13558 source_kind: SourceKind::PreIndexed,
13559 id: "ndd.Notepad--".to_owned(),
13560 name: "Notepad--".to_owned(),
13561 moniker: None,
13562 version: Some("1.0.0".to_owned()),
13563 channel: None,
13564 match_criteria: None,
13565 },
13566 SearchMatch {
13567 source_name: "winget".to_owned(),
13568 source_kind: SourceKind::PreIndexed,
13569 id: "Notepad++.Notepad++".to_owned(),
13570 name: "Notepad++".to_owned(),
13571 moniker: None,
13572 version: Some("8.9.5".to_owned()),
13573 channel: None,
13574 match_criteria: None,
13575 },
13576 ];
13577
13578 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13579 assert_eq!(correlated.id, "Notepad++.Notepad++");
13580 }
13581
13582 #[test]
13583 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
13584 let installed = InstalledPackage {
13589 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
13590 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
13591 installed_version: "40.10.18029".to_owned(),
13592 publisher: None,
13593 scope: Some("Machine".to_owned()),
13594 installer_category: Some("msi".to_owned()),
13595 install_location: None,
13596 package_family_names: Vec::new(),
13597 product_codes: Vec::new(),
13598 upgrade_codes: Vec::new(),
13599 correlated: None,
13600 winget_package_identifier: None,
13601 winget_source_identifier: None,
13602 installed_version_canonical: false,
13603 correlated_requires_explicit_upgrade: false,
13604 correlated_lacks_compatible_installer: false,
13605 };
13606 let candidates = vec![
13607 SearchMatch {
13608 source_name: "winget".to_owned(),
13609 source_kind: SourceKind::PreIndexed,
13610 id: "ZeroBrane.Studio".to_owned(),
13611 name: "Studio".to_owned(),
13612 moniker: None,
13613 version: Some("1.0".to_owned()),
13614 channel: None,
13615 match_criteria: None,
13616 },
13617 SearchMatch {
13618 source_name: "winget".to_owned(),
13619 source_kind: SourceKind::PreIndexed,
13620 id: "BrickLink.Studio".to_owned(),
13621 name: "Studio".to_owned(),
13622 moniker: None,
13623 version: Some("1.0".to_owned()),
13624 channel: None,
13625 match_criteria: None,
13626 },
13627 SearchMatch {
13628 source_name: "winget".to_owned(),
13629 source_kind: SourceKind::PreIndexed,
13630 id: "Microsoft.DotNet.SDK.10".to_owned(),
13631 name: "Microsoft .NET SDK 10.0".to_owned(),
13632 moniker: None,
13633 version: Some("10.0.204".to_owned()),
13634 channel: None,
13635 match_criteria: None,
13636 },
13637 ];
13638
13639 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
13640 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
13641 }
13642
13643 #[test]
13644 fn msix_packages_do_not_correlate_via_name() {
13645 let installed = InstalledPackage {
13653 name: "Microsoft Teams".to_owned(),
13654 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13655 installed_version: "25290.205.4069.4894".to_owned(),
13656 publisher: None,
13657 scope: Some("User".to_owned()),
13658 installer_category: Some("msix".to_owned()),
13659 install_location: None,
13660 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13661 product_codes: Vec::new(),
13662 upgrade_codes: Vec::new(),
13663 correlated: None,
13664 winget_package_identifier: None,
13665 winget_source_identifier: None,
13666 installed_version_canonical: false,
13667 correlated_requires_explicit_upgrade: false,
13668 correlated_lacks_compatible_installer: false,
13669 };
13670 let candidates = vec![SearchMatch {
13671 source_name: "winget".to_owned(),
13672 source_kind: SourceKind::PreIndexed,
13673 id: "Microsoft.Teams".to_owned(),
13674 name: "Microsoft Teams".to_owned(),
13675 moniker: None,
13676 version: Some("26106.1906.4665.7308".to_owned()),
13677 channel: None,
13678 match_criteria: None,
13679 }];
13680
13681 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13682 }
13683
13684 #[test]
13685 fn name_fallback_refuses_ambiguous_winners() {
13686 let installed = InstalledPackage {
13694 name: "Git".to_owned(),
13695 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
13696 installed_version: "2.53.0".to_owned(),
13697 publisher: Some("The Git Development Community".to_owned()),
13698 scope: Some("Machine".to_owned()),
13699 installer_category: Some("exe".to_owned()),
13700 install_location: None,
13701 package_family_names: Vec::new(),
13702 product_codes: Vec::new(),
13703 upgrade_codes: Vec::new(),
13704 correlated: None,
13705 winget_package_identifier: None,
13706 winget_source_identifier: None,
13707 installed_version_canonical: false,
13708 correlated_requires_explicit_upgrade: false,
13709 correlated_lacks_compatible_installer: false,
13710 };
13711 let candidates = vec![
13712 SearchMatch {
13713 source_name: "winget".to_owned(),
13714 source_kind: SourceKind::PreIndexed,
13715 id: "Git.Git".to_owned(),
13716 name: "Git".to_owned(),
13717 moniker: None,
13718 version: Some("2.54.0".to_owned()),
13719 channel: None,
13720 match_criteria: None,
13721 },
13722 SearchMatch {
13723 source_name: "winget".to_owned(),
13724 source_kind: SourceKind::PreIndexed,
13725 id: "Microsoft.Git".to_owned(),
13726 name: "Git".to_owned(),
13727 moniker: None,
13728 version: Some("2.53.0.0.7".to_owned()),
13729 channel: None,
13730 match_criteria: None,
13731 },
13732 ];
13733
13734 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13735 }
13736
13737 #[test]
13738 fn msix_with_resource_string_name_does_not_correlate() {
13739 let installed = InstalledPackage {
13743 name: "ms-resource:appDisplayName".to_owned(),
13744 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13745 installed_version: "1.28.239.0".to_owned(),
13746 publisher: None,
13747 scope: Some("User".to_owned()),
13748 installer_category: Some("msix".to_owned()),
13749 install_location: None,
13750 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13751 product_codes: Vec::new(),
13752 upgrade_codes: Vec::new(),
13753 correlated: None,
13754 winget_package_identifier: None,
13755 winget_source_identifier: None,
13756 installed_version_canonical: false,
13757 correlated_requires_explicit_upgrade: false,
13758 correlated_lacks_compatible_installer: false,
13759 };
13760 let candidates = vec![SearchMatch {
13761 source_name: "winget".to_owned(),
13762 source_kind: SourceKind::PreIndexed,
13763 id: "Microsoft.AppInstaller".to_owned(),
13764 name: "App Installer".to_owned(),
13765 moniker: None,
13766 version: Some("1.28.240.0".to_owned()),
13767 channel: None,
13768 match_criteria: None,
13769 }];
13770
13771 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
13772 }
13773
13774 #[test]
13775 fn strict_list_correlation_avoids_short_substring_matches() {
13776 let installed = InstalledPackage {
13777 name: "GitHub".to_owned(),
13778 local_id: r"ARP\User\X64\GitHub".to_owned(),
13779 installed_version: "1.0.0".to_owned(),
13780 publisher: None,
13781 scope: Some("User".to_owned()),
13782 installer_category: Some("exe".to_owned()),
13783 install_location: None,
13784 package_family_names: Vec::new(),
13785 product_codes: Vec::new(),
13786 upgrade_codes: Vec::new(),
13787 correlated: None,
13788 winget_package_identifier: None,
13789 winget_source_identifier: None,
13790 installed_version_canonical: false,
13791 correlated_requires_explicit_upgrade: false,
13792 correlated_lacks_compatible_installer: false,
13793 };
13794 let candidates = vec![SearchMatch {
13795 source_name: "winget".to_owned(),
13796 source_kind: SourceKind::PreIndexed,
13797 id: "Git.Git.PreRelease".to_owned(),
13798 name: "Git".to_owned(),
13799 moniker: None,
13800 version: Some("2.54.0".to_owned()),
13801 channel: None,
13802 match_criteria: None,
13803 }];
13804
13805 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
13806 }
13807
13808 #[test]
13809 fn detects_available_upgrade_from_correlated_package() {
13810 let package = InstalledPackage {
13811 name: "AzCopy v10".to_owned(),
13812 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13813 installed_version: "10.32.2".to_owned(),
13814 publisher: None,
13815 scope: Some("Machine".to_owned()),
13816 installer_category: Some("exe".to_owned()),
13817 install_location: None,
13818 package_family_names: Vec::new(),
13819 product_codes: Vec::new(),
13820 upgrade_codes: Vec::new(),
13821 correlated: Some(SearchMatch {
13822 source_name: "winget".to_owned(),
13823 source_kind: SourceKind::PreIndexed,
13824 id: "Microsoft.Azure.AZCopy.10".to_owned(),
13825 name: "AzCopy v10".to_owned(),
13826 moniker: None,
13827 version: Some("10.32.3".to_owned()),
13828 channel: None,
13829 match_criteria: None,
13830 }),
13831 winget_package_identifier: None,
13832 winget_source_identifier: None,
13833 installed_version_canonical: false,
13834 correlated_requires_explicit_upgrade: false,
13835 correlated_lacks_compatible_installer: false,
13836 };
13837
13838 assert!(installed_package_has_upgrade(&package));
13839 }
13840
13841 #[test]
13842 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
13843 let package = InstalledPackage {
13844 name: "Example Tool".to_owned(),
13845 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
13846 installed_version: "Unknown".to_owned(),
13847 publisher: None,
13848 scope: Some("User".to_owned()),
13849 installer_category: Some("exe".to_owned()),
13850 install_location: None,
13851 package_family_names: Vec::new(),
13852 product_codes: Vec::new(),
13853 upgrade_codes: Vec::new(),
13854 correlated: Some(SearchMatch {
13855 source_name: "winget".to_owned(),
13856 source_kind: SourceKind::PreIndexed,
13857 id: "Contoso.ExampleTool".to_owned(),
13858 name: "Example Tool".to_owned(),
13859 moniker: None,
13860 version: Some("2.0.0".to_owned()),
13861 channel: None,
13862 match_criteria: None,
13863 }),
13864 winget_package_identifier: None,
13865 winget_source_identifier: None,
13866 installed_version_canonical: false,
13867 correlated_requires_explicit_upgrade: false,
13868 correlated_lacks_compatible_installer: false,
13869 };
13870 let query = ListQuery {
13871 upgrade_only: true,
13872 include_unknown: true,
13873 ..Default::default()
13874 };
13875
13876 assert!(installed_package_matches_upgrade_filter(&package, &query));
13877 assert_eq!(
13878 list_match_from_installed(package, &[]).available_version.as_deref(),
13879 Some("2.0.0")
13880 );
13881 }
13882
13883 #[test]
13884 fn create_repair_list_query_includes_installed_selectors() {
13885 let request = RepairRequest {
13886 query: PackageQuery {
13887 query: Some("powertoys".to_owned()),
13888 id: Some("Microsoft.PowerToys".to_owned()),
13889 name: Some("PowerToys".to_owned()),
13890 moniker: Some("powertoys".to_owned()),
13891 source: Some("winget".to_owned()),
13892 exact: true,
13893 version: Some("0.98.1".to_owned()),
13894 install_scope: Some("Machine".to_owned()),
13895 ..PackageQuery::default()
13896 },
13897 manifest_path: None,
13898 product_code: Some("{GUID}".to_owned()),
13899 mode: InstallerMode::Silent,
13900 log_path: Some(PathBuf::from("repair.log")),
13901 accept_package_agreements: true,
13902 force: true,
13903 ignore_security_hash: true,
13904 };
13905
13906 let query = Repository::create_repair_list_query(&request);
13907 assert_eq!(query.query.as_deref(), Some("powertoys"));
13908 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
13909 assert_eq!(query.name.as_deref(), Some("PowerToys"));
13910 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
13911 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
13912 assert_eq!(query.version.as_deref(), Some("0.98.1"));
13913 assert_eq!(query.source.as_deref(), Some("winget"));
13914 assert!(query.exact);
13915 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
13916 assert_eq!(query.count, Some(100));
13917 }
13918
13919 #[test]
13920 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
13921 let request = RepairRequest {
13922 query: PackageQuery {
13923 name: Some("PowerToys".to_owned()),
13924 source: Some("winget".to_owned()),
13925 locale: Some("en-US".to_owned()),
13926 installer_architecture: Some("x64".to_owned()),
13927 install_scope: Some("Machine".to_owned()),
13928 ..PackageQuery::default()
13929 },
13930 manifest_path: None,
13931 product_code: None,
13932 mode: InstallerMode::SilentWithProgress,
13933 log_path: Some(PathBuf::from("repair.log")),
13934 accept_package_agreements: true,
13935 force: false,
13936 ignore_security_hash: true,
13937 };
13938 let installed = ListMatch {
13939 name: "PowerToys".to_owned(),
13940 id: "Microsoft.PowerToys".to_owned(),
13941 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
13942 installed_version: "0.98.1".to_owned(),
13943 available_version: Some("0.98.2".to_owned()),
13944 source_name: Some("winget".to_owned()),
13945 publisher: None,
13946 scope: Some("Machine".to_owned()),
13947 installer_category: Some("exe".to_owned()),
13948 install_location: None,
13949 package_family_names: Vec::new(),
13950 product_codes: Vec::new(),
13951 upgrade_codes: Vec::new(),
13952 };
13953
13954 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
13955 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
13956 assert!(install_request.query.query.is_none());
13957 assert!(install_request.query.name.is_none());
13958 assert!(install_request.query.moniker.is_none());
13959 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
13960 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
13961 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
13962 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
13963 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
13964 assert!(install_request.query.exact);
13965 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
13966 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
13967 assert!(install_request.accept_package_agreements);
13968 assert!(install_request.force);
13969 assert!(install_request.ignore_security_hash);
13970 }
13971
13972 #[test]
13973 fn source_scoped_pin_operations_round_trip() {
13974 let app_root = temp_app_root("pins");
13975 let result = (|| -> Result<()> {
13976 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
13977 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
13978 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
13979 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
13980
13981 assert_eq!(repository.list_pins(None)?.len(), 3);
13982 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
13983 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
13984
13985 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
13986 assert_eq!(repository.list_pins(None)?.len(), 2);
13987 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
13988
13989 repository.reset_pins(Some("test"))?;
13990 let remaining = repository.list_pins(None)?;
13991 assert_eq!(remaining.len(), 1);
13992 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
13993
13994 repository.reset_pins(None)?;
13995 assert!(repository.list_pins(None)?.is_empty());
13996 Ok(())
13997 })();
13998
13999 let _ = fs::remove_dir_all(&app_root);
14000 result.expect("pin round trip");
14001 }
14002
14003 #[test]
14004 fn pin_operations_work_with_packaged_pin_schema() {
14005 let app_root = temp_app_root("pins_packaged_schema");
14006 let result = (|| -> Result<()> {
14007 let db_path = pins_db_path(&app_root);
14008 if let Some(parent) = db_path.parent() {
14009 fs::create_dir_all(parent)?;
14010 }
14011
14012 let conn = Repository::open_sqlite_connection(db_path.clone())?;
14013 execute_batch_sql(
14014 &conn,
14015 "CREATE TABLE pin (
14016 package_id TEXT NOT NULL,
14017 source_id TEXT NOT NULL,
14018 type INTEGER NOT NULL,
14019 version TEXT NOT NULL,
14020 PRIMARY KEY (package_id, source_id)
14021 )",
14022 )?;
14023 conn.cacheflush().context("failed to flush pin database")?;
14024 drop(conn);
14025
14026 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14027 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14028
14029 let pins = repository.list_pins(None)?;
14030 assert_eq!(pins.len(), 1);
14031 assert_eq!(pins[0].package_id, "Contoso.Tool");
14032 assert_eq!(pins[0].version, "1.2.*");
14033 assert_eq!(pins[0].source_id, "winget");
14034 assert_eq!(pins[0].pin_type, PinType::Gating);
14035
14036 let conn = Repository::open_sqlite_connection(db_path)?;
14037 let stored_pin = query_optional_value(
14038 &conn,
14039 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14040 vec![
14041 SqlValue::Text("Contoso.Tool".to_owned()),
14042 SqlValue::Text("winget".to_owned()),
14043 ],
14044 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14045 )?
14046 .ok_or_else(|| anyhow!("expected stored pin"))?;
14047 assert_eq!(stored_pin.0, 3);
14048 assert_eq!(stored_pin.1, "1.2.*");
14049
14050 Ok(())
14051 })();
14052
14053 let _ = fs::remove_dir_all(&app_root);
14054 result.expect("packaged pin schema round trip");
14055 }
14056
14057 #[test]
14058 fn version_pin_patterns_match_exact_and_prefix_values() {
14059 assert!(version_matches_pin_pattern("1.2.3", "*"));
14060 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14061 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14062 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14063 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14064 }
14065
14066 #[test]
14067 fn source_specific_pins_override_source_agnostic_pins() {
14068 let item = ListMatch {
14069 name: "Contoso Tool".to_owned(),
14070 id: "Contoso.Tool".to_owned(),
14071 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14072 installed_version: "1.0.0".to_owned(),
14073 available_version: Some("2.0.0".to_owned()),
14074 source_name: Some("winget".to_owned()),
14075 publisher: None,
14076 scope: None,
14077 installer_category: None,
14078 install_location: None,
14079 package_family_names: Vec::new(),
14080 product_codes: Vec::new(),
14081 upgrade_codes: Vec::new(),
14082 };
14083 let pins = vec![
14084 PinRecord {
14085 package_id: "Contoso.Tool".to_owned(),
14086 version: "*".to_owned(),
14087 source_id: String::new(),
14088 pin_type: PinType::Blocking,
14089 },
14090 PinRecord {
14091 package_id: "Contoso.Tool".to_owned(),
14092 version: "2.0.*".to_owned(),
14093 source_id: "winget".to_owned(),
14094 pin_type: PinType::Pinning,
14095 },
14096 ];
14097
14098 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14099 }
14100
14101 #[test]
14102 fn blocking_pin_blocks_upgrade() {
14103 let item = ListMatch {
14104 name: "Contoso Tool".to_owned(),
14105 id: "Contoso.Tool".to_owned(),
14106 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14107 installed_version: "1.0.0".to_owned(),
14108 available_version: Some("2.0.0".to_owned()),
14109 source_name: Some("winget".to_owned()),
14110 publisher: None,
14111 scope: None,
14112 installer_category: None,
14113 install_location: None,
14114 package_family_names: Vec::new(),
14115 product_codes: Vec::new(),
14116 upgrade_codes: Vec::new(),
14117 };
14118 let pins = vec![PinRecord {
14119 package_id: "Contoso.Tool".to_owned(),
14120 version: "*".to_owned(),
14121 source_id: "winget".to_owned(),
14122 pin_type: PinType::Blocking,
14123 }];
14124
14125 assert!(is_upgrade_blocked_by_pin(&item, &pins));
14126 }
14127
14128 #[cfg(windows)]
14129 #[test]
14130 fn parses_msix_package_full_name_into_version_and_family() {
14131 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14132 .expect("package metadata");
14133
14134 assert_eq!(parsed.version, "0.98.1.0");
14135 assert_eq!(parsed.resource_id, "");
14136 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14137 assert!(!is_msix_split_resource_package(&parsed.resource_id));
14138 }
14139
14140 #[cfg(windows)]
14141 #[test]
14142 fn classifies_msix_split_resource_package() {
14143 let parsed = parse_msix_package_full_name(
14144 "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14145 )
14146 .expect("package metadata");
14147
14148 assert_eq!(parsed.version, "1.97.17002.0");
14149 assert_eq!(parsed.resource_id, "split.scale-125");
14150 assert_eq!(
14151 parsed.family_name,
14152 "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14153 );
14154 assert!(is_msix_split_resource_package(&parsed.resource_id));
14155 }
14156
14157 #[cfg(windows)]
14158 #[test]
14159 fn recognizes_windows_system_paths() {
14160 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14161 assert!(!is_windows_system_path(
14162 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14163 ));
14164 }
14165
14166 #[test]
14167 fn search_ranking_prefers_exact_name_over_tag_match() {
14168 let query = PackageQuery {
14169 query: Some("PowerToys".to_owned()),
14170 ..Default::default()
14171 };
14172 let exact_name = SearchMatch {
14173 source_name: "winget".to_owned(),
14174 source_kind: SourceKind::PreIndexed,
14175 id: "Microsoft.PowerToys".to_owned(),
14176 name: "PowerToys".to_owned(),
14177 moniker: Some("powertoys".to_owned()),
14178 version: Some("0.98.1".to_owned()),
14179 channel: None,
14180 match_criteria: Some("Moniker: powertoys".to_owned()),
14181 };
14182 let tag_match = SearchMatch {
14183 source_name: "winget".to_owned(),
14184 source_kind: SourceKind::PreIndexed,
14185 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14186 name: "QR Codes for Command Palette".to_owned(),
14187 moniker: None,
14188 version: Some("0.4.0.0".to_owned()),
14189 channel: None,
14190 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14191 };
14192
14193 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14194 }
14195
14196 #[test]
14197 fn select_best_text_match_prefers_exact_tag_value() {
14198 let values = vec![
14199 "microsoft-powertoys".to_owned(),
14200 "powertoys-run".to_owned(),
14201 "powertoys".to_owned(),
14202 ];
14203
14204 assert_eq!(
14205 select_best_text_match(values, "PowerToys", false).as_deref(),
14206 Some("powertoys")
14207 );
14208 }
14209
14210 #[test]
14211 fn search_match_criteria_is_blank_for_name_match() {
14212 let query = PackageQuery {
14213 query: Some("PowerToys".to_owned()),
14214 ..Default::default()
14215 };
14216
14217 let result = infer_match_criteria(
14218 "Microsoft.PowerToys",
14219 "PowerToys",
14220 Some("powertoys"),
14221 &query,
14222 SearchSemantics::Many,
14223 |_| Ok(Some("powertoys".to_owned())),
14224 |_| Ok(None),
14225 )
14226 .expect("match criteria");
14227
14228 assert_eq!(result, None);
14229 }
14230
14231 #[test]
14232 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14233 let query = PackageQuery {
14234 query: Some("PowerToys".to_owned()),
14235 ..Default::default()
14236 };
14237 let unknown_version = SearchMatch {
14238 source_name: "msstore".to_owned(),
14239 source_kind: SourceKind::Rest,
14240 id: "XP89DCGQ3K6VLD".to_owned(),
14241 name: "Microsoft PowerToys".to_owned(),
14242 moniker: None,
14243 version: Some("Unknown".to_owned()),
14244 channel: None,
14245 match_criteria: None,
14246 };
14247 let tag_match = SearchMatch {
14248 source_name: "winget".to_owned(),
14249 source_kind: SourceKind::PreIndexed,
14250 id: "Riri.QRCodeforCmdPal".to_owned(),
14251 name: "QR Code for CmdPal".to_owned(),
14252 moniker: None,
14253 version: Some("0.0.1.0".to_owned()),
14254 channel: None,
14255 match_criteria: Some("Tag: powertoys".to_owned()),
14256 };
14257
14258 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14259 }
14260
14261 #[test]
14262 fn search_source_fetch_results_ignore_small_display_count() {
14263 let query = PackageQuery {
14264 count: Some(5),
14265 ..Default::default()
14266 };
14267
14268 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14269 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14270 }
14271
14272 #[test]
14273 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14274 let query = PackageQuery {
14275 query: Some("PowerToys".to_owned()),
14276 ..Default::default()
14277 };
14278 let prefix_name = SearchMatch {
14279 source_name: "winget".to_owned(),
14280 source_kind: SourceKind::PreIndexed,
14281 id: "advaith.CurrencyConverterPowerToys".to_owned(),
14282 name: "PowerToys-Run-Currency-Converter".to_owned(),
14283 moniker: None,
14284 version: Some("1.5.4".to_owned()),
14285 channel: None,
14286 match_criteria: None,
14287 };
14288 let exact_tag = SearchMatch {
14289 source_name: "winget".to_owned(),
14290 source_kind: SourceKind::PreIndexed,
14291 id: "Riri.QRCodeforCmdPal".to_owned(),
14292 name: "QR Code for CmdPal".to_owned(),
14293 moniker: None,
14294 version: Some("0.0.1.0".to_owned()),
14295 channel: None,
14296 match_criteria: Some("Tag: powertoys".to_owned()),
14297 };
14298
14299 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14300 }
14301
14302 #[test]
14303 fn list_sort_prefers_main_package_then_sparse_app() {
14304 let main = InstalledPackage {
14305 name: "PowerToys (Preview) x64".to_owned(),
14306 local_id: r"ARP\User\X64\PowerToys".to_owned(),
14307 installed_version: "0.98.1".to_owned(),
14308 publisher: None,
14309 scope: Some("User".to_owned()),
14310 installer_category: Some("exe".to_owned()),
14311 install_location: None,
14312 package_family_names: Vec::new(),
14313 product_codes: Vec::new(),
14314 upgrade_codes: Vec::new(),
14315 correlated: None,
14316 winget_package_identifier: None,
14317 winget_source_identifier: None,
14318 installed_version_canonical: false,
14319 correlated_requires_explicit_upgrade: false,
14320 correlated_lacks_compatible_installer: false,
14321 };
14322 let sparse = InstalledPackage {
14323 name: "PowerToys.SparseApp".to_owned(),
14324 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14325 installed_version: "0.98.1.0".to_owned(),
14326 publisher: None,
14327 scope: Some("User".to_owned()),
14328 installer_category: Some("msix".to_owned()),
14329 install_location: None,
14330 package_family_names: Vec::new(),
14331 product_codes: Vec::new(),
14332 upgrade_codes: Vec::new(),
14333 correlated: None,
14334 winget_package_identifier: None,
14335 winget_source_identifier: None,
14336 installed_version_canonical: false,
14337 correlated_requires_explicit_upgrade: false,
14338 correlated_lacks_compatible_installer: false,
14339 };
14340 let extension = InstalledPackage {
14341 name: "PowerToys FileLocksmith Context Menu".to_owned(),
14342 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14343 installed_version: "0.98.1.0".to_owned(),
14344 publisher: None,
14345 scope: Some("User".to_owned()),
14346 installer_category: Some("msix".to_owned()),
14347 install_location: None,
14348 package_family_names: Vec::new(),
14349 product_codes: Vec::new(),
14350 upgrade_codes: Vec::new(),
14351 correlated: None,
14352 winget_package_identifier: None,
14353 winget_source_identifier: None,
14354 installed_version_canonical: false,
14355 correlated_requires_explicit_upgrade: false,
14356 correlated_lacks_compatible_installer: false,
14357 };
14358
14359 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14360 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14361 }
14362
14363 #[test]
14364 #[cfg(windows)]
14365 fn arp_subkey_name_extracts_registry_subkey() {
14366 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14367 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14368 }
14369
14370 #[test]
14371 #[cfg(windows)]
14372 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14373 let installed = ListMatch {
14374 name: "ShareX".to_owned(),
14375 id: "ShareX.ShareX".to_owned(),
14376 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14377 installed_version: "19.0.2".to_owned(),
14378 available_version: None,
14379 source_name: Some("winget".to_owned()),
14380 publisher: Some("ShareX Team".to_owned()),
14381 scope: Some("Machine".to_owned()),
14382 installer_category: Some("exe".to_owned()),
14383 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14384 package_family_names: Vec::new(),
14385 product_codes: Vec::new(),
14386 upgrade_codes: Vec::new(),
14387 };
14388
14389 assert!(registry_entry_matches_installed_package(
14390 "ShareX", "ShareX", None, &installed
14391 ));
14392 assert!(!registry_entry_matches_installed_package(
14393 "ShareX.ShareX",
14394 "ShareX.ShareX",
14395 None,
14396 &installed
14397 ));
14398 }
14399
14400 #[test]
14401 #[cfg(windows)]
14402 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14403 assert_eq!(
14404 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14405 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14406 );
14407 assert_eq!(
14408 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14409 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14410 );
14411 assert_eq!(
14412 r#""C:\Program Files\ShareX\unins000.exe""#,
14413 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14414 );
14415 assert_eq!(
14416 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14417 build_uninstall_command(
14418 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14419 true,
14420 false,
14421 )
14422 );
14423 }
14424
14425 #[test]
14426 #[cfg(windows)]
14427 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14428 let installed = ListMatch {
14429 name: "lazygit".to_owned(),
14430 id: "JesseDuffield.lazygit".to_owned(),
14431 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14432 installed_version: "0.61.1".to_owned(),
14433 available_version: None,
14434 source_name: Some("winget".to_owned()),
14435 publisher: Some("Jesse Duffield".to_owned()),
14436 scope: Some("User".to_owned()),
14437 installer_category: Some("exe".to_owned()),
14438 install_location: Some(
14439 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14440 ),
14441 package_family_names: Vec::new(),
14442 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14443 upgrade_codes: Vec::new(),
14444 };
14445 let mut request = UninstallRequest::new(PackageQuery {
14446 id: Some("JesseDuffield.lazygit".to_owned()),
14447 install_scope: Some("user".to_owned()),
14448 ..PackageQuery::default()
14449 });
14450 request.mode = InstallerMode::Silent;
14451
14452 assert_eq!(
14453 Some(vec![
14454 "uninstall".to_owned(),
14455 "--id".to_owned(),
14456 "JesseDuffield.lazygit".to_owned(),
14457 "--exact".to_owned(),
14458 "--disable-interactivity".to_owned(),
14459 "--source".to_owned(),
14460 "winget".to_owned(),
14461 "--scope".to_owned(),
14462 "user".to_owned(),
14463 "--silent".to_owned(),
14464 ]),
14465 build_winget_uninstall_arguments(&installed, &request)
14466 );
14467
14468 assert_eq!(
14469 Some(vec![
14470 "uninstall".to_owned(),
14471 "--id".to_owned(),
14472 "JesseDuffield.lazygit".to_owned(),
14473 "--exact".to_owned(),
14474 "--disable-interactivity".to_owned(),
14475 "--source".to_owned(),
14476 "winget".to_owned(),
14477 "--silent".to_owned(),
14478 ]),
14479 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
14480 );
14481 }
14482
14483 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
14484 PackageVersionDataEntry {
14485 version: version.to_owned(),
14486 manifest_relative_path: format!("manifests/x/{version}"),
14487 manifest_hash: "00".repeat(32),
14488 arp_min_version: arp_min.map(str::to_owned),
14489 arp_max_version: arp_max.map(str::to_owned),
14490 }
14491 }
14492
14493 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
14494 V1VersionRow {
14495 version: version.to_owned(),
14496 channel: channel.to_owned(),
14497 pathpart_id: 0,
14498 manifest_hash: None,
14499 }
14500 }
14501
14502 #[derive(Clone)]
14503 struct TestHttpResponse {
14504 status: u16,
14505 body: Vec<u8>,
14506 source_version: Option<String>,
14507 etag: Option<String>,
14508 last_modified: Option<String>,
14509 }
14510
14511 struct TestHttpServer {
14512 addr: SocketAddr,
14513 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14514 counts: Arc<Mutex<HashMap<String, usize>>>,
14515 running: Arc<AtomicBool>,
14516 handle: Option<JoinHandle<()>>,
14517 }
14518
14519 impl TestHttpServer {
14520 fn start() -> Self {
14521 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
14522 let addr = listener.local_addr().expect("test HTTP server address");
14523 let routes = Arc::new(Mutex::new(HashMap::new()));
14524 let counts = Arc::new(Mutex::new(HashMap::new()));
14525 let running = Arc::new(AtomicBool::new(true));
14526 let thread_routes = Arc::clone(&routes);
14527 let thread_counts = Arc::clone(&counts);
14528 let thread_running = Arc::clone(&running);
14529 let handle = thread::spawn(move || {
14530 while thread_running.load(AtomicOrdering::SeqCst) {
14531 let Ok((stream, _)) = listener.accept() else {
14532 continue;
14533 };
14534 handle_test_http_request(stream, &thread_routes, &thread_counts);
14535 }
14536 });
14537
14538 Self {
14539 addr,
14540 routes,
14541 counts,
14542 running,
14543 handle: Some(handle),
14544 }
14545 }
14546
14547 fn url(&self) -> String {
14548 format!("http://{}", self.addr)
14549 }
14550
14551 fn set_bytes(&self, path: &str, body: Vec<u8>) {
14552 self.set_response(
14553 path,
14554 TestHttpResponse {
14555 status: 200,
14556 body,
14557 source_version: None,
14558 etag: None,
14559 last_modified: None,
14560 },
14561 );
14562 }
14563
14564 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
14565 self.set_source_package_with_validators(body, source_version, None, None);
14566 }
14567
14568 fn set_source_package_with_validators(
14569 &self,
14570 body: Vec<u8>,
14571 source_version: &str,
14572 etag: Option<&str>,
14573 last_modified: Option<&str>,
14574 ) {
14575 self.set_response(
14576 "/source.msix",
14577 TestHttpResponse {
14578 status: 200,
14579 body,
14580 source_version: Some(source_version.to_owned()),
14581 etag: etag.map(str::to_owned),
14582 last_modified: last_modified.map(str::to_owned),
14583 },
14584 );
14585 }
14586
14587 fn set_status(&self, path: &str, status: u16) {
14588 self.set_response(
14589 path,
14590 TestHttpResponse {
14591 status,
14592 body: Vec::new(),
14593 source_version: None,
14594 etag: None,
14595 last_modified: None,
14596 },
14597 );
14598 }
14599
14600 fn set_response(&self, path: &str, response: TestHttpResponse) {
14601 self.routes
14602 .lock()
14603 .expect("test HTTP routes")
14604 .insert(path.to_owned(), response);
14605 }
14606
14607 fn request_count(&self, path: &str) -> usize {
14608 self.counts
14609 .lock()
14610 .expect("test HTTP counts")
14611 .get(path)
14612 .copied()
14613 .unwrap_or_default()
14614 }
14615 }
14616
14617 impl Drop for TestHttpServer {
14618 fn drop(&mut self) {
14619 self.running.store(false, AtomicOrdering::SeqCst);
14620 let _ = TcpStream::connect(self.addr);
14621 if let Some(handle) = self.handle.take() {
14622 let _ = handle.join();
14623 }
14624 }
14625 }
14626
14627 fn handle_test_http_request(
14628 mut stream: TcpStream,
14629 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
14630 counts: &Arc<Mutex<HashMap<String, usize>>>,
14631 ) {
14632 let mut buffer = [0u8; 2048];
14633 let read = stream.read(&mut buffer).unwrap_or(0);
14634 let request = String::from_utf8_lossy(&buffer[..read]);
14635 let path = request
14636 .lines()
14637 .next()
14638 .and_then(|line| line.split_whitespace().nth(1))
14639 .unwrap_or("/");
14640 *counts
14641 .lock()
14642 .expect("test HTTP counts")
14643 .entry(path.to_owned())
14644 .or_default() += 1;
14645 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
14646 let response = response.unwrap_or(TestHttpResponse {
14647 status: 404,
14648 body: Vec::new(),
14649 source_version: None,
14650 etag: None,
14651 last_modified: None,
14652 });
14653 let not_modified = response.status == 200
14654 && (response.etag.as_deref().is_some_and(|etag| {
14655 request_header_value(&request, "If-None-Match")
14656 .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
14657 }) || response.last_modified.as_deref().is_some_and(|last_modified| {
14658 request_header_value(&request, "If-Modified-Since")
14659 .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
14660 }));
14661 let status = if not_modified { 304 } else { response.status };
14662 let body = if not_modified {
14663 &[][..]
14664 } else {
14665 response.body.as_slice()
14666 };
14667 let reason = match status {
14668 200 => "OK",
14669 304 => "Not Modified",
14670 _ => "Not Found",
14671 };
14672 let mut headers = format!(
14673 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
14674 status,
14675 reason,
14676 body.len()
14677 );
14678 if let Some(source_version) = response.source_version {
14679 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
14680 }
14681 if let Some(etag) = response.etag {
14682 headers.push_str(&format!("ETag: {etag}\r\n"));
14683 }
14684 if let Some(last_modified) = response.last_modified {
14685 headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
14686 }
14687 headers.push_str("\r\n");
14688 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
14689 stream.write_all(body).expect("write test HTTP body");
14690 }
14691
14692 fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
14693 request.lines().find_map(|line| {
14694 let (header_name, value) = line.split_once(':')?;
14695 header_name.eq_ignore_ascii_case(name).then(|| value.trim())
14696 })
14697 }
14698
14699 fn test_source_record(source_url: String) -> SourceRecord {
14700 SourceRecord {
14701 name: "test-winget".to_owned(),
14702 kind: SourceKind::PreIndexed,
14703 arg: source_url,
14704 identifier: "Test.Source".to_owned(),
14705 trust_level: "Trusted".to_owned(),
14706 explicit: false,
14707 priority: 0,
14708 last_update: Some(Utc::now()),
14709 source_version: None,
14710 etag: None,
14711 last_modified: None,
14712 }
14713 }
14714
14715 fn open_test_preindexed_repository(
14716 app_root: &Path,
14717 source: &SourceRecord,
14718 interval: Option<Duration>,
14719 ) -> Repository {
14720 fs::create_dir_all(app_root).expect("create app root");
14721 save_store(
14722 app_root,
14723 &SourceStore {
14724 sources: vec![source.clone()],
14725 },
14726 )
14727 .expect("save source store");
14728 Repository::open_with_options(
14729 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
14730 )
14731 .expect("open repository")
14732 }
14733
14734 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
14735 let index_path = preindexed_index_path(app_root, source);
14736 if let Some(parent) = index_path.parent() {
14737 fs::create_dir_all(parent).expect("create source state directory");
14738 }
14739 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
14740 }
14741
14742 #[test]
14743 fn replace_file_failure_keeps_existing_target() {
14744 let root = temp_app_root("replace_file_failure");
14745 fs::create_dir_all(&root).expect("create replace root");
14746 let target = root.join("index.db");
14747 let missing_source = root.join("missing.tmp");
14748 fs::write(&target, b"old-index").expect("write existing target");
14749
14750 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
14751
14752 assert!(format!("{error:#}").contains("failed to replace"));
14753 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
14754 let _ = fs::remove_dir_all(root);
14755 }
14756
14757 #[cfg(windows)]
14758 #[test]
14759 fn replace_file_succeeds_while_target_is_open() {
14760 use std::os::windows::fs::OpenOptionsExt;
14761 const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
14765
14766 let root = temp_app_root("replace_file_open_target");
14767 fs::create_dir_all(&root).expect("create replace root");
14768 let target = root.join("index.db");
14769 let source = root.join("index.new.tmp");
14770 fs::write(&target, b"old-index").expect("write existing target");
14771 fs::write(&source, b"new-index").expect("write replacement");
14772
14773 let held = OpenOptions::new()
14774 .read(true)
14775 .share_mode(SHARE_ALL)
14776 .open(&target)
14777 .expect("open target with share-delete");
14778
14779 replace_file(&source, &target).expect("stage-aside replace should succeed");
14780
14781 assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
14782 drop(held);
14783 let _ = fs::remove_dir_all(root);
14784 }
14785
14786 fn make_source_package(versions: &[&str]) -> Vec<u8> {
14787 let cursor = Cursor::new(Vec::new());
14788 let mut archive = zip::ZipWriter::new(cursor);
14789 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
14790 archive
14791 .start_file("Public/index.db", options)
14792 .expect("start index entry");
14793 archive
14794 .write_all(&make_v1_index_bytes(versions))
14795 .expect("write index entry");
14796 archive.finish().expect("finish source package").into_inner()
14797 }
14798
14799 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
14800 let root = temp_app_root("preindexed_index_bytes");
14801 fs::create_dir_all(&root).expect("create index temp root");
14802 let db_path = root.join("index.db");
14803 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
14804 let mut sql = String::from(
14805 "CREATE TABLE ids (id TEXT);
14806 CREATE TABLE names (name TEXT);
14807 CREATE TABLE monikers (moniker TEXT);
14808 CREATE TABLE versions (version TEXT);
14809 CREATE TABLE channels (channel TEXT);
14810 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
14811 CREATE TABLE manifest (
14812 id INT64,
14813 name INT64,
14814 moniker INT64,
14815 version INT64,
14816 channel INT64,
14817 pathpart INT64
14818 );
14819 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
14820 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
14821 INSERT INTO channels(rowid, channel) VALUES (1, '');
14822 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
14823 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
14824 );
14825 for (index, version) in versions.iter().enumerate() {
14826 let rowid = index + 1;
14827 sql.push_str(&format!(
14828 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
14829 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
14830 ));
14831 }
14832 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
14833 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
14834 .expect("checkpoint preindexed V1 database");
14835 connection.cacheflush().expect("flush preindexed V1 database");
14836 drop(connection);
14837 let bytes = fs::read(&db_path).expect("read index bytes");
14838 let _ = fs::remove_dir_all(root);
14839 bytes
14840 }
14841
14842 fn test_manifest_yaml(version: &str) -> Vec<u8> {
14843 format!(
14844 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
14845 )
14846 .into_bytes()
14847 }
14848
14849 fn show_test_package(version: Option<&str>) -> PackageQuery {
14850 PackageQuery {
14851 id: Some("Test.Package".to_owned()),
14852 source: Some("test-winget".to_owned()),
14853 exact: true,
14854 version: version.map(str::to_owned),
14855 ..PackageQuery::default()
14856 }
14857 }
14858
14859 #[test]
14860 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
14861 let app_root = temp_app_root("preindexed_explicit_refresh");
14862 let server = TestHttpServer::start();
14863 server.set_status("/source2.msix", 404);
14864 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
14865 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14866 let source = test_source_record(server.url());
14867 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14868 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14869
14870 let result = repository
14871 .show(&show_test_package(Some("2.0.0")))
14872 .expect("show after refresh");
14873
14874 assert_eq!(result.manifest.version, "2.0.0");
14875 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
14876 let _ = fs::remove_dir_all(app_root);
14877 }
14878
14879 #[test]
14880 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
14881 let app_root = temp_app_root("preindexed_explicit_still_missing");
14882 let server = TestHttpServer::start();
14883 server.set_status("/source2.msix", 404);
14884 server.set_source_package(make_source_package(&["1.0.0"]), "same");
14885 let source = test_source_record(server.url());
14886 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14887 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
14888
14889 let error = repository
14890 .show(&show_test_package(Some("2.0.0")))
14891 .expect_err("missing explicit version must fail");
14892
14893 assert!(format!("{error:#}").contains("2.0.0"));
14894 let _ = fs::remove_dir_all(app_root);
14895 }
14896
14897 #[test]
14898 fn preindexed_stale_index_refreshes_for_latest_requests() {
14899 let app_root = temp_app_root("preindexed_ttl_refresh");
14900 let server = TestHttpServer::start();
14901 server.set_status("/source2.msix", 404);
14902 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14903 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
14904 let mut source = test_source_record(server.url());
14905 source.last_update = Some(Utc::now() - Duration::days(2));
14906 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14907 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14908
14909 let result = repository
14910 .show(&show_test_package(None))
14911 .expect("show latest after TTL refresh");
14912
14913 assert_eq!(result.manifest.version, "2.0.0");
14914 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
14915 let _ = fs::remove_dir_all(app_root);
14916 }
14917
14918 #[test]
14919 fn preindexed_stale_index_not_modified_keeps_local_index() {
14920 let app_root = temp_app_root("preindexed_ttl_not_modified");
14921 let server = TestHttpServer::start();
14922 server.set_status("/source2.msix", 404);
14923 server.set_source_package_with_validators(
14924 make_source_package(&["1.0.0", "2.0.0"]),
14925 "fresh",
14926 Some("\"same-index\""),
14927 Some("Wed, 03 Jun 2026 12:00:00 GMT"),
14928 );
14929 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14930 let mut source = test_source_record(server.url());
14931 source.last_update = Some(Utc::now() - Duration::days(2));
14932 source.etag = Some("\"same-index\"".to_owned());
14933 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
14934 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14935 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14936
14937 let result = repository
14938 .show(&show_test_package(None))
14939 .expect("show latest from not-modified stale index");
14940
14941 let refreshed_source = &repository.list_sources()[0];
14942 assert_eq!(result.manifest.version, "1.0.0");
14943 assert_eq!(server.request_count("/source.msix"), 1);
14944 assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
14945 assert_eq!(
14946 refreshed_source.last_modified.as_deref(),
14947 Some("Wed, 03 Jun 2026 12:00:00 GMT")
14948 );
14949 let _ = fs::remove_dir_all(app_root);
14950 }
14951
14952 #[test]
14953 fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
14954 let app_root = temp_app_root("preindexed_fresh_mtime");
14955 let server = TestHttpServer::start();
14956 server.set_status("/source2.msix", 404);
14957 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
14958 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14959 let mut source = test_source_record(server.url());
14960 source.last_update = Some(Utc::now() - Duration::days(2));
14961 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14962 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
14963
14964 let result = repository
14965 .show(&show_test_package(None))
14966 .expect("show latest from fresh index mtime");
14967
14968 assert_eq!(result.manifest.version, "1.0.0");
14969 assert_eq!(server.request_count("/source.msix"), 0);
14970 let _ = fs::remove_dir_all(app_root);
14971 }
14972
14973 #[test]
14974 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
14975 let app_root = temp_app_root("preindexed_ttl_fallback");
14976 let server = TestHttpServer::start();
14977 server.set_status("/source2.msix", 404);
14978 server.set_status("/source.msix", 404);
14979 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
14980 let mut source = test_source_record(server.url());
14981 source.last_update = Some(Utc::now() - Duration::days(2));
14982 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
14983 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
14984
14985 let result = repository
14986 .show(&show_test_package(None))
14987 .expect("show latest from stale cache after refresh failure");
14988
14989 assert_eq!(result.manifest.version, "1.0.0");
14990 let _ = fs::remove_dir_all(app_root);
14991 }
14992
14993 #[test]
14994 fn explicit_source_search_failure_is_not_reported_as_no_match() {
14995 let app_root = temp_app_root("preindexed_source_search_failure");
14996 let server = TestHttpServer::start();
14997 server.set_status("/source2.msix", 404);
14998 server.set_status("/source.msix", 404);
14999 let source = test_source_record(server.url());
15000 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15001
15002 let error = repository
15003 .show(&show_test_package(None))
15004 .expect_err("source failure must surface");
15005 let message = format!("{error:#}");
15006
15007 assert!(message.contains("failed to search source 'test-winget'"));
15008 assert!(message.contains("source.msix"));
15009 assert!(!message.contains("no package matched the supplied query"));
15010 let _ = fs::remove_dir_all(app_root);
15011 }
15012
15013 #[test]
15014 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15015 let app_root = temp_app_root("preindexed_no_double_refresh");
15016 let server = TestHttpServer::start();
15017 server.set_status("/source2.msix", 404);
15018 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15019 let mut source = test_source_record(server.url());
15020 source.last_update = Some(Utc::now() - Duration::days(2));
15021 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15022 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15023
15024 let error = repository
15025 .show(&show_test_package(Some("3.0.0")))
15026 .expect_err("still-missing explicit version must fail after one stale refresh");
15027
15028 assert!(format!("{error:#}").contains("3.0.0"));
15029 assert_eq!(server.request_count("/source.msix"), 1);
15030 let _ = fs::remove_dir_all(app_root);
15031 }
15032
15033 #[test]
15034 fn select_v2_version_without_explicit_filters_returns_latest() {
15035 let versions = vec![
15036 version_entry("1.0.0", None, None),
15037 version_entry("1.2.0", None, None),
15038 version_entry("1.1.0", None, None),
15039 ];
15040
15041 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15042
15043 assert_eq!(selected.version, "1.2.0");
15044 }
15045
15046 #[test]
15047 fn select_v2_version_with_missing_requested_version_throws() {
15048 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15049
15050 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15051 .expect_err("missing requested version");
15052
15053 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15054 assert!(error.to_string().contains("9.9.9"));
15055 }
15056
15057 #[test]
15058 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15059 let versions = vec![version_entry("1.2.0", None, None)];
15060
15061 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15062 .expect_err("unsupported channel request");
15063
15064 assert!(error.to_string().contains("beta"));
15065 assert!(error.to_string().contains("channel metadata"));
15066 }
15067
15068 #[test]
15069 fn select_v1_version_channel_only_returns_latest_matching_channel() {
15070 let versions = vec![
15071 v1_version("1.2.0", "stable"),
15072 v1_version("1.3.0", "stable"),
15073 v1_version("2.0.0", "preview"),
15074 ];
15075
15076 let selected =
15077 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15078
15079 assert_eq!(selected.version, "1.3.0");
15080 }
15081
15082 #[test]
15083 fn select_rest_version_channel_mismatch_throws() {
15084 let versions = vec![
15085 VersionKey {
15086 version: "1.2.0".to_owned(),
15087 channel: "stable".to_owned(),
15088 },
15089 VersionKey {
15090 version: "1.1.0".to_owned(),
15091 channel: "beta".to_owned(),
15092 },
15093 ];
15094
15095 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15096 .expect_err("missing version and channel pair");
15097
15098 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15099 }
15100
15101 #[test]
15102 fn map_arp_version_returns_catalog_version_inside_range() {
15103 let entries = vec![
15108 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15109 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15110 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15111 ];
15112
15113 assert_eq!(
15114 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15115 Some("10.0.108")
15116 );
15117 }
15118
15119 #[test]
15120 fn map_arp_version_returns_none_when_no_range_matches() {
15121 let entries = vec![
15126 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15127 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15128 ];
15129
15130 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15131 }
15132
15133 #[test]
15134 fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15135 let entries = vec![
15139 version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15140 version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15141 ];
15142
15143 assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15144 assert_eq!(
15145 less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15146 Some("< 25.4.1")
15147 );
15148 assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15149 }
15150
15151 #[test]
15152 fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15153 let entries = vec![
15157 version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15158 version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15159 ];
15160
15161 assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15162 assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15163 assert_eq!(
15164 greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15165 Some("> 1.5.8")
15166 );
15167 assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15168
15169 let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15170 assert!(
15171 greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15172 );
15173 }
15174
15175 #[test]
15176 fn map_arp_version_skips_entries_missing_arp_bounds() {
15177 let entries = vec![
15181 version_entry("9.0.300", None, None),
15182 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15183 ];
15184
15185 assert_eq!(
15186 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15187 Some("9.0.117")
15188 );
15189 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15190 }
15191
15192 #[test]
15193 fn map_arp_version_handles_inclusive_range_endpoints() {
15194 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15197
15198 assert_eq!(
15199 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15200 Some("3.14.5")
15201 );
15202 assert_eq!(
15203 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15204 Some("3.14.5")
15205 );
15206 assert_eq!(
15207 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15208 Some("3.14.5")
15209 );
15210 }
15211
15212 #[test]
15213 fn map_arp_version_ignores_unknown_marker() {
15214 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15218
15219 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15220 assert!(map_arp_version_to_catalog(&entries, "").is_none());
15221 }
15222
15223 #[test]
15224 fn latest_arp_anchored_skips_internal_versions() {
15225 let entries = vec![
15231 version_entry("8000.836.2153.0", None, None),
15232 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15233 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15234 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15235 ];
15236
15237 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15238 }
15239
15240 #[test]
15241 fn latest_arp_anchored_returns_none_without_bounds() {
15242 let entries = vec![
15246 version_entry("1.28.240.0", None, None),
15247 version_entry("1.27.470.0", None, None),
15248 ];
15249
15250 assert!(latest_arp_anchored_version(&entries).is_none());
15251 }
15252
15253 #[test]
15254 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15255 let package = InstalledPackage {
15256 name: "AzCopy v10".to_owned(),
15257 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15258 installed_version: "10.32.2".to_owned(),
15259 publisher: Some("Microsoft".to_owned()),
15260 scope: Some("Machine".to_owned()),
15261 installer_category: Some("exe".to_owned()),
15262 install_location: None,
15263 package_family_names: Vec::new(),
15264 product_codes: Vec::new(),
15265 upgrade_codes: Vec::new(),
15266 correlated: Some(SearchMatch {
15267 source_name: "winget".to_owned(),
15268 source_kind: SourceKind::PreIndexed,
15269 id: "Microsoft.Azure.AZCopy.10".to_owned(),
15270 name: "AzCopy v10".to_owned(),
15271 moniker: None,
15272 version: Some("10.32.3".to_owned()),
15273 channel: None,
15274 match_criteria: None,
15275 }),
15276 winget_package_identifier: None,
15277 winget_source_identifier: None,
15278 installed_version_canonical: false,
15279 correlated_requires_explicit_upgrade: false,
15280 correlated_lacks_compatible_installer: false,
15281 };
15282
15283 let item = list_match_from_installed(package, &[]);
15284
15285 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15286 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15287 assert_eq!(item.source_name.as_deref(), Some("winget"));
15288 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15289 }
15290
15291 #[test]
15292 fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15293 let rows = vec![
15294 ListMatch {
15295 name: "Example 1.0".to_owned(),
15296 id: "Example.Tool".to_owned(),
15297 local_id: r"ARP\Machine\X64\old".to_owned(),
15298 installed_version: "1.0.0".to_owned(),
15299 available_version: Some("3.0.0".to_owned()),
15300 source_name: Some("winget".to_owned()),
15301 publisher: None,
15302 scope: None,
15303 installer_category: None,
15304 install_location: None,
15305 package_family_names: Vec::new(),
15306 product_codes: Vec::new(),
15307 upgrade_codes: Vec::new(),
15308 },
15309 ListMatch {
15310 name: "Example 2.0".to_owned(),
15311 id: "Example.Tool".to_owned(),
15312 local_id: r"ARP\Machine\X64\new".to_owned(),
15313 installed_version: "2.0.0".to_owned(),
15314 available_version: Some("3.0.0".to_owned()),
15315 source_name: Some("winget".to_owned()),
15316 publisher: None,
15317 scope: None,
15318 installer_category: None,
15319 install_location: None,
15320 package_family_names: Vec::new(),
15321 product_codes: Vec::new(),
15322 upgrade_codes: Vec::new(),
15323 },
15324 ListMatch {
15325 name: "Other".to_owned(),
15326 id: "Other.Tool".to_owned(),
15327 local_id: r"ARP\Machine\X64\other".to_owned(),
15328 installed_version: "1.0.0".to_owned(),
15329 available_version: Some("2.0.0".to_owned()),
15330 source_name: Some("winget".to_owned()),
15331 publisher: None,
15332 scope: None,
15333 installer_category: None,
15334 install_location: None,
15335 package_family_names: Vec::new(),
15336 product_codes: Vec::new(),
15337 upgrade_codes: Vec::new(),
15338 },
15339 ];
15340
15341 let result = suppress_duplicate_available_versions(rows);
15342
15343 assert_eq!(result[0].available_version, None);
15344 assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15345 assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15346 }
15347
15348 #[test]
15349 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15350 let package = InstalledPackage {
15351 name: "Atlassian CLI".to_owned(),
15352 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15353 installed_version: "1.3.18-stable".to_owned(),
15354 publisher: Some("Atlassian".to_owned()),
15355 scope: Some("User".to_owned()),
15356 installer_category: Some("exe".to_owned()),
15357 install_location: None,
15358 package_family_names: Vec::new(),
15359 product_codes: Vec::new(),
15360 upgrade_codes: Vec::new(),
15361 correlated: Some(SearchMatch {
15362 source_name: "winget".to_owned(),
15363 source_kind: SourceKind::PreIndexed,
15364 id: "Atlassian.AtlassianCLI".to_owned(),
15365 name: "Atlassian CLI".to_owned(),
15366 moniker: None,
15367 version: Some("1.3.18-stable".to_owned()),
15368 channel: None,
15369 match_criteria: Some("ProductCode".to_owned()),
15370 }),
15371 winget_package_identifier: None,
15372 winget_source_identifier: None,
15373 installed_version_canonical: false,
15374 correlated_requires_explicit_upgrade: false,
15375 correlated_lacks_compatible_installer: false,
15376 };
15377
15378 let item = list_match_from_installed(package, &[]);
15379
15380 assert_eq!(item.id, "Atlassian.AtlassianCLI");
15381 assert_eq!(
15382 item.local_id,
15383 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15384 );
15385 assert_ne!(item.id, item.local_id);
15386 assert_eq!(item.source_name.as_deref(), Some("winget"));
15387 }
15388
15389 #[test]
15390 fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15391 let package = InstalledPackage {
15392 name: "FFmpeg".to_owned(),
15393 local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15394 installed_version: "8.0".to_owned(),
15395 publisher: Some("Gyan".to_owned()),
15396 scope: Some("User".to_owned()),
15397 installer_category: Some("portable".to_owned()),
15398 install_location: None,
15399 package_family_names: Vec::new(),
15400 product_codes: Vec::new(),
15401 upgrade_codes: Vec::new(),
15402 correlated: None,
15403 winget_package_identifier: None,
15404 winget_source_identifier: None,
15405 installed_version_canonical: false,
15406 correlated_requires_explicit_upgrade: false,
15407 correlated_lacks_compatible_installer: false,
15408 };
15409 let sources = vec![SourceRecord {
15410 name: "winget".to_owned(),
15411 kind: SourceKind::PreIndexed,
15412 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15413 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15414 trust_level: "Trusted".to_owned(),
15415 explicit: false,
15416 priority: 0,
15417 last_update: None,
15418 source_version: None,
15419 etag: None,
15420 last_modified: None,
15421 }];
15422
15423 let item = list_match_from_installed(package, &sources);
15424
15425 assert_eq!(item.id, "Gyan.FFmpeg");
15426 assert_eq!(
15427 item.local_id,
15428 r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15429 );
15430 assert_eq!(item.source_name.as_deref(), Some("winget"));
15431 }
15432
15433 #[test]
15434 fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15435 let package = InstalledPackage {
15436 name: "tessl".to_owned(),
15437 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15438 installed_version: "0.77.0".to_owned(),
15439 publisher: Some("tessl.io".to_owned()),
15440 scope: Some("User".to_owned()),
15441 installer_category: Some("portable".to_owned()),
15442 install_location: None,
15443 package_family_names: Vec::new(),
15444 product_codes: Vec::new(),
15445 upgrade_codes: Vec::new(),
15446 correlated: None,
15447 winget_package_identifier: Some("tessl.tessl".to_owned()),
15448 winget_source_identifier: Some("api.winget.pro".to_owned()),
15449 installed_version_canonical: false,
15450 correlated_requires_explicit_upgrade: false,
15451 correlated_lacks_compatible_installer: false,
15452 };
15453
15454 let item = list_match_from_installed(package, &[]);
15455
15456 assert_eq!(item.id, "tessl.tessl");
15457 assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15458 }
15459
15460 #[test]
15461 fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15462 let package = InstalledPackage {
15463 name: "tessl".to_owned(),
15464 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15465 installed_version: "0.77.0".to_owned(),
15466 publisher: Some("tessl.io".to_owned()),
15467 scope: Some("User".to_owned()),
15468 installer_category: Some("portable".to_owned()),
15469 install_location: None,
15470 package_family_names: Vec::new(),
15471 product_codes: Vec::new(),
15472 upgrade_codes: Vec::new(),
15473 correlated: None,
15474 winget_package_identifier: Some("tessl.tessl".to_owned()),
15475 winget_source_identifier: Some("api.winget.pro".to_owned()),
15476 installed_version_canonical: false,
15477 correlated_requires_explicit_upgrade: false,
15478 correlated_lacks_compatible_installer: false,
15479 };
15480 let sources = vec![SourceRecord {
15481 name: "winget.pro".to_owned(),
15482 kind: SourceKind::Rest,
15483 arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
15484 identifier: "api.winget.pro".to_owned(),
15485 trust_level: "Trusted".to_owned(),
15486 explicit: false,
15487 priority: 0,
15488 last_update: None,
15489 source_version: None,
15490 etag: None,
15491 last_modified: None,
15492 }];
15493
15494 let item = list_match_from_installed(package, &sources);
15495
15496 assert_eq!(item.id, "tessl.tessl");
15497 assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
15498 }
15499
15500 #[test]
15501 fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
15502 let package = InstalledPackage {
15503 name: "Example".to_owned(),
15504 local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
15505 installed_version: "1.0".to_owned(),
15506 publisher: Some("Vendor".to_owned()),
15507 scope: Some("User".to_owned()),
15508 installer_category: Some("portable".to_owned()),
15509 install_location: None,
15510 package_family_names: Vec::new(),
15511 product_codes: Vec::new(),
15512 upgrade_codes: Vec::new(),
15513 correlated: None,
15514 winget_package_identifier: None,
15515 winget_source_identifier: None,
15516 installed_version_canonical: false,
15517 correlated_requires_explicit_upgrade: false,
15518 correlated_lacks_compatible_installer: false,
15519 };
15520 let sources = vec![
15521 SourceRecord {
15522 name: "long".to_owned(),
15523 kind: SourceKind::PreIndexed,
15524 arg: "https://example.invalid/long".to_owned(),
15525 identifier: "identifier-longer-than-local-id".to_owned(),
15526 trust_level: "Trusted".to_owned(),
15527 explicit: false,
15528 priority: 0,
15529 last_update: None,
15530 source_version: None,
15531 etag: None,
15532 last_modified: None,
15533 },
15534 SourceRecord {
15535 name: "short".to_owned(),
15536 kind: SourceKind::PreIndexed,
15537 arg: "https://example.invalid/short".to_owned(),
15538 identifier: "short".to_owned(),
15539 trust_level: "Trusted".to_owned(),
15540 explicit: false,
15541 priority: 1,
15542 last_update: None,
15543 source_version: None,
15544 etag: None,
15545 last_modified: None,
15546 },
15547 ];
15548
15549 let item = list_match_from_installed(package, &sources);
15550
15551 assert_eq!(item.id, "Vendor.Example");
15552 assert_eq!(item.source_name.as_deref(), Some("short"));
15553 }
15554
15555 #[test]
15556 fn list_response_serialization_keeps_pascal_case_fields() {
15557 let response = ListResponse {
15558 matches: vec![ListMatch {
15559 name: "PowerToys".to_owned(),
15560 id: "Microsoft.PowerToys".to_owned(),
15561 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
15562 installed_version: "0.98.1".to_owned(),
15563 available_version: Some("0.99.0".to_owned()),
15564 source_name: Some("winget".to_owned()),
15565 publisher: Some("Microsoft".to_owned()),
15566 scope: Some("Machine".to_owned()),
15567 installer_category: Some("msi".to_owned()),
15568 install_location: None,
15569 package_family_names: Vec::new(),
15570 product_codes: Vec::new(),
15571 upgrade_codes: Vec::new(),
15572 }],
15573 warnings: Vec::new(),
15574 truncated: false,
15575 };
15576
15577 let value = serde_json::to_value(&response).expect("serialize list response");
15578 let match_value = &value["Matches"][0];
15579
15580 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
15581 assert_eq!(match_value["InstalledVersion"], "0.98.1");
15582 assert_eq!(match_value["AvailableVersion"], "0.99.0");
15583 assert_eq!(match_value["SourceName"], "winget");
15584 assert!(match_value.get("local_id").is_none());
15585 assert!(match_value.get("installed_version").is_none());
15586 assert!(match_value.get("available_version").is_none());
15587 assert!(match_value.get("source_name").is_none());
15588 }
15589
15590 #[test]
15591 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
15592 let response = SearchResponse {
15593 matches: vec![SearchMatch {
15594 source_name: "msstore".to_owned(),
15595 source_kind: SourceKind::Rest,
15596 id: "9WZDNCRFJBMP".to_owned(),
15597 name: "Microsoft To Do".to_owned(),
15598 moniker: None,
15599 version: Some("2.123.456.0".to_owned()),
15600 channel: None,
15601 match_criteria: None,
15602 }],
15603 warnings: Vec::new(),
15604 truncated: false,
15605 };
15606
15607 let value = serde_json::to_value(&response).expect("serialize search response");
15608 let match_value = &value["Matches"][0];
15609
15610 assert_eq!(match_value["Name"], "Microsoft To Do");
15611 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
15612 assert_eq!(match_value["Version"], "2.123.456.0");
15613 assert_eq!(match_value["SourceName"], "msstore");
15614 assert!(match_value.get("source_name").is_none());
15615 }
15616
15617 #[test]
15618 fn versions_result_serialization_keeps_pascal_case_fields() {
15619 let result = VersionsResult {
15620 package: SearchMatch {
15621 source_name: "winget".to_owned(),
15622 source_kind: SourceKind::PreIndexed,
15623 id: "Microsoft.PowerToys".to_owned(),
15624 name: "PowerToys".to_owned(),
15625 moniker: None,
15626 version: Some("0.99.0".to_owned()),
15627 channel: None,
15628 match_criteria: None,
15629 },
15630 versions: vec![
15631 VersionKey {
15632 version: "0.99.0".to_owned(),
15633 channel: "stable".to_owned(),
15634 },
15635 VersionKey {
15636 version: "0.100.0-preview".to_owned(),
15637 channel: "preview".to_owned(),
15638 },
15639 ],
15640 warnings: Vec::new(),
15641 };
15642
15643 let value = serde_json::to_value(&result).expect("serialize versions result");
15644
15645 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
15646 assert_eq!(value["Versions"][0]["Channel"], "stable");
15647 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
15648 assert_eq!(value["Versions"][1]["Channel"], "preview");
15649 assert!(value.get("versions").is_none());
15650 }
15651
15652 #[test]
15653 fn version_data_parses_arp_bounds_from_winget_payload() {
15654 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";
15658
15659 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
15660 assert_eq!(document.versions.len(), 2);
15661 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
15662 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
15663 assert!(document.versions[1].arp_min_version.is_none());
15664 assert!(document.versions[1].arp_max_version.is_none());
15665 }
15666
15667 #[test]
15668 fn msix_resource_string_name_resolves_to_catalog_name() {
15669 let mut package = InstalledPackage {
15674 name: "ms-resource:appDisplayName".to_owned(),
15675 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
15676 installed_version: "1.28.239.0".to_owned(),
15677 publisher: None,
15678 scope: Some("User".to_owned()),
15679 installer_category: Some("msix".to_owned()),
15680 install_location: None,
15681 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
15682 product_codes: Vec::new(),
15683 upgrade_codes: Vec::new(),
15684 correlated: Some(SearchMatch {
15685 source_name: "winget".to_owned(),
15686 source_kind: SourceKind::PreIndexed,
15687 id: "Microsoft.AppInstaller".to_owned(),
15688 name: "App Installer".to_owned(),
15689 moniker: None,
15690 version: Some("1.28.240.0".to_owned()),
15691 channel: None,
15692 match_criteria: Some("PackageFamilyName".to_owned()),
15693 }),
15694 winget_package_identifier: None,
15695 winget_source_identifier: None,
15696 installed_version_canonical: false,
15697 correlated_requires_explicit_upgrade: false,
15698 correlated_lacks_compatible_installer: false,
15699 };
15700 apply_msix_resource_string_name_fix(&mut package);
15701 assert_eq!(package.name, "App Installer");
15702 }
15703
15704 #[test]
15705 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
15706 let mut package = InstalledPackage {
15711 name: "ms-resource:appDisplayName".to_owned(),
15712 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
15713 installed_version: "1.0".to_owned(),
15714 publisher: None,
15715 scope: Some("Machine".to_owned()),
15716 installer_category: Some("msi".to_owned()),
15717 install_location: None,
15718 package_family_names: Vec::new(),
15719 product_codes: Vec::new(),
15720 upgrade_codes: Vec::new(),
15721 correlated: Some(SearchMatch {
15722 source_name: "winget".to_owned(),
15723 source_kind: SourceKind::PreIndexed,
15724 id: "Some.Package".to_owned(),
15725 name: "Should Not Apply".to_owned(),
15726 moniker: None,
15727 version: Some("1.0".to_owned()),
15728 channel: None,
15729 match_criteria: None,
15730 }),
15731 winget_package_identifier: None,
15732 winget_source_identifier: None,
15733 installed_version_canonical: false,
15734 correlated_requires_explicit_upgrade: false,
15735 correlated_lacks_compatible_installer: false,
15736 };
15737 apply_msix_resource_string_name_fix(&mut package);
15738 assert_eq!(package.name, "ms-resource:appDisplayName");
15739 }
15740
15741 #[test]
15742 fn msix_resource_string_fix_skips_resolved_names() {
15743 let mut package = InstalledPackage {
15748 name: "Microsoft Teams".to_owned(),
15749 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
15750 installed_version: "25290.205.4069.4894".to_owned(),
15751 publisher: None,
15752 scope: Some("User".to_owned()),
15753 installer_category: Some("msix".to_owned()),
15754 install_location: None,
15755 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
15756 product_codes: Vec::new(),
15757 upgrade_codes: Vec::new(),
15758 correlated: Some(SearchMatch {
15759 source_name: "winget".to_owned(),
15760 source_kind: SourceKind::PreIndexed,
15761 id: "Microsoft.Teams".to_owned(),
15762 name: "Microsoft Teams Catalog Name".to_owned(),
15763 moniker: None,
15764 version: Some("26106.1906.4665.7308".to_owned()),
15765 channel: None,
15766 match_criteria: Some("PackageFamilyName".to_owned()),
15767 }),
15768 winget_package_identifier: None,
15769 winget_source_identifier: None,
15770 installed_version_canonical: false,
15771 correlated_requires_explicit_upgrade: false,
15772 correlated_lacks_compatible_installer: false,
15773 };
15774 apply_msix_resource_string_name_fix(&mut package);
15775 assert_eq!(package.name, "Microsoft Teams");
15776 }
15777
15778 #[cfg(windows)]
15779 #[test]
15780 fn unflip_packed_guid_reverses_msi_installer_packing() {
15781 assert_eq!(
15788 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
15789 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
15790 );
15791 assert_eq!(
15794 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
15795 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
15796 );
15797 assert!(unflip_packed_guid("nothex").is_none());
15799 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
15801 }
15802
15803 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
15804 InstalledPackage {
15805 name: format!("{id} install"),
15806 local_id: format!(r"ARP\Machine\X64\{id}"),
15807 installed_version: installed_version.to_owned(),
15808 publisher: None,
15809 scope: Some("Machine".to_owned()),
15810 installer_category: Some("msi".to_owned()),
15811 install_location: None,
15812 package_family_names: Vec::new(),
15813 product_codes: Vec::new(),
15814 upgrade_codes: Vec::new(),
15815 correlated: Some(SearchMatch {
15816 source_name: "winget".to_owned(),
15817 source_kind: SourceKind::PreIndexed,
15818 id: id.to_owned(),
15819 name: id.to_owned(),
15820 moniker: None,
15821 version: Some("99.0.0".to_owned()),
15822 channel: None,
15823 match_criteria: None,
15824 }),
15825 winget_package_identifier: None,
15826 winget_source_identifier: None,
15827 installed_version_canonical: canonical,
15828 correlated_requires_explicit_upgrade: false,
15829 correlated_lacks_compatible_installer: false,
15830 }
15831 }
15832
15833 #[test]
15834 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
15835 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
15842 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
15843
15844 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
15845 assert_eq!(result.len(), 1);
15846 assert_eq!(result[0].installed_version, "10.0.108");
15847 assert!(result[0].installed_version_canonical);
15848 }
15849
15850 #[test]
15851 fn dedupe_keeps_highest_among_canonical_rows() {
15852 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
15853 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
15854
15855 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
15856 assert_eq!(result.len(), 1);
15857 assert_eq!(result[0].installed_version, "1.7.9");
15858 }
15859
15860 #[test]
15861 fn dedupe_leaves_uncorrelated_rows_alone() {
15862 let uncorrelated = InstalledPackage {
15866 name: "Foo".to_owned(),
15867 local_id: r"ARP\Machine\X64\Foo".to_owned(),
15868 installed_version: "1.0".to_owned(),
15869 publisher: None,
15870 scope: Some("Machine".to_owned()),
15871 installer_category: Some("exe".to_owned()),
15872 install_location: None,
15873 package_family_names: Vec::new(),
15874 product_codes: Vec::new(),
15875 upgrade_codes: Vec::new(),
15876 correlated: None,
15877 winget_package_identifier: None,
15878 winget_source_identifier: None,
15879 installed_version_canonical: false,
15880 correlated_requires_explicit_upgrade: false,
15881 correlated_lacks_compatible_installer: false,
15882 };
15883 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
15884 assert_eq!(result.len(), 1);
15885 }
15886}