1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs;
7use std::io::{Cursor, Read};
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};
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
21use sha2::{Digest, Sha256};
22use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
23#[cfg(windows)]
24use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
25#[cfg(windows)]
26use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
27#[cfg(windows)]
28use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
29#[cfg(windows)]
30use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
31#[cfg(windows)]
32use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
33#[cfg(windows)]
34use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
35#[cfg(windows)]
36use winreg::{RegKey, enums::*};
37use zip::ZipArchive;
38
39const DEFAULT_MARKET: &str = "US";
40const DEFAULT_MAX_RESULTS: usize = 50;
41const LIST_LOOKUP_MAX_RESULTS: usize = 500;
42const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
43const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
44const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
45const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
46#[cfg(windows)]
47const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
48#[cfg(windows)]
49const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
50const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
51 "Installed package discovery is not supported on this platform; returning no installed packages.";
52const INSTALL_UNSUPPORTED_WARNING: &str =
53 "Installing packages is not supported on this platform; no changes were made.";
54const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
55const REPAIR_REINSTALL_WARNING: &str =
56 "Pinget repair currently re-runs the package install flow for the selected package.";
57const UNINSTALL_UNSUPPORTED_WARNING: &str =
58 "Uninstalling packages is not supported on this platform; no changes were made.";
59#[cfg(windows)]
60const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
61const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
62 "LocalManifestFiles",
63 "BypassCertificatePinningForMicrosoftStore",
64 "InstallerHashOverride",
65 "LocalArchiveMalwareScanOverride",
66 "ProxyCommandLineOptions",
67];
68const REST_SUPPORTED_CONTRACTS: &[&str] = &[
69 "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",
70];
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
73pub enum SourceKind {
74 #[serde(rename = "PreIndexed", alias = "preIndexed")]
75 PreIndexed,
76 #[serde(rename = "Rest", alias = "rest")]
77 Rest,
78}
79
80impl Display for SourceKind {
81 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82 match self {
83 SourceKind::PreIndexed => f.write_str("preindexed"),
84 SourceKind::Rest => f.write_str("rest"),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SourceRecord {
91 pub name: String,
92 pub kind: SourceKind,
93 pub arg: String,
94 pub identifier: String,
95 #[serde(default = "default_source_trust_level")]
96 pub trust_level: String,
97 #[serde(default)]
98 pub explicit: bool,
99 #[serde(default)]
100 pub priority: i32,
101 #[serde(default)]
102 pub last_update: Option<DateTime<Utc>>,
103 #[serde(default)]
104 pub source_version: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108struct SourceStore {
109 sources: Vec<SourceRecord>,
110}
111
112impl Default for SourceStore {
113 fn default() -> Self {
114 Self {
115 sources: vec![
116 SourceRecord {
117 name: "winget".to_owned(),
118 kind: SourceKind::PreIndexed,
119 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
120 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
121 trust_level: "Trusted".to_owned(),
122 explicit: false,
123 priority: 0,
124 last_update: None,
125 source_version: None,
126 },
127 SourceRecord {
128 name: "msstore".to_owned(),
129 kind: SourceKind::Rest,
130 arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
131 identifier: "StoreEdgeFD".to_owned(),
132 trust_level: "Trusted".to_owned(),
133 explicit: false,
134 priority: 0,
135 last_update: None,
136 source_version: None,
137 },
138 ],
139 }
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct RepositoryOptions {
146 pub app_root: PathBuf,
147 pub user_agent: String,
148 pub pre_indexed_source_auto_update_interval: Option<Duration>,
149}
150
151impl RepositoryOptions {
152 pub fn new(app_root: impl Into<PathBuf>) -> Self {
154 Self {
155 app_root: app_root.into(),
156 user_agent: DEFAULT_USER_AGENT.to_owned(),
157 pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
158 }
159 }
160
161 pub fn for_current_user() -> Result<Self> {
163 Ok(Self::new(default_app_root()?))
164 }
165
166 #[must_use]
168 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
169 self.user_agent = user_agent.into();
170 self
171 }
172
173 #[must_use]
175 pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
176 self.pre_indexed_source_auto_update_interval = interval;
177 self
178 }
179}
180
181#[derive(Debug, Clone, Default)]
182pub struct PackageQuery {
183 pub query: Option<String>,
184 pub id: Option<String>,
185 pub name: Option<String>,
186 pub moniker: Option<String>,
187 pub tag: Option<String>,
188 pub command: Option<String>,
189 pub source: Option<String>,
190 pub count: Option<usize>,
191 pub exact: bool,
192 pub version: Option<String>,
193 pub channel: Option<String>,
194 pub locale: Option<String>,
195 pub installer_type: Option<String>,
196 pub installer_architecture: Option<String>,
197 pub platform: Option<String>,
198 pub os_version: Option<String>,
199 pub install_scope: Option<String>,
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct ListQuery {
204 pub query: Option<String>,
205 pub id: Option<String>,
206 pub name: Option<String>,
207 pub moniker: Option<String>,
208 pub tag: Option<String>,
209 pub command: Option<String>,
210 pub product_code: Option<String>,
211 pub version: Option<String>,
212 pub source: Option<String>,
213 pub count: Option<usize>,
214 pub exact: bool,
215 pub install_scope: Option<String>,
216 pub upgrade_only: bool,
217 pub include_unknown: bool,
218 pub include_pinned: bool,
219}
220
221#[derive(Debug, Clone, serde::Serialize)]
222#[serde(rename_all = "PascalCase")]
223pub struct SearchMatch {
224 pub source_name: String,
225 pub source_kind: SourceKind,
226 pub id: String,
227 pub name: String,
228 pub moniker: Option<String>,
229 pub version: Option<String>,
230 pub channel: Option<String>,
231 pub match_criteria: Option<String>,
232}
233
234#[derive(Debug, Clone, serde::Serialize)]
235#[serde(rename_all = "PascalCase")]
236pub struct SearchResponse {
237 pub matches: Vec<SearchMatch>,
238 pub warnings: Vec<String>,
239 pub truncated: bool,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
243#[serde(rename_all = "PascalCase")]
244pub struct ListMatch {
245 pub name: String,
246 pub id: String,
247 pub local_id: String,
248 pub installed_version: String,
249 pub available_version: Option<String>,
250 pub source_name: Option<String>,
251 pub publisher: Option<String>,
252 pub scope: Option<String>,
253 pub installer_category: Option<String>,
254 pub install_location: Option<String>,
255 pub package_family_names: Vec<String>,
256 pub product_codes: Vec<String>,
257 pub upgrade_codes: Vec<String>,
258}
259
260#[derive(Debug, Clone, serde::Serialize)]
261#[serde(rename_all = "PascalCase")]
262pub struct ListResponse {
263 pub matches: Vec<ListMatch>,
264 pub warnings: Vec<String>,
265 pub truncated: bool,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
269#[serde(rename_all = "PascalCase")]
270pub struct VersionKey {
271 pub version: String,
272 pub channel: String,
273}
274
275#[derive(Debug, Clone, serde::Serialize)]
276pub struct Manifest {
277 pub id: String,
278 pub name: String,
279 pub version: String,
280 pub channel: String,
281 pub publisher: Option<String>,
282 pub description: Option<String>,
283 pub moniker: Option<String>,
284 pub package_url: Option<String>,
285 pub publisher_url: Option<String>,
286 pub publisher_support_url: Option<String>,
287 pub license: Option<String>,
288 pub license_url: Option<String>,
289 pub privacy_url: Option<String>,
290 pub author: Option<String>,
291 pub copyright: Option<String>,
292 pub copyright_url: Option<String>,
293 pub release_notes: Option<String>,
294 pub release_notes_url: Option<String>,
295 pub tags: Vec<String>,
296 pub agreements: Vec<PackageAgreement>,
297 pub package_dependencies: Vec<String>,
298 pub documentation: Vec<Documentation>,
299 pub icons: Vec<PackageIcon>,
300 pub installers: Vec<Installer>,
301 pub require_explicit_upgrade: bool,
306}
307
308impl Manifest {
309 pub fn icon(&self) -> Option<&PackageIcon> {
310 select_best_icon(&self.icons)
311 }
312
313 pub fn icon_url(&self) -> Option<&str> {
314 self.icon().and_then(|icon| icon.icon_url.as_deref())
315 }
316}
317
318#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
319#[serde(rename_all = "PascalCase")]
320pub struct PackageIcon {
321 pub icon_url: Option<String>,
322 pub icon_file_type: Option<String>,
323 pub icon_resolution: Option<String>,
324 pub icon_theme: Option<String>,
325 pub icon_sha256: Option<String>,
326}
327
328impl PackageIcon {
329 fn is_empty(&self) -> bool {
330 self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
331 && self
332 .icon_file_type
333 .as_deref()
334 .is_none_or(|value| value.trim().is_empty())
335 && self
336 .icon_resolution
337 .as_deref()
338 .is_none_or(|value| value.trim().is_empty())
339 && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
340 && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
341 }
342}
343
344fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
345 icons
346 .iter()
347 .enumerate()
348 .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
349 .max_by(|(left_index, left), (right_index, right)| {
350 icon_theme_score(left.icon_theme.as_deref())
351 .cmp(&icon_theme_score(right.icon_theme.as_deref()))
352 .then_with(|| {
353 icon_resolution_score(left.icon_resolution.as_deref())
354 .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
355 })
356 .then_with(|| {
357 icon_file_type_score(left.icon_file_type.as_deref())
358 .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
359 })
360 .then_with(|| right_index.cmp(left_index))
361 })
362 .map(|(_, icon)| icon)
363}
364
365fn icon_theme_score(theme: Option<&str>) -> usize {
366 match theme {
367 None => 1,
368 Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
369 Some(_) => 0,
370 }
371}
372
373fn icon_file_type_score(file_type: Option<&str>) -> usize {
374 match file_type {
375 Some(value) if value.eq_ignore_ascii_case("ico") => 2,
376 Some(value) if value.eq_ignore_ascii_case("png") => 1,
377 _ => 0,
378 }
379}
380
381fn icon_resolution_score(resolution: Option<&str>) -> usize {
382 let Some(resolution) = resolution else {
383 return 0;
384 };
385 let resolution = resolution.trim();
386 let Some(separator) = resolution.find(['x', 'X']) else {
387 return 0;
388 };
389 let (width, height) = resolution.split_at(separator);
390 let height = &height[1..];
391 let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
392 return 0;
393 };
394 if width == height { width } else { 0 }
395}
396
397#[derive(Debug, Clone, serde::Serialize)]
398pub struct Documentation {
399 pub label: Option<String>,
400 pub url: String,
401}
402
403#[derive(Debug, Clone, serde::Serialize)]
404pub struct PackageAgreement {
405 pub label: Option<String>,
406 pub text: Option<String>,
407 pub url: Option<String>,
408}
409
410#[derive(Debug, Clone, Default, serde::Serialize)]
411pub struct Installer {
412 pub architecture: Option<String>,
413 pub installer_type: Option<String>,
414 pub nested_installer_type: Option<String>,
415 pub nested_installer_files: Vec<NestedInstallerFile>,
416 pub url: Option<String>,
417 pub sha256: Option<String>,
418 pub product_code: Option<String>,
419 pub locale: Option<String>,
420 pub scope: Option<String>,
421 pub release_date: Option<String>,
422 pub package_family_name: Option<String>,
423 pub upgrade_code: Option<String>,
424 pub platforms: Vec<String>,
425 pub minimum_os_version: Option<String>,
426 pub switches: InstallerSwitches,
427 pub commands: Vec<String>,
428 pub package_dependencies: Vec<String>,
429 pub require_explicit_upgrade: bool,
430}
431
432#[derive(Debug, Clone, Default, serde::Serialize)]
433pub struct NestedInstallerFile {
434 pub relative_file_path: String,
436 pub portable_command_alias: Option<String>,
440}
441
442#[derive(Debug, Clone, Default, serde::Serialize)]
443pub struct InstallerSwitches {
444 pub silent: Option<String>,
445 pub silent_with_progress: Option<String>,
446 pub interactive: Option<String>,
447 pub custom: Option<String>,
448 pub log: Option<String>,
449 pub install_location: Option<String>,
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum InstallerMode {
454 Interactive,
455 SilentWithProgress,
456 Silent,
457}
458
459#[derive(Debug, Clone)]
460pub struct InstallRequest {
461 pub query: PackageQuery,
462 pub manifest_path: Option<PathBuf>,
463 pub mode: InstallerMode,
464 pub log_path: Option<PathBuf>,
465 pub custom: Option<String>,
466 pub override_args: Option<String>,
467 pub install_location: Option<String>,
468 pub skip_dependencies: bool,
469 pub dependencies_only: bool,
470 pub accept_package_agreements: bool,
471 pub force: bool,
472 pub rename: Option<String>,
473 pub uninstall_previous: bool,
474 pub ignore_security_hash: bool,
475 pub dependency_source: Option<String>,
476 pub no_upgrade: bool,
477}
478
479impl InstallRequest {
480 pub fn new(query: PackageQuery) -> Self {
481 Self {
482 query,
483 manifest_path: None,
484 mode: InstallerMode::SilentWithProgress,
485 log_path: None,
486 custom: None,
487 override_args: None,
488 install_location: None,
489 skip_dependencies: false,
490 dependencies_only: false,
491 accept_package_agreements: false,
492 force: false,
493 rename: None,
494 uninstall_previous: false,
495 ignore_security_hash: false,
496 dependency_source: None,
497 no_upgrade: false,
498 }
499 }
500}
501
502#[derive(Debug, Clone)]
503pub struct UninstallRequest {
504 pub query: PackageQuery,
505 pub manifest_path: Option<PathBuf>,
506 pub product_code: Option<String>,
507 pub mode: InstallerMode,
508 pub all_versions: bool,
509 pub force: bool,
510 pub purge: bool,
511 pub preserve: bool,
512 pub log_path: Option<PathBuf>,
513}
514
515impl UninstallRequest {
516 pub fn new(query: PackageQuery) -> Self {
517 Self {
518 query,
519 manifest_path: None,
520 product_code: None,
521 mode: InstallerMode::SilentWithProgress,
522 all_versions: false,
523 force: false,
524 purge: false,
525 preserve: false,
526 log_path: None,
527 }
528 }
529}
530
531#[derive(Debug, Clone)]
532pub struct RepairRequest {
533 pub query: PackageQuery,
534 pub manifest_path: Option<PathBuf>,
535 pub product_code: Option<String>,
536 pub mode: InstallerMode,
537 pub log_path: Option<PathBuf>,
538 pub accept_package_agreements: bool,
539 pub force: bool,
540 pub ignore_security_hash: bool,
541}
542
543impl RepairRequest {
544 pub fn new(query: PackageQuery) -> Self {
545 Self {
546 query,
547 manifest_path: None,
548 product_code: None,
549 mode: InstallerMode::SilentWithProgress,
550 log_path: None,
551 accept_package_agreements: false,
552 force: false,
553 ignore_security_hash: false,
554 }
555 }
556}
557
558#[derive(Debug, Clone, serde::Serialize)]
559pub struct ShowResult {
560 pub package: SearchMatch,
561 pub manifest: Manifest,
562 pub selected_installer: Option<Installer>,
563 pub cached_files: Vec<PathBuf>,
564 pub warnings: Vec<String>,
565 #[serde(skip_serializing)]
566 manifest_documents: JsonValue,
567}
568
569impl ShowResult {
570 pub fn structured_document(&self) -> JsonValue {
571 add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
572 }
573}
574
575#[derive(Debug, Clone, serde::Serialize)]
580pub struct DownloadOutput {
581 pub manifest: Manifest,
582 pub installer_path: PathBuf,
583 pub manifest_path: PathBuf,
584}
585
586struct FetchedInstaller {
590 manifest: Manifest,
591 manifest_documents: JsonValue,
592 installer: Installer,
593 installer_path: PathBuf,
594}
595
596#[derive(Debug, Clone, serde::Serialize)]
597#[serde(rename_all = "PascalCase")]
598pub struct VersionsResult {
599 pub package: SearchMatch,
600 pub versions: Vec<VersionKey>,
601 pub warnings: Vec<String>,
602}
603
604#[derive(Debug, Clone, serde::Serialize)]
605#[serde(rename_all = "PascalCase")]
606pub struct CacheWarmResult {
607 pub package: SearchMatch,
608 pub cached_files: Vec<PathBuf>,
609 pub warnings: Vec<String>,
610}
611
612#[derive(Debug, Clone, serde::Serialize)]
613#[serde(rename_all = "PascalCase")]
614pub struct SourceUpdateResult {
615 pub name: String,
616 pub kind: SourceKind,
617 pub detail: String,
618}
619
620#[derive(Debug, Clone, serde::Serialize)]
621#[serde(rename_all = "PascalCase")]
622pub struct PinRecord {
623 pub package_id: String,
624 pub version: String,
625 pub source_id: String,
626 pub pin_type: PinType,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
630pub enum PinType {
631 Pinning,
632 Blocking,
633 Gating,
634}
635
636impl Display for PinType {
637 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
638 match self {
639 PinType::Pinning => f.write_str("Pinning"),
640 PinType::Blocking => f.write_str("Blocking"),
641 PinType::Gating => f.write_str("Gating"),
642 }
643 }
644}
645
646#[derive(Debug, Clone, serde::Serialize)]
648pub struct InstallResult {
649 pub package_id: String,
650 pub version: String,
651 pub installer_path: PathBuf,
652 pub installer_type: String,
653 pub exit_code: i32,
654 pub success: bool,
655 pub no_op: bool,
656 pub warnings: Vec<String>,
657}
658
659impl InstallerSwitches {
660 fn with_fallback(&self, fallback: &Self) -> Self {
661 Self {
662 silent: self.silent.clone().or_else(|| fallback.silent.clone()),
663 silent_with_progress: self
664 .silent_with_progress
665 .clone()
666 .or_else(|| fallback.silent_with_progress.clone()),
667 interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
668 custom: self.custom.clone().or_else(|| fallback.custom.clone()),
669 log: self.log.clone().or_else(|| fallback.log.clone()),
670 install_location: self
671 .install_location
672 .clone()
673 .or_else(|| fallback.install_location.clone()),
674 }
675 }
676}
677
678fn installed_package_discovery_supported() -> bool {
679 cfg!(windows)
680}
681
682fn package_actions_supported() -> bool {
683 cfg!(windows)
684}
685
686fn unsupported_action_result(
687 package_id: impl Into<String>,
688 version: impl Into<String>,
689 installer_type: impl Into<String>,
690 warning: &str,
691) -> InstallResult {
692 InstallResult {
693 package_id: package_id.into(),
694 version: version.into(),
695 installer_path: PathBuf::new(),
696 installer_type: installer_type.into(),
697 exit_code: 0,
698 success: true,
699 no_op: true,
700 warnings: vec![warning.to_owned()],
701 }
702}
703
704#[derive(Debug, Clone)]
705struct InstalledPackage {
706 name: String,
707 local_id: String,
708 installed_version: String,
709 publisher: Option<String>,
710 scope: Option<String>,
711 installer_category: Option<String>,
712 install_location: Option<String>,
713 package_family_names: Vec<String>,
714 product_codes: Vec<String>,
715 upgrade_codes: Vec<String>,
716 correlated: Option<SearchMatch>,
717 installed_version_canonical: bool,
724 correlated_requires_explicit_upgrade: bool,
728 correlated_lacks_compatible_installer: bool,
732}
733
734#[derive(Debug, Clone)]
735struct LocatedMatch {
736 display: SearchMatch,
737 source_index: usize,
738 locator: MatchLocator,
739}
740
741#[derive(Debug)]
742struct SearchSourceMatches {
743 matches: Vec<LocatedMatch>,
744 truncated: bool,
745}
746
747#[derive(Debug, Clone)]
748enum MatchLocator {
749 PreIndexedV1 {
750 package_rowid: i64,
751 },
752 PreIndexedV2 {
753 package_rowid: i64,
754 package_hash: String,
755 },
756 Rest {
757 package_id: String,
758 versions: Vec<VersionKey>,
759 },
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763enum SearchSemantics {
764 Many,
765 Single,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize)]
769struct RestInfoCache {
770 expires_at: DateTime<Utc>,
771 value: RestInformation,
772}
773
774#[derive(Debug, Clone, Default, Serialize, Deserialize)]
775struct RestInformation {
776 #[serde(rename = "SourceIdentifier")]
777 source_identifier: String,
778 #[serde(rename = "ServerSupportedVersions", default)]
779 server_supported_versions: Vec<String>,
780 #[serde(rename = "RequiredPackageMatchFields", default)]
781 required_package_match_fields: Vec<String>,
782 #[serde(rename = "UnsupportedPackageMatchFields", default)]
783 unsupported_package_match_fields: Vec<String>,
784 #[serde(rename = "RequiredQueryParameters", default)]
785 required_query_parameters: Vec<String>,
786 #[serde(rename = "UnsupportedQueryParameters", default)]
787 unsupported_query_parameters: Vec<String>,
788}
789
790#[derive(Debug, Clone, Deserialize)]
791struct PackageVersionDataDocument {
792 #[serde(rename = "vD", default)]
793 versions: Vec<PackageVersionDataEntry>,
794}
795
796#[derive(Debug, Clone, Deserialize)]
797struct PackageVersionDataEntry {
798 #[serde(rename = "v")]
799 version: String,
800 #[serde(rename = "rP")]
801 manifest_relative_path: String,
802 #[serde(rename = "s256H")]
803 manifest_hash: String,
804 #[serde(rename = "aMiV", default)]
809 arp_min_version: Option<String>,
810 #[serde(rename = "aMaV", default)]
811 arp_max_version: Option<String>,
812}
813
814pub struct Repository {
815 app_root: PathBuf,
816 client: Client,
817 store: SourceStore,
818 use_system_winget_sources: bool,
819 pre_indexed_source_auto_update_interval: Option<Duration>,
820 preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
821}
822
823#[derive(Debug, Clone)]
824struct PreindexedRefreshAttempt {
825 attempted_at: DateTime<Utc>,
826 succeeded: bool,
827}
828
829#[derive(Debug, Clone, Default)]
830struct PreindexedRefreshOutcome {
831 attempted: bool,
832 reused_recent_attempt: bool,
833 error: Option<String>,
834}
835
836struct PreindexedOpenConnection {
837 connection: Connection,
838 refresh: PreindexedRefreshOutcome,
839}
840
841#[derive(Debug)]
842struct MissingPackageVersionError {
843 package_id: String,
844 source_name: String,
845 requested_version: String,
846 requested_channel: Option<String>,
847}
848
849impl Display for MissingPackageVersionError {
850 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
851 let channel = self
852 .requested_channel
853 .as_deref()
854 .filter(|value| !value.is_empty())
855 .map(|value| format!(" with channel '{value}'"))
856 .unwrap_or_default();
857 write!(
858 f,
859 "Version '{}'{} was not found for package '{}' in source '{}'.",
860 self.requested_version, channel, self.package_id, self.source_name
861 )
862 }
863}
864
865impl std::error::Error for MissingPackageVersionError {}
866
867fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
868 static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
869 LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
870}
871
872impl Repository {
873 pub fn open() -> Result<Self> {
875 Self::open_with_options(RepositoryOptions::for_current_user()?)
876 }
877
878 pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
880 ensure_app_dirs(&options.app_root)?;
881 let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
882 let store = load_store(&options.app_root)?;
883 let client = Client::builder()
884 .user_agent(&options.user_agent)
885 .build()
886 .context("failed to build HTTP client")?;
887 Ok(Self {
888 app_root: options.app_root,
889 client,
890 store,
891 use_system_winget_sources,
892 pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
893 preindexed_refresh_attempts: HashMap::new(),
894 })
895 }
896
897 pub fn app_root(&self) -> &Path {
898 &self.app_root
899 }
900
901 pub fn list_sources(&self) -> Vec<SourceRecord> {
902 self.store.sources.clone()
903 }
904
905 pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
906 self.add_source_with_metadata(name, arg, kind, None, false, 0)
907 }
908
909 pub fn add_source_with_metadata(
910 &mut self,
911 name: &str,
912 arg: &str,
913 kind: SourceKind,
914 trust_level: Option<&str>,
915 explicit: bool,
916 priority: i32,
917 ) -> Result<()> {
918 if self.use_system_winget_sources {
919 system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
920 self.refresh_system_winget_sources()?;
921 return Ok(());
922 }
923
924 if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
925 bail!("A source with name '{name}' already exists.");
926 }
927 if self.store.sources.iter().any(|s| s.arg == arg) {
928 bail!("A source with argument '{arg}' already exists.");
929 }
930 let identifier = name.to_owned();
931 self.store.sources.push(SourceRecord {
932 name: name.to_owned(),
933 kind,
934 arg: arg.to_owned(),
935 identifier,
936 trust_level: normalize_source_trust_level(trust_level)?,
937 explicit,
938 priority,
939 last_update: None,
940 source_version: None,
941 });
942 self.save_store()?;
943 Ok(())
944 }
945
946 pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
947 if self.use_system_winget_sources {
948 bail!("Editing system WinGet sources is not supported by winget source commands.");
949 }
950
951 let source = self
952 .store
953 .sources
954 .iter_mut()
955 .find(|source| source.name.eq_ignore_ascii_case(name))
956 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
957
958 if let Some(explicit) = explicit {
959 source.explicit = explicit;
960 }
961
962 if trust_level.is_some() {
963 source.trust_level = normalize_source_trust_level(trust_level)?;
964 }
965
966 self.save_store()?;
967 Ok(())
968 }
969
970 pub fn remove_source(&mut self, name: &str) -> Result<()> {
971 if self.use_system_winget_sources {
972 system_winget_remove_source(name)?;
973 self.refresh_system_winget_sources()?;
974 return Ok(());
975 }
976
977 let idx = self
978 .store
979 .sources
980 .iter()
981 .position(|s| s.name.eq_ignore_ascii_case(name))
982 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
983 let source = self.store.sources.remove(idx);
984 let state_dir = self.source_state_dir(&source);
986 if state_dir.exists() {
987 let _ = fs::remove_dir_all(&state_dir);
988 }
989 self.save_store()?;
990 Ok(())
991 }
992
993 pub fn reset_source(&mut self, name: &str) -> Result<()> {
994 if self.use_system_winget_sources {
995 system_winget_reset_source(name)?;
996 self.refresh_system_winget_sources()?;
997 return Ok(());
998 }
999
1000 let idx = self
1001 .store
1002 .sources
1003 .iter()
1004 .position(|source| source.name.eq_ignore_ascii_case(name))
1005 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1006
1007 let source = self.store.sources[idx].clone();
1008 let state_dir = self.source_state_dir(&source);
1009 if state_dir.exists() {
1010 let _ = fs::remove_dir_all(&state_dir);
1011 }
1012
1013 if let Some(default_source) = SourceStore::default()
1014 .sources
1015 .into_iter()
1016 .find(|source| source.name.eq_ignore_ascii_case(name))
1017 {
1018 self.store.sources[idx] = default_source;
1019 } else {
1020 self.store.sources[idx].last_update = None;
1021 self.store.sources[idx].source_version = None;
1022 }
1023
1024 self.save_store()?;
1025 Ok(())
1026 }
1027
1028 pub fn reset_sources(&mut self) -> Result<()> {
1029 if self.use_system_winget_sources {
1030 system_winget_reset_sources()?;
1031 self.refresh_system_winget_sources()?;
1032 return Ok(());
1033 }
1034
1035 for source in &self.store.sources {
1037 let state_dir = self.source_state_dir(source);
1038 if state_dir.exists() {
1039 let _ = fs::remove_dir_all(&state_dir);
1040 }
1041 }
1042 self.store = SourceStore::default();
1043 self.save_store()?;
1044 Ok(())
1045 }
1046
1047 pub fn supported_admin_settings() -> &'static [&'static str] {
1048 SUPPORTED_ADMIN_SETTINGS
1049 }
1050
1051 pub fn get_user_settings(&self) -> Result<JsonValue> {
1052 Ok(JsonValue::Object(load_json_object(&user_settings_path(
1053 &self.app_root,
1054 ))?))
1055 }
1056
1057 pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1058 let update = match user_settings {
1059 JsonValue::Object(object) => object.clone(),
1060 _ => bail!("user settings must be a JSON object"),
1061 };
1062
1063 let effective = if merge {
1064 merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1065 } else {
1066 update
1067 };
1068 save_json_object(user_settings_path(&self.app_root), &effective)?;
1069 Ok(JsonValue::Object(effective))
1070 }
1071
1072 pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1073 let current = self.get_user_settings()?;
1074 Ok(if ignore_not_set {
1075 json_contains(¤t, expected)
1076 } else {
1077 current == *expected
1078 })
1079 }
1080
1081 pub fn get_admin_settings(&self) -> Result<JsonValue> {
1082 let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1083 for name in SUPPORTED_ADMIN_SETTINGS {
1084 settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1085 }
1086
1087 Ok(JsonValue::Object(settings))
1088 }
1089
1090 pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1091 let normalized = normalize_admin_setting_name(name)?;
1092 let mut settings = match self.get_admin_settings()? {
1093 JsonValue::Object(settings) => settings,
1094 _ => JsonMap::new(),
1095 };
1096 settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1097 save_json_object(admin_settings_path(&self.app_root), &settings)
1098 }
1099
1100 pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1101 if !reset_all && name.is_none() {
1102 bail!("resetting admin settings requires a setting name or reset-all");
1103 }
1104
1105 let mut settings = match self.get_admin_settings()? {
1106 JsonValue::Object(settings) => settings,
1107 _ => JsonMap::new(),
1108 };
1109
1110 if reset_all {
1111 for setting_name in SUPPORTED_ADMIN_SETTINGS {
1112 settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1113 }
1114 } else {
1115 settings.insert(
1116 normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1117 JsonValue::Bool(false),
1118 );
1119 }
1120
1121 save_json_object(admin_settings_path(&self.app_root), &settings)
1122 }
1123
1124 pub fn ensure_settings_files(&self) -> Result<()> {
1125 let user_settings = user_settings_path(&self.app_root);
1126 if !user_settings.exists() {
1127 save_json_object(user_settings, &JsonMap::new())?;
1128 }
1129
1130 let admin_settings = admin_settings_path(&self.app_root);
1131 if !admin_settings.exists() {
1132 let mut values = JsonMap::new();
1133 for name in SUPPORTED_ADMIN_SETTINGS {
1134 values.insert((*name).to_owned(), JsonValue::Bool(false));
1135 }
1136 save_json_object(admin_settings, &values)?;
1137 }
1138
1139 Ok(())
1140 }
1141
1142 pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1143 if self.use_system_winget_sources {
1144 return self.update_system_winget_sources(source_name);
1145 }
1146
1147 let indexes = self.resolve_source_indexes(source_name)?;
1148 let mut results = Vec::new();
1149
1150 for index in indexes {
1151 let detail = match self.store.sources[index].kind {
1152 SourceKind::PreIndexed => self.update_preindexed(index)?,
1153 SourceKind::Rest => self.update_rest(index)?,
1154 };
1155
1156 results.push(SourceUpdateResult {
1157 name: self.store.sources[index].name.clone(),
1158 kind: self.store.sources[index].kind,
1159 detail,
1160 });
1161 }
1162
1163 self.save_store()?;
1164 Ok(results)
1165 }
1166
1167 pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1168 let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
1169 Ok(SearchResponse {
1170 matches: matches.into_iter().map(|item| item.display).collect(),
1171 warnings,
1172 truncated,
1173 })
1174 }
1175
1176 pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1177 let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
1178 let mut structured_documents = Vec::with_capacity(matches.len());
1179 for located in matches {
1180 let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1181 structured_documents.push(manifest_documents);
1182 }
1183
1184 Ok(collapse_structured_documents(&structured_documents))
1185 }
1186
1187 pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1188 if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1189 bail!("--include-unknown and --include-pinned require --upgrade-available");
1190 }
1191 if query.source.is_some()
1192 && query.query.is_none()
1193 && query.id.is_none()
1194 && query.name.is_none()
1195 && query.moniker.is_none()
1196 && query.tag.is_none()
1197 && query.command.is_none()
1198 {
1199 bail!("list --source currently requires a query or explicit filter");
1200 }
1201
1202 let has_filter = list_query_needs_available_lookup(query);
1203 let needs_available = true;
1208
1209 let mut warnings = Vec::new();
1210 if !installed_package_discovery_supported() {
1211 warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1212 }
1213 let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1214
1215 if needs_available {
1216 warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1222
1223 warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1232 }
1233
1234 if needs_available && has_filter {
1235 let available_query = package_query_from_list_query(query);
1238 let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1239 warnings.extend(source_warnings);
1240 let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1241 for package in &mut installed {
1242 if package.correlated.is_some() {
1243 continue;
1244 }
1245 package.correlated =
1246 correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1247 }
1248 } else if needs_available {
1249 warnings.extend(self.correlate_all_installed(&mut installed)?);
1251 }
1252
1253 if needs_available {
1254 warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1265 }
1266
1267 if query.upgrade_only {
1274 installed = dedupe_correlated_for_upgrade(installed);
1275 }
1276
1277 let mut matches = installed
1278 .into_iter()
1279 .filter(|package| {
1280 list_package_matches(package, query)
1281 && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1282 })
1283 .collect::<Vec<_>>();
1284
1285 matches.sort_by(|left, right| {
1286 list_sort_weight(left)
1287 .cmp(&list_sort_weight(right))
1288 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1289 .then_with(|| left.local_id.cmp(&right.local_id))
1290 });
1291
1292 let pins = if query.upgrade_only {
1293 self.list_pins(query.source.as_deref())?
1294 } else {
1295 Vec::new()
1296 };
1297 let mut list_matches = matches
1298 .into_iter()
1299 .map(list_match_from_installed)
1300 .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1301 .collect::<Vec<_>>();
1302 let truncated = if let Some(limit) = query.count {
1303 let was_truncated = list_matches.len() > limit;
1304 list_matches.truncate(limit);
1305 was_truncated
1306 } else {
1307 false
1308 };
1309
1310 Ok(ListResponse {
1311 matches: list_matches,
1312 warnings,
1313 truncated,
1314 })
1315 }
1316
1317 fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1321 if installed.iter().all(|p| p.correlated.is_some()) {
1322 return Ok(Vec::new());
1323 }
1324 let all_query = PackageQuery {
1325 query: None,
1326 id: None,
1327 name: None,
1328 moniker: None,
1329 tag: None,
1330 command: None,
1331 source: None,
1332 count: Some(100_000), exact: false,
1334 version: None,
1335 channel: None,
1336 locale: None,
1337 installer_type: None,
1338 installer_architecture: None,
1339 platform: None,
1340 os_version: None,
1341 install_scope: None,
1342 };
1343 let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1344 let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1345
1346 for package in installed.iter_mut() {
1347 if package.correlated.is_some() {
1348 continue;
1349 }
1350 package.correlated = correlate_installed_package(package, &candidates, true);
1351 }
1352
1353 Ok(warnings)
1354 }
1355
1356 fn correlate_installed_via_index(
1365 &mut self,
1366 installed: &mut [InstalledPackage],
1367 requested_source: Option<&str>,
1368 ) -> Result<Vec<String>> {
1369 let source_indices: Vec<usize> = self
1370 .store
1371 .sources
1372 .iter()
1373 .enumerate()
1374 .filter(|(_, source)| {
1375 source.kind == SourceKind::PreIndexed
1376 && match requested_source {
1377 Some(name) => source.name.eq_ignore_ascii_case(name),
1378 None => true,
1379 }
1380 })
1381 .map(|(index, _)| index)
1382 .collect();
1383
1384 for source_index in source_indices {
1385 let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1386 let connection = match self.open_preindexed_connection(source_index) {
1387 Ok(c) => c,
1388 Err(_) => continue,
1389 };
1390 if !v2_identity_tables_present(&connection) {
1391 continue;
1392 }
1393 let mut acc = Vec::new();
1394 for (idx, package) in installed.iter().enumerate() {
1395 if package.correlated.is_some() {
1396 continue;
1397 }
1398 let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1399 continue;
1400 };
1401 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1402 continue;
1403 };
1404 acc.push((idx, meta, by));
1405 }
1406 acc
1407 };
1408
1409 let source = self.source_clone(source_index);
1410 for (idx, meta, by) in resolved {
1411 let (canonical_installed, anchored_latest) =
1412 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1413 Ok((entries, _)) => (
1414 map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1415 latest_arp_anchored_version(&entries),
1416 ),
1417 Err(_) => (None, None),
1418 };
1419 let available_version = match (&canonical_installed, anchored_latest) {
1426 (Some(_), Some(anchored)) => anchored,
1427 _ => meta.latest_version,
1428 };
1429 let installed_pkg = &mut installed[idx];
1430 installed_pkg.correlated = Some(SearchMatch {
1431 source_name: source.name.clone(),
1432 source_kind: source.kind,
1433 id: meta.id,
1434 name: meta.name,
1435 moniker: meta.moniker,
1436 version: Some(available_version),
1437 channel: None,
1438 match_criteria: Some(by.to_owned()),
1439 });
1440 if let Some(version) = canonical_installed {
1441 installed_pkg.installed_version = version;
1442 installed_pkg.installed_version_canonical = true;
1443 }
1444 }
1445 }
1446
1447 Ok(Vec::new())
1448 }
1449
1450 fn correlate_installed_by_normalized_identity(
1459 &mut self,
1460 installed: &mut [InstalledPackage],
1461 requested_source: Option<&str>,
1462 ) -> Result<Vec<String>> {
1463 let source_indices: Vec<usize> = self
1464 .store
1465 .sources
1466 .iter()
1467 .enumerate()
1468 .filter(|(_, source)| {
1469 source.kind == SourceKind::PreIndexed
1470 && match requested_source {
1471 Some(name) => source.name.eq_ignore_ascii_case(name),
1472 None => true,
1473 }
1474 })
1475 .map(|(index, _)| index)
1476 .collect();
1477
1478 for source_index in source_indices {
1479 let resolved: Vec<(usize, V2PackageMetadata)> = {
1480 let connection = match self.open_preindexed_connection(source_index) {
1481 Ok(c) => c,
1482 Err(_) => continue,
1483 };
1484 if !v2_normalized_identity_tables_present(&connection) {
1485 continue;
1486 }
1487 let mut acc = Vec::new();
1488 for (idx, pkg) in installed.iter().enumerate() {
1489 if pkg.correlated.is_some() {
1490 continue;
1491 }
1492 if pkg.local_id.starts_with("MSIX\\") {
1497 continue;
1498 }
1499 let Some(publisher) = pkg.publisher.as_deref() else {
1500 continue;
1501 };
1502 let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1503 if normalized_name.is_empty() {
1504 continue;
1505 }
1506 let normalized_publisher = name_normalization::normalize_publisher(publisher);
1507 if normalized_publisher.is_empty() {
1508 continue;
1509 }
1510 let Some(rowid) =
1511 lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1512 else {
1513 continue;
1514 };
1515 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1516 continue;
1517 };
1518 acc.push((idx, meta));
1519 }
1520 acc
1521 };
1522
1523 let source = self.source_clone(source_index);
1524 for (idx, meta) in resolved {
1525 let (canonical_installed, anchored_latest) =
1526 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1527 Ok((entries, _)) => (
1528 map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1529 latest_arp_anchored_version(&entries),
1530 ),
1531 Err(_) => (None, None),
1532 };
1533 let available_version = match (&canonical_installed, anchored_latest) {
1534 (Some(_), Some(anchored)) => anchored,
1535 _ => meta.latest_version,
1536 };
1537 let installed_pkg = &mut installed[idx];
1538 installed_pkg.correlated = Some(SearchMatch {
1539 source_name: source.name.clone(),
1540 source_kind: source.kind,
1541 id: meta.id,
1542 name: meta.name,
1543 moniker: meta.moniker,
1544 version: Some(available_version),
1545 channel: None,
1546 match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1547 });
1548 if let Some(version) = canonical_installed {
1549 installed_pkg.installed_version = version;
1550 installed_pkg.installed_version_canonical = true;
1551 }
1552 }
1553 }
1554
1555 Ok(Vec::new())
1556 }
1557
1558 fn enrich_correlated_via_index(
1570 &mut self,
1571 installed: &mut [InstalledPackage],
1572 requested_source: Option<&str>,
1573 ) -> Result<Vec<String>> {
1574 for package in installed.iter_mut() {
1576 apply_msix_resource_string_name_fix(package);
1577 }
1578
1579 let source_indices: Vec<usize> = self
1582 .store
1583 .sources
1584 .iter()
1585 .enumerate()
1586 .filter(|(_, source)| {
1587 source.kind == SourceKind::PreIndexed
1588 && match requested_source {
1589 Some(name) => source.name.eq_ignore_ascii_case(name),
1590 None => true,
1591 }
1592 })
1593 .map(|(index, _)| index)
1594 .collect();
1595
1596 for source_index in source_indices {
1597 let needs: Vec<(usize, V2PackageMetadata)> = {
1598 let connection = match self.open_preindexed_connection(source_index) {
1599 Ok(c) => c,
1600 Err(_) => continue,
1601 };
1602 if !v2_identity_tables_present(&connection) {
1603 continue;
1604 }
1605 let mut acc = Vec::new();
1606 for (idx, pkg) in installed.iter().enumerate() {
1607 let Some(candidate) = pkg.correlated.as_ref() else {
1608 continue;
1609 };
1610 if !candidate
1614 .source_name
1615 .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1616 {
1617 continue;
1618 }
1619 let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1620 continue;
1621 };
1622 acc.push((idx, meta));
1623 }
1624 acc
1625 };
1626
1627 let source = self.source_clone(source_index);
1628 for (idx, meta) in needs {
1629 let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1634 Ok((entries, _)) => entries,
1635 Err(_) => continue,
1636 };
1637
1638 if !installed[idx].installed_version_canonical {
1639 let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1640 let anchored_latest = latest_arp_anchored_version(&entries);
1641 if let Some(canonical) = canonical_installed {
1642 installed[idx].installed_version = canonical;
1643 installed[idx].installed_version_canonical = true;
1644 if let (Some(correlated), Some(anchored)) =
1645 (installed[idx].correlated.as_mut(), anchored_latest)
1646 {
1647 correlated.version = Some(anchored);
1648 }
1649 }
1650 }
1651
1652 if let Some(latest) = entries.first() {
1658 let bytes_result = self.get_cached_source_file(
1659 "V2_M",
1660 &source,
1661 &latest.manifest_relative_path,
1662 Some(latest.manifest_hash.as_str()),
1663 );
1664 if let Ok(bytes) = bytes_result
1665 && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1666 {
1667 installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1668 installed[idx].correlated_lacks_compatible_installer =
1669 !manifest_has_compatible_installer(&manifest);
1670 }
1671 }
1672 }
1673 }
1674
1675 Ok(Vec::new())
1676 }
1677
1678 pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1679 let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1680 let versions = self.versions_for_match(&located, query)?;
1681 Ok(VersionsResult {
1682 package: located.display,
1683 versions,
1684 warnings,
1685 })
1686 }
1687
1688 pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1689 let (located, warnings) = self.find_single_match(query)?;
1690 let versions = self.versions_for_match(&located, query)?;
1691 Ok(VersionsResult {
1692 package: located.display,
1693 versions,
1694 warnings,
1695 })
1696 }
1697
1698 pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1699 let (located, warnings) = self.find_single_match(query)?;
1700 let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1701 let selected_installer = select_installer(&manifest.installers, query);
1702
1703 Ok(ShowResult {
1704 package: located.display,
1705 manifest,
1706 selected_installer,
1707 cached_files,
1708 warnings,
1709 manifest_documents,
1710 })
1711 }
1712
1713 pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1714 let (located, warnings) = self.find_single_match(query)?;
1715 let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1716
1717 Ok(CacheWarmResult {
1718 package: located.display,
1719 cached_files,
1720 warnings,
1721 })
1722 }
1723
1724 pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1727 let db_path = pins_db_path(&self.app_root);
1728 if !db_path.exists() {
1729 return Ok(Vec::new());
1730 }
1731 let conn = Self::open_sqlite_connection(db_path)?;
1732 let pin_type_column = match resolve_pin_type_column(&conn)? {
1733 Some(column) => column,
1734 None => return Ok(Vec::new()),
1735 };
1736 let sql = if source_id.is_some() {
1737 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1738 } else {
1739 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1740 };
1741 let params = if let Some(source_id) = source_id {
1742 vec![SqlValue::Text(source_id.to_owned())]
1743 } else {
1744 Vec::new()
1745 };
1746 query_rows(&conn, &sql, params, |row| {
1747 let pin_type_int = row_i64(row, 3)?;
1748 Ok(PinRecord {
1749 package_id: row_string(row, 0)?,
1750 version: row_string(row, 1)?,
1751 source_id: row_string(row, 2)?,
1752 pin_type: decode_pin_type(pin_type_int),
1753 })
1754 })
1755 }
1756
1757 pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1758 let db_path = pins_db_path(&self.app_root);
1759 let conn = Self::open_sqlite_connection(db_path)?;
1760 execute_batch_sql(
1761 &conn,
1762 "CREATE TABLE IF NOT EXISTS pin (
1763 package_id TEXT NOT NULL,
1764 version TEXT NOT NULL DEFAULT '*',
1765 source_id TEXT NOT NULL DEFAULT '',
1766 type INTEGER NOT NULL DEFAULT 0,
1767 PRIMARY KEY (package_id, source_id)
1768 )",
1769 )?;
1770 let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1771 let type_int: i64 = match pin_type {
1772 PinType::Pinning => 2,
1773 PinType::Blocking => 4,
1774 PinType::Gating => 3,
1775 };
1776 execute_sql(
1777 &conn,
1778 &format!(
1779 "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1780 ),
1781 vec![
1782 SqlValue::Text(package_id.to_owned()),
1783 SqlValue::Text(version.to_owned()),
1784 SqlValue::Text(source_id.to_owned()),
1785 SqlValue::Integer(type_int),
1786 ],
1787 )?;
1788 conn.cacheflush().context("failed to flush pin database")?;
1789 Ok(())
1790 }
1791
1792 pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1793 let db_path = pins_db_path(&self.app_root);
1794 if !db_path.exists() {
1795 return Ok(false);
1796 }
1797 let conn = Self::open_sqlite_connection(db_path)?;
1798 let count = if let Some(source_id) = source_id {
1799 execute_sql(
1800 &conn,
1801 "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1802 vec![
1803 SqlValue::Text(package_id.to_owned()),
1804 SqlValue::Text(source_id.to_owned()),
1805 ],
1806 )?
1807 } else {
1808 execute_sql(
1809 &conn,
1810 "DELETE FROM pin WHERE package_id = ?1",
1811 vec![SqlValue::Text(package_id.to_owned())],
1812 )?
1813 };
1814 conn.cacheflush().context("failed to flush pin database")?;
1815 Ok(count > 0)
1816 }
1817
1818 pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1819 let db_path = pins_db_path(&self.app_root);
1820 if !db_path.exists() {
1821 return Ok(());
1822 }
1823
1824 if let Some(source_id) = source_id {
1825 let conn = Self::open_sqlite_connection(db_path)?;
1826 execute_sql(
1827 &conn,
1828 "DELETE FROM pin WHERE source_id = ?1",
1829 vec![SqlValue::Text(source_id.to_owned())],
1830 )?;
1831 conn.cacheflush().context("failed to flush pin database")?;
1832 } else {
1833 fs::remove_file(&db_path)?;
1834 }
1835 Ok(())
1836 }
1837
1838 fn save_store(&self) -> Result<()> {
1839 save_store(&self.app_root, &self.store)
1840 }
1841
1842 fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1843 source_state_dir(&self.app_root, source)
1844 }
1845
1846 pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
1849 self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1850 }
1851
1852 pub fn download_installer_for_request(
1853 &mut self,
1854 request: &InstallRequest,
1855 download_dir: &Path,
1856 ) -> Result<DownloadOutput> {
1857 let fetched = self.fetch_installer_for_request(request, download_dir)?;
1858 let manifest_yaml =
1859 build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
1860 .context("failed to render manifest YAML")?;
1861 let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
1862 let manifest_path = download_dir.join(&manifest_filename);
1863 fs::write(&manifest_path, manifest_yaml.as_bytes())
1864 .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
1865
1866 Ok(DownloadOutput {
1867 manifest: fetched.manifest,
1868 installer_path: fetched.installer_path,
1869 manifest_path,
1870 })
1871 }
1872
1873 fn fetch_installer_for_request(
1878 &mut self,
1879 request: &InstallRequest,
1880 download_dir: &Path,
1881 ) -> Result<FetchedInstaller> {
1882 let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
1883 let installer = select_installer(&manifest.installers, &request.query)
1884 .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1885 let url = installer
1886 .url
1887 .as_deref()
1888 .ok_or_else(|| anyhow!("Installer has no URL"))?;
1889
1890 fs::create_dir_all(download_dir)?;
1891 let filename = request.rename.as_deref().unwrap_or_else(|| {
1892 url.rsplit('/')
1893 .next()
1894 .unwrap_or("installer")
1895 .split('?')
1896 .next()
1897 .unwrap_or("installer")
1898 });
1899 let dest = download_dir.join(filename);
1900
1901 let response = self.client.get(url).send().context("failed to download installer")?;
1902 if !response.status().is_success() {
1903 bail!("Download failed: HTTP {}", response.status());
1904 }
1905 let bytes = response.bytes()?;
1906 fs::write(&dest, &bytes)?;
1907
1908 if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1909 let _ = fs::remove_file(&dest);
1910 return Err(error);
1911 }
1912
1913 Ok(FetchedInstaller {
1914 manifest,
1915 manifest_documents,
1916 installer,
1917 installer_path: dest,
1918 })
1919 }
1920
1921 pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1922 let mut request = InstallRequest::new(query.clone());
1923 request.mode = if silent {
1924 InstallerMode::Silent
1925 } else {
1926 InstallerMode::SilentWithProgress
1927 };
1928 self.install_request(&request)
1929 }
1930
1931 pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1932 let mut request = InstallRequest::new(query.clone());
1933 request.mode = mode;
1934 self.install_request(&request)
1935 }
1936
1937 pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1938 let manifest = self.resolve_manifest_for_install(request)?;
1939 if !package_actions_supported() {
1940 let installer_type = select_installer(&manifest.installers, &request.query)
1941 .and_then(|installer| installer.installer_type)
1942 .unwrap_or_else(|| "install".to_owned())
1943 .to_lowercase();
1944 return Ok(unsupported_action_result(
1945 manifest.id.clone(),
1946 manifest.version.clone(),
1947 installer_type,
1948 INSTALL_UNSUPPORTED_WARNING,
1949 ));
1950 }
1951
1952 let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1953 if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1954 return Ok(no_op_result);
1955 }
1956
1957 Self::ensure_package_agreements_accepted(&manifest, request)?;
1958 self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1959
1960 if request.dependencies_only {
1961 return Ok(InstallResult {
1962 package_id: manifest.id.clone(),
1963 version: manifest.version.clone(),
1964 installer_path: PathBuf::new(),
1965 installer_type: "dependencies".to_owned(),
1966 exit_code: 0,
1967 success: true,
1968 no_op: false,
1969 warnings: Vec::new(),
1970 });
1971 }
1972
1973 let installer = select_installer(&manifest.installers, &request.query)
1974 .ok_or_else(|| anyhow!("No applicable installer found"))?;
1975
1976 if request.uninstall_previous {
1977 let mut uninstall_request = UninstallRequest::new(PackageQuery {
1978 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1979 query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1980 source: request.query.source.clone(),
1981 exact: true,
1982 version: request.query.version.clone(),
1983 ..PackageQuery::default()
1984 });
1985 uninstall_request.product_code = installer.product_code.clone();
1986 uninstall_request.mode = InstallerMode::Silent;
1987 uninstall_request.all_versions = true;
1988 uninstall_request.force = true;
1989 let _ = self.uninstall_request(&uninstall_request);
1990 }
1991
1992 let temp_dir = std::env::temp_dir().join("pinget-install");
1993 let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
1994
1995 let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1996
1997 let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1998
1999 Ok(InstallResult {
2000 package_id: manifest.id.clone(),
2001 version: manifest.version.clone(),
2002 installer_path,
2003 installer_type,
2004 exit_code,
2005 success: exit_code == 0,
2006 no_op: false,
2007 warnings: Vec::new(),
2008 })
2009 }
2010
2011 fn find_installed_package_for_install(
2012 &mut self,
2013 request: &InstallRequest,
2014 manifest: &Manifest,
2015 ) -> Result<Option<ListMatch>> {
2016 let installed_matches = self.list(&ListQuery {
2017 query: request.query.query.clone(),
2018 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2019 name: request.query.name.clone(),
2020 moniker: request.query.moniker.clone(),
2021 tag: None,
2022 command: None,
2023 product_code: None,
2024 version: None,
2025 source: request.query.source.clone(),
2026 count: Some(100),
2027 exact: request.query.exact || !manifest.id.trim().is_empty(),
2028 install_scope: request.query.install_scope.clone(),
2029 upgrade_only: false,
2030 include_unknown: false,
2031 include_pinned: false,
2032 })?;
2033
2034 Ok(installed_matches.matches.into_iter().find(|candidate| {
2035 candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2036 }))
2037 }
2038
2039 fn create_install_no_op_result(
2040 request: &InstallRequest,
2041 manifest: &Manifest,
2042 existing_match: Option<&ListMatch>,
2043 ) -> Option<InstallResult> {
2044 let existing_match = existing_match?;
2045
2046 if request.no_upgrade {
2047 return Some(InstallResult {
2048 package_id: manifest.id.clone(),
2049 version: existing_match.installed_version.clone(),
2050 installer_path: PathBuf::new(),
2051 installer_type: "install".to_owned(),
2052 exit_code: 0,
2053 success: true,
2054 no_op: true,
2055 warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2056 });
2057 }
2058
2059 if !request.force
2060 && !existing_match.installed_version.trim().is_empty()
2061 && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2062 {
2063 return Some(InstallResult {
2064 package_id: manifest.id.clone(),
2065 version: existing_match.installed_version.clone(),
2066 installer_path: PathBuf::new(),
2067 installer_type: "install".to_owned(),
2068 exit_code: 0,
2069 success: true,
2070 no_op: true,
2071 warnings: vec![
2072 "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2073 ],
2074 });
2075 }
2076
2077 None
2078 }
2079
2080 pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2081 if request.manifest_path.is_none() && !package_actions_supported() {
2082 let package_id = request
2083 .query
2084 .id
2085 .clone()
2086 .or_else(|| request.query.name.clone())
2087 .or_else(|| request.query.moniker.clone())
2088 .or_else(|| request.query.query.clone())
2089 .or_else(|| request.product_code.clone())
2090 .unwrap_or_else(|| "repair".to_owned());
2091 return Ok(unsupported_action_result(
2092 package_id,
2093 request.query.version.clone().unwrap_or_default(),
2094 "repair".to_owned(),
2095 REPAIR_UNSUPPORTED_WARNING,
2096 ));
2097 }
2098
2099 let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2100 let installed_match = if request.manifest_path.is_some() {
2101 None
2102 } else {
2103 let installed = self.list(&Self::create_repair_list_query(request))?;
2104 warnings.extend(installed.warnings);
2105 if installed.matches.is_empty() {
2106 bail!("No installed package matched the supplied repair query.");
2107 }
2108 if installed.matches.len() > 1 {
2109 bail!("Multiple installed packages matched the supplied repair query.");
2110 }
2111 installed.matches.into_iter().next()
2112 };
2113
2114 let mut result =
2115 self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2116 warnings.extend(result.warnings);
2117 result.warnings = warnings;
2118 Ok(result)
2119 }
2120
2121 pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2122 let mut request = UninstallRequest::new(query.clone());
2123 request.mode = if silent {
2124 InstallerMode::Silent
2125 } else {
2126 InstallerMode::SilentWithProgress
2127 };
2128 self.uninstall_request(&request)
2129 }
2130
2131 pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2132 if !package_actions_supported() {
2133 let (package_id, version) = if let Some(path) = &request.manifest_path {
2134 let manifest = Self::load_manifest_from_path(path)?;
2135 (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2136 } else {
2137 (
2138 request
2139 .query
2140 .id
2141 .clone()
2142 .or_else(|| request.query.query.clone())
2143 .or_else(|| request.query.name.clone())
2144 .or_else(|| request.query.moniker.clone())
2145 .or_else(|| request.product_code.clone())
2146 .unwrap_or_else(|| "unknown".to_owned()),
2147 request.query.version.clone().unwrap_or_default(),
2148 )
2149 };
2150
2151 return Ok(unsupported_action_result(
2152 package_id,
2153 version,
2154 "uninstall",
2155 UNINSTALL_UNSUPPORTED_WARNING,
2156 ));
2157 }
2158
2159 let matches = self.resolve_uninstall_matches(request)?;
2160 let mut exit_code = 0;
2161
2162 for installed in &matches {
2163 exit_code = uninstall_package(installed, request)?;
2164 if exit_code != 0 && !request.all_versions {
2165 break;
2166 }
2167 }
2168
2169 let installed = matches
2170 .first()
2171 .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2172
2173 Ok(InstallResult {
2174 package_id: installed.id.clone(),
2175 version: installed.installed_version.clone(),
2176 installer_path: PathBuf::new(),
2177 installer_type: "uninstall".to_owned(),
2178 exit_code,
2179 success: exit_code == 0,
2180 no_op: false,
2181 warnings: Vec::new(),
2182 })
2183 }
2184
2185 fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2186 self.resolve_manifest_and_documents_for_install(request)
2187 .map(|(manifest, _)| manifest)
2188 }
2189
2190 fn resolve_manifest_and_documents_for_install(
2191 &mut self,
2192 request: &InstallRequest,
2193 ) -> Result<(Manifest, JsonValue)> {
2194 if let Some(path) = &request.manifest_path {
2195 return Self::load_manifest_from_path_with_documents(path);
2196 }
2197
2198 let (located, _warnings) = self.find_single_match(&request.query)?;
2199 let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2200 Ok((manifest, documents))
2201 }
2202
2203 fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2204 if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2205 bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2206 }
2207 Ok(())
2208 }
2209
2210 fn install_dependencies(
2211 &mut self,
2212 manifest: &Manifest,
2213 request: &InstallRequest,
2214 visited: &mut HashSet<String>,
2215 ) -> Result<()> {
2216 if request.skip_dependencies {
2217 return Ok(());
2218 }
2219
2220 let dependency_ids = manifest
2221 .package_dependencies
2222 .iter()
2223 .chain(
2224 manifest
2225 .installers
2226 .iter()
2227 .flat_map(|installer| installer.package_dependencies.iter()),
2228 )
2229 .filter(|value| !value.trim().is_empty())
2230 .cloned()
2231 .collect::<BTreeSet<_>>();
2232
2233 for dependency_id in dependency_ids {
2234 if !visited.insert(dependency_id.clone()) {
2235 continue;
2236 }
2237
2238 let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2239 self.install_request(&dependency_request)?;
2240 }
2241
2242 Ok(())
2243 }
2244
2245 fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2246 let mut dependency_request = InstallRequest::new(PackageQuery {
2247 id: Some(dependency_id),
2248 source: request
2249 .dependency_source
2250 .clone()
2251 .or_else(|| request.query.source.clone()),
2252 exact: true,
2253 ..PackageQuery::default()
2254 });
2255 dependency_request.mode = match request.mode {
2256 InstallerMode::Interactive => InstallerMode::Interactive,
2257 InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2258 };
2259 dependency_request.accept_package_agreements = request.accept_package_agreements;
2260 dependency_request.no_upgrade = true;
2261 dependency_request.ignore_security_hash = request.ignore_security_hash;
2262 dependency_request.dependency_source = request.dependency_source.clone();
2263 dependency_request
2264 }
2265
2266 fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2267 ListQuery {
2268 query: request.query.query.clone(),
2269 id: request.query.id.clone(),
2270 name: request.query.name.clone(),
2271 moniker: request.query.moniker.clone(),
2272 product_code: request.product_code.clone(),
2273 version: request.query.version.clone(),
2274 source: request.query.source.clone(),
2275 count: Some(100),
2276 exact: request.query.exact,
2277 install_scope: request.query.install_scope.clone(),
2278 ..ListQuery::default()
2279 }
2280 }
2281
2282 fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2283 let mut query = request.query.clone();
2284 query.query = if installed_match.is_none() {
2285 request.query.query.clone()
2286 } else {
2287 None
2288 };
2289 query.id = installed_match
2290 .map(|item| item.id.clone())
2291 .or_else(|| request.query.id.clone());
2292 query.name = if installed_match.is_none() {
2293 request.query.name.clone()
2294 } else {
2295 None
2296 };
2297 query.moniker = if installed_match.is_none() {
2298 request.query.moniker.clone()
2299 } else {
2300 None
2301 };
2302 query.source = request
2303 .query
2304 .source
2305 .clone()
2306 .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2307 query.exact = true;
2308 query.version = request
2309 .query
2310 .version
2311 .clone()
2312 .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2313
2314 InstallRequest {
2315 query,
2316 manifest_path: request.manifest_path.clone(),
2317 mode: request.mode,
2318 log_path: request.log_path.clone(),
2319 custom: None,
2320 override_args: None,
2321 install_location: None,
2322 skip_dependencies: false,
2323 dependencies_only: false,
2324 accept_package_agreements: request.accept_package_agreements,
2325 force: true,
2326 rename: None,
2327 uninstall_previous: false,
2328 ignore_security_hash: request.ignore_security_hash,
2329 dependency_source: None,
2330 no_upgrade: false,
2331 }
2332 }
2333
2334 fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2335 let mut effective_query = request.query.clone();
2336 let mut effective_product_code = request.product_code.clone();
2337
2338 if let Some(path) = &request.manifest_path {
2339 let manifest = Self::load_manifest_from_path(path)?;
2340 effective_query = PackageQuery {
2341 query: Some(manifest.id.clone()),
2342 id: Some(manifest.id.clone()),
2343 name: Some(manifest.name.clone()),
2344 exact: true,
2345 version: effective_query.version.clone().or(Some(manifest.version)),
2346 ..PackageQuery::default()
2347 };
2348 effective_product_code = effective_product_code.or_else(|| {
2349 manifest
2350 .installers
2351 .iter()
2352 .find_map(|installer| installer.product_code.clone())
2353 });
2354 }
2355
2356 let list_query = ListQuery {
2357 query: effective_query.query.clone(),
2358 id: effective_query.id.clone(),
2359 name: effective_query.name.clone(),
2360 moniker: effective_query.moniker.clone(),
2361 tag: None,
2362 command: None,
2363 product_code: effective_product_code,
2364 version: effective_query.version.clone(),
2365 source: effective_query.source.clone(),
2366 count: if request.all_versions { None } else { Some(100) },
2367 exact: effective_query.exact,
2368 install_scope: effective_query.install_scope,
2369 upgrade_only: false,
2370 include_unknown: false,
2371 include_pinned: false,
2372 };
2373
2374 let list_result = self.list(&list_query)?;
2375 if list_result.matches.is_empty() {
2376 bail!("No installed package found matching the query");
2377 }
2378 if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2379 bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2380 }
2381
2382 Ok(if request.all_versions {
2383 list_result.matches
2384 } else {
2385 vec![list_result.matches[0].clone()]
2386 })
2387 }
2388
2389 fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2390 Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2391 }
2392
2393 fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2394 let resolved = resolve_manifest_path(manifest_path)?;
2395 let bytes =
2396 fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2397 parse_yaml_manifest_bundle(&bytes)
2398 }
2399
2400 fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2401 self.find_single_match_with_semantics(query, SearchSemantics::Single)
2402 }
2403
2404 fn find_single_match_with_semantics(
2405 &mut self,
2406 query: &PackageQuery,
2407 semantics: SearchSemantics,
2408 ) -> Result<(LocatedMatch, Vec<String>)> {
2409 let (matches, warnings, _) = self.search_located(query, semantics)?;
2410
2411 if matches.is_empty() {
2412 bail!("no package matched the supplied query");
2413 }
2414
2415 if matches.len() > 1 {
2416 let choices = matches
2417 .iter()
2418 .take(10)
2419 .map(|item| {
2420 format!(
2421 "{} [{}] ({})",
2422 item.display.name, item.display.id, item.display.source_name
2423 )
2424 })
2425 .collect::<Vec<_>>()
2426 .join(", ");
2427 bail!("multiple packages matched: {choices}");
2428 }
2429
2430 Ok((matches.into_iter().next().expect("one match"), warnings))
2431 }
2432
2433 fn search_located(
2434 &mut self,
2435 query: &PackageQuery,
2436 semantics: SearchSemantics,
2437 ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2438 let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2439 let mut matches = Vec::new();
2440 let mut warnings = Vec::new();
2441 let mut truncated = false;
2442
2443 for index in indexes {
2444 match self.search_source(index, query, semantics) {
2445 Ok(mut source_matches) => {
2446 truncated |= source_matches.truncated;
2447 matches.append(&mut source_matches.matches);
2448 }
2449 Err(_error) => warnings.push(format!(
2450 "Failed when searching source; results will not be included: {}",
2451 self.store.sources[index].name
2452 )),
2453 }
2454 }
2455
2456 if semantics == SearchSemantics::Many {
2457 matches.sort_by(|left, right| {
2458 search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2459 });
2460 let limit = max_results(query);
2461 if matches.len() > limit {
2462 truncated = true;
2463 matches.truncate(limit);
2464 }
2465 }
2466
2467 Ok((matches, warnings, truncated))
2468 }
2469
2470 fn search_source(
2471 &mut self,
2472 source_index: usize,
2473 query: &PackageQuery,
2474 semantics: SearchSemantics,
2475 ) -> Result<SearchSourceMatches> {
2476 match self.store.sources[source_index].kind {
2477 SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2478 SourceKind::Rest => self.search_rest(source_index, query, semantics),
2479 }
2480 }
2481
2482 fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2483 match &located.locator {
2484 MatchLocator::PreIndexedV1 { package_rowid } => {
2485 let source = self.source_clone(located.source_index);
2486 let connection = self.open_preindexed_connection(located.source_index)?;
2487 let versions = query_v1_versions(&connection, *package_rowid)?;
2488 let mut keys = versions
2489 .into_iter()
2490 .map(|row| VersionKey {
2491 version: row.version,
2492 channel: row.channel,
2493 })
2494 .collect::<Vec<_>>();
2495 sort_versions_desc(&mut keys);
2496 if keys.is_empty() {
2497 bail!("no versions found for {} in {}", located.display.id, source.name);
2498 }
2499 Ok(keys)
2500 }
2501 MatchLocator::PreIndexedV2 {
2502 package_rowid,
2503 package_hash,
2504 } => {
2505 let source = self.source_clone(located.source_index);
2506 let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2507 let mut keys = entries
2508 .into_iter()
2509 .map(|entry| VersionKey {
2510 version: entry.version,
2511 channel: String::new(),
2512 })
2513 .collect::<Vec<_>>();
2514 sort_versions_desc(&mut keys);
2515 Ok(keys)
2516 }
2517 MatchLocator::Rest { versions, .. } => {
2518 let mut keys = versions.clone();
2519 sort_versions_desc(&mut keys);
2520 Ok(keys)
2521 }
2522 }
2523 }
2524
2525 fn manifest_for_match(
2526 &mut self,
2527 located: &LocatedMatch,
2528 query: &PackageQuery,
2529 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2530 self.manifest_for_match_with_refresh(located, query, true)
2531 }
2532
2533 fn manifest_for_match_with_refresh(
2534 &mut self,
2535 located: &LocatedMatch,
2536 query: &PackageQuery,
2537 allow_explicit_version_refresh: bool,
2538 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2539 match &located.locator {
2540 MatchLocator::PreIndexedV1 { package_rowid } => {
2541 let source = self.source_clone(located.source_index);
2542 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2543 let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2544 let selected = match select_v1_version(
2545 &versions,
2546 query.version.as_deref(),
2547 query.channel.as_deref(),
2548 &located.display.id,
2549 &source.name,
2550 ) {
2551 Ok(selected) => selected,
2552 Err(error)
2553 if allow_explicit_version_refresh
2554 && query.version.is_some()
2555 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2556 {
2557 let open_refresh = opened.refresh.clone();
2558 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2559 return missing_error_after_open_refresh(error, &open_refresh);
2560 }
2561 if self.has_recent_successful_preindexed_refresh_attempt(
2562 &self.preindexed_refresh_lock_key(&source),
2563 ) {
2564 return Err(error);
2565 }
2566 drop(opened);
2567 return self.refresh_preindexed_and_retry_manifest(
2568 located.source_index,
2569 &located.display,
2570 query,
2571 error,
2572 );
2573 }
2574 Err(error) => return Err(error),
2575 };
2576 let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2577 let bytes =
2578 self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2579 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2580 manifest.version = selected.version.clone();
2581 manifest.channel = selected.channel;
2582 Ok((manifest, manifest_documents, vec![bytes.path]))
2583 }
2584 MatchLocator::PreIndexedV2 {
2585 package_rowid,
2586 package_hash,
2587 } => {
2588 let source = self.source_clone(located.source_index);
2589 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2590 let (entries, version_data_file) =
2591 self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2592 let selected = match select_v2_version(
2593 &entries,
2594 query.version.as_deref(),
2595 query.channel.as_deref(),
2596 &located.display.id,
2597 &source.name,
2598 ) {
2599 Ok(selected) => selected,
2600 Err(error)
2601 if allow_explicit_version_refresh
2602 && query.version.is_some()
2603 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2604 {
2605 let open_refresh = opened.refresh.clone();
2606 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2607 return missing_error_after_open_refresh(error, &open_refresh);
2608 }
2609 if self.has_recent_successful_preindexed_refresh_attempt(
2610 &self.preindexed_refresh_lock_key(&source),
2611 ) {
2612 return Err(error);
2613 }
2614 drop(opened);
2615 return self.refresh_preindexed_and_retry_manifest(
2616 located.source_index,
2617 &located.display,
2618 query,
2619 error,
2620 );
2621 }
2622 Err(error) => return Err(error),
2623 };
2624 let manifest_bytes = self.get_cached_source_file(
2625 "V2_M",
2626 &source,
2627 &selected.manifest_relative_path,
2628 Some(selected.manifest_hash.as_str()),
2629 )?;
2630 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2631 manifest.version = selected.version;
2632 Ok((
2633 manifest,
2634 manifest_documents,
2635 vec![version_data_file, manifest_bytes.path],
2636 ))
2637 }
2638 MatchLocator::Rest { package_id, versions } => {
2639 let source = self.source_clone(located.source_index);
2640 let selected = select_rest_version(
2641 versions,
2642 query.version.as_deref(),
2643 query.channel.as_deref(),
2644 &located.display.id,
2645 &source.name,
2646 )?;
2647 let (bytes, cache_path) =
2648 self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2649 let (manifest, manifest_documents) =
2650 parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2651 Ok((manifest, manifest_documents, vec![cache_path]))
2652 }
2653 }
2654 }
2655
2656 fn refresh_preindexed_and_retry_manifest(
2657 &mut self,
2658 source_index: usize,
2659 previous_display: &SearchMatch,
2660 query: &PackageQuery,
2661 missing_error: anyhow::Error,
2662 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2663 self.refresh_preindexed_source(source_index, true)
2664 .with_context(|| format!("{missing_error} The source refresh also failed"))?;
2665 let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
2666 self.manifest_for_match_with_refresh(&refreshed, query, false)
2667 }
2668
2669 fn find_refreshed_preindexed_match(
2670 &mut self,
2671 source_index: usize,
2672 previous_display: &SearchMatch,
2673 query: &PackageQuery,
2674 ) -> Result<LocatedMatch> {
2675 let source_name = self.store.sources[source_index].name.clone();
2676 let mut source_query = query.clone();
2677 source_query.source = Some(source_name.clone());
2678 let matches = self
2679 .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
2680 .matches;
2681 if let Some(found) = matches
2682 .iter()
2683 .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
2684 {
2685 return Ok(found.clone());
2686 }
2687 if matches.len() == 1 {
2688 return Ok(matches.into_iter().next().expect("single match"));
2689 }
2690 bail!(
2691 "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
2692 previous_display.id,
2693 source_name
2694 )
2695 }
2696
2697 fn search_preindexed(
2698 &mut self,
2699 source_index: usize,
2700 query: &PackageQuery,
2701 semantics: SearchSemantics,
2702 ) -> Result<SearchSourceMatches> {
2703 let connection = self.open_preindexed_connection(source_index)?;
2704 let source = self.source_clone(source_index);
2705
2706 match query_v2_matches(&connection, query, semantics) {
2707 Ok((rows, truncated)) => Ok(SearchSourceMatches {
2708 truncated,
2709 matches: rows
2710 .into_iter()
2711 .map(|row| LocatedMatch {
2712 display: SearchMatch {
2713 source_name: source.name.clone(),
2714 source_kind: source.kind,
2715 id: row.id.clone(),
2716 name: row.name.clone(),
2717 moniker: row.moniker.clone(),
2718 version: Some(row.version.clone()),
2719 channel: None,
2720 match_criteria: row.match_criteria.clone(),
2721 },
2722 source_index,
2723 locator: MatchLocator::PreIndexedV2 {
2724 package_rowid: row.package_rowid,
2725 package_hash: row.package_hash,
2726 },
2727 })
2728 .collect(),
2729 }),
2730 Err(error) if can_fallback_to_v1(&error) => {
2731 let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2732 let grouped = group_v1_rows(rows);
2733
2734 Ok(SearchSourceMatches {
2735 truncated,
2736 matches: grouped
2737 .into_iter()
2738 .map(|row| LocatedMatch {
2739 display: SearchMatch {
2740 source_name: source.name.clone(),
2741 source_kind: source.kind,
2742 id: row.id.clone(),
2743 name: row.name.clone(),
2744 moniker: row.moniker.clone(),
2745 version: Some(row.version.clone()),
2746 channel: Some(row.channel.clone()),
2747 match_criteria: row.match_criteria.clone(),
2748 },
2749 source_index,
2750 locator: MatchLocator::PreIndexedV1 {
2751 package_rowid: row.package_rowid,
2752 },
2753 })
2754 .collect(),
2755 })
2756 }
2757 Err(error) => Err(error),
2758 }
2759 }
2760
2761 fn search_rest(
2762 &mut self,
2763 source_index: usize,
2764 query: &PackageQuery,
2765 semantics: SearchSemantics,
2766 ) -> Result<SearchSourceMatches> {
2767 let source = self.source_clone(source_index);
2768 let info = self.load_rest_information(source_index)?;
2769 let contract = choose_contract(&info.server_supported_versions)
2770 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2771
2772 let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2773 let body = build_rest_search_body(query, &info, semantics)?;
2774 let response = self
2775 .client
2776 .post(url)
2777 .header("Version", contract)
2778 .json(&body)
2779 .send()
2780 .context("REST search request failed")?
2781 .error_for_status()
2782 .context("REST search request returned an error")?;
2783 let json = response
2784 .json::<JsonValue>()
2785 .context("failed to parse REST search response")?;
2786 let data = json
2787 .get("Data")
2788 .and_then(JsonValue::as_array)
2789 .cloned()
2790 .unwrap_or_default();
2791 let max_results = source_fetch_results(query, semantics);
2792
2793 let mut results = Vec::new();
2794 for item in data {
2795 let package_id = json_string(&item, "PackageIdentifier")
2796 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2797 let package_name =
2798 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2799 let mut versions = parse_rest_versions(&item)?;
2800 sort_versions_desc(&mut versions);
2801 let latest = versions
2802 .first()
2803 .cloned()
2804 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2805
2806 results.push(LocatedMatch {
2807 display: SearchMatch {
2808 source_name: source.name.clone(),
2809 source_kind: source.kind,
2810 id: package_id.clone(),
2811 name: package_name,
2812 moniker: json_string(&item, "Moniker"),
2813 version: Some(latest.version.clone()),
2814 channel: if latest.channel.is_empty() {
2815 None
2816 } else {
2817 Some(latest.channel.clone())
2818 },
2819 match_criteria: rest_match_criteria(&item, query, semantics),
2820 },
2821 source_index,
2822 locator: MatchLocator::Rest { package_id, versions },
2823 });
2824 }
2825
2826 Ok(SearchSourceMatches {
2827 truncated: results.len() >= max_results,
2828 matches: results,
2829 })
2830 }
2831
2832 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2833 Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
2834 }
2835
2836 fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
2837 let source = self.source_clone(source_index);
2838 let index_path = preindexed_index_path(&self.app_root, &source);
2839 let mut refresh = PreindexedRefreshOutcome::default();
2840 if !index_path.exists() {
2841 let _ = self.refresh_preindexed_source(source_index, true)?;
2842 refresh.attempted = true;
2843 } else if self.is_preindexed_index_stale(&source, &index_path) {
2844 let lock_key = self.preindexed_refresh_lock_key(&source);
2845 if self.has_recent_preindexed_refresh_attempt(&lock_key) {
2846 refresh.attempted = true;
2847 refresh.reused_recent_attempt = true;
2848 } else {
2849 refresh.attempted = true;
2850 if let Err(error) = self.refresh_preindexed_source(source_index, false) {
2851 refresh.error = Some(format!("{error:#}"));
2852 }
2853 }
2854 }
2855
2856 Ok(PreindexedOpenConnection {
2857 connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
2858 refresh,
2859 })
2860 }
2861
2862 fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
2863 let Some(interval) = self.pre_indexed_source_auto_update_interval else {
2864 return false;
2865 };
2866
2867 let index_modified = || {
2868 fs::metadata(index_path)
2869 .ok()
2870 .and_then(|metadata| metadata.modified().ok())
2871 .map(DateTime::<Utc>::from)
2872 };
2873
2874 let last_update = match (source.last_update, index_modified()) {
2875 (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
2876 (Some(source_last_update), None) => Some(source_last_update),
2877 (None, Some(index_last_write)) => Some(index_last_write),
2878 (None, None) => None,
2879 };
2880
2881 match last_update {
2882 Some(last_update) => Utc::now() - last_update >= interval,
2883 None => true,
2884 }
2885 }
2886
2887 fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
2888 format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
2889 }
2890
2891 fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2892 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2893 Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2894 })
2895 }
2896
2897 fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2898 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2899 attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2900 })
2901 }
2902
2903 fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
2904 self.preindexed_refresh_attempts.insert(
2905 lock_key,
2906 PreindexedRefreshAttempt {
2907 attempted_at: Utc::now(),
2908 succeeded,
2909 },
2910 );
2911 }
2912
2913 fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
2914 {
2915 let locks = preindexed_refresh_locks()
2916 .read()
2917 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2918 if let Some(existing) = locks.get(lock_key) {
2919 return Ok(Arc::clone(existing));
2920 }
2921 }
2922
2923 let mut locks = preindexed_refresh_locks()
2924 .write()
2925 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2926 Ok(Arc::clone(
2927 locks
2928 .entry(lock_key.to_owned())
2929 .or_insert_with(|| Arc::new(Mutex::new(()))),
2930 ))
2931 }
2932
2933 fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
2934 let source = self.source_clone(source_index);
2935 let index_path = preindexed_index_path(&self.app_root, &source);
2936 let lock_key = self.preindexed_refresh_lock_key(&source);
2937 let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
2938 let _guard = refresh_lock
2939 .lock()
2940 .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
2941
2942 if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
2943 return Ok("source already refreshed".to_owned());
2944 }
2945
2946 let result = self.update_preindexed(source_index);
2947 self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
2948 let detail = result?;
2949 if !self.use_system_winget_sources {
2950 self.save_store()?;
2951 }
2952 Ok(detail)
2953 }
2954
2955 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2956 let path_text = path.to_string_lossy().into_owned();
2957 let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
2958 .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
2959 database
2960 .connect()
2961 .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
2962 }
2963
2964 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2965 let source = &mut self.store.sources[source_index];
2966 let state_dir = source_state_dir(&self.app_root, source);
2967 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2968
2969 let mut last_error = None;
2970 for candidate in PREINDEXED_CANDIDATES {
2971 let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2972 match self.client.get(&url).send() {
2973 Ok(response) if response.status().is_success() => {
2974 let header_version = response
2975 .headers()
2976 .get("x-ms-meta-sourceversion")
2977 .and_then(|value| value.to_str().ok())
2978 .map(str::to_owned);
2979 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2980 let payload = bytes.to_vec();
2981 let index_bytes = extract_zip_member(&payload, "Public/index.db")
2982 .context("preindexed package did not contain Public/index.db")?;
2983
2984 fs::write(preindexed_package_path(&self.app_root, source), &payload)
2985 .context("failed to persist source package")?;
2986 let index_path = preindexed_index_path(&self.app_root, source);
2987 let temp_index_path = state_dir.join(format!(
2988 "index.{}.tmp",
2989 Utc::now().timestamp_nanos_opt().unwrap_or_default()
2990 ));
2991 fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
2992 if let Err(error) = replace_file(&temp_index_path, &index_path) {
2993 let _ = fs::remove_file(&temp_index_path);
2994 return Err(error).context("failed to persist source index");
2995 }
2996 remove_sqlite_sidecars(&index_path);
3001 source.last_update = Some(Utc::now());
3002 source.source_version = header_version;
3003 return Ok(format!("downloaded {}", candidate));
3004 }
3005 Ok(response) => {
3006 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3007 }
3008 Err(error) => {
3009 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3010 }
3011 }
3012 }
3013
3014 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3015 }
3016
3017 fn update_rest(&mut self, source_index: usize) -> Result<String> {
3018 let info = self.fetch_rest_information(source_index, true)?;
3019 let source = &mut self.store.sources[source_index];
3020 if !info.source_identifier.is_empty() {
3021 source.identifier = info.source_identifier.clone();
3022 }
3023 source.last_update = Some(Utc::now());
3024 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3025 Ok("refreshed information cache".to_owned())
3026 }
3027
3028 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3029 let source = self.source_clone(source_index);
3030 let cache_path = rest_information_cache_path(&self.app_root, &source);
3031
3032 if cache_path.exists() {
3033 let cache = serde_json::from_slice::<RestInfoCache>(
3034 &fs::read(&cache_path).context("failed to read REST information cache")?,
3035 )
3036 .context("failed to parse REST information cache")?;
3037 if cache.expires_at > Utc::now() {
3038 return Ok(cache.value);
3039 }
3040 }
3041
3042 self.fetch_rest_information(source_index, true)
3043 }
3044
3045 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3046 let source = self.source_clone(source_index);
3047 let url = format!("{}/information", source.arg.trim_end_matches('/'));
3048 let response = self
3049 .client
3050 .get(url)
3051 .send()
3052 .context("REST information request failed")?
3053 .error_for_status()
3054 .context("REST information request returned an error")?;
3055
3056 let max_age = cache_control_max_age(&response);
3057 let json = response
3058 .json::<JsonValue>()
3059 .context("failed to parse REST information response")?;
3060 let data = json
3061 .get("Data")
3062 .cloned()
3063 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3064 let info = serde_json::from_value::<RestInformation>(data)
3065 .context("failed to deserialize REST information payload")?;
3066
3067 if persist {
3068 let cache = RestInfoCache {
3069 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3070 value: info.clone(),
3071 };
3072 write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
3073 }
3074
3075 Ok(info)
3076 }
3077
3078 fn load_v2_version_data(
3079 &self,
3080 source: &SourceRecord,
3081 package_rowid: i64,
3082 package_hash: &str,
3083 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3084 let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
3085 .context("failed to reopen preindexed index for V2 version data")?;
3086 let package_hash = package_hash.to_ascii_lowercase();
3087 let package_id = query_optional_value(
3088 &connection,
3089 "SELECT id FROM packages WHERE rowid = ?1",
3090 vec![SqlValue::Integer(package_rowid)],
3091 |row| row_string(row, 0),
3092 )?
3093 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3094 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3095 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3096 let yaml = decompress_mszyml(&file.bytes)?;
3097 let document =
3098 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3099 Ok((document.versions, file.path))
3100 }
3101
3102 fn get_cached_source_file(
3103 &self,
3104 bucket: &str,
3105 source: &SourceRecord,
3106 relative_path: &str,
3107 expected_hash: Option<&str>,
3108 ) -> Result<CachedBytes> {
3109 let normalized_relative = relative_path.replace('\\', "/");
3110 let cache_path =
3111 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3112
3113 if cache_path.exists() {
3114 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3115 if hash_matches(expected_hash, &cached) {
3116 return Ok(CachedBytes {
3117 path: cache_path,
3118 bytes: cached,
3119 });
3120 }
3121 }
3122
3123 let url = format!(
3124 "{}/{}",
3125 source.arg.trim_end_matches('/'),
3126 normalized_relative.trim_start_matches('/')
3127 );
3128 let response = self
3129 .client
3130 .get(url)
3131 .send()
3132 .context("failed to fetch source file")?
3133 .error_for_status()
3134 .context("source file request returned an error")?;
3135 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3136
3137 if let Some(hash) = expected_hash {
3138 verify_hash(hash, &bytes)?;
3139 }
3140
3141 if let Some(parent) = cache_path.parent() {
3142 fs::create_dir_all(parent).context("failed to create cache directory")?;
3143 }
3144 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3145
3146 Ok(CachedBytes {
3147 path: cache_path,
3148 bytes,
3149 })
3150 }
3151
3152 fn get_or_fetch_rest_manifest(
3153 &mut self,
3154 source: &SourceRecord,
3155 package_id: &str,
3156 version: &str,
3157 channel: &str,
3158 ) -> Result<(Vec<u8>, PathBuf)> {
3159 let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
3160 if cache_path.exists() {
3161 return Ok((
3162 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3163 cache_path,
3164 ));
3165 }
3166
3167 let info = self.load_rest_information_by_name(&source.name)?;
3168 let contract = choose_contract(&info.server_supported_versions)
3169 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3170 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3171
3172 let mut params = vec![("Version", version.to_owned())];
3173 if !channel.is_empty() {
3174 params.push(("Channel", channel.to_owned()));
3175 }
3176 if info
3177 .required_query_parameters
3178 .iter()
3179 .any(|value| value.eq_ignore_ascii_case("Market"))
3180 {
3181 params.push(("Market", default_market()));
3182 }
3183
3184 let response = self
3185 .client
3186 .get(url)
3187 .header("Version", contract)
3188 .query(¶ms)
3189 .send()
3190 .context("REST manifest request failed")?
3191 .error_for_status()
3192 .context("REST manifest request returned an error")?;
3193 let bytes = response
3194 .bytes()
3195 .context("failed to read REST manifest response")?
3196 .to_vec();
3197
3198 if let Some(parent) = cache_path.parent() {
3199 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3200 }
3201 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3202
3203 Ok((bytes, cache_path))
3204 }
3205
3206 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3207 let index = self
3208 .store
3209 .sources
3210 .iter()
3211 .position(|source| source.name.eq_ignore_ascii_case(name))
3212 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3213 self.load_rest_information(index)
3214 }
3215
3216 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3217 if let Some(name) = source_name {
3218 let index = self
3219 .store
3220 .sources
3221 .iter()
3222 .position(|source| source.name.eq_ignore_ascii_case(name))
3223 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3224 return Ok(vec![index]);
3225 }
3226
3227 let mut indexes: Vec<_> = self
3228 .store
3229 .sources
3230 .iter()
3231 .enumerate()
3232 .filter(|(_, source)| !source.explicit)
3233 .collect();
3234 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3235 right_source
3236 .priority
3237 .cmp(&left_source.priority)
3238 .then_with(|| left_index.cmp(right_index))
3239 });
3240 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3241 }
3242
3243 fn source_clone(&self, index: usize) -> SourceRecord {
3244 self.store.sources[index].clone()
3245 }
3246
3247 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3248 if self.use_system_winget_sources {
3249 self.store = load_system_winget_source_store()?;
3250 }
3251 Ok(())
3252 }
3253
3254 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3255 self.refresh_system_winget_sources()?;
3256 let selected_names: Vec<_> = if source_name.is_none() {
3257 self.store.sources.iter().map(|source| source.name.clone()).collect()
3258 } else {
3259 self.resolve_source_indexes(source_name)?
3260 .into_iter()
3261 .map(|index| self.store.sources[index].name.clone())
3262 .collect()
3263 };
3264
3265 let detail = system_winget_update_sources(source_name)?;
3266 self.refresh_system_winget_sources()?;
3267
3268 Ok(selected_names
3269 .into_iter()
3270 .filter_map(|name| {
3271 self.store
3272 .sources
3273 .iter()
3274 .find(|source| source.name.eq_ignore_ascii_case(&name))
3275 .map(|source| SourceUpdateResult {
3276 name: source.name.clone(),
3277 kind: source.kind,
3278 detail: detail.clone(),
3279 })
3280 })
3281 .collect())
3282 }
3283}
3284
3285#[derive(Debug)]
3286struct CachedBytes {
3287 path: PathBuf,
3288 bytes: Vec<u8>,
3289}
3290
3291#[derive(Debug, Clone)]
3292struct V1SearchRow {
3293 manifest_rowid: i64,
3294 package_rowid: i64,
3295 version: String,
3296 channel: String,
3297 id: String,
3298 name: String,
3299 moniker: Option<String>,
3300 match_criteria: Option<String>,
3301}
3302
3303#[derive(Debug, Clone)]
3304struct V1VersionRow {
3305 version: String,
3306 channel: String,
3307 pathpart_id: i64,
3308 manifest_hash: Option<String>,
3309}
3310
3311#[derive(Debug, Clone)]
3312struct V2SearchRow {
3313 package_rowid: i64,
3314 package_hash: String,
3315 id: String,
3316 name: String,
3317 moniker: Option<String>,
3318 version: String,
3319 match_criteria: Option<String>,
3320}
3321
3322fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3323 query.query.is_some()
3324 || query.id.is_some()
3325 || query.name.is_some()
3326 || query.moniker.is_some()
3327 || query.tag.is_some()
3328 || query.command.is_some()
3329 || query.source.is_some()
3330}
3331
3332fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3333 PackageQuery {
3334 query: query.query.clone(),
3335 id: query.id.clone(),
3336 name: query.name.clone(),
3337 moniker: query.moniker.clone(),
3338 tag: query.tag.clone(),
3339 command: query.command.clone(),
3340 source: query.source.clone(),
3341 count: Some(LIST_LOOKUP_MAX_RESULTS),
3342 exact: query.exact,
3343 version: None,
3344 channel: None,
3345 locale: None,
3346 installer_type: None,
3347 installer_architecture: None,
3348 platform: None,
3349 os_version: None,
3350 install_scope: query.install_scope.clone(),
3351 }
3352}
3353
3354fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3355 query.query.is_some() || query.id.is_some() || query.name.is_some()
3356}
3357
3358fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3359 package
3360 .correlated
3361 .as_ref()
3362 .and_then(|candidate| candidate.version.as_deref())
3363 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3364}
3365
3366fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3367 package.installed_version.eq_ignore_ascii_case("Unknown")
3368}
3369
3370fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3371 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3374 return false;
3375 }
3376 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3379 return false;
3380 }
3381 installed_package_has_upgrade(package)
3382 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3383}
3384
3385fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3391 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3392 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3393
3394 for package in packages {
3395 match package.correlated.as_ref() {
3396 Some(candidate) => {
3397 let key = (
3398 candidate.id.to_ascii_lowercase(),
3399 candidate.source_name.to_ascii_lowercase(),
3400 );
3401 let keep_new = match by_id.get(&key) {
3402 Some(existing) => {
3403 match (
3409 package.installed_version_canonical,
3410 existing.installed_version_canonical,
3411 ) {
3412 (true, false) => true,
3413 (false, true) => false,
3414 _ => {
3415 compare_version(&package.installed_version, &existing.installed_version)
3416 == Ordering::Greater
3417 }
3418 }
3419 }
3420 None => true,
3421 };
3422 if keep_new {
3423 by_id.insert(key, package);
3424 }
3425 }
3426 None => uncorrelated.push(package),
3427 }
3428 }
3429
3430 let mut result = uncorrelated;
3431 result.extend(by_id.into_values());
3432 result
3433}
3434
3435fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3436 let mut source_specific = None;
3437 let mut source_agnostic = None;
3438
3439 for pin in pins {
3440 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3441 continue;
3442 }
3443
3444 if pin.source_id.is_empty() {
3445 if source_agnostic.is_none() {
3446 source_agnostic = Some(pin);
3447 }
3448 } else if item
3449 .source_name
3450 .as_deref()
3451 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3452 {
3453 source_specific = Some(pin);
3454 break;
3455 }
3456 }
3457
3458 source_specific.or(source_agnostic)
3459}
3460
3461fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3462 let Some(available_version) = item.available_version.as_deref() else {
3463 return false;
3464 };
3465 let Some(pin) = find_applicable_pin(item, pins) else {
3466 return false;
3467 };
3468
3469 match pin.pin_type {
3470 PinType::Blocking => true,
3471 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3472 }
3473}
3474
3475fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3476 if pattern.trim().is_empty() || pattern == "*" {
3477 return true;
3478 }
3479
3480 if !pattern.contains('*') {
3481 return version.eq_ignore_ascii_case(pattern);
3482 }
3483
3484 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3485 let prefix = &pattern[..pattern.len() - 1];
3486 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3487 }
3488
3489 version.eq_ignore_ascii_case(pattern)
3490}
3491
3492fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3493 let available_version = package.correlated.as_ref().and_then(|candidate| {
3494 candidate.version.as_ref().and_then(|candidate_version| {
3495 if installed_package_has_unknown_version(&package)
3496 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3497 {
3498 Some(candidate_version.clone())
3499 } else {
3500 None
3501 }
3502 })
3503 });
3504 let source_name = package
3505 .correlated
3506 .as_ref()
3507 .map(|candidate| candidate.source_name.clone())
3508 .filter(|value| !value.is_empty());
3509 let id = package
3510 .correlated
3511 .as_ref()
3512 .map(|candidate| candidate.id.clone())
3513 .unwrap_or_else(|| package.local_id.clone());
3514
3515 ListMatch {
3516 name: package.name,
3517 id,
3518 local_id: package.local_id,
3519 installed_version: package.installed_version,
3520 available_version,
3521 source_name,
3522 publisher: package.publisher,
3523 scope: package.scope,
3524 installer_category: package.installer_category,
3525 install_location: package.install_location,
3526 package_family_names: package.package_family_names,
3527 product_codes: package.product_codes,
3528 upgrade_codes: package.upgrade_codes,
3529 }
3530}
3531
3532fn list_sort_weight(package: &InstalledPackage) -> usize {
3533 if package.local_id.starts_with("ARP\\") {
3534 0
3535 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3536 1
3537 } else {
3538 2
3539 }
3540}
3541
3542fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3543 let correlated = package.correlated.as_ref();
3544
3545 if let Some(value) = &query.id {
3546 let local_match = matches_text(&package.local_id, value, query.exact);
3547 let correlated_match = correlated
3548 .map(|candidate| matches_text(&candidate.id, value, query.exact))
3549 .unwrap_or(false);
3550 if !local_match && !correlated_match {
3551 return false;
3552 }
3553 }
3554
3555 if let Some(value) = &query.name
3556 && !matches_text(&package.name, value, query.exact)
3557 {
3558 return false;
3559 }
3560
3561 if let Some(value) = &query.product_code
3562 && !package
3563 .product_codes
3564 .iter()
3565 .any(|code| code.eq_ignore_ascii_case(value))
3566 {
3567 return false;
3568 }
3569
3570 if let Some(value) = &query.version
3571 && !package.installed_version.eq_ignore_ascii_case(value)
3572 {
3573 return false;
3574 }
3575
3576 if let Some(value) = &query.query {
3577 let local_match =
3578 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3579 let correlated_match = correlated
3580 .map(|candidate| {
3581 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3582 })
3583 .unwrap_or(false);
3584 if !local_match && !correlated_match {
3585 return false;
3586 }
3587 }
3588
3589 if let Some(source) = &query.source
3590 && correlated
3591 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3592 .unwrap_or(true)
3593 {
3594 return false;
3595 }
3596
3597 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3598 return false;
3599 }
3600
3601 true
3602}
3603
3604fn correlate_installed_package(
3605 package: &InstalledPackage,
3606 candidates: &[SearchMatch],
3607 allow_loose_name_match: bool,
3608) -> Option<SearchMatch> {
3609 if package.local_id.starts_with("MSIX\\") {
3617 return None;
3618 }
3619
3620 let installed_name = normalize_correlation_name(&package.name);
3621 let installed_name_lower = package.name.to_ascii_lowercase();
3622 let candidate_names = correlation_name_candidates(&package.name);
3623
3624 let mut scored: Vec<(i32, SearchMatch)> = candidates
3625 .iter()
3626 .filter_map(|candidate| {
3627 let candidate_name = normalize_correlation_name(&candidate.name);
3628 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3629 1000
3630 } else if candidate_names.iter().any(|name| {
3631 let normalized = normalize_correlation_name(name);
3632 normalized == candidate_name
3633 }) {
3634 900
3635 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3636 700
3637 } else {
3638 0
3639 };
3640
3641 if base_score == 0 {
3642 return None;
3643 }
3644
3645 let candidate_lower = candidate.name.to_ascii_lowercase();
3658 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3659 100
3660 } else if installed_name_lower.contains(&candidate_lower) {
3661 50
3662 } else {
3663 0
3664 };
3665
3666 Some((base_score + prefix_bonus, candidate.clone()))
3667 })
3668 .collect();
3669
3670 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3671 let mut iter = scored.into_iter();
3672 let (top_score, top_match) = iter.next()?;
3673
3674 if let Some((next_score, _)) = iter.next()
3682 && next_score == top_score
3683 {
3684 return None;
3685 }
3686 Some(top_match)
3687}
3688
3689fn correlation_name_candidates(name: &str) -> Vec<String> {
3690 let mut candidates = Vec::new();
3691 candidates.push(name.trim().to_owned());
3692
3693 let mut trimmed = name.trim().to_owned();
3694 if let Some(index) = trimmed.find(" (") {
3695 trimmed.truncate(index);
3696 }
3697
3698 let mut words = Vec::new();
3699 for token in trimmed.split_whitespace() {
3700 let lower = token
3701 .trim_matches(|ch: char| ch == '(' || ch == ')')
3702 .to_ascii_lowercase();
3703 if !words.is_empty()
3704 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3705 {
3706 break;
3707 }
3708 words.push(token);
3709 }
3710
3711 if !words.is_empty() {
3712 candidates.push(words.join(" "));
3713 }
3714
3715 candidates.sort();
3716 candidates.dedup();
3717 candidates
3718}
3719
3720fn normalize_correlation_name(value: &str) -> String {
3721 value
3722 .chars()
3723 .filter(|ch| ch.is_ascii_alphanumeric())
3724 .flat_map(|ch| ch.to_lowercase())
3725 .collect()
3726}
3727
3728struct V2PackageMetadata {
3729 rowid: i64,
3730 id: String,
3731 name: String,
3732 moniker: Option<String>,
3733 latest_version: String,
3734 package_hash: String,
3735}
3736
3737fn lookup_identity_match_v2(
3743 connection: &Connection,
3744 package: &InstalledPackage,
3745) -> Result<Option<(i64, &'static str)>> {
3746 for pfn in &package.package_family_names {
3747 if let Some(rowid) = query_optional_value(
3748 connection,
3749 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3750 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3751 |row| row_i64(row, 0),
3752 )? {
3753 return Ok(Some((rowid, "PackageFamilyName")));
3754 }
3755 }
3756 for code in &package.upgrade_codes {
3765 if let Some(rowid) = query_optional_value(
3766 connection,
3767 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3768 vec![SqlValue::Text(code.to_ascii_lowercase())],
3769 |row| row_i64(row, 0),
3770 )? {
3771 return Ok(Some((rowid, "UpgradeCode")));
3772 }
3773 }
3774 for code in &package.product_codes {
3775 if let Some(rowid) = query_optional_value(
3776 connection,
3777 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3778 vec![SqlValue::Text(code.to_ascii_lowercase())],
3779 |row| row_i64(row, 0),
3780 )? {
3781 return Ok(Some((rowid, "ProductCode")));
3782 }
3783 }
3784 Ok(None)
3785}
3786
3787fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3791 query_optional_value(
3792 connection,
3793 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3794 vec![SqlValue::Text(package_id.to_owned())],
3795 |row| {
3796 Ok(V2PackageMetadata {
3797 rowid: row_i64(row, 0)?,
3798 id: row_string(row, 1)?,
3799 name: row_string(row, 2)?,
3800 moniker: row_opt_string(row, 3)?,
3801 latest_version: row_string(row, 4)?,
3802 package_hash: row_hex_string(row, 5)?,
3803 })
3804 },
3805 )
3806}
3807
3808fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3816 if !package.local_id.starts_with("MSIX\\") {
3817 return;
3818 }
3819 if !package.name.starts_with("ms-resource:") {
3820 return;
3821 }
3822 let Some(candidate) = package.correlated.as_ref() else {
3823 return;
3824 };
3825 if !candidate.name.is_empty() {
3826 package.name = candidate.name.clone();
3827 }
3828}
3829
3830fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3831 query_optional_value(
3832 connection,
3833 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3834 vec![SqlValue::Integer(package_rowid)],
3835 |row| {
3836 Ok(V2PackageMetadata {
3837 rowid: package_rowid,
3838 id: row_string(row, 0)?,
3839 name: row_string(row, 1)?,
3840 moniker: row_opt_string(row, 2)?,
3841 latest_version: row_string(row, 3)?,
3842 package_hash: row_hex_string(row, 4)?,
3843 })
3844 },
3845 )
3846}
3847
3848fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3849 query_optional_value(
3850 connection,
3851 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3852 Vec::new(),
3853 |row| row_i64(row, 0),
3854 )
3855 .ok()
3856 .flatten()
3857 .is_some()
3858}
3859
3860fn lookup_unique_normalized_identity(
3867 connection: &Connection,
3868 norm_name: &str,
3869 norm_publisher: &str,
3870) -> Result<Option<i64>> {
3871 let rows: Vec<i64> = query_rows(
3872 connection,
3873 "SELECT DISTINCT n.package FROM norm_names2 n \
3874 WHERE n.norm_name = ?1 \
3875 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3876 LIMIT 2",
3877 vec![
3878 SqlValue::Text(norm_name.to_owned()),
3879 SqlValue::Text(norm_publisher.to_owned()),
3880 ],
3881 |row| row_i64(row, 0),
3882 )?;
3883 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3884}
3885
3886fn v2_identity_tables_present(connection: &Connection) -> bool {
3887 query_optional_value(
3888 connection,
3889 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3890 Vec::new(),
3891 |row| row_i64(row, 0),
3892 )
3893 .ok()
3894 .flatten()
3895 .is_some()
3896}
3897
3898fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3905 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3906 return None;
3907 }
3908 for entry in entries {
3909 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3910 continue;
3911 };
3912 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3913 {
3914 return Some(entry.version.clone());
3915 }
3916 }
3917 None
3918}
3919
3920fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3929 entries
3930 .iter()
3931 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3932 .max_by(|a, b| compare_version(&a.version, &b.version))
3933 .map(|entry| entry.version.clone())
3934}
3935
3936#[cfg(windows)]
3937fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3938 let mut packages = Vec::new();
3939 let mut seen = BTreeSet::new();
3940
3941 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3942 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3943
3944 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3952
3953 if machine {
3954 collect_uninstall_view(
3955 &mut packages,
3956 &mut seen,
3957 &upgrade_codes,
3958 RegKey::predef(HKEY_LOCAL_MACHINE),
3959 "Machine",
3960 "X64",
3961 KEY_READ | KEY_WOW64_64KEY,
3962 )?;
3963 collect_uninstall_view(
3964 &mut packages,
3965 &mut seen,
3966 &upgrade_codes,
3967 RegKey::predef(HKEY_LOCAL_MACHINE),
3968 "Machine",
3969 "X86",
3970 KEY_READ | KEY_WOW64_32KEY,
3971 )?;
3972 collect_appmodel_packages(
3973 &mut packages,
3974 &mut seen,
3975 RegKey::predef(HKEY_LOCAL_MACHINE),
3976 "Machine",
3977 KEY_READ | KEY_WOW64_64KEY,
3978 )?;
3979 }
3980
3981 if user {
3982 collect_uninstall_view(
3983 &mut packages,
3984 &mut seen,
3985 &upgrade_codes,
3986 RegKey::predef(HKEY_CURRENT_USER),
3987 "User",
3988 "X64",
3989 KEY_READ,
3990 )?;
3991 collect_appmodel_packages(
3992 &mut packages,
3993 &mut seen,
3994 RegKey::predef(HKEY_CURRENT_USER),
3995 "User",
3996 KEY_READ,
3997 )?;
3998 }
3999
4000 Ok(packages)
4001}
4002
4003#[cfg(windows)]
4009fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4010 let mut map = HashMap::new();
4011 if include_machine {
4012 collect_msi_upgrade_codes_from(
4013 RegKey::predef(HKEY_LOCAL_MACHINE),
4014 r"SOFTWARE\Classes\Installer\UpgradeCodes",
4015 KEY_READ | KEY_WOW64_64KEY,
4016 &mut map,
4017 );
4018 }
4019 if include_user {
4020 collect_msi_upgrade_codes_from(
4021 RegKey::predef(HKEY_CURRENT_USER),
4022 r"Software\Microsoft\Installer\UpgradeCodes",
4023 KEY_READ,
4024 &mut map,
4025 );
4026 }
4027 map
4028}
4029
4030#[cfg(windows)]
4031fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4032 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4033 return;
4034 };
4035 for key_name in upgrade_codes.enum_keys().flatten() {
4036 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4037 continue;
4038 };
4039 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4040 continue;
4041 };
4042 for (value_name, _) in subkey.enum_values().flatten() {
4043 if let Some(product_code) = unflip_packed_guid(&value_name) {
4044 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4048 }
4049 }
4050 }
4051}
4052
4053#[cfg(windows)]
4058fn unflip_packed_guid(packed: &str) -> Option<String> {
4059 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4060 return None;
4061 }
4062 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4063 let bytes = packed.as_bytes();
4064 let mut reversed = String::with_capacity(32);
4065 let mut offset = 0;
4066 for size in CHUNKS {
4067 let chunk = &bytes[offset..offset + size];
4068 for &c in chunk.iter().rev() {
4069 reversed.push(c as char);
4070 }
4071 offset += size;
4072 }
4073 let r = reversed.to_ascii_lowercase();
4074 Some(format!(
4075 "{{{}-{}-{}-{}-{}}}",
4076 &r[0..8],
4077 &r[8..12],
4078 &r[12..16],
4079 &r[16..20],
4080 &r[20..32]
4081 ))
4082}
4083
4084#[cfg(not(windows))]
4085fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4086 Ok(Vec::new())
4087}
4088
4089#[cfg(windows)]
4090fn collect_uninstall_view(
4091 packages: &mut Vec<InstalledPackage>,
4092 seen: &mut BTreeSet<String>,
4093 upgrade_code_map: &HashMap<String, String>,
4094 root: RegKey,
4095 scope: &str,
4096 arch: &str,
4097 flags: u32,
4098) -> Result<()> {
4099 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4100
4101 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4102 Ok(key) => key,
4103 Err(_) => return Ok(()),
4104 };
4105
4106 for key_name in uninstall.enum_keys().flatten() {
4107 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4108 Ok(key) => key,
4109 Err(_) => continue,
4110 };
4111 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4112 {
4113 continue;
4114 }
4115
4116 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4117 continue;
4118 };
4119
4120 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4121 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4122 let publisher = read_reg_string(&subkey, "Publisher");
4123 let install_location = read_reg_string(&subkey, "InstallLocation");
4124 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4125 .into_iter()
4126 .collect::<Vec<_>>();
4127 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4128 if product_codes.is_empty() && looks_like_product_code(&key_name) {
4129 product_codes.push(key_name.to_ascii_lowercase());
4130 }
4131 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4132 if upgrade_codes.is_empty() {
4133 for code in &product_codes {
4137 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4138 upgrade_codes.push(uc.clone());
4139 break;
4140 }
4141 }
4142 }
4143 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4144 .as_deref()
4145 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4146 {
4147 Some("portable".to_owned())
4153 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4154 Some("msi".to_owned())
4155 } else if key_name.starts_with("MSIX\\") {
4156 Some("msix".to_owned())
4157 } else {
4158 Some("exe".to_owned())
4159 };
4160
4161 let dedupe_key = format!(
4162 "{}|{}|{}|{}",
4163 local_id,
4164 name.to_ascii_lowercase(),
4165 installed_version.to_ascii_lowercase(),
4166 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4167 );
4168 if !seen.insert(dedupe_key) {
4169 continue;
4170 }
4171
4172 packages.push(InstalledPackage {
4173 name,
4174 local_id,
4175 installed_version,
4176 publisher,
4177 scope: Some(scope.to_owned()),
4178 installer_category,
4179 install_location,
4180 package_family_names,
4181 product_codes,
4182 upgrade_codes,
4183 correlated: None,
4184 installed_version_canonical: false,
4185 correlated_requires_explicit_upgrade: false,
4186 correlated_lacks_compatible_installer: false,
4187 });
4188 }
4189
4190 Ok(())
4191}
4192
4193#[cfg(windows)]
4194fn collect_appmodel_packages(
4195 packages: &mut Vec<InstalledPackage>,
4196 seen: &mut BTreeSet<String>,
4197 root: RegKey,
4198 scope: &str,
4199 flags: u32,
4200) -> Result<()> {
4201 const APPMODEL_PACKAGES_PATH: &str =
4202 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4203
4204 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4205 Ok(key) => key,
4206 Err(_) => return Ok(()),
4207 };
4208
4209 for key_name in appmodel.enum_keys().flatten() {
4210 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4211 Ok(key) => key,
4212 Err(_) => continue,
4213 };
4214
4215 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4216 continue;
4217 };
4218 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4219 if install_location.as_deref().is_some_and(is_windows_system_path) {
4220 continue;
4221 }
4222
4223 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4224 continue;
4225 };
4226
4227 let local_id = format!(r"MSIX\{key_name}");
4228 let dedupe_key = format!(
4229 "{}|{}|{}",
4230 local_id,
4231 name.to_ascii_lowercase(),
4232 metadata.version.to_ascii_lowercase()
4233 );
4234 if !seen.insert(dedupe_key) {
4235 continue;
4236 }
4237
4238 packages.push(InstalledPackage {
4239 name,
4240 local_id,
4241 installed_version: metadata.version,
4242 publisher: None,
4243 scope: Some(scope.to_owned()),
4244 installer_category: Some("msix".to_owned()),
4245 install_location,
4246 package_family_names: vec![metadata.family_name],
4247 product_codes: Vec::new(),
4248 upgrade_codes: Vec::new(),
4249 correlated: None,
4250 installed_version_canonical: false,
4251 correlated_requires_explicit_upgrade: false,
4252 correlated_lacks_compatible_installer: false,
4253 });
4254 }
4255
4256 Ok(())
4257}
4258
4259#[cfg(windows)]
4260struct ParsedMsixPackageFullName {
4261 version: String,
4262 family_name: String,
4263}
4264
4265#[cfg(windows)]
4266fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4267 let segments = value.split('_').collect::<Vec<_>>();
4268 if segments.len() < 5 {
4269 return None;
4270 }
4271
4272 let name = segments[..segments.len() - 4].join("_");
4273 if name.is_empty() {
4274 return None;
4275 }
4276
4277 let version = segments[segments.len() - 4].trim();
4278 let resource_id = segments[segments.len() - 2].trim();
4279 let publisher_id = segments[segments.len() - 1].trim();
4280 if version.is_empty() || publisher_id.is_empty() {
4281 return None;
4282 }
4283
4284 let family_name = if resource_id.is_empty() {
4285 format!("{name}_{publisher_id}")
4286 } else {
4287 format!("{name}_{resource_id}_{publisher_id}")
4288 };
4289
4290 Some(ParsedMsixPackageFullName {
4291 version: version.to_owned(),
4292 family_name,
4293 })
4294}
4295
4296#[cfg(windows)]
4297fn is_windows_system_path(path: &str) -> bool {
4298 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
4299}
4300
4301#[cfg(windows)]
4302fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
4303 key.get_value::<String, _>(value_name)
4304 .ok()
4305 .map(|value| value.trim().to_owned())
4306 .filter(|value| !value.is_empty())
4307}
4308
4309#[cfg(windows)]
4310fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
4311 key.get_value::<u32, _>(value_name).ok()
4312}
4313
4314#[cfg(windows)]
4315fn looks_like_product_code(value: &str) -> bool {
4316 value.starts_with('{') && value.ends_with('}')
4317}
4318
4319fn ensure_app_dirs(app_root: &Path) -> Result<()> {
4320 fs::create_dir_all(app_root).context("failed to create app root directory")?;
4321 if uses_packaged_layout(app_root) {
4322 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
4323 let user_sources_path = packaged_user_sources_path(app_root)?;
4324 if let Some(parent) = user_sources_path.parent() {
4325 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
4326 }
4327 } else {
4328 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
4329 }
4330 Ok(())
4331}
4332
4333fn default_app_root() -> Result<PathBuf> {
4334 if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
4335 return Ok(PathBuf::from(app_root));
4336 }
4337
4338 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
4339
4340 #[cfg(windows)]
4341 {
4342 if has_current_package_identity() {
4343 return Ok(local_app_data
4344 .join("Packages")
4345 .join(PACKAGED_FAMILY_NAME)
4346 .join("LocalState"));
4347 }
4348 Ok(local_app_data.join("Devolutions").join("Pinget"))
4349 }
4350
4351 #[cfg(not(windows))]
4352 {
4353 Ok(local_app_data.join("pinget"))
4354 }
4355}
4356
4357#[cfg(windows)]
4358fn has_current_package_identity() -> bool {
4359 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
4362
4363 let mut length: u32 = 0;
4364 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
4367 rc != APPMODEL_ERROR_NO_PACKAGE
4368}
4369
4370fn store_path(app_root: &Path) -> PathBuf {
4371 app_root.join("sources.json")
4372}
4373
4374fn user_settings_path(app_root: &Path) -> PathBuf {
4375 if uses_packaged_layout(app_root) {
4376 app_root.join("settings.json")
4377 } else {
4378 app_root.join("user-settings.json")
4379 }
4380}
4381
4382fn admin_settings_path(app_root: &Path) -> PathBuf {
4383 app_root.join("admin-settings.json")
4384}
4385
4386fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
4387 if uses_packaged_layout(app_root) {
4388 return app_root
4389 .join(packaged_source_type(source.kind))
4390 .join(sanitize_path_segment(&source.identifier));
4391 }
4392
4393 app_root.join("sources").join(sanitize_path_segment(&source.name))
4394}
4395
4396fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4397 source_state_dir(app_root, source).join("source.msix")
4398}
4399
4400fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4401 source_state_dir(app_root, source).join("index.db")
4402}
4403
4404fn remove_sqlite_sidecars(db_path: &Path) {
4408 for suffix in ["-wal", "-shm"] {
4409 let mut sidecar = db_path.as_os_str().to_owned();
4410 sidecar.push(suffix);
4411 let _ = fs::remove_file(PathBuf::from(sidecar));
4412 }
4413}
4414
4415#[cfg(windows)]
4416const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
4417
4418#[cfg(windows)]
4419fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
4420 let source_wide = source
4421 .as_os_str()
4422 .encode_wide()
4423 .chain(std::iter::once(0))
4424 .collect::<Vec<_>>();
4425 let target_wide = target
4426 .as_os_str()
4427 .encode_wide()
4428 .chain(std::iter::once(0))
4429 .collect::<Vec<_>>();
4430 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
4431 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
4434 if ok == 0 {
4435 return Err(std::io::Error::last_os_error());
4436 }
4437 Ok(())
4438}
4439
4440#[cfg(windows)]
4441fn replace_file(source: &Path, target: &Path) -> Result<()> {
4442 let to_err = |error: std::io::Error| -> anyhow::Error {
4443 anyhow::Error::new(error).context(format!(
4444 "failed to replace {} with {}",
4445 target.display(),
4446 source.display()
4447 ))
4448 };
4449
4450 let first = match move_replace_existing(source, target) {
4452 Ok(()) => return Ok(()),
4453 Err(error) => error,
4454 };
4455
4456 if !source.exists() || !target.exists() {
4459 return Err(to_err(first));
4460 }
4461
4462 let mut last = first;
4467 for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
4468 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
4469 match move_replace_existing(source, target) {
4470 Ok(()) => return Ok(()),
4471 Err(error) => last = error,
4472 }
4473 }
4474
4475 let staged = target.with_extension("old.tmp");
4481 if move_replace_existing(target, &staged).is_err() {
4482 return Err(to_err(last));
4483 }
4484 match move_replace_existing(source, target) {
4485 Ok(()) => {
4486 let _ = fs::remove_file(&staged);
4487 Ok(())
4488 }
4489 Err(error) => {
4490 let _ = move_replace_existing(&staged, target);
4491 Err(to_err(error))
4492 }
4493 }
4494}
4495
4496#[cfg(not(windows))]
4497fn replace_file(source: &Path, target: &Path) -> Result<()> {
4498 fs::rename(source, target)
4499 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
4500}
4501
4502fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4503 source_state_dir(app_root, source).join("rest-information.json")
4504}
4505
4506fn rest_manifest_cache_path_with_root(
4507 app_root: &Path,
4508 source: &SourceRecord,
4509 package_id: &str,
4510 version: &str,
4511 channel: &str,
4512) -> PathBuf {
4513 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
4514 let digest = sha256_hex(key.as_bytes());
4515 cache_root(app_root)
4516 .join("REST_M")
4517 .join(sanitize_path_segment(&source.identifier))
4518 .join(format!("{digest}.json"))
4519}
4520
4521fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
4522 cache_root(app_root)
4523 .join(bucket)
4524 .join(sanitize_path_segment(identifier))
4525}
4526
4527fn load_store(app_root: &Path) -> Result<SourceStore> {
4528 if uses_packaged_layout(app_root)
4529 && let Some(store) = load_packaged_store(app_root)?
4530 {
4531 return Ok(store);
4532 }
4533
4534 let path = store_path(app_root);
4535 if !path.exists() {
4536 let store = SourceStore::default();
4537 save_store(app_root, &store)?;
4538 return Ok(store);
4539 }
4540
4541 let bytes = fs::read(path).context("failed to read source store")?;
4542 serde_json::from_slice(&bytes).context("failed to parse source store")
4543}
4544
4545fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4546 if uses_system_winget_source_commands(app_root) {
4547 bail!("System WinGet source settings must be modified through winget.");
4548 }
4549
4550 if uses_packaged_layout(app_root) {
4551 return save_packaged_store(app_root, store);
4552 }
4553
4554 write_json(store_path(app_root), store)
4555}
4556
4557fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
4558 let user_sources_path = packaged_user_sources_path(app_root)?;
4559 let metadata_path = app_root.join("sources_metadata");
4560 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
4561 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
4562 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4563}
4564
4565fn load_packaged_store_from_streams(
4566 user_sources_yaml: Option<&str>,
4567 metadata_yaml: Option<&str>,
4568) -> Result<Option<SourceStore>> {
4569 if is_secure_settings_stub(user_sources_yaml) {
4570 return load_system_winget_source_store().map(Some);
4571 }
4572
4573 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4574}
4575
4576fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4577 let user_sources_path = packaged_user_sources_path(app_root)?;
4578 if let Some(parent) = user_sources_path.parent() {
4579 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4580 }
4581
4582 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4583 .context("failed to write packaged user_sources stream")?;
4584 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4585 .context("failed to write packaged source metadata")?;
4586 Ok(())
4587}
4588
4589fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4590 if !uses_packaged_layout(app_root) {
4591 return false;
4592 }
4593
4594 #[cfg(windows)]
4595 {
4596 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4597 return false;
4598 };
4599
4600 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4601 is_secure_settings_stub(user_sources_yaml.as_deref())
4602 }
4603
4604 #[cfg(not(windows))]
4605 {
4606 false
4607 }
4608}
4609
4610fn is_secure_settings_stub(value: Option<&str>) -> bool {
4611 value
4612 .map(str::trim_start)
4613 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4614}
4615
4616fn load_system_winget_source_store() -> Result<SourceStore> {
4617 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4618 Ok(SourceStore {
4619 sources: parse_system_winget_source_export(&result.stdout)?,
4620 })
4621}
4622
4623fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4624 run_checked_winget_source_command(
4625 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4626 &format!("add WinGet source '{name}'"),
4627 )?;
4628 Ok(())
4629}
4630
4631fn system_winget_remove_source(name: &str) -> Result<()> {
4632 run_checked_winget_source_command(
4633 vec![
4634 "source".to_owned(),
4635 "remove".to_owned(),
4636 "--name".to_owned(),
4637 name.to_owned(),
4638 "--disable-interactivity".to_owned(),
4639 ],
4640 &format!("remove WinGet source '{name}'"),
4641 )?;
4642 Ok(())
4643}
4644
4645fn system_winget_reset_source(name: &str) -> Result<()> {
4646 run_checked_winget_source_command(
4647 vec![
4648 "source".to_owned(),
4649 "reset".to_owned(),
4650 "--name".to_owned(),
4651 name.to_owned(),
4652 "--force".to_owned(),
4653 "--disable-interactivity".to_owned(),
4654 ],
4655 &format!("reset WinGet source '{name}'"),
4656 )?;
4657 Ok(())
4658}
4659
4660fn system_winget_reset_sources() -> Result<()> {
4661 run_checked_winget_source_command(
4662 vec![
4663 "source".to_owned(),
4664 "reset".to_owned(),
4665 "--force".to_owned(),
4666 "--disable-interactivity".to_owned(),
4667 ],
4668 "reset WinGet sources",
4669 )?;
4670 Ok(())
4671}
4672
4673fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4674 let mut args = vec!["source".to_owned(), "update".to_owned()];
4675 if let Some(name) = name {
4676 args.push("--name".to_owned());
4677 args.push(name.to_owned());
4678 }
4679
4680 args.push("--disable-interactivity".to_owned());
4681 let result = run_checked_winget_source_command(
4682 args,
4683 if name.is_some() {
4684 "update WinGet source"
4685 } else {
4686 "update WinGet sources"
4687 },
4688 )?;
4689 let detail = result.stdout.trim();
4690 Ok(if detail.is_empty() {
4691 "Done".to_owned()
4692 } else {
4693 detail.to_owned()
4694 })
4695}
4696
4697fn build_winget_source_export_arguments() -> Vec<String> {
4698 vec![
4699 "source".to_owned(),
4700 "export".to_owned(),
4701 "--disable-interactivity".to_owned(),
4702 ]
4703}
4704
4705fn build_winget_source_add_arguments(
4706 name: &str,
4707 arg: &str,
4708 kind: SourceKind,
4709 trust_level: &str,
4710 explicit: bool,
4711) -> Vec<String> {
4712 let mut args = vec![
4713 "source".to_owned(),
4714 "add".to_owned(),
4715 "--name".to_owned(),
4716 name.to_owned(),
4717 "--arg".to_owned(),
4718 arg.to_owned(),
4719 "--type".to_owned(),
4720 packaged_source_type(kind).to_owned(),
4721 "--disable-interactivity".to_owned(),
4722 ];
4723
4724 if !trust_level.trim().is_empty() {
4725 args.push("--trust-level".to_owned());
4726 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4727 "trusted".to_owned()
4728 } else {
4729 "none".to_owned()
4730 });
4731 }
4732
4733 if explicit {
4734 args.push("--explicit".to_owned());
4735 }
4736
4737 args
4738}
4739
4740#[derive(Debug)]
4741struct WingetSourceCommandResult {
4742 exit_code: i32,
4743 stdout: String,
4744 stderr: String,
4745}
4746
4747type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4748
4749static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4750 RwLock::new(run_winget_source_command_process);
4751
4752fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4753 let result = run_winget_source_command(&args)?;
4754 if result.exit_code == 0 {
4755 return Ok(result);
4756 }
4757
4758 let detail = [result.stderr.trim(), result.stdout.trim()]
4759 .into_iter()
4760 .filter(|value| !value.is_empty())
4761 .collect::<Vec<_>>()
4762 .join("\n");
4763 if detail.is_empty() {
4764 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4765 }
4766
4767 bail!(
4768 "Failed to {action}; winget exited with code {}:\n{}",
4769 result.exit_code,
4770 detail
4771 );
4772}
4773
4774fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4775 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4776 .read()
4777 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4778 runner(args)
4779}
4780
4781fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4782 use std::process::Command;
4783
4784 let output = Command::new("winget")
4785 .args(args)
4786 .output()
4787 .context("failed to run winget source command")?;
4788
4789 Ok(WingetSourceCommandResult {
4790 exit_code: output.status.code().unwrap_or(-1),
4791 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4792 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4793 })
4794}
4795
4796fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4797 let trimmed = output.trim();
4798 if trimmed.is_empty() {
4799 return Ok(Vec::new());
4800 }
4801
4802 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4803 && let Some(sources) = parse_system_winget_source_export_value(&value)?
4804 {
4805 return Ok(order_sources(sources));
4806 }
4807
4808 let mut sources = Vec::new();
4809 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4810 if line.is_empty() {
4811 continue;
4812 }
4813
4814 let value: JsonValue = serde_json::from_str(line)
4815 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4816 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4817 bail!("WinGet source export returned a non-source line: {line}");
4818 };
4819 sources.append(&mut parsed);
4820 }
4821
4822 Ok(order_sources(sources))
4823}
4824
4825fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4826 match value {
4827 JsonValue::Array(items) => items
4828 .iter()
4829 .map(parse_system_winget_source_record)
4830 .collect::<Result<Vec<_>>>()
4831 .map(Some),
4832 JsonValue::Object(object) => {
4833 if let Some(JsonValue::Array(items)) = object.get("Sources") {
4834 items
4835 .iter()
4836 .map(parse_system_winget_source_record)
4837 .collect::<Result<Vec<_>>>()
4838 .map(Some)
4839 } else if object.contains_key("Name") {
4840 Ok(Some(vec![parse_system_winget_source_record(value)?]))
4841 } else {
4842 Ok(None)
4843 }
4844 }
4845 _ => Ok(None),
4846 }
4847}
4848
4849fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4850 let object = value
4851 .as_object()
4852 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4853 let name = required_json_string(object, "Name")?;
4854 let source_type = required_json_string(object, "Type")?;
4855 let arg = required_json_string(object, "Arg")?;
4856 let kind = parse_system_winget_source_kind(&source_type)?;
4857 let identifier = optional_json_string(object, "Identifier")
4858 .or_else(|| optional_json_string(object, "Data"))
4859 .unwrap_or_else(|| name.clone());
4860
4861 Ok(SourceRecord {
4862 name,
4863 kind,
4864 arg,
4865 identifier,
4866 trust_level: parse_system_winget_trust_level(object),
4867 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4868 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4869 last_update: None,
4870 source_version: None,
4871 })
4872}
4873
4874fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4875 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4876}
4877
4878fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4879 match object.get(key)? {
4880 JsonValue::String(value) => Some(value.clone()),
4881 JsonValue::Number(value) => Some(value.to_string()),
4882 _ => None,
4883 }
4884}
4885
4886fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4887 match object.get(key)? {
4888 JsonValue::Bool(value) => Some(*value),
4889 JsonValue::String(value) => value.parse().ok(),
4890 _ => None,
4891 }
4892}
4893
4894fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4895 match object.get(key)? {
4896 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4897 JsonValue::String(value) => value.parse().ok(),
4898 _ => None,
4899 }
4900}
4901
4902fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4903 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4904 Ok(SourceKind::PreIndexed)
4905 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4906 Ok(SourceKind::Rest)
4907 } else {
4908 bail!("Unsupported WinGet source type '{value}'.")
4909 }
4910}
4911
4912fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4913 let Some(value) = object.get("TrustLevel") else {
4914 return "None".to_owned();
4915 };
4916
4917 match value {
4918 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4919 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4920 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4921 _ => "None".to_owned(),
4922 }
4923}
4924
4925fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4926 value
4927 .as_str()
4928 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4929}
4930
4931fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4932 sources.sort_by(|left, right| {
4933 left.explicit
4934 .cmp(&right.explicit)
4935 .then_with(|| right.priority.cmp(&left.priority))
4936 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4937 });
4938 sources
4939}
4940
4941fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4942 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4943 return None;
4944 }
4945
4946 let mut sources = SourceStore::default().sources;
4947 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4948 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4949 continue;
4950 };
4951
4952 if yaml_bool(entry.get("IsTombstone")) {
4953 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4954 continue;
4955 }
4956
4957 let Some(mapped) = map_packaged_source_entry(&entry) else {
4958 continue;
4959 };
4960
4961 if let Some(existing) = sources
4962 .iter_mut()
4963 .find(|source| source.name.eq_ignore_ascii_case(&name))
4964 {
4965 *existing = mapped;
4966 } else {
4967 sources.push(mapped);
4968 }
4969 }
4970
4971 for entry in parse_packaged_yaml_entries(metadata_yaml) {
4972 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4973 continue;
4974 };
4975
4976 let Some(source) = sources
4977 .iter_mut()
4978 .find(|source| source.name.eq_ignore_ascii_case(&name))
4979 else {
4980 continue;
4981 };
4982
4983 if let Some(last_update) = entry
4984 .get("LastUpdate")
4985 .and_then(|value| value.as_deref())
4986 .and_then(parse_packaged_datetime)
4987 {
4988 source.last_update = Some(last_update);
4989 }
4990
4991 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4992 && !source_version.is_empty()
4993 {
4994 source.source_version = Some(source_version);
4995 }
4996 }
4997
4998 Some(SourceStore { sources })
4999}
5000
5001fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
5002 let Some(yaml) = yaml else {
5003 return Vec::new();
5004 };
5005
5006 let mut entries = Vec::new();
5007 let mut current: Option<BTreeMap<String, Option<String>>> = None;
5008 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
5009 let line = raw_line.trim_end();
5010 let trimmed = line.trim();
5011 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
5012 continue;
5013 }
5014
5015 if let Some(remainder) = trimmed.strip_prefix("- ") {
5016 if let Some(entry) = current.take() {
5017 entries.push(entry);
5018 }
5019
5020 let mut entry = BTreeMap::new();
5021 add_packaged_yaml_key_value(&mut entry, remainder);
5022 current = Some(entry);
5023 continue;
5024 }
5025
5026 if let Some(entry) = current.as_mut() {
5027 add_packaged_yaml_key_value(entry, trimmed);
5028 }
5029 }
5030
5031 if let Some(entry) = current {
5032 entries.push(entry);
5033 }
5034
5035 entries
5036}
5037
5038fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
5039 let Some((key, value)) = line.split_once(':') else {
5040 return;
5041 };
5042
5043 entry.insert(
5044 key.trim().to_owned(),
5045 Some(unquote_yaml_scalar(value.trim()).to_owned()),
5046 );
5047}
5048
5049fn unquote_yaml_scalar(value: &str) -> &str {
5050 if value.len() >= 2 {
5051 let bytes = value.as_bytes();
5052 let first = bytes[0] as char;
5053 let last = bytes[value.len() - 1] as char;
5054 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
5055 return &value[1..value.len() - 1];
5056 }
5057 }
5058
5059 value
5060}
5061
5062fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
5063 let name = entry.get("Name").and_then(|value| value.clone())?;
5064 let source_type = entry.get("Type").and_then(|value| value.clone())?;
5065 let arg = entry.get("Arg").and_then(|value| value.clone())?;
5066 let kind = parse_packaged_source_kind(&source_type)?;
5067
5068 Some(SourceRecord {
5069 name: name.clone(),
5070 kind,
5071 arg,
5072 identifier: entry
5073 .get("Identifier")
5074 .and_then(|value| value.clone())
5075 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
5076 .unwrap_or(name),
5077 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
5078 explicit: yaml_bool(entry.get("Explicit")),
5079 priority: entry
5080 .get("Priority")
5081 .and_then(|value| value.as_deref())
5082 .and_then(|value| value.parse::<i32>().ok())
5083 .unwrap_or_default(),
5084 last_update: None,
5085 source_version: None,
5086 })
5087}
5088
5089fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
5090 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5091 Some(SourceKind::PreIndexed)
5092 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5093 Some(SourceKind::Rest)
5094 } else {
5095 None
5096 }
5097}
5098
5099fn parse_packaged_trust_level(value: Option<&str>) -> String {
5100 match value {
5101 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5102 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5103 _ => "None".to_owned(),
5104 }
5105}
5106
5107fn yaml_bool(value: Option<&Option<String>>) -> bool {
5108 value
5109 .and_then(|raw| raw.as_deref())
5110 .and_then(|raw| raw.parse::<bool>().ok())
5111 .unwrap_or(false)
5112}
5113
5114fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5115 if let Ok(unix_seconds) = value.parse::<i64>() {
5116 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5117 }
5118
5119 DateTime::parse_from_rfc3339(value)
5120 .ok()
5121 .map(|value| value.with_timezone(&Utc))
5122}
5123
5124fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5125 let mut lines = vec!["Sources:".to_owned()];
5126 for source in &store.sources {
5127 lines.push(format!(" - Name: {}", source.name));
5128 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
5129 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
5130 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
5131 lines.push(format!(
5132 " TrustLevel: {}",
5133 if source.trust_level.eq_ignore_ascii_case("Trusted") {
5134 1
5135 } else {
5136 0
5137 }
5138 ));
5139 lines.push(format!(" Explicit: {}", source.explicit));
5140 lines.push(format!(" Priority: {}", source.priority));
5141 lines.push(" IsTombstone: false".to_owned());
5142 }
5143 lines.push(String::new());
5144 lines.join("\n")
5145}
5146
5147fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5148 let mut lines = vec!["Sources:".to_owned()];
5149 for source in &store.sources {
5150 if source.last_update.is_none() && source.source_version.is_none() {
5151 continue;
5152 }
5153
5154 lines.push(format!(" - Name: {}", source.name));
5155 if let Some(last_update) = source.last_update {
5156 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
5157 }
5158 if let Some(source_version) = &source.source_version {
5159 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
5160 }
5161 }
5162 lines.push(String::new());
5163 lines.join("\n")
5164}
5165
5166fn yaml_scalar(value: &str) -> String {
5167 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5168 format!("\"{}\"", value.replace('"', "\\\""))
5169 } else {
5170 value.to_owned()
5171 }
5172}
5173
5174fn uses_packaged_layout(app_root: &Path) -> bool {
5175 #[cfg(windows)]
5176 {
5177 try_get_packaged_local_state_root(app_root).is_some()
5178 }
5179
5180 #[cfg(not(windows))]
5181 {
5182 let _ = app_root;
5183 false
5184 }
5185}
5186
5187#[cfg(windows)]
5188fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5189 let local_state = app_root.file_name()?.to_string_lossy();
5190 if !local_state.eq_ignore_ascii_case("LocalState") {
5191 return None;
5192 }
5193
5194 let family = app_root.parent()?.file_name()?.to_string_lossy();
5195 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
5196 return None;
5197 }
5198
5199 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
5200 if !packages.eq_ignore_ascii_case("Packages") {
5201 return None;
5202 }
5203
5204 Some(app_root.to_path_buf())
5205}
5206
5207#[cfg(windows)]
5208fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
5209 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
5210 Ok(packaged_secure_settings_root()?.join("user_sources"))
5211}
5212
5213#[cfg(not(windows))]
5214fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
5215 bail!("packaged WinGet source streams are only available on Windows")
5216}
5217
5218#[cfg(windows)]
5219fn packaged_secure_settings_root() -> Result<PathBuf> {
5220 let program_data = std::env::var_os("PROGRAMDATA")
5221 .map(PathBuf::from)
5222 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
5223 Ok(program_data
5224 .join("Microsoft")
5225 .join("WinGet")
5226 .join(current_user_sid()?)
5227 .join("settings")
5228 .join("pkg")
5229 .join(PACKAGED_NAME))
5230}
5231
5232#[cfg(windows)]
5233#[allow(clippy::multiple_unsafe_ops_per_block)]
5234fn current_user_sid() -> Result<String> {
5235 fn close_token(token: HANDLE) {
5236 if !token.is_null() {
5237 unsafe {
5239 CloseHandle(token);
5240 }
5241 }
5242 }
5243
5244 let mut token: HANDLE = std::ptr::null_mut();
5245 let current_process = unsafe { GetCurrentProcess() };
5247 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
5249 bail!("failed to open current process token")
5250 }
5251
5252 let mut length = 0;
5253 unsafe {
5255 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
5256 }
5257
5258 let element_size = size_of::<TOKEN_USER>();
5259 let element_count = (length as usize).div_ceil(element_size);
5260 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
5261 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
5263 close_token(token);
5264 bail!("failed to get current user token information")
5265 }
5266
5267 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
5269 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
5270 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
5272 close_token(token);
5273 bail!("failed to convert current user SID")
5274 }
5275
5276 let mut len = 0usize;
5277 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
5279 len += 1;
5280 }
5281
5282 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
5284 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
5285 unsafe {
5287 LocalFree(sid_ptr.cast());
5288 }
5289 close_token(token);
5290 Ok(sid)
5291}
5292
5293fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
5294 app_root.join("Microsoft").join("Windows Package Manager")
5295}
5296
5297fn cache_root(app_root: &Path) -> PathBuf {
5298 if uses_packaged_layout(app_root) {
5299 packaged_file_cache_root(app_root)
5300 } else {
5301 default_cache_root_fallback()
5302 }
5303}
5304
5305fn default_cache_root_fallback() -> PathBuf {
5306 std::env::temp_dir().join("cache")
5307}
5308
5309fn packaged_source_type(kind: SourceKind) -> &'static str {
5310 match kind {
5311 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
5312 SourceKind::Rest => "Microsoft.Rest",
5313 }
5314}
5315
5316fn sanitize_path_segment(value: &str) -> String {
5317 value
5318 .chars()
5319 .map(|ch| match ch {
5320 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
5321 _ => ch,
5322 })
5323 .collect()
5324}
5325
5326fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
5327 if !path.exists() {
5328 return Ok(JsonMap::new());
5329 }
5330
5331 let bytes = fs::read(path).context("failed to read JSON file")?;
5332 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
5333 Ok(match value {
5334 JsonValue::Object(object) => object,
5335 _ => JsonMap::new(),
5336 })
5337}
5338
5339fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
5340 write_json(path, value)
5341}
5342
5343fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
5344 if let Some(parent) = path.parent() {
5345 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
5346 }
5347
5348 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
5349 fs::write(path, bytes).context("failed to write JSON file")
5350}
5351
5352fn merge_json_objects(
5353 current: &JsonMap<String, JsonValue>,
5354 update: &JsonMap<String, JsonValue>,
5355) -> JsonMap<String, JsonValue> {
5356 let mut merged = current.clone();
5357 for (key, value) in update {
5358 match (merged.get(key), value) {
5359 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
5360 merged.insert(
5361 key.clone(),
5362 JsonValue::Object(merge_json_objects(current_object, update_object)),
5363 );
5364 }
5365 _ => {
5366 merged.insert(key.clone(), value.clone());
5367 }
5368 }
5369 }
5370
5371 merged
5372}
5373
5374fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
5375 match expected {
5376 JsonValue::Object(expected_object) => match current {
5377 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
5378 current_object
5379 .get(key)
5380 .is_some_and(|current_child| json_contains(current_child, expected_child))
5381 }),
5382 _ => false,
5383 },
5384 JsonValue::Array(expected_array) => match current {
5385 JsonValue::Array(current_array) => {
5386 current_array.len() == expected_array.len()
5387 && current_array
5388 .iter()
5389 .zip(expected_array.iter())
5390 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
5391 }
5392 _ => false,
5393 },
5394 _ => current == expected,
5395 }
5396}
5397
5398fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
5399 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
5400 "none" | "default" => Ok("None".to_owned()),
5401 "trusted" => Ok("Trusted".to_owned()),
5402 other => bail!("unsupported source trust level: {other}"),
5403 }
5404}
5405
5406fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
5407 SUPPORTED_ADMIN_SETTINGS
5408 .iter()
5409 .copied()
5410 .find(|setting| setting.eq_ignore_ascii_case(name))
5411 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
5412}
5413
5414fn default_source_trust_level() -> String {
5415 "None".to_owned()
5416}
5417
5418fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
5419where
5420 F: FnMut(&SqlRow) -> Result<T>,
5421{
5422 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5423 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5424 let mut result = Vec::new();
5425
5426 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
5427 result.push(map(&row)?);
5428 }
5429
5430 Ok(result)
5431}
5432
5433fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
5434where
5435 F: FnOnce(&SqlRow) -> Result<T>,
5436{
5437 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5438 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5439
5440 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
5441 Some(row) => Ok(Some(map(&row)?)),
5442 None => Ok(None),
5443 }
5444}
5445
5446fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
5447 pollster::block_on(connection.execute(sql, params_from_iter(params)))
5448 .with_context(|| format!("failed to execute SQL statement: {sql}"))
5449}
5450
5451fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
5452 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
5453}
5454
5455fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
5456 row.get_value(index)
5457 .with_context(|| format!("failed to read SQL column {index}"))
5458}
5459
5460fn row_string(row: &SqlRow, index: usize) -> Result<String> {
5461 match row_value(row, index)? {
5462 SqlValue::Text(value) => Ok(value),
5463 SqlValue::Integer(value) => Ok(value.to_string()),
5464 SqlValue::Real(value) => Ok(value.to_string()),
5465 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
5466 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5467 }
5468}
5469
5470fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5471 match row_value(row, index)? {
5472 SqlValue::Null => Ok(None),
5473 SqlValue::Text(value) => Ok(Some(value)),
5474 SqlValue::Integer(value) => Ok(Some(value.to_string())),
5475 SqlValue::Real(value) => Ok(Some(value.to_string())),
5476 SqlValue::Blob(value) => Ok(Some(
5477 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
5478 )),
5479 }
5480}
5481
5482fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
5483 match row_value(row, index)? {
5484 SqlValue::Integer(value) => Ok(value),
5485 SqlValue::Text(value) => value
5486 .parse::<i64>()
5487 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
5488 SqlValue::Real(value) => f64_to_i64_checked(value, index),
5489 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5490 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
5491 }
5492}
5493
5494fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
5495 match row_value(row, index)? {
5496 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
5497 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
5498 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5499 SqlValue::Integer(value) => Ok(format!("{value:x}")),
5500 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
5501 }
5502}
5503
5504fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5505 match row_value(row, index)? {
5506 SqlValue::Null => Ok(None),
5507 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
5508 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
5509 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
5510 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
5511 }
5512}
5513
5514fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
5515 if !value.is_finite() {
5516 bail!("SQL column {index} contained a non-finite floating-point value");
5517 }
5518
5519 if value.fract() != 0.0 {
5520 bail!("SQL column {index} contained a non-integer floating-point value");
5521 }
5522
5523 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
5524 bail!("SQL column {index} contained a floating-point value outside the i64 range");
5525 }
5526
5527 format!("{value:.0}")
5528 .parse::<i64>()
5529 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
5530}
5531
5532fn bytes_to_hex(bytes: &[u8]) -> String {
5533 let mut result = String::with_capacity(bytes.len() * 2);
5534 for byte in bytes {
5535 result.push_str(&format!("{byte:02x}"));
5536 }
5537 result
5538}
5539
5540fn query_v2_matches(
5541 connection: &Connection,
5542 query: &PackageQuery,
5543 semantics: SearchSemantics,
5544) -> Result<(Vec<V2SearchRow>, bool)> {
5545 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
5546 let limit = source_fetch_results(query, semantics);
5547 let sql = format!(
5548 "SELECT rowid, id, name, moniker, latest_version, hash \
5549 FROM packages WHERE {where_clause} LIMIT {}",
5550 limit + 1
5551 );
5552 let mut rows = query_rows(
5553 connection,
5554 &sql,
5555 params.into_iter().map(SqlValue::Text).collect(),
5556 |row| {
5557 Ok(V2SearchRow {
5558 package_rowid: row_i64(row, 0)?,
5559 id: row_string(row, 1)?,
5560 name: row_string(row, 2)?,
5561 moniker: row_opt_string(row, 3)?,
5562 version: row_string(row, 4)?,
5563 package_hash: row_hex_string(row, 5)?,
5564 match_criteria: None,
5565 })
5566 },
5567 )?;
5568 for row in &mut rows {
5569 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5570 }
5571 let truncated = rows.len() > limit;
5572 rows.truncate(limit);
5573 Ok((rows, truncated))
5574}
5575
5576fn query_v1_matches(
5577 connection: &Connection,
5578 query: &PackageQuery,
5579 semantics: SearchSemantics,
5580) -> Result<(Vec<V1SearchRow>, bool)> {
5581 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5582 let limit = source_fetch_results(query, semantics);
5583 let sql = format!(
5584 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5585 FROM manifest \
5586 JOIN ids ON manifest.id = ids.rowid \
5587 JOIN names ON manifest.name = names.rowid \
5588 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5589 JOIN versions ON manifest.version = versions.rowid \
5590 JOIN channels ON manifest.channel = channels.rowid \
5591 WHERE {where_clause} LIMIT {}",
5592 limit + 1
5593 );
5594 let mut rows = query_rows(
5595 connection,
5596 &sql,
5597 params.into_iter().map(SqlValue::Text).collect(),
5598 |row| {
5599 Ok(V1SearchRow {
5600 manifest_rowid: row_i64(row, 0)?,
5601 package_rowid: row_i64(row, 1)?,
5602 version: row_string(row, 2)?,
5603 channel: row_string(row, 3)?,
5604 id: row_string(row, 4)?,
5605 name: row_string(row, 5)?,
5606 moniker: row_opt_string(row, 6)?,
5607 match_criteria: None,
5608 })
5609 },
5610 )?;
5611 for row in &mut rows {
5612 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5613 }
5614 let truncated = rows.len() > limit;
5615 rows.truncate(limit);
5616 Ok((rows, truncated))
5617}
5618
5619fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5620 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5621
5622 for row in rows {
5623 match grouped.get(&row.package_rowid) {
5624 Some(existing)
5625 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5626 != Ordering::Less => {}
5627 _ => {
5628 grouped.insert(row.package_rowid, row);
5629 }
5630 }
5631 }
5632
5633 grouped.into_values().collect()
5634}
5635
5636fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5637 let has_hash = table_has_column(connection, "manifest", "hash")?;
5638 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5639 let sql = format!(
5640 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5641 FROM manifest \
5642 JOIN versions ON manifest.version = versions.rowid \
5643 JOIN channels ON manifest.channel = channels.rowid \
5644 WHERE manifest.id = ?1"
5645 );
5646 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5647 Ok(V1VersionRow {
5648 version: row_string(row, 0)?,
5649 channel: row_string(row, 1)?,
5650 pathpart_id: row_i64(row, 2)?,
5651 manifest_hash: row_opt_hex_string(row, 3)?,
5652 })
5653 })
5654}
5655
5656fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5657 let mut parts = Vec::new();
5658 let mut current = Some(pathpart_id);
5659
5660 while let Some(id) = current {
5661 let (parent, pathpart) = query_optional_value(
5662 connection,
5663 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5664 vec![SqlValue::Integer(id)],
5665 |row| {
5666 Ok((
5667 row_opt_string(row, 0)?
5668 .and_then(|value| value.parse::<i64>().ok())
5669 .filter(|value| *value != 0),
5670 row_string(row, 1)?,
5671 ))
5672 },
5673 )?
5674 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5675 parts.push(pathpart);
5676 current = parent;
5677 }
5678
5679 parts.reverse();
5680 Ok(parts.join("/"))
5681}
5682
5683fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5684 format!(
5685 "packages/{}/{}/versionData.mszyml",
5686 package_id,
5687 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5688 )
5689}
5690
5691fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5692 let message = format!("{error:#}").to_ascii_lowercase();
5693 message.contains("no such table") && message.contains("packages")
5694}
5695
5696fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5697 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5698 row_string(row, 1)
5699 })? {
5700 if value.eq_ignore_ascii_case(column) {
5701 return Ok(true);
5702 }
5703 }
5704
5705 Ok(false)
5706}
5707
5708fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5709 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5710 let id_column = if v2 { "id" } else { "ids.id" };
5711 let name_column = if v2 { "name" } else { "names.name" };
5712 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5713 let exact_match = query.exact || semantics == SearchSemantics::Single;
5714 let mut params = Vec::new();
5715
5716 if let Some(value) = &query.id {
5717 return (
5718 single_field_condition(id_column, value, exact_match, &mut params),
5719 params,
5720 );
5721 }
5722 if let Some(value) = &query.name {
5723 return (
5724 single_field_condition(name_column, value, exact_match, &mut params),
5725 params,
5726 );
5727 }
5728 if let Some(value) = &query.moniker {
5729 return (
5730 single_field_condition(moniker_column, value, exact_match, &mut params),
5731 params,
5732 );
5733 }
5734 if let Some(value) = &query.tag {
5735 return (
5736 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5737 params,
5738 );
5739 }
5740 if let Some(value) = &query.command {
5741 return (
5742 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5743 params,
5744 );
5745 }
5746 if let Some(value) = &query.query {
5747 if exact_match {
5748 let conditions = [
5749 single_field_condition(id_column, value, true, &mut params),
5750 single_field_condition(name_column, value, true, &mut params),
5751 single_field_condition(moniker_column, value, true, &mut params),
5752 ];
5753 return (format!("({})", conditions.join(" OR ")), params);
5754 }
5755
5756 let mut conditions = vec![
5757 single_field_condition(id_column, value, false, &mut params),
5758 single_field_condition(name_column, value, false, &mut params),
5759 single_field_condition(moniker_column, value, false, &mut params),
5760 ];
5761 if semantics == SearchSemantics::Many {
5762 conditions.push(mapped_field_condition(
5763 v2,
5764 "tag",
5765 value,
5766 rowid_column,
5767 false,
5768 &mut params,
5769 ));
5770 conditions.push(mapped_field_condition(
5771 v2,
5772 "command",
5773 value,
5774 rowid_column,
5775 false,
5776 &mut params,
5777 ));
5778 }
5779 return (format!("({})", conditions.join(" OR ")), params);
5780 }
5781
5782 ("1 = 1".to_owned(), Vec::new())
5783}
5784
5785fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5786 params.push(match_parameter(value, exact));
5787 format!("{column} LIKE ?{}", params.len())
5788}
5789
5790fn mapped_field_condition(
5791 v2: bool,
5792 value_name: &str,
5793 value: &str,
5794 rowid_column: &str,
5795 exact: bool,
5796 params: &mut Vec<String>,
5797) -> String {
5798 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5799 (
5800 format!("{value_name}s2"),
5801 format!("{value_name}s2_map"),
5802 value_name.to_owned(),
5803 "package".to_owned(),
5804 )
5805 } else {
5806 (
5807 format!("{value_name}s"),
5808 format!("{value_name}s_map"),
5809 value_name.to_owned(),
5810 "manifest".to_owned(),
5811 )
5812 };
5813 params.push(match_parameter(value, exact));
5814 let parameter = params.len();
5815 format!(
5822 "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
5823 FROM {map_table_name} JOIN {table_name} ON \
5824 {map_table_name}.{value_name} = {table_name}.rowid \
5825 WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
5826 )
5827}
5828
5829fn match_parameter(value: &str, exact: bool) -> String {
5830 if exact { value.to_owned() } else { format!("%{value}%") }
5831}
5832
5833fn build_rest_search_body(
5834 query: &PackageQuery,
5835 info: &RestInformation,
5836 semantics: SearchSemantics,
5837) -> Result<JsonValue> {
5838 let mut root = serde_json::Map::new();
5839 root.insert(
5840 "MaximumResults".to_owned(),
5841 JsonValue::from(source_fetch_results(query, semantics) as u64),
5842 );
5843 let exact_match = query.exact || semantics == SearchSemantics::Single;
5844
5845 if let Some(value) = &query.query {
5846 if semantics == SearchSemantics::Single {
5847 let mut filters = vec![
5848 rest_filter("PackageIdentifier", value, true),
5849 rest_filter("PackageName", value, true),
5850 rest_filter("Moniker", value, true),
5851 ];
5852 append_required_rest_filters(&mut filters, info);
5853 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5854 return Ok(JsonValue::Object(root));
5855 }
5856
5857 root.insert(
5858 "Query".to_owned(),
5859 serde_json::json!({
5860 "KeyWord": value,
5861 "MatchType": if exact_match { "Exact" } else { "Substring" },
5862 }),
5863 );
5864 }
5865
5866 let mut filters = Vec::new();
5867 if let Some(value) = &query.id {
5868 filters.push(rest_filter("PackageIdentifier", value, exact_match));
5869 }
5870 if let Some(value) = &query.name {
5871 filters.push(rest_filter("PackageName", value, exact_match));
5872 }
5873 if let Some(value) = &query.moniker {
5874 filters.push(rest_filter("Moniker", value, exact_match));
5875 }
5876 if let Some(value) = &query.tag {
5877 filters.push(rest_filter("Tag", value, true));
5878 }
5879 if let Some(value) = &query.command {
5880 filters.push(rest_filter("Command", value, true));
5881 }
5882 append_required_rest_filters(&mut filters, info);
5883
5884 if !filters.is_empty() {
5885 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5886 }
5887
5888 Ok(JsonValue::Object(root))
5889}
5890
5891fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5892 if info
5893 .required_package_match_fields
5894 .iter()
5895 .any(|field| field.eq_ignore_ascii_case("Market"))
5896 {
5897 filters.push(rest_filter("Market", &default_market(), true));
5898 }
5899}
5900
5901fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5902 serde_json::json!({
5903 "PackageMatchField": field,
5904 "RequestMatch": {
5905 "KeyWord": value,
5906 "MatchType": if exact { "Exact" } else { "Substring" },
5907 }
5908 })
5909}
5910
5911fn max_results(query: &PackageQuery) -> usize {
5912 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5913}
5914
5915fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5916 match semantics {
5917 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5918 SearchSemantics::Single => max_results(query),
5919 }
5920}
5921
5922fn infer_preindexed_match_criteria_v2(
5923 connection: &Connection,
5924 row: &V2SearchRow,
5925 query: &PackageQuery,
5926 semantics: SearchSemantics,
5927) -> Result<Option<String>> {
5928 infer_match_criteria(
5929 &row.id,
5930 &row.name,
5931 row.moniker.as_deref(),
5932 query,
5933 semantics,
5934 |value| {
5935 find_mapped_value_v2(
5936 connection,
5937 "tags2",
5938 "tags2_map",
5939 "tag",
5940 row.package_rowid,
5941 value,
5942 query.exact,
5943 )
5944 },
5945 |value| {
5946 find_mapped_value_v2(
5947 connection,
5948 "commands2",
5949 "commands2_map",
5950 "command",
5951 row.package_rowid,
5952 value,
5953 query.exact,
5954 )
5955 },
5956 )
5957}
5958
5959fn infer_preindexed_match_criteria_v1(
5960 connection: &Connection,
5961 row: &V1SearchRow,
5962 query: &PackageQuery,
5963 semantics: SearchSemantics,
5964) -> Result<Option<String>> {
5965 infer_match_criteria(
5966 &row.id,
5967 &row.name,
5968 row.moniker.as_deref(),
5969 query,
5970 semantics,
5971 |value| {
5972 find_mapped_value_v1(
5973 connection,
5974 "tags",
5975 "tags_map",
5976 "tag",
5977 row.manifest_rowid,
5978 value,
5979 query.exact,
5980 )
5981 },
5982 |value| {
5983 find_mapped_value_v1(
5984 connection,
5985 "commands",
5986 "commands_map",
5987 "command",
5988 row.manifest_rowid,
5989 value,
5990 query.exact,
5991 )
5992 },
5993 )
5994}
5995
5996fn infer_match_criteria<FTag, FCommand>(
5997 id: &str,
5998 name: &str,
5999 moniker: Option<&str>,
6000 query: &PackageQuery,
6001 semantics: SearchSemantics,
6002 find_tag: FTag,
6003 find_command: FCommand,
6004) -> Result<Option<String>>
6005where
6006 FTag: FnOnce(&str) -> Result<Option<String>>,
6007 FCommand: FnOnce(&str) -> Result<Option<String>>,
6008{
6009 if let Some(value) = &query.tag {
6010 return Ok(Some(format_match_criteria("Tag", value)));
6011 }
6012 if let Some(value) = &query.command {
6013 return Ok(Some(format_match_criteria("Command", value)));
6014 }
6015 if let Some(value) = &query.moniker {
6016 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
6017 }
6018 if semantics == SearchSemantics::Single {
6019 return Ok(None);
6020 }
6021 if let Some(value) = &query.query {
6022 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
6023 return Ok(None);
6024 }
6025 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
6026 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
6027 }
6028 if let Some(tag) = find_tag(value)? {
6029 return Ok(Some(format_match_criteria("Tag", &tag)));
6030 }
6031 if let Some(command) = find_command(value)? {
6032 return Ok(Some(format_match_criteria("Command", &command)));
6033 }
6034 }
6035 Ok(None)
6036}
6037
6038fn find_mapped_value_v2(
6039 connection: &Connection,
6040 table_name: &str,
6041 map_table_name: &str,
6042 value_name: &str,
6043 package_rowid: i64,
6044 query: &str,
6045 exact: bool,
6046) -> Result<Option<String>> {
6047 let sql = format!(
6048 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6049 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6050 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
6051 );
6052 let values = query_rows(
6053 connection,
6054 &sql,
6055 vec![
6056 SqlValue::Integer(package_rowid),
6057 SqlValue::Text(match_parameter(query, exact)),
6058 ],
6059 |row| row_string(row, 0),
6060 )?;
6061 Ok(select_best_text_match(values, query, exact))
6062}
6063
6064fn find_mapped_value_v1(
6065 connection: &Connection,
6066 table_name: &str,
6067 map_table_name: &str,
6068 value_name: &str,
6069 manifest_rowid: i64,
6070 query: &str,
6071 exact: bool,
6072) -> Result<Option<String>> {
6073 let sql = format!(
6074 "SELECT {table_name}.{value_name} FROM {map_table_name} \
6075 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
6076 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
6077 );
6078 let values = query_rows(
6079 connection,
6080 &sql,
6081 vec![
6082 SqlValue::Integer(manifest_rowid),
6083 SqlValue::Text(match_parameter(query, exact)),
6084 ],
6085 |row| row_string(row, 0),
6086 )?;
6087 Ok(select_best_text_match(values, query, exact))
6088}
6089
6090fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
6091 if let Some(value) = &query.tag {
6092 return Some(format_match_criteria("Tag", value));
6093 }
6094 if let Some(value) = &query.command {
6095 return Some(format_match_criteria("Command", value));
6096 }
6097 if let Some(value) = &query.moniker {
6098 return Some(format_match_criteria("Moniker", value));
6099 }
6100 if semantics == SearchSemantics::Single {
6101 return None;
6102 }
6103 let value = query.query.as_deref()?;
6104 if json_string(item, "PackageIdentifier")
6105 .as_deref()
6106 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6107 || json_string(item, "PackageName")
6108 .as_deref()
6109 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6110 {
6111 return None;
6112 }
6113 json_string(item, "Moniker")
6114 .filter(|candidate| matches_text(candidate, value, query.exact))
6115 .map(|candidate| format_match_criteria("Moniker", &candidate))
6116}
6117
6118fn format_match_criteria(field: &str, value: &str) -> String {
6119 format!("{field}: {value}")
6120}
6121
6122fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6123 if exact {
6124 candidate.eq_ignore_ascii_case(query)
6125 } else {
6126 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6127 }
6128}
6129
6130fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6131 let mut score = 0;
6132
6133 if let Some(value) = &query.query {
6134 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6135 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6136 if let Some(moniker) = candidate.moniker.as_deref() {
6137 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6138 }
6139 }
6140
6141 if let Some(value) = &query.id {
6142 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6143 }
6144 if let Some(value) = &query.name {
6145 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6146 }
6147 if let Some(value) = &query.moniker
6148 && let Some(moniker) = candidate.moniker.as_deref()
6149 {
6150 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6151 }
6152 if let Some(value) = &query.tag
6153 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6154 {
6155 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6156 }
6157 if let Some(value) = &query.command
6158 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6159 {
6160 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6161 }
6162
6163 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6164 score += 5;
6165 }
6166 if search_match_has_unknown_version(candidate) {
6167 score = score.saturating_sub(10);
6168 }
6169
6170 score
6171}
6172
6173fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6174 candidate
6175 .version
6176 .as_deref()
6177 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6178}
6179
6180fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6189 let mut document = match documents {
6190 JsonValue::Object(map) => map.clone(),
6191 _ => serde_json::Map::new(),
6192 };
6193
6194 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
6195 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
6196 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
6197 if !manifest.channel.is_empty() {
6198 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
6199 } else {
6200 document.remove("Channel");
6201 }
6202
6203 let mut single_installer = serde_json::Map::new();
6206 if let Some(arch) = installer.architecture.as_ref() {
6207 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
6208 }
6209 if let Some(installer_type) = installer.installer_type.as_ref() {
6210 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
6211 }
6212 if let Some(url) = installer.url.as_ref() {
6213 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
6214 }
6215 if let Some(sha) = installer.sha256.as_ref() {
6216 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
6217 }
6218 if let Some(scope) = installer.scope.as_ref() {
6219 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
6220 }
6221 if let Some(locale) = installer.locale.as_ref() {
6222 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
6223 }
6224 if let Some(product_code) = installer.product_code.as_ref() {
6225 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
6226 }
6227 if let Some(release_date) = installer.release_date.as_ref() {
6228 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
6229 }
6230
6231 document.insert(
6232 "Installers".to_owned(),
6233 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
6234 );
6235 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
6236
6237 let value = JsonValue::Object(document);
6238 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
6239}
6240
6241fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
6246 let mut filename = derive_download_filename_stem(manifest, installer);
6247 filename.push_str(".yaml");
6248 filename
6249}
6250
6251fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
6252 let mut stem = sanitize_filename_component(&manifest.name);
6253 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
6254 stem.push('_');
6255 stem.push_str(&sanitize_filename_component(&manifest.version));
6256 }
6257 if let Some(scope) = installer.scope.as_deref() {
6258 stem.push('_');
6259 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
6260 }
6261 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
6262 stem.push('_');
6263 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
6264
6265 let installer_type = installer.installer_type.as_deref().unwrap_or("");
6266 if !installer_type.is_empty() {
6267 stem.push('_');
6268 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
6269 }
6270 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
6271 stem.push('_');
6272 stem.push_str(&sanitize_filename_component(locale));
6273 }
6274
6275 stem
6276}
6277
6278fn capitalize_first(value: &str) -> String {
6279 let mut chars = value.chars();
6280 match chars.next() {
6281 Some(first) => {
6282 let mut out = first.to_ascii_uppercase().to_string();
6283 out.push_str(&chars.as_str().to_ascii_lowercase());
6284 out
6285 }
6286 None => String::new(),
6287 }
6288}
6289
6290fn format_architecture(value: &str) -> String {
6291 match value.to_ascii_lowercase().as_str() {
6292 "x64" => "X64".to_owned(),
6293 "x86" => "X86".to_owned(),
6294 "arm" => "Arm".to_owned(),
6295 "arm64" => "Arm64".to_owned(),
6296 "neutral" => "Neutral".to_owned(),
6297 other => capitalize_first(other),
6298 }
6299}
6300
6301fn sanitize_filename_component(value: &str) -> String {
6302 value
6303 .chars()
6304 .map(|ch| match ch {
6305 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
6306 ch if ch.is_control() => '_',
6307 ch => ch,
6308 })
6309 .collect()
6310}
6311
6312fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
6313 if manifest_path.is_file() {
6314 return Ok(manifest_path.to_path_buf());
6315 }
6316
6317 if !manifest_path.is_dir() {
6318 bail!("Manifest path not found: {}", manifest_path.display());
6319 }
6320
6321 let candidate = fs::read_dir(manifest_path)?
6322 .filter_map(|entry| entry.ok())
6323 .map(|entry| entry.path())
6324 .filter(|path| {
6325 path.extension()
6326 .and_then(|value| value.to_str())
6327 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
6328 })
6329 .min();
6330
6331 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
6332}
6333
6334fn score_text_match(
6335 candidate: &str,
6336 query: &str,
6337 exact: bool,
6338 exact_score: usize,
6339 prefix_score: usize,
6340 substring_score: usize,
6341) -> usize {
6342 if candidate.eq_ignore_ascii_case(query) {
6343 return exact_score;
6344 }
6345 if exact {
6346 return 0;
6347 }
6348
6349 let candidate_lower = candidate.to_ascii_lowercase();
6350 let query_lower = query.to_ascii_lowercase();
6351 if candidate_lower.starts_with(&query_lower) {
6352 prefix_score
6353 } else if candidate_lower.contains(&query_lower) {
6354 substring_score
6355 } else {
6356 0
6357 }
6358}
6359
6360fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
6361where
6362 I: IntoIterator<Item = String>,
6363{
6364 values
6365 .into_iter()
6366 .filter_map(|candidate| {
6367 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
6368 (score > 0).then_some((score, candidate))
6369 })
6370 .max_by(|(left_score, left_value), (right_score, right_value)| {
6371 left_score
6372 .cmp(right_score)
6373 .then_with(|| right_value.len().cmp(&left_value.len()))
6374 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
6375 })
6376 .map(|(_, candidate)| candidate)
6377}
6378
6379fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
6380 let (field, value) = criteria.split_once(": ")?;
6381 Some((field, value))
6382}
6383
6384fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
6385 let versions = item
6386 .get("Versions")
6387 .and_then(JsonValue::as_array)
6388 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
6389 let mut result = Vec::new();
6390 for version in versions {
6391 let value = json_string(version, "PackageVersion")
6392 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
6393 let channel = json_string(version, "Channel").unwrap_or_default();
6394 result.push(VersionKey {
6395 version: value,
6396 channel,
6397 });
6398 }
6399 Ok(result)
6400}
6401
6402fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
6403 let mut merged = YamlMapping::new();
6404 let mut documents = Vec::new();
6405 for document in serde_yaml::Deserializer::from_slice(bytes) {
6406 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
6407 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
6408 if let Some(mapping) = value.as_mapping() {
6409 merge_yaml_mapping(&mut merged, mapping);
6410 }
6411 }
6412
6413 let id = yaml_string_from_root(&merged, "PackageIdentifier")
6414 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
6415 let version =
6416 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
6417 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
6418
6419 let installers = parse_yaml_installers(&merged);
6420 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
6421 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6422
6423 Ok((
6424 Manifest {
6425 id,
6426 name,
6427 version,
6428 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
6429 publisher: yaml_localized_string(&merged, "Publisher"),
6430 description: yaml_localized_string(&merged, "Description")
6431 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
6432 moniker: yaml_string_from_root(&merged, "Moniker"),
6433 package_url: yaml_localized_string(&merged, "PackageUrl"),
6434 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
6435 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
6436 license: yaml_localized_string(&merged, "License"),
6437 license_url: yaml_localized_string(&merged, "LicenseUrl"),
6438 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
6439 author: yaml_localized_string(&merged, "Author"),
6440 copyright: yaml_localized_string(&merged, "Copyright"),
6441 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
6442 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
6443 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
6444 tags: yaml_string_list(&merged, "Tags"),
6445 agreements: yaml_agreement_list(&merged),
6446 package_dependencies: yaml_package_dependencies(&merged),
6447 documentation: yaml_documentation_list(&merged),
6448 icons: yaml_icon_list(&merged),
6449 installers,
6450 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6451 },
6452 collapse_structured_document(&JsonValue::Array(documents)),
6453 ))
6454}
6455
6456fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
6457 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
6458}
6459
6460fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
6461 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
6462 let data = root
6463 .get("Data")
6464 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
6465 let versions = data
6466 .get("Versions")
6467 .and_then(JsonValue::as_array)
6468 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
6469 let selected = versions
6470 .iter()
6471 .find(|item| {
6472 json_string(item, "PackageVersion").as_deref() == Some(version)
6473 && json_string(item, "Channel").unwrap_or_default() == channel
6474 })
6475 .or_else(|| versions.first())
6476 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
6477
6478 let default_locale = selected
6479 .get("DefaultLocale")
6480 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
6481 let package_default_locale = data.get("DefaultLocale");
6482 let name = json_string(default_locale, "PackageName")
6483 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
6484 let installer_switch_defaults = json_installer_switches(selected);
6485 let top_platforms = json_string_list(selected, "Platform");
6486 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
6487 let top_installer_type = json_string(selected, "InstallerType");
6488 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
6489 let top_nested_installer_files = json_nested_installer_files(selected);
6490
6491 let installers = selected
6492 .get("Installers")
6493 .and_then(JsonValue::as_array)
6494 .map(|items| {
6495 items
6496 .iter()
6497 .map(|item| Installer {
6498 platforms: {
6499 let platforms = json_string_list(item, "Platform");
6500 if platforms.is_empty() {
6501 top_platforms.clone()
6502 } else {
6503 platforms
6504 }
6505 },
6506 minimum_os_version: json_string(item, "MinimumOSVersion")
6507 .or_else(|| top_minimum_os_version.clone()),
6508 architecture: json_string(item, "Architecture"),
6509 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
6510 nested_installer_type: json_string(item, "NestedInstallerType")
6511 .or_else(|| top_nested_installer_type.clone()),
6512 nested_installer_files: {
6513 let files = json_nested_installer_files(item);
6514 if files.is_empty() {
6515 top_nested_installer_files.clone()
6516 } else {
6517 files
6518 }
6519 },
6520 url: json_string(item, "InstallerUrl"),
6521 sha256: json_string(item, "InstallerSha256"),
6522 product_code: json_string(item, "ProductCode"),
6523 locale: json_string(item, "InstallerLocale"),
6524 scope: json_string(item, "Scope"),
6525 release_date: json_string(item, "ReleaseDate"),
6526 package_family_name: json_string(item, "PackageFamilyName"),
6527 upgrade_code: json_string(item, "UpgradeCode"),
6528 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
6529 commands: json_string_list(item, "Commands"),
6530 package_dependencies: json_package_dependencies(item),
6531 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
6532 })
6533 .collect::<Vec<_>>()
6534 })
6535 .unwrap_or_default();
6536 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
6537 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6538 let mut icons = json_icon_list(default_locale);
6539 if icons.is_empty() {
6540 icons = package_default_locale.map(json_icon_list).unwrap_or_default();
6541 }
6542
6543 let manifest = Manifest {
6544 id: package_id.to_owned(),
6545 name,
6546 version: version.to_owned(),
6547 channel: channel.to_owned(),
6548 publisher: json_string(default_locale, "Publisher"),
6549 description: json_string(default_locale, "Description")
6550 .or_else(|| json_string(default_locale, "ShortDescription")),
6551 moniker: json_string(default_locale, "Moniker"),
6552 package_url: json_string(default_locale, "PackageUrl"),
6553 publisher_url: json_string(default_locale, "PublisherUrl"),
6554 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
6555 license: json_string(default_locale, "License"),
6556 license_url: json_string(default_locale, "LicenseUrl"),
6557 privacy_url: json_string(default_locale, "PrivacyUrl"),
6558 author: json_string(default_locale, "Author"),
6559 copyright: json_string(default_locale, "Copyright"),
6560 copyright_url: json_string(default_locale, "CopyrightUrl"),
6561 release_notes: json_string(default_locale, "ReleaseNotes"),
6562 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
6563 tags: json_string_list(default_locale, "Tags"),
6564 agreements: json_agreement_list(default_locale),
6565 package_dependencies: json_package_dependencies(selected),
6566 documentation: json_documentation_list(default_locale),
6567 icons,
6568 installers,
6569 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6570 };
6571
6572 Ok((
6573 manifest,
6574 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
6575 ))
6576}
6577
6578fn collapse_structured_document(document: &JsonValue) -> JsonValue {
6579 match document {
6580 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
6581 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
6582 }
6583 JsonValue::Array(documents) => merge_manifest_documents(documents),
6584 JsonValue::Object(_) if is_merged_manifest_document(document) => {
6585 merge_manifest_documents(&split_merged_manifest_document(document))
6586 }
6587 _ => document.clone(),
6588 }
6589}
6590
6591fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
6592 documents.iter().map(collapse_structured_document).collect()
6593}
6594
6595fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
6596 let Some(root) = document.as_object_mut() else {
6597 return document;
6598 };
6599
6600 root.insert(
6601 "IconUrl".to_owned(),
6602 manifest
6603 .icon_url()
6604 .map(|url| JsonValue::String(url.to_owned()))
6605 .unwrap_or(JsonValue::Null),
6606 );
6607 root.insert(
6608 "Icon".to_owned(),
6609 manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
6610 );
6611 root.entry("Icons".to_owned())
6612 .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
6613
6614 document
6615}
6616
6617fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
6618 serde_json::to_value(icon).expect("package icon serialization cannot fail")
6619}
6620
6621fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
6622 let version = documents.iter().find(|document| {
6623 document
6624 .get("ManifestType")
6625 .and_then(JsonValue::as_str)
6626 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
6627 });
6628 let default_locale = documents.iter().find(|document| {
6629 document
6630 .get("ManifestType")
6631 .and_then(JsonValue::as_str)
6632 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
6633 });
6634 let installer = documents.iter().find(|document| {
6635 document
6636 .get("ManifestType")
6637 .and_then(JsonValue::as_str)
6638 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
6639 });
6640
6641 let mut singleton = JsonMap::new();
6642 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
6643 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
6644 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
6645
6646 if !singleton.contains_key("PackageLocale")
6647 && let Some(package_locale) = default_locale
6648 .and_then(|document| json_string(document, "PackageLocale"))
6649 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
6650 {
6651 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6652 }
6653
6654 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
6655 singleton.insert(
6656 "ManifestVersion".to_owned(),
6657 JsonValue::String(
6658 installer
6659 .and_then(|document| json_string(document, "ManifestVersion"))
6660 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
6661 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
6662 .unwrap_or_else(|| "1.10.0".to_owned()),
6663 ),
6664 );
6665 singleton.remove("DefaultLocale");
6666
6667 JsonValue::Object(singleton)
6668}
6669
6670fn is_merged_manifest_document(document: &JsonValue) -> bool {
6671 document
6672 .get("ManifestType")
6673 .and_then(JsonValue::as_str)
6674 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
6675}
6676
6677fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
6678 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
6679 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
6680 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6681 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
6682
6683 let version_document = serde_json::json!({
6684 "PackageIdentifier": package_identifier,
6685 "PackageVersion": package_version,
6686 "DefaultLocale": package_locale,
6687 "ManifestType": "version",
6688 "ManifestVersion": manifest_version,
6689 });
6690
6691 let mut default_locale_document = project_manifest_document(
6692 merged,
6693 &[
6694 "PackageIdentifier",
6695 "PackageVersion",
6696 "PackageLocale",
6697 "Publisher",
6698 "PublisherUrl",
6699 "PublisherSupportUrl",
6700 "PrivacyUrl",
6701 "Author",
6702 "PackageName",
6703 "PackageUrl",
6704 "License",
6705 "LicenseUrl",
6706 "Copyright",
6707 "CopyrightUrl",
6708 "ShortDescription",
6709 "Description",
6710 "Moniker",
6711 "Tags",
6712 "Agreements",
6713 "ReleaseNotes",
6714 "ReleaseNotesUrl",
6715 "PurchaseUrl",
6716 "InstallationNotes",
6717 "Documentations",
6718 "Icons",
6719 ],
6720 );
6721 default_locale_document.insert(
6722 "PackageIdentifier".to_owned(),
6723 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6724 );
6725 default_locale_document.insert(
6726 "PackageVersion".to_owned(),
6727 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6728 );
6729 default_locale_document.insert(
6730 "PackageLocale".to_owned(),
6731 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6732 );
6733 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6734 default_locale_document.insert(
6735 "ManifestVersion".to_owned(),
6736 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6737 );
6738
6739 let mut installer_document = project_manifest_document(
6740 merged,
6741 &[
6742 "PackageIdentifier",
6743 "PackageVersion",
6744 "Channel",
6745 "InstallerLocale",
6746 "Platform",
6747 "MinimumOSVersion",
6748 "InstallerType",
6749 "NestedInstallerType",
6750 "NestedInstallerFiles",
6751 "Scope",
6752 "InstallModes",
6753 "InstallerSwitches",
6754 "InstallerSuccessCodes",
6755 "ExpectedReturnCodes",
6756 "UpgradeBehavior",
6757 "Commands",
6758 "Protocols",
6759 "FileExtensions",
6760 "Dependencies",
6761 "PackageFamilyName",
6762 "ProductCode",
6763 "Capabilities",
6764 "RestrictedCapabilities",
6765 "Markets",
6766 "InstallerAbortsTerminal",
6767 "ReleaseDate",
6768 "InstallLocationRequired",
6769 "RequireExplicitUpgrade",
6770 "DisplayInstallWarnings",
6771 "UnsupportedOSArchitectures",
6772 "UnsupportedArguments",
6773 "AppsAndFeaturesEntries",
6774 "ElevationRequirement",
6775 "InstallationMetadata",
6776 "DownloadCommandProhibited",
6777 "RepairBehavior",
6778 "ArchiveBinariesDependOnPath",
6779 "Authentication",
6780 "Installers",
6781 ],
6782 );
6783 installer_document.insert(
6784 "PackageIdentifier".to_owned(),
6785 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6786 );
6787 installer_document.insert(
6788 "PackageVersion".to_owned(),
6789 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6790 );
6791 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6792 installer_document.insert(
6793 "ManifestVersion".to_owned(),
6794 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6795 );
6796
6797 vec![
6798 version_document,
6799 JsonValue::Object(default_locale_document),
6800 JsonValue::Object(installer_document),
6801 ]
6802}
6803
6804fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6805 let mut result = JsonMap::new();
6806 if let Some(source) = source.as_object() {
6807 for key in keys {
6808 if let Some(value) = source.get(*key) {
6809 result.insert((*key).to_owned(), value.clone());
6810 }
6811 }
6812 }
6813
6814 result
6815}
6816
6817fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6818 if let Some(source) = source.and_then(JsonValue::as_object) {
6819 for key in keys {
6820 if let Some(value) = source.get(*key) {
6821 target.insert((*key).to_owned(), value.clone());
6822 }
6823 }
6824 }
6825}
6826
6827fn copy_all_manifest_keys_except(
6828 target: &mut JsonMap<String, JsonValue>,
6829 source: Option<&JsonValue>,
6830 excluded_keys: &[&str],
6831) {
6832 if let Some(source) = source.and_then(JsonValue::as_object) {
6833 for (key, value) in source {
6834 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6835 target.insert(key.clone(), value.clone());
6836 }
6837 }
6838 }
6839}
6840
6841fn build_rest_manifest_documents(
6842 data: &JsonValue,
6843 installer_source: &JsonValue,
6844 default_locale: &JsonValue,
6845 package_id: &str,
6846 version: &str,
6847 channel: &str,
6848) -> JsonValue {
6849 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6850 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6851 let manifest_version = json_string(installer_source, "ManifestVersion")
6852 .or_else(|| json_string(default_locale, "ManifestVersion"))
6853 .or_else(|| json_string(data, "ManifestVersion"))
6854 .unwrap_or_else(|| "1.10.0".to_owned());
6855
6856 let mut version_document = JsonMap::new();
6857 version_document.insert(
6858 "PackageIdentifier".to_owned(),
6859 JsonValue::String(package_identifier.clone()),
6860 );
6861 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6862 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6863 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6864 version_document.insert(
6865 "ManifestVersion".to_owned(),
6866 JsonValue::String(manifest_version.clone()),
6867 );
6868
6869 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6870 default_locale_document.insert(
6871 "PackageIdentifier".to_owned(),
6872 JsonValue::String(package_identifier.clone()),
6873 );
6874 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6875 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6876 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6877 default_locale_document.insert(
6878 "ManifestVersion".to_owned(),
6879 JsonValue::String(manifest_version.clone()),
6880 );
6881
6882 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6883 installer_document.remove("DefaultLocale");
6884 installer_document.remove("Locales");
6885 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6886 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6887 if !channel.is_empty() {
6888 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6889 }
6890 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6891 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6892
6893 let documents = vec![
6894 JsonValue::Object(version_document),
6895 JsonValue::Object(default_locale_document),
6896 JsonValue::Object(installer_document),
6897 ];
6898
6899 merge_manifest_documents(&documents)
6900}
6901
6902fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6903 let base = installer_defaults(root);
6904 let base_switches = yaml_installer_switches(root);
6905 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6906 let installers = items
6907 .iter()
6908 .filter_map(YamlValue::as_mapping)
6909 .map(|item| {
6910 let mut merged = base.clone();
6911 merge_yaml_mapping(&mut merged, item);
6912 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6913 installer_from_yaml(&merged, switches)
6914 })
6915 .collect::<Vec<_>>();
6916 if !installers.is_empty() {
6917 return installers;
6918 }
6919 }
6920
6921 if base.is_empty() {
6922 Vec::new()
6923 } else {
6924 vec![installer_from_yaml(&base, base_switches)]
6925 }
6926}
6927
6928fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6929 let keys = [
6930 "Architecture",
6931 "InstallerType",
6932 "NestedInstallerType",
6933 "NestedInstallerFiles",
6934 "InstallerUrl",
6935 "InstallerSha256",
6936 "ProductCode",
6937 "InstallerLocale",
6938 "Platform",
6939 "MinimumOSVersion",
6940 "Scope",
6941 "ReleaseDate",
6942 "PackageFamilyName",
6943 "UpgradeCode",
6944 "Commands",
6945 ];
6946 let mut defaults = YamlMapping::new();
6947 for key in keys {
6948 if let Some(value) = root.get(YamlValue::from(key)) {
6949 defaults.insert(YamlValue::from(key), value.clone());
6950 }
6951 }
6952 defaults
6953}
6954
6955fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6956 Installer {
6957 architecture: yaml_string(root, "Architecture"),
6958 installer_type: yaml_string(root, "InstallerType"),
6959 nested_installer_type: yaml_string(root, "NestedInstallerType"),
6960 nested_installer_files: yaml_nested_installer_files(root),
6961 url: yaml_string(root, "InstallerUrl"),
6962 sha256: yaml_string(root, "InstallerSha256"),
6963 product_code: yaml_string(root, "ProductCode"),
6964 locale: yaml_string(root, "InstallerLocale"),
6965 scope: yaml_string(root, "Scope"),
6966 release_date: yaml_string(root, "ReleaseDate"),
6967 package_family_name: yaml_string(root, "PackageFamilyName"),
6968 upgrade_code: yaml_string(root, "UpgradeCode"),
6969 platforms: yaml_string_list(root, "Platform"),
6970 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6971 switches,
6972 commands: yaml_string_list(root, "Commands"),
6973 package_dependencies: yaml_package_dependencies(root),
6974 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6975 }
6976}
6977
6978fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6981 let Some(value) = root.get(YamlValue::from(key)) else {
6982 return false;
6983 };
6984 match value {
6985 YamlValue::Bool(b) => *b,
6986 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6987 _ => false,
6988 }
6989}
6990
6991fn json_bool(value: &JsonValue, key: &str) -> bool {
6992 match value.get(key) {
6993 Some(JsonValue::Bool(b)) => *b,
6994 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6995 _ => false,
6996 }
6997}
6998
6999fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
7000 let mapping = root
7001 .get(YamlValue::from("InstallerSwitches"))
7002 .or_else(|| root.get(YamlValue::from("Switches")))
7003 .and_then(YamlValue::as_mapping);
7004 yaml_installer_switches_from_mapping(mapping)
7005}
7006
7007fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
7008 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
7009
7010 InstallerSwitches {
7011 silent: string("Silent"),
7012 silent_with_progress: string("SilentWithProgress"),
7013 interactive: string("Interactive"),
7014 custom: string("Custom"),
7015 log: string("Log"),
7016 install_location: string("InstallLocation"),
7017 }
7018}
7019
7020fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
7021 yaml_string(root, key).or_else(|| {
7022 root.get(YamlValue::from("DefaultLocale"))
7023 .and_then(YamlValue::as_mapping)
7024 .and_then(|mapping| yaml_string(mapping, key))
7025 })
7026}
7027
7028fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
7029 yaml_string(root, key)
7030}
7031
7032fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
7033 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
7034}
7035
7036fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
7037 match value {
7038 YamlValue::Null => None,
7039 YamlValue::Bool(value) => Some(value.to_string()),
7040 YamlValue::Number(value) => Some(value.to_string()),
7041 YamlValue::String(value) => Some(value.clone()),
7042 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
7043 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
7044 }
7045}
7046
7047fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
7048 root.get(YamlValue::from(key))
7049 .and_then(YamlValue::as_sequence)
7050 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
7051 .unwrap_or_default()
7052}
7053
7054fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
7055 root.get(YamlValue::from("Documentations"))
7056 .and_then(YamlValue::as_sequence)
7057 .map(|items| {
7058 items
7059 .iter()
7060 .filter_map(YamlValue::as_mapping)
7061 .filter_map(|item| {
7062 let url = yaml_string(item, "DocumentUrl")?;
7063 Some(Documentation {
7064 label: yaml_string(item, "DocumentLabel"),
7065 url,
7066 })
7067 })
7068 .collect()
7069 })
7070 .unwrap_or_default()
7071}
7072
7073fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
7074 root.get(YamlValue::from("Icons"))
7075 .and_then(YamlValue::as_sequence)
7076 .map(|items| {
7077 items
7078 .iter()
7079 .filter_map(YamlValue::as_mapping)
7080 .filter_map(|item| {
7081 let icon = PackageIcon {
7082 icon_url: yaml_string(item, "IconUrl"),
7083 icon_file_type: yaml_string(item, "IconFileType"),
7084 icon_resolution: yaml_string(item, "IconResolution"),
7085 icon_theme: yaml_string(item, "IconTheme"),
7086 icon_sha256: yaml_string(item, "IconSha256"),
7087 };
7088 (!icon.is_empty()).then_some(icon)
7089 })
7090 .collect()
7091 })
7092 .unwrap_or_default()
7093}
7094
7095fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
7096 root.get(YamlValue::from("Agreements"))
7097 .and_then(YamlValue::as_sequence)
7098 .map(|items| {
7099 items
7100 .iter()
7101 .filter_map(YamlValue::as_mapping)
7102 .map(|item| PackageAgreement {
7103 label: yaml_string(item, "AgreementLabel"),
7104 text: yaml_string(item, "Agreement"),
7105 url: yaml_string(item, "AgreementUrl"),
7106 })
7107 .collect()
7108 })
7109 .unwrap_or_default()
7110}
7111
7112fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
7113 root.get(YamlValue::from("Dependencies"))
7114 .and_then(YamlValue::as_mapping)
7115 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
7116 .and_then(YamlValue::as_sequence)
7117 .map(|items| {
7118 items
7119 .iter()
7120 .filter_map(YamlValue::as_mapping)
7121 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
7122 .collect()
7123 })
7124 .unwrap_or_default()
7125}
7126
7127fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
7128 root.get(YamlValue::from("NestedInstallerFiles"))
7129 .and_then(YamlValue::as_sequence)
7130 .map(|items| {
7131 items
7132 .iter()
7133 .filter_map(YamlValue::as_mapping)
7134 .filter_map(|item| {
7135 let relative = yaml_string(item, "RelativeFilePath")?;
7136 Some(NestedInstallerFile {
7137 relative_file_path: relative,
7138 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
7139 })
7140 })
7141 .collect()
7142 })
7143 .unwrap_or_default()
7144}
7145
7146fn json_string(value: &JsonValue, key: &str) -> Option<String> {
7147 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
7148}
7149
7150fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
7151 value
7152 .get(key)
7153 .and_then(JsonValue::as_array)
7154 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7155 .unwrap_or_default()
7156}
7157
7158fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7159 value
7160 .get("Documentations")
7161 .and_then(JsonValue::as_array)
7162 .map(|items| {
7163 items
7164 .iter()
7165 .filter_map(|item| {
7166 let url = json_string(item, "DocumentUrl")?;
7167 Some(Documentation {
7168 label: json_string(item, "DocumentLabel"),
7169 url,
7170 })
7171 })
7172 .collect()
7173 })
7174 .unwrap_or_default()
7175}
7176
7177fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
7178 value
7179 .get("Icons")
7180 .and_then(JsonValue::as_array)
7181 .map(|items| {
7182 items
7183 .iter()
7184 .filter_map(|item| {
7185 let icon = PackageIcon {
7186 icon_url: json_string(item, "IconUrl"),
7187 icon_file_type: json_string(item, "IconFileType"),
7188 icon_resolution: json_string(item, "IconResolution"),
7189 icon_theme: json_string(item, "IconTheme"),
7190 icon_sha256: json_string(item, "IconSha256"),
7191 };
7192 (!icon.is_empty()).then_some(icon)
7193 })
7194 .collect()
7195 })
7196 .unwrap_or_default()
7197}
7198
7199fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
7200 value
7201 .get("Agreements")
7202 .and_then(JsonValue::as_array)
7203 .map(|items| {
7204 items
7205 .iter()
7206 .map(|item| PackageAgreement {
7207 label: json_string(item, "AgreementLabel"),
7208 text: json_string(item, "Agreement"),
7209 url: json_string(item, "AgreementUrl"),
7210 })
7211 .collect()
7212 })
7213 .unwrap_or_default()
7214}
7215
7216fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
7217 value
7218 .get("Dependencies")
7219 .and_then(JsonValue::as_object)
7220 .and_then(|deps| deps.get("PackageDependencies"))
7221 .and_then(JsonValue::as_array)
7222 .map(|items| {
7223 items
7224 .iter()
7225 .filter_map(|item| json_string(item, "PackageIdentifier"))
7226 .collect()
7227 })
7228 .unwrap_or_default()
7229}
7230
7231fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
7232 value
7233 .get("NestedInstallerFiles")
7234 .and_then(JsonValue::as_array)
7235 .map(|items| {
7236 items
7237 .iter()
7238 .filter_map(|item| {
7239 let relative = json_string(item, "RelativeFilePath")?;
7240 Some(NestedInstallerFile {
7241 relative_file_path: relative,
7242 portable_command_alias: json_string(item, "PortableCommandAlias"),
7243 })
7244 })
7245 .collect()
7246 })
7247 .unwrap_or_default()
7248}
7249
7250fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
7251 let switches = value
7252 .get("InstallerSwitches")
7253 .or_else(|| value.get("Switches"))
7254 .and_then(JsonValue::as_object);
7255
7256 let string = |key: &str| {
7257 switches
7258 .and_then(|switches| switches.get(key))
7259 .and_then(JsonValue::as_str)
7260 .map(str::to_owned)
7261 };
7262
7263 InstallerSwitches {
7264 silent: string("Silent"),
7265 silent_with_progress: string("SilentWithProgress"),
7266 interactive: string("Interactive"),
7267 custom: string("Custom"),
7268 log: string("Log"),
7269 install_location: string("InstallLocation"),
7270 }
7271}
7272
7273fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
7274 for (key, value) in source {
7275 target.insert(key.clone(), value.clone());
7276 }
7277}
7278
7279fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
7280 let reader = Cursor::new(bytes.to_vec());
7281 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
7282 let mut file = archive
7283 .by_name(member_name)
7284 .with_context(|| format!("zip payload missing {member_name}"))?;
7285 let mut output = Vec::new();
7286 file.read_to_end(&mut output)
7287 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
7288 Ok(output)
7289}
7290
7291fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
7292 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
7293 return Ok(output);
7294 }
7295
7296 if !bytes.starts_with(b"CK")
7297 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
7298 {
7299 return decompress_mszyml_payload(&bytes[offset + 2..]);
7300 }
7301
7302 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
7303}
7304
7305fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
7306 let mut decoder = flate2::read::DeflateDecoder::new(payload);
7307 let mut output = String::new();
7308 decoder.read_to_string(&mut output)?;
7309 Ok(output)
7310}
7311
7312fn cache_control_max_age(response: &Response) -> u64 {
7313 response
7314 .headers()
7315 .get(reqwest::header::CACHE_CONTROL)
7316 .and_then(|value| value.to_str().ok())
7317 .and_then(|header| {
7318 header.split(',').find_map(|part| {
7319 let trimmed = part.trim();
7320 trimmed
7321 .strip_prefix("max-age=")
7322 .and_then(|value| value.parse::<u64>().ok())
7323 })
7324 })
7325 .unwrap_or(60)
7326}
7327
7328fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
7329 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
7330 let (major, minor) = major_minor(candidate);
7331 server_versions.iter().any(|version| {
7332 let (server_major, server_minor) = major_minor(version);
7333 server_major == major && server_minor == minor
7334 })
7335 })
7336}
7337
7338fn major_minor(version: &str) -> (String, String) {
7339 let mut parts = version.split('.');
7340 (
7341 parts.next().unwrap_or_default().to_owned(),
7342 parts.next().unwrap_or_default().to_owned(),
7343 )
7344}
7345
7346fn default_market() -> String {
7347 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
7348}
7349
7350fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
7351 let requested_locale = query.locale.as_deref();
7352 let requested_architecture = query.installer_architecture.as_deref();
7353 let requested_type = query.installer_type.as_deref();
7354 let requested_scope = query.install_scope.as_deref();
7355 let requested_platform = query.platform.as_deref();
7356 let requested_os_version = query.os_version.as_deref();
7357 let system_architecture = current_architecture();
7358 let system_platform = current_platform();
7359 let current_os_version = current_os_version();
7360
7361 installers
7362 .iter()
7363 .enumerate()
7364 .filter(|(_, installer)| {
7365 installer_matches_requested(
7366 installer,
7367 requested_type,
7368 requested_scope,
7369 requested_platform,
7370 requested_os_version,
7371 system_platform,
7372 current_os_version.as_deref(),
7373 )
7374 })
7375 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
7376 .max_by_key(|(index, installer)| {
7377 (
7378 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
7379 std::cmp::Reverse(*index),
7380 )
7381 })
7382 .map(|(_, installer)| installer)
7383 .cloned()
7384}
7385
7386fn installer_matches_requested(
7387 installer: &Installer,
7388 requested_type: Option<&str>,
7389 requested_scope: Option<&str>,
7390 requested_platform: Option<&str>,
7391 requested_os_version: Option<&str>,
7392 system_platform: Option<&str>,
7393 current_os_version: Option<&str>,
7394) -> bool {
7395 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
7396 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
7397 && installer_matches_platform(installer, requested_platform, system_platform)
7398 && installer_matches_os_version(installer, requested_os_version, current_os_version)
7399}
7400
7401fn installer_matches_platform(
7402 installer: &Installer,
7403 requested_platform: Option<&str>,
7404 system_platform: Option<&str>,
7405) -> bool {
7406 if installer.platforms.is_empty() {
7407 return true;
7408 }
7409
7410 let Some(effective_platform) = requested_platform.or(system_platform) else {
7411 return true;
7412 };
7413
7414 installer
7415 .platforms
7416 .iter()
7417 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
7418}
7419
7420fn installer_matches_os_version(
7421 installer: &Installer,
7422 requested_os_version: Option<&str>,
7423 current_os_version: Option<&str>,
7424) -> bool {
7425 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
7426 return true;
7427 };
7428
7429 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
7430 return true;
7431 };
7432
7433 match (
7434 parse_dotted_version(minimum_os_version),
7435 parse_dotted_version(actual_os_version),
7436 ) {
7437 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
7438 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
7439 }
7440}
7441
7442fn installer_matches_architecture(
7443 installer: &Installer,
7444 requested_architecture: Option<&str>,
7445 system_architecture: &str,
7446) -> bool {
7447 let Some(architecture) = installer.architecture.as_deref() else {
7448 return true;
7449 };
7450
7451 let architecture = architecture.to_ascii_lowercase();
7452 if let Some(requested) = requested_architecture {
7453 return architecture.eq_ignore_ascii_case(requested);
7454 }
7455
7456 preferred_architectures(system_architecture)
7457 .iter()
7458 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
7459}
7460
7461fn installer_rank(
7462 installer: &Installer,
7463 requested_locale: Option<&str>,
7464 requested_architecture: Option<&str>,
7465 system_architecture: &str,
7466) -> (i32, i32, i32) {
7467 let architecture_rank = architecture_rank(
7468 installer.architecture.as_deref(),
7469 requested_architecture.unwrap_or(system_architecture),
7470 requested_architecture.is_some(),
7471 system_architecture,
7472 );
7473 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
7474 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
7475 (architecture_rank, locale_rank, command_rank)
7476}
7477
7478fn architecture_rank(
7479 installer_architecture: Option<&str>,
7480 preferred_architecture: &str,
7481 strict: bool,
7482 system_architecture: &str,
7483) -> i32 {
7484 let Some(value) = installer_architecture else {
7485 return 0;
7486 };
7487 if value.eq_ignore_ascii_case(preferred_architecture) {
7488 return 5;
7489 }
7490 if value.eq_ignore_ascii_case("neutral") {
7491 return 4;
7492 }
7493 if strict {
7494 return -1;
7495 }
7496
7497 preferred_architectures(system_architecture)
7498 .iter()
7499 .rev()
7500 .position(|candidate| value.eq_ignore_ascii_case(candidate))
7501 .and_then(|index| i32::try_from(index).ok())
7502 .and_then(|index| index.checked_add(1))
7503 .unwrap_or(-1)
7504}
7505
7506fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
7510 if manifest.installers.is_empty() {
7511 return true;
7512 }
7513 let allowed = preferred_architectures(current_architecture());
7514 manifest.installers.iter().any(|installer| {
7515 let arch = installer.architecture.as_deref().unwrap_or("neutral");
7516 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
7517 })
7518}
7519
7520fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
7521 match system_architecture {
7522 "arm64" => &["arm64", "neutral", "x64", "x86"],
7523 "x64" => &["x64", "neutral", "x86"],
7524 "x86" => &["x86", "neutral"],
7525 _ => &["neutral"],
7526 }
7527}
7528
7529fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
7530 match (installer_locale, requested_locale) {
7531 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
7532 (Some(installer), Some(requested))
7533 if installer
7534 .split('-')
7535 .next()
7536 .zip(requested.split('-').next())
7537 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
7538 {
7539 2
7540 }
7541 (None, Some(_)) => 1,
7542 (Some(_), None) => 1,
7543 (None, None) => 0,
7544 _ => -1,
7545 }
7546}
7547
7548fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
7549 match requested {
7550 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
7551 None => true,
7552 }
7553}
7554
7555#[cfg(windows)]
7556fn current_architecture() -> &'static str {
7557 use std::sync::OnceLock;
7558 static CELL: OnceLock<&'static str> = OnceLock::new();
7559 CELL.get_or_init(|| {
7560 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
7565 let mut process_machine: u16 = 0;
7566 let mut native_machine: u16 = 0;
7567 let handle = unsafe { GetCurrentProcess() };
7570 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
7572 if ok != 0 {
7573 match native_machine {
7574 0xAA64 => return "arm64",
7575 0x8664 => return "x64",
7576 0x014C => return "x86",
7577 _ => {}
7578 }
7579 }
7580 compiled_architecture()
7581 })
7582}
7583
7584#[cfg(not(windows))]
7585fn current_architecture() -> &'static str {
7586 compiled_architecture()
7587}
7588
7589fn compiled_architecture() -> &'static str {
7590 match std::env::consts::ARCH {
7591 "x86_64" => "x64",
7592 "x86" => "x86",
7593 "aarch64" => "arm64",
7594 _ => "neutral",
7595 }
7596}
7597
7598fn current_platform() -> Option<&'static str> {
7599 if cfg!(windows) { Some("Windows.Desktop") } else { None }
7600}
7601
7602#[cfg(windows)]
7603fn current_os_version() -> Option<String> {
7604 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7605 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
7606 let build: String = key.get_value("CurrentBuildNumber").ok()?;
7607 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
7608 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
7609 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
7610 Some(format!("{major}.{minor}.{build}.{ubr}"))
7611}
7612
7613#[cfg(not(windows))]
7614fn current_os_version() -> Option<String> {
7615 None
7616}
7617
7618fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
7619 let mut parsed = Vec::new();
7620 for component in version.split('.') {
7621 if component.is_empty() {
7622 continue;
7623 }
7624 parsed.push(component.parse().ok()?);
7625 }
7626
7627 if parsed.is_empty() { None } else { Some(parsed) }
7628}
7629
7630fn select_v1_version(
7631 versions: &[V1VersionRow],
7632 requested_version: Option<&str>,
7633 requested_channel: Option<&str>,
7634 package_id: &str,
7635 source_name: &str,
7636) -> Result<V1VersionRow> {
7637 let mut ordered = versions.to_vec();
7638 ordered.sort_by(|left, right| {
7639 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7640 });
7641
7642 if let Some(version) = requested_version {
7643 let channel = requested_channel.unwrap_or_default();
7644 return ordered
7645 .iter()
7646 .find(|candidate| {
7647 candidate.version.eq_ignore_ascii_case(version)
7648 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7649 })
7650 .cloned()
7651 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7652 }
7653
7654 if let Some(channel) = requested_channel {
7655 return ordered
7656 .iter()
7657 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7658 .cloned()
7659 .ok_or_else(|| {
7660 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7661 });
7662 }
7663
7664 ordered
7665 .into_iter()
7666 .next()
7667 .ok_or_else(|| anyhow!("package had no versions"))
7668}
7669
7670fn select_v2_version(
7671 versions: &[PackageVersionDataEntry],
7672 requested_version: Option<&str>,
7673 requested_channel: Option<&str>,
7674 package_id: &str,
7675 source_name: &str,
7676) -> Result<PackageVersionDataEntry> {
7677 let mut ordered = versions.to_vec();
7678 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
7679
7680 if let Some(channel) = requested_channel {
7681 bail!(
7682 "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."
7683 );
7684 }
7685
7686 if let Some(version) = requested_version {
7687 return ordered
7688 .iter()
7689 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
7690 .cloned()
7691 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7692 }
7693
7694 ordered
7695 .into_iter()
7696 .next()
7697 .ok_or_else(|| anyhow!("package had no V2 versions"))
7698}
7699
7700fn missing_package_version(
7701 package_id: &str,
7702 source_name: &str,
7703 requested_version: &str,
7704 requested_channel: Option<&str>,
7705) -> anyhow::Error {
7706 anyhow!(MissingPackageVersionError {
7707 package_id: package_id.to_owned(),
7708 source_name: source_name.to_owned(),
7709 requested_version: requested_version.to_owned(),
7710 requested_channel: requested_channel.map(str::to_owned),
7711 })
7712}
7713
7714fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
7715 if let Some(refresh_error) = refresh.error.as_deref() {
7716 let missing_error = error.to_string();
7717 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
7718 }
7719
7720 Err(error)
7721}
7722
7723fn select_rest_version<'a>(
7724 versions: &'a [VersionKey],
7725 requested_version: Option<&str>,
7726 requested_channel: Option<&str>,
7727 package_id: &str,
7728 source_name: &str,
7729) -> Result<&'a VersionKey> {
7730 if let Some(version) = requested_version {
7731 let channel = requested_channel.unwrap_or_default();
7732 return versions
7733 .iter()
7734 .find(|candidate| {
7735 candidate.version.eq_ignore_ascii_case(version)
7736 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7737 })
7738 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7739 }
7740
7741 if let Some(channel) = requested_channel {
7742 return versions
7743 .iter()
7744 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7745 .ok_or_else(|| {
7746 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7747 });
7748 }
7749
7750 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
7751}
7752
7753fn sort_versions_desc(versions: &mut [VersionKey]) {
7754 versions.sort_by(|left, right| {
7755 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7756 });
7757}
7758
7759fn compare_version_and_channel(
7760 left_version: &str,
7761 left_channel: &str,
7762 right_version: &str,
7763 right_channel: &str,
7764) -> Ordering {
7765 compare_version(left_version, right_version).then_with(|| {
7766 left_channel
7767 .to_ascii_lowercase()
7768 .cmp(&right_channel.to_ascii_lowercase())
7769 })
7770}
7771
7772fn compare_version(left: &str, right: &str) -> Ordering {
7773 let left_parts = tokenize_version(left);
7774 let right_parts = tokenize_version(right);
7775 let max_len = left_parts.len().max(right_parts.len());
7776
7777 for index in 0..max_len {
7778 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
7779 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
7780 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
7781
7782 let ordering = if let Some((left_number, right_number)) = numeric {
7783 left_number.cmp(&right_number)
7784 } else {
7785 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
7786 };
7787
7788 if ordering != Ordering::Equal {
7789 return ordering;
7790 }
7791 }
7792
7793 Ordering::Equal
7794}
7795
7796fn tokenize_version(value: &str) -> Vec<String> {
7797 let mut parts = Vec::new();
7798 let mut current = String::new();
7799 let mut current_is_digit = None;
7800
7801 for ch in value.chars() {
7802 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
7803 if !current.is_empty() {
7804 parts.push(std::mem::take(&mut current));
7805 }
7806 current_is_digit = None;
7807 continue;
7808 }
7809
7810 let is_digit = ch.is_ascii_digit();
7811 match current_is_digit {
7812 Some(previous) if previous != is_digit => {
7813 parts.push(std::mem::take(&mut current));
7814 current.push(ch);
7815 current_is_digit = Some(is_digit);
7816 }
7817 Some(_) => current.push(ch),
7818 None => {
7819 current.push(ch);
7820 current_is_digit = Some(is_digit);
7821 }
7822 }
7823 }
7824
7825 if !current.is_empty() {
7826 parts.push(current);
7827 }
7828
7829 parts
7830}
7831
7832fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7833 if !hash_matches(Some(expected_hash), bytes) {
7834 bail!("downloaded file hash did not match expected SHA256");
7835 }
7836 Ok(())
7837}
7838
7839fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7840 let Some(expected_hash) = expected_hash else {
7841 return Ok(());
7842 };
7843 if ignore_security_hash {
7844 return Ok(());
7845 }
7846
7847 if !hash_matches(Some(expected_hash), bytes) {
7848 let actual = sha256_hex(bytes);
7849 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7850 }
7851 Ok(())
7852}
7853
7854fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7855 expected_hash
7856 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7857 .unwrap_or(true)
7858}
7859
7860fn sha256_hex(bytes: &[u8]) -> String {
7861 let digest = Sha256::digest(bytes);
7862 let mut output = String::with_capacity(digest.len() * 2);
7863 for byte in digest {
7864 output.push_str(&format!("{byte:02X}"));
7865 }
7866 output
7867}
7868
7869fn pins_db_path(app_root: &Path) -> PathBuf {
7870 if uses_packaged_layout(app_root) {
7871 app_root.join("pinning.db")
7872 } else {
7873 app_root.join("pins.db")
7874 }
7875}
7876
7877fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7878 let mut has_current_column = false;
7879 let mut has_legacy_column = false;
7880
7881 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
7882 match column_name.as_str() {
7883 "type" => has_current_column = true,
7884 "pin_type" => has_legacy_column = true,
7885 _ => {}
7886 }
7887 }
7888
7889 Ok(if has_current_column {
7890 Some("type")
7891 } else if has_legacy_column {
7892 Some("pin_type")
7893 } else {
7894 None
7895 })
7896}
7897
7898fn decode_pin_type(pin_type_int: i64) -> PinType {
7899 match pin_type_int {
7900 4 => PinType::Blocking,
7901 3 => PinType::Gating,
7902 _ => PinType::Pinning,
7903 }
7904}
7905
7906#[cfg(windows)]
7907fn dispatch_installer(
7908 installer_path: &Path,
7909 installer_type: &str,
7910 request: &InstallRequest,
7911 manifest: &Manifest,
7912 installer: &Installer,
7913) -> Result<i32> {
7914 use std::process::Command;
7915
7916 match installer_type {
7917 "msi" | "wix" => {
7918 let mut cmd = Command::new("msiexec");
7919 cmd.args(installer_command_arguments(
7920 installer_type,
7921 request,
7922 manifest,
7923 installer_path,
7924 installer,
7925 ));
7926 let status = cmd.status().context("failed to run msiexec")?;
7927 Ok(status.code().unwrap_or(-1))
7928 }
7929 "msix" | "appx" => {
7930 let mut cmd = Command::new("powershell");
7931 cmd.arg("-NoProfile")
7932 .arg("-Command")
7933 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7934 let status = cmd.status().context("failed to run Add-AppxPackage")?;
7935 Ok(status.code().unwrap_or(-1))
7936 }
7937 "zip" => {
7938 if installer
7944 .nested_installer_type
7945 .as_deref()
7946 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7947 {
7948 return install_portable(installer_path, request, manifest, installer);
7949 }
7950 let target = dirs::data_local_dir()
7951 .unwrap_or_else(|| PathBuf::from("."))
7952 .join("Programs");
7953 fs::create_dir_all(&target)?;
7954 let file = fs::File::open(installer_path)?;
7955 let mut archive = ZipArchive::new(file)?;
7956 archive.extract(&target)?;
7957 Ok(0)
7958 }
7959 "portable" => install_portable(installer_path, request, manifest, installer),
7960 _ => {
7962 let mut cmd = Command::new(installer_path);
7963 cmd.args(installer_command_arguments(
7964 installer_type,
7965 request,
7966 manifest,
7967 installer_path,
7968 installer,
7969 ));
7970 let status = cmd.status().context("failed to run installer")?;
7971 Ok(status.code().unwrap_or(-1))
7972 }
7973 }
7974}
7975
7976#[cfg(not(windows))]
7977fn dispatch_installer(
7978 _installer_path: &Path,
7979 _installer_type: &str,
7980 _request: &InstallRequest,
7981 _manifest: &Manifest,
7982 _installer: &Installer,
7983) -> Result<i32> {
7984 bail!("Installing packages is only supported on Windows")
7985}
7986
7987#[cfg(any(windows, test))]
7988fn installer_command_arguments(
7989 installer_type: &str,
7990 request: &InstallRequest,
7991 _manifest: &Manifest,
7992 installer_path: &Path,
7993 installer: &Installer,
7994) -> Vec<String> {
7995 if let Some(override_args) = request.override_args.as_deref() {
7996 return split_installer_switches(override_args);
7997 }
7998
7999 let mut args = Vec::new();
8000 match installer_type {
8001 "msi" | "wix" => {
8002 args.push("/i".to_owned());
8003 args.push(installer_path.display().to_string());
8004 }
8005 "msix" | "appx" | "zip" => return Vec::new(),
8006 _ => {}
8007 }
8008
8009 let configured = match request.mode {
8010 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
8011 InstallerMode::SilentWithProgress => installer
8012 .switches
8013 .silent_with_progress
8014 .as_deref()
8015 .or(installer.switches.silent.as_deref()),
8016 InstallerMode::Silent => installer
8017 .switches
8018 .silent
8019 .as_deref()
8020 .or(installer.switches.silent_with_progress.as_deref()),
8021 };
8022
8023 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
8024 args.extend(split_installer_switches(value));
8025 } else {
8026 args.extend(default_installer_arguments(installer_type, request.mode));
8027 }
8028
8029 append_switches(
8030 &mut args,
8031 resolve_template_switch(
8032 installer.switches.log.as_deref(),
8033 default_log_switch(installer_type),
8034 request.log_path.as_ref().map(|path| path.display().to_string()),
8035 "<LOGPATH>",
8036 ),
8037 );
8038 append_switches(&mut args, installer.switches.custom.clone());
8039 append_switches(&mut args, request.custom.clone());
8040 append_switches(
8041 &mut args,
8042 resolve_template_switch(
8043 installer.switches.install_location.as_deref(),
8044 default_install_location_switch(installer_type),
8045 request.install_location.clone(),
8046 "<INSTALLPATH>",
8047 ),
8048 );
8049 args
8050}
8051
8052#[cfg(any(windows, test))]
8053fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
8054 match mode {
8055 InstallerMode::Interactive => Vec::new(),
8056 InstallerMode::SilentWithProgress => match installer_type {
8057 "inno" => vec![
8058 "/SP-".to_owned(),
8059 "/SILENT".to_owned(),
8060 "/SUPPRESSMSGBOXES".to_owned(),
8061 "/NORESTART".to_owned(),
8062 ],
8063 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
8064 "nullsoft" | "nsis" => vec!["/S".to_owned()],
8065 _ => vec!["/SILENT".to_owned()],
8066 },
8067 InstallerMode::Silent => match installer_type {
8068 "inno" => vec![
8069 "/SP-".to_owned(),
8070 "/VERYSILENT".to_owned(),
8071 "/SUPPRESSMSGBOXES".to_owned(),
8072 "/NORESTART".to_owned(),
8073 ],
8074 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
8075 "nullsoft" | "nsis" => vec!["/S".to_owned()],
8076 _ => vec!["/S".to_owned()],
8077 },
8078 }
8079}
8080
8081#[cfg(any(windows, test))]
8082fn default_log_switch(installer_type: &str) -> Option<&'static str> {
8083 match installer_type {
8084 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
8085 "inno" => Some("/LOG=\"<LOGPATH>\""),
8086 _ => None,
8087 }
8088}
8089
8090#[cfg(any(windows, test))]
8091fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
8092 match installer_type {
8093 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
8094 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
8095 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
8096 _ => None,
8097 }
8098}
8099
8100#[cfg(any(windows, test))]
8101fn resolve_template_switch(
8102 manifest_value: Option<&str>,
8103 fallback: Option<&str>,
8104 replacement: Option<String>,
8105 token: &str,
8106) -> Option<String> {
8107 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
8108 let replacement = replacement?;
8109 Some(template.replace(token, &replacement))
8110}
8111
8112#[cfg(any(windows, test))]
8113fn append_switches(args: &mut Vec<String>, value: Option<String>) {
8114 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
8115 args.extend(split_installer_switches(&value));
8116 }
8117}
8118
8119#[cfg(any(windows, test))]
8120fn split_installer_switches(value: &str) -> Vec<String> {
8121 let mut args = Vec::new();
8122 let mut current = String::new();
8123 let mut in_quotes = false;
8124
8125 for ch in value.chars() {
8126 match ch {
8127 '"' => in_quotes = !in_quotes,
8128 c if c.is_whitespace() && !in_quotes => {
8129 if !current.is_empty() {
8130 args.push(std::mem::take(&mut current));
8131 }
8132 }
8133 _ => current.push(ch),
8134 }
8135 }
8136
8137 if !current.is_empty() {
8138 args.push(current);
8139 }
8140
8141 args
8142}
8143
8144#[cfg(windows)]
8145fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8146 if request.purge && request.preserve {
8147 bail!("--purge and --preserve cannot be used together.");
8148 }
8149
8150 if installed
8151 .installer_category
8152 .as_deref()
8153 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8154 {
8155 return uninstall_portable(installed, request);
8156 }
8157
8158 if (request.purge || request.preserve) && !request.force {
8159 bail!("--purge and --preserve are currently only supported for portable packages.");
8160 }
8161
8162 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
8163 return Ok(exit_code);
8164 }
8165
8166 if let Some(exit_code) = try_uninstall_msix(installed)? {
8167 return Ok(exit_code);
8168 }
8169
8170 bail!(
8171 "No uninstall command found for installed package '{}' ({})",
8172 installed.name,
8173 installed.local_id
8174 );
8175}
8176
8177#[cfg(windows)]
8178fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8179 use std::os::windows::process::CommandExt;
8180 use std::process::Command;
8181
8182 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8183 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8184
8185 let arp_paths = [
8186 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
8187 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
8188 ];
8189
8190 for root in [&hklm, &hkcu] {
8191 for arp_path in &arp_paths {
8192 if let Ok(key) = root.open_subkey(arp_path) {
8193 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
8194 if let Ok(subkey) = key.open_subkey(&subkey_name) {
8195 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
8196 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
8197 if !registry_entry_matches_installed_package(
8198 &subkey_name,
8199 &display_name,
8200 product_code.as_deref(),
8201 installed,
8202 ) {
8203 continue;
8204 }
8205
8206 if let Some(exit_code) =
8207 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
8208 {
8209 return Ok(Some(exit_code));
8210 }
8211
8212 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
8213 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
8214 subkey.get_value("UninstallString").ok()
8215 } else {
8216 quiet_uninstall_cmd
8217 .clone()
8218 .or_else(|| subkey.get_value("UninstallString").ok())
8219 }
8220 .context("No uninstall command found in registry")?;
8221
8222 if is_winget_uninstall_command(&uninstall_cmd)
8223 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
8224 {
8225 return Ok(Some(exit_code));
8226 }
8227
8228 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
8229 let uninstall_line = build_uninstall_command_with_mode(
8230 &uninstall_cmd,
8231 request.mode,
8232 quiet_uninstall_cmd.is_some(),
8233 log_path.as_deref(),
8234 );
8235 let mut cmd = Command::new("cmd");
8244 cmd.raw_arg("/S /C");
8245 cmd.raw_arg(format!("\"{uninstall_line}\""));
8246 let status = cmd.status().context("failed to run uninstaller")?;
8247 return Ok(Some(status.code().unwrap_or(-1)));
8248 }
8249 }
8250 }
8251 }
8252 }
8253
8254 Ok(None)
8255}
8256
8257#[cfg(windows)]
8258fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
8259 use std::process::Command;
8260
8261 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
8262 return Ok(None);
8263 }
8264
8265 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
8266 let mut cmd = Command::new("powershell");
8267 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
8268 package_full_name,
8269 &installed.package_family_names,
8270 ));
8271 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
8272 Ok(Some(status.code().unwrap_or(-1)))
8273}
8274
8275#[cfg(windows)]
8276fn arp_subkey_name(local_id: &str) -> Option<&str> {
8277 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
8278}
8279
8280#[cfg(windows)]
8281fn registry_entry_matches_installed_package(
8282 subkey_name: &str,
8283 display_name: &str,
8284 product_code: Option<&str>,
8285 installed: &ListMatch,
8286) -> bool {
8287 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
8288 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
8289 {
8290 return true;
8291 }
8292
8293 if installed.product_codes.iter().any(|code| {
8294 code.eq_ignore_ascii_case(subkey_name)
8295 || product_code
8296 .map(|value| code.eq_ignore_ascii_case(value))
8297 .unwrap_or(false)
8298 }) {
8299 return true;
8300 }
8301
8302 display_name.eq_ignore_ascii_case(&installed.name)
8303}
8304
8305#[cfg(all(windows, test))]
8306fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
8307 build_uninstall_command_with_mode(
8308 uninstall_cmd,
8309 if silent {
8310 InstallerMode::Silent
8311 } else {
8312 InstallerMode::Interactive
8313 },
8314 has_quiet_command,
8315 None,
8316 )
8317}
8318
8319#[cfg(windows)]
8320fn build_uninstall_command_with_mode(
8321 uninstall_cmd: &str,
8322 mode: InstallerMode,
8323 has_quiet_command: bool,
8324 log_path: Option<&str>,
8325) -> String {
8326 let uninstall_cmd = log_path
8327 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
8328 .unwrap_or_else(|| uninstall_cmd.to_owned());
8329 if mode == InstallerMode::Interactive || has_quiet_command {
8330 return uninstall_cmd;
8331 }
8332
8333 let lower = uninstall_cmd.to_ascii_lowercase();
8334 if is_winget_uninstall_command_lower(&lower) {
8335 return uninstall_cmd;
8336 }
8337 if lower.contains("/quiet")
8338 || lower.contains("/passive")
8339 || lower.contains("/verysilent")
8340 || lower.contains("/silent")
8341 || lower.contains(" /s")
8342 {
8343 return uninstall_cmd;
8344 }
8345
8346 format!("{uninstall_cmd} /S")
8347}
8348
8349#[cfg(windows)]
8350fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8351 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
8352 return Ok(None);
8353 };
8354
8355 let exit_code = run_winget_uninstall(&args)?;
8356 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
8357 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
8358 return Ok(Some(exit_code));
8359 };
8360
8361 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
8362 }
8363
8364 Ok(Some(exit_code))
8365}
8366
8367#[cfg(windows)]
8368fn run_winget_uninstall(args: &[String]) -> Result<i32> {
8369 use std::process::Command;
8370
8371 let status = Command::new("winget")
8372 .args(args)
8373 .status()
8374 .context("failed to run delegated winget uninstall")?;
8375 Ok(status.code().unwrap_or(-1))
8376}
8377
8378#[cfg(windows)]
8379fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
8380 build_winget_uninstall_arguments_with_scope(installed, request, true)
8381}
8382
8383#[cfg(windows)]
8384fn build_winget_uninstall_arguments_with_scope(
8385 installed: &ListMatch,
8386 request: &UninstallRequest,
8387 include_scope: bool,
8388) -> Option<Vec<String>> {
8389 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
8390 return None;
8391 }
8392
8393 let mut args = vec![
8394 "uninstall".to_owned(),
8395 "--id".to_owned(),
8396 installed.id.clone(),
8397 "--exact".to_owned(),
8398 "--disable-interactivity".to_owned(),
8399 ];
8400
8401 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
8402 args.push("--source".to_owned());
8403 args.push(source_name.to_owned());
8404 }
8405
8406 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
8407 args.push("--scope".to_owned());
8408 args.push(scope.to_owned());
8409 }
8410
8411 if request.mode == InstallerMode::Silent {
8412 args.push("--silent".to_owned());
8413 }
8414
8415 Some(args)
8416}
8417
8418#[cfg(windows)]
8419fn is_winget_uninstall_command(command: &str) -> bool {
8420 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
8421}
8422
8423#[cfg(windows)]
8424fn is_winget_uninstall_command_lower(command: &str) -> bool {
8425 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
8426}
8427
8428#[cfg(windows)]
8429fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
8430 let full_name_literal = package_full_name
8431 .map(|value| format!("'{}'", value.replace('\'', "''")))
8432 .unwrap_or_else(|| "$null".to_owned());
8433 let family_names_literal = if package_family_names.is_empty() {
8434 "@()".to_owned()
8435 } else {
8436 format!(
8437 "@({})",
8438 package_family_names
8439 .iter()
8440 .map(|value| format!("'{}'", value.replace('\'', "''")))
8441 .collect::<Vec<_>>()
8442 .join(", ")
8443 )
8444 };
8445
8446 format!(
8447 "$fullName = {full_name_literal}; \
8448 $familyNames = {family_names_literal}; \
8449 $targets = Get-AppxPackage | Where-Object {{ \
8450 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
8451 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
8452 if (-not $targets) {{ exit 1 }}; \
8453 $targets | Remove-AppxPackage"
8454 )
8455}
8456
8457#[cfg(windows)]
8458fn try_run_msi_uninstall(
8459 installed: &ListMatch,
8460 subkey_name: &str,
8461 product_code: Option<&str>,
8462 request: &UninstallRequest,
8463) -> Result<Option<i32>> {
8464 use std::process::Command;
8465
8466 let uninstall_code = installed
8467 .product_codes
8468 .iter()
8469 .map(String::as_str)
8470 .chain(product_code)
8471 .chain(Some(subkey_name))
8472 .find(|value| is_product_code_like(value));
8473
8474 let Some(uninstall_code) = uninstall_code else {
8475 return Ok(None);
8476 };
8477
8478 let mut cmd = Command::new("msiexec");
8479 cmd.arg("/x").arg(uninstall_code);
8480 match request.mode {
8481 InstallerMode::Interactive => {}
8482 InstallerMode::SilentWithProgress => {
8483 cmd.arg("/passive").arg("/norestart");
8484 }
8485 InstallerMode::Silent => {
8486 cmd.arg("/quiet").arg("/norestart");
8487 }
8488 }
8489 if let Some(log_path) = &request.log_path {
8490 cmd.arg("/log").arg(log_path);
8491 }
8492
8493 let status = cmd.status().context("failed to run msiexec uninstall")?;
8494 Ok(Some(status.code().unwrap_or(-1)))
8495}
8496
8497#[cfg(windows)]
8498fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8499 let known_symlink = read_portable_symlink_full_path(installed);
8505
8506 let Some(location) = installed.install_location.as_deref() else {
8507 if request.force {
8508 try_remove_portable_symlinks(installed, &known_symlink, None);
8509 try_remove_portable_registry_entry(installed);
8510 return Ok(0);
8511 }
8512 bail!(
8513 "Portable package '{}' does not expose an install location.",
8514 installed.name
8515 );
8516 };
8517
8518 if request.preserve {
8519 return Ok(0);
8520 }
8521
8522 let path = Path::new(location);
8523 let removed = if path.is_dir() {
8524 fs::remove_dir_all(path)?;
8525 true
8526 } else if path.is_file() {
8527 fs::remove_file(path)?;
8528 true
8529 } else {
8530 false
8531 };
8532
8533 if removed || request.force {
8534 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
8537 try_remove_portable_registry_entry(installed);
8538 return Ok(0);
8539 }
8540
8541 bail!("Portable package location not found: {location}")
8542}
8543
8544#[cfg(windows)]
8550fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
8551 const ARP_PREFIX: &str = r"ARP\";
8552 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
8553 let mut parts = rest.splitn(3, '\\');
8554 let scope = parts.next().unwrap_or("");
8555 let _arch = parts.next();
8556 let subkey_name = parts.next().unwrap_or("");
8557 if subkey_name.is_empty() {
8558 return None;
8559 }
8560 let hive = if scope.eq_ignore_ascii_case("User") {
8561 RegKey::predef(HKEY_CURRENT_USER)
8562 } else {
8563 RegKey::predef(HKEY_LOCAL_MACHINE)
8564 };
8565 let subkey = hive
8566 .open_subkey(format!(
8567 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
8568 ))
8569 .ok()?;
8570 return read_reg_string(&subkey, "PortableSymlinkFullPath");
8571 }
8572 None
8573}
8574
8575#[cfg(windows)]
8581fn try_remove_portable_symlinks(
8582 _installed: &ListMatch,
8583 known_symlink: &Option<String>,
8584 install_location: Option<&Path>,
8585) {
8586 if let Some(path) = known_symlink.as_deref() {
8587 let _ = fs::remove_file(path);
8588 }
8589
8590 let Some(install_dir) = install_location else { return };
8595
8596 let Some(local) = dirs::data_local_dir() else { return };
8597 let links_root = local.join("Microsoft").join("WinGet").join("Links");
8598 let Ok(entries) = fs::read_dir(&links_root) else { return };
8599 for entry in entries.flatten() {
8600 let path = entry.path();
8601 let Ok(target) = fs::read_link(&path) else { continue };
8602 if path_starts_with_case_insensitive(&target, install_dir) {
8603 let _ = fs::remove_file(&path);
8604 }
8605 }
8606}
8607
8608#[cfg(windows)]
8615fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
8616 let lower_components = |p: &Path| -> Vec<String> {
8617 p.components()
8618 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
8619 .collect()
8620 };
8621 let parent_components = lower_components(parent);
8622 let child_components = lower_components(child);
8623 if parent_components.is_empty() || parent_components.len() > child_components.len() {
8624 return false;
8625 }
8626 parent_components
8627 .iter()
8628 .zip(child_components.iter())
8629 .all(|(a, b)| a == b)
8630}
8631
8632#[cfg(windows)]
8639fn try_remove_portable_registry_entry(installed: &ListMatch) {
8640 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
8643 let mut parts = rest.splitn(3, '\\');
8645 let scope = parts.next().unwrap_or("");
8646 let _arch = parts.next();
8647 let subkey_name = parts.next().unwrap_or("");
8648 if subkey_name.is_empty() {
8649 return;
8650 }
8651 let is_hkcu = scope.eq_ignore_ascii_case("User");
8652 vec![(
8653 is_hkcu,
8654 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
8655 )]
8656 } else {
8657 let mut found = Vec::new();
8659 for (is_hkcu, hive) in [
8660 (true, RegKey::predef(HKEY_CURRENT_USER)),
8661 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
8662 ] {
8663 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
8664 continue;
8665 };
8666 for name in uninstall.enum_keys().flatten() {
8667 let Ok(subkey) = uninstall.open_subkey(&name) else {
8668 continue;
8669 };
8670 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
8671 found.push((
8672 is_hkcu,
8673 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
8674 ));
8675 }
8676 }
8677 }
8678 found
8679 };
8680
8681 for (is_hkcu, path) in candidates {
8682 let hive = if is_hkcu {
8683 RegKey::predef(HKEY_CURRENT_USER)
8684 } else {
8685 RegKey::predef(HKEY_LOCAL_MACHINE)
8686 };
8687 let _ = hive.delete_subkey_all(&path);
8688 }
8689}
8690
8691#[cfg(windows)]
8692fn is_product_code_like(value: &str) -> bool {
8693 value.starts_with('{') && value.ends_with('}')
8694}
8695
8696#[cfg(not(windows))]
8697fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
8698 bail!("Uninstalling packages is only supported on Windows")
8699}
8700
8701#[cfg(windows)]
8723fn install_portable(
8724 installer_path: &Path,
8725 request: &InstallRequest,
8726 manifest: &Manifest,
8727 installer: &Installer,
8728) -> Result<i32> {
8729 let source_identifier = portable_source_identifier(&request.query);
8730 let existing_entry = read_existing_portable_entry(&manifest.id);
8731 let subkey_name = existing_entry
8734 .as_ref()
8735 .map(|entry| entry.subkey_name.clone())
8736 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
8737
8738 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
8739
8740 let prev_dir_created = existing_entry
8745 .as_ref()
8746 .and_then(|entry| entry.install_directory_created)
8747 == Some(1);
8748 let dir_existed = target_dir.is_dir();
8749 if prev_dir_created && dir_existed {
8750 clean_directory_contents(&target_dir).with_context(|| {
8751 format!(
8752 "failed to clean existing portable install directory: {}",
8753 target_dir.display()
8754 )
8755 })?;
8756 }
8757
8758 fs::create_dir_all(&target_dir)
8759 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
8760 let install_directory_created = !dir_existed || prev_dir_created;
8764
8765 let installer_type = installer.installer_type.as_deref().unwrap_or("");
8766 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
8767 let file = fs::File::open(installer_path)
8768 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
8769 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
8770 archive
8771 .extract(&target_dir)
8772 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
8773 installer
8777 .nested_installer_files
8778 .first()
8779 .map(|file| target_dir.join(&file.relative_file_path))
8780 .unwrap_or_else(|| target_dir.clone())
8781 } else {
8782 let basename = installer_path
8786 .file_name()
8787 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
8788 let copied = target_dir.join(basename);
8789 fs::copy(installer_path, &copied)
8790 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
8791 copied
8792 };
8793
8794 let alias = determine_portable_alias(installer);
8799 let symlink_full_path = match alias.as_deref() {
8800 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
8801 _ => None,
8802 };
8803
8804 let added_to_path = if symlink_full_path.is_some() {
8808 try_add_links_dir_to_user_path()
8809 } else {
8810 false
8811 };
8812
8813 write_portable_arp_entry(&PortableArpEntry {
8814 subkey_name: &subkey_name,
8815 install_location: &target_dir,
8816 portable_target_full_path: &portable_target_full_path,
8817 portable_symlink_full_path: symlink_full_path.as_deref(),
8818 install_directory_created,
8819 added_to_path,
8820 source_identifier: &source_identifier,
8821 manifest,
8822 })?;
8823
8824 Ok(0)
8825}
8826
8827#[cfg(windows)]
8828struct PortableArpEntry<'a> {
8829 subkey_name: &'a str,
8830 install_location: &'a Path,
8831 portable_target_full_path: &'a Path,
8832 portable_symlink_full_path: Option<&'a Path>,
8833 install_directory_created: bool,
8834 added_to_path: bool,
8835 source_identifier: &'a str,
8836 manifest: &'a Manifest,
8837}
8838
8839#[cfg(windows)]
8845fn determine_portable_alias(installer: &Installer) -> Option<String> {
8846 installer
8847 .nested_installer_files
8848 .iter()
8849 .find_map(|file| file.portable_command_alias.clone())
8850 .or_else(|| installer.commands.first().cloned())
8851}
8852
8853#[cfg(windows)]
8854fn winget_links_dir() -> PathBuf {
8855 dirs::data_local_dir()
8856 .unwrap_or_else(|| PathBuf::from("."))
8857 .join("Microsoft")
8858 .join("WinGet")
8859 .join("Links")
8860}
8861
8862#[cfg(windows)]
8867fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8868 use std::os::windows::fs::symlink_file;
8869 let links_dir = winget_links_dir();
8870 if fs::create_dir_all(&links_dir).is_err() {
8871 return None;
8872 }
8873 let link_path = links_dir.join(format!("{alias}.exe"));
8874
8875 if link_path.symlink_metadata().is_ok() {
8878 let _ = fs::remove_file(&link_path);
8879 }
8880
8881 symlink_file(target, &link_path).ok().map(|()| link_path)
8886}
8887
8888#[cfg(windows)]
8894fn try_add_links_dir_to_user_path() -> bool {
8895 let links_dir = winget_links_dir();
8896 let links_str = links_dir.to_string_lossy().to_string();
8897 let normalized_links = links_str.to_ascii_lowercase();
8898
8899 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8900 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8901 return false;
8902 };
8903 let existing: String = env_key.get_value("Path").unwrap_or_default();
8904 let already_present = existing
8905 .split(';')
8906 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8907 if already_present {
8908 return false;
8909 }
8910
8911 let new_path = if existing.is_empty() {
8912 links_str
8913 } else if existing.ends_with(';') {
8914 format!("{existing}{links_str}")
8915 } else {
8916 format!("{existing};{links_str}")
8917 };
8918
8919 if env_key.set_value("Path", &new_path).is_err() {
8920 return false;
8921 }
8922
8923 broadcast_environment_change();
8924 true
8925}
8926
8927#[cfg(windows)]
8928fn broadcast_environment_change() {
8929 use std::ffi::OsStr;
8930 use std::os::windows::ffi::OsStrExt;
8931
8932 use windows_sys::Win32::UI::WindowsAndMessaging::{
8933 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8934 };
8935
8936 let param: Vec<u16> = OsStr::new("Environment")
8937 .encode_wide()
8938 .chain(std::iter::once(0))
8939 .collect();
8940 unsafe {
8944 let mut result: usize = 0;
8945 SendMessageTimeoutW(
8946 HWND_BROADCAST,
8947 WM_SETTINGCHANGE,
8948 0usize,
8949 param.as_ptr() as isize,
8950 SMTO_ABORTIFHUNG,
8951 5000,
8952 &mut result,
8953 );
8954 }
8955}
8956
8957#[cfg(windows)]
8962fn portable_source_identifier(query: &PackageQuery) -> String {
8963 match query.source.as_deref() {
8964 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8965 Some(name) if !name.is_empty() => name.to_owned(),
8966 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8967 }
8968}
8969
8970#[cfg(windows)]
8971fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8972 format!("{package_id}_{source_identifier}")
8973}
8974
8975#[cfg(windows)]
8976struct ExistingPortableEntry {
8977 install_location: Option<PathBuf>,
8978 install_directory_created: Option<u32>,
8979 subkey_name: String,
8980}
8981
8982#[cfg(windows)]
8983fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8984 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8985 let uninstall = hkcu
8986 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8987 .ok()?;
8988 for name in uninstall.enum_keys().flatten() {
8989 let Ok(subkey) = uninstall.open_subkey(&name) else {
8990 continue;
8991 };
8992 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8993 continue;
8994 }
8995 if !read_reg_string(&subkey, "WinGetInstallerType")
8996 .as_deref()
8997 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8998 {
8999 continue;
9000 }
9001 return Some(ExistingPortableEntry {
9002 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
9003 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
9004 subkey_name: name,
9005 });
9006 }
9007 None
9008}
9009
9010#[cfg(windows)]
9011fn resolve_portable_install_location(
9012 request: &InstallRequest,
9013 existing: &Option<ExistingPortableEntry>,
9014 subkey_name: &str,
9015) -> PathBuf {
9016 resolve_portable_install_location_pure(
9017 request.install_location.as_deref(),
9018 existing
9019 .as_ref()
9020 .and_then(|e| e.install_location.as_deref())
9021 .and_then(Path::to_str),
9022 subkey_name,
9023 &default_user_portable_root(),
9024 )
9025}
9026
9027#[cfg(windows)]
9032fn resolve_portable_install_location_pure(
9033 request_location: Option<&str>,
9034 existing_location: Option<&str>,
9035 subkey_name: &str,
9036 default_user_portable_root: &Path,
9037) -> PathBuf {
9038 if let Some(custom) = request_location
9039 && !custom.is_empty()
9040 {
9041 return PathBuf::from(custom);
9042 }
9043 if let Some(loc) = existing_location
9044 && !loc.is_empty()
9045 {
9046 return PathBuf::from(loc);
9047 }
9048 default_user_portable_root.join(subkey_name)
9049}
9050
9051#[cfg(windows)]
9052fn default_user_portable_root() -> PathBuf {
9053 dirs::data_local_dir()
9054 .unwrap_or_else(|| PathBuf::from("."))
9055 .join("Microsoft")
9056 .join("WinGet")
9057 .join("Packages")
9058}
9059
9060#[cfg(windows)]
9061fn clean_directory_contents(dir: &Path) -> Result<()> {
9062 if !dir.is_dir() {
9063 return Ok(());
9064 }
9065 for entry in fs::read_dir(dir)? {
9066 let entry = entry?;
9067 let path = entry.path();
9068 if path.is_dir() {
9069 fs::remove_dir_all(&path)?;
9070 } else {
9071 fs::remove_file(&path)?;
9072 }
9073 }
9074 Ok(())
9075}
9076
9077#[cfg(windows)]
9081fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
9082 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9083 let uninstall_path = format!(
9084 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
9085 entry.subkey_name
9086 );
9087 let (subkey, _) = hkcu
9088 .create_subkey(&uninstall_path)
9089 .context("failed to create portable ARP registry subkey")?;
9090
9091 let manifest = entry.manifest;
9092 let install_location_str = entry.install_location.to_string_lossy().to_string();
9093 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
9094 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
9095 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
9096 subkey.set_value("InstallLocation", &install_location_str)?;
9097 subkey.set_value(
9098 "PortableTargetFullPath",
9099 &entry.portable_target_full_path.to_string_lossy().to_string(),
9100 )?;
9101 if let Some(symlink_path) = entry.portable_symlink_full_path {
9102 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
9103 }
9104 subkey.set_value(
9105 "InstallDirectoryAddedToPath",
9106 &(if entry.added_to_path { 1u32 } else { 0u32 }),
9107 )?;
9108 subkey.set_value(
9109 "InstallDirectoryCreated",
9110 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
9111 )?;
9112 subkey.set_value(
9113 "DisplayName",
9114 &if manifest.name.is_empty() {
9115 manifest.id.clone()
9116 } else {
9117 manifest.name.clone()
9118 },
9119 )?;
9120 subkey.set_value("DisplayVersion", &manifest.version)?;
9121 if let Some(publisher) = manifest.publisher.as_ref() {
9122 subkey.set_value("Publisher", publisher)?;
9123 }
9124 subkey.set_value(
9125 "UninstallString",
9126 &format!("winget uninstall --product-code {}", entry.subkey_name),
9127 )?;
9128 let today = Utc::now().format("%Y%m%d").to_string();
9129 subkey.set_value("InstallDate", &today)?;
9130 if let Some(url) = manifest.package_url.as_ref() {
9131 subkey.set_value("URLInfoAbout", url)?;
9132 }
9133
9134 Ok(())
9135}
9136
9137#[cfg(test)]
9138mod tests {
9139 use std::io::{Read as _, Write};
9140 use std::net::{SocketAddr, TcpListener, TcpStream};
9141 use std::path::{Path, PathBuf};
9142 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
9143 use std::thread::{self, JoinHandle};
9144
9145 use flate2::Compression;
9146 use flate2::write::DeflateEncoder;
9147 use zip::write::SimpleFileOptions;
9148
9149 use super::*;
9150
9151 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
9152 Manifest {
9153 id: id.to_owned(),
9154 name: name.to_owned(),
9155 version: version.to_owned(),
9156 channel: String::new(),
9157 publisher: Some("Acme".to_owned()),
9158 description: Some("desc".to_owned()),
9159 moniker: None,
9160 package_url: None,
9161 publisher_url: None,
9162 publisher_support_url: None,
9163 license: None,
9164 license_url: None,
9165 privacy_url: None,
9166 author: None,
9167 copyright: None,
9168 copyright_url: None,
9169 release_notes: None,
9170 release_notes_url: None,
9171 tags: Vec::new(),
9172 agreements: Vec::new(),
9173 package_dependencies: Vec::new(),
9174 documentation: Vec::new(),
9175 icons: Vec::new(),
9176 installers: Vec::new(),
9177 require_explicit_upgrade: false,
9178 }
9179 }
9180
9181 #[test]
9182 fn derive_download_filename_matches_winget_pattern() {
9183 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
9184 let installer = Installer {
9185 architecture: Some("arm64".to_owned()),
9186 installer_type: Some("exe".to_owned()),
9187 scope: Some("user".to_owned()),
9188 locale: Some("en-US".to_owned()),
9189 ..Installer::default()
9190 };
9191 assert_eq!(
9192 derive_download_manifest_filename(&manifest, &installer),
9193 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
9194 );
9195 }
9196
9197 #[test]
9198 fn derive_download_filename_omits_optional_components() {
9199 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
9200 let installer = Installer {
9201 architecture: Some("x64".to_owned()),
9202 installer_type: Some("exe".to_owned()),
9203 ..Installer::default()
9204 };
9205 assert_eq!(
9207 derive_download_manifest_filename(&manifest, &installer),
9208 "7-Zip_26.01_X64_exe.yaml"
9209 );
9210 }
9211
9212 #[test]
9213 fn derive_download_filename_skips_unknown_version() {
9214 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
9215 let installer = Installer {
9216 architecture: Some("neutral".to_owned()),
9217 installer_type: Some("msi".to_owned()),
9218 ..Installer::default()
9219 };
9220 assert_eq!(
9221 derive_download_manifest_filename(&manifest, &installer),
9222 "Test_Neutral_msi.yaml"
9223 );
9224 }
9225
9226 #[test]
9227 fn derive_download_filename_sanitizes_illegal_chars() {
9228 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
9229 let installer = Installer {
9230 architecture: Some("x64".to_owned()),
9231 installer_type: Some("exe".to_owned()),
9232 ..Installer::default()
9233 };
9234 let filename = derive_download_manifest_filename(&manifest, &installer);
9236 assert!(!filename.contains('/') && !filename.contains(':'));
9237 assert!(filename.starts_with("Some_Bad_Name_1.0"));
9238 }
9239
9240 #[test]
9241 fn build_download_manifest_yaml_overrides_identity_fields() {
9242 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9243 let documents = serde_json::json!({
9246 "PackageIdentifier": "stale-id",
9247 "PackageVersion": "",
9248 "PackageName": "Old Name",
9249 "Channel": "preview",
9250 "Installers": [
9251 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
9252 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
9253 ],
9254 });
9255 let installer = Installer {
9256 architecture: Some("x64".to_owned()),
9257 installer_type: Some("exe".to_owned()),
9258 url: Some("https://example.com/a".to_owned()),
9259 ..Installer::default()
9260 };
9261 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9262 assert!(yaml.contains("PackageIdentifier: App.Id"));
9263 assert!(yaml.contains("PackageVersion: 2.0.0"));
9264 assert!(yaml.contains("PackageName: App Name"));
9265 assert!(yaml.contains("ManifestType: merged"));
9266 assert!(!yaml.contains("Channel: preview"));
9269 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
9271 assert!(yaml.contains("https://example.com/a"));
9272 assert!(!yaml.contains("https://example.com/b"));
9273 }
9274
9275 #[test]
9276 fn build_download_manifest_yaml_preserves_typed_channel() {
9277 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9278 manifest.channel = "beta".to_owned();
9279 let documents = serde_json::json!({
9280 "PackageIdentifier": "App.Id",
9281 "PackageVersion": "2.0.0",
9282 "Installers": [],
9283 });
9284 let installer = Installer::default();
9285 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9286 assert!(yaml.contains("Channel: beta"));
9287 }
9288
9289 fn temp_app_root(label: &str) -> PathBuf {
9290 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
9291 std::env::temp_dir().join(format!(
9292 "pinget-rs-tests-{label}-{}-{}",
9293 std::process::id(),
9294 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
9295 ))
9296 }
9297
9298 #[cfg(windows)]
9299 #[test]
9300 fn path_prefix_check_rejects_string_prefix_collisions() {
9301 assert!(!path_starts_with_case_insensitive(
9305 Path::new(r"C:\Foobar\bin.exe"),
9306 Path::new(r"C:\Foo"),
9307 ));
9308 assert!(!path_starts_with_case_insensitive(
9309 Path::new(r"C:\Foo\bin.exe.bak"),
9310 Path::new(r"C:\Foo\bin.exe"),
9311 ));
9312 }
9313
9314 #[cfg(windows)]
9315 #[test]
9316 fn path_prefix_check_accepts_real_children_and_self() {
9317 assert!(path_starts_with_case_insensitive(
9319 Path::new(r"C:\Foo\sub\bin.exe"),
9320 Path::new(r"C:\Foo"),
9321 ));
9322 assert!(path_starts_with_case_insensitive(
9324 Path::new(r"C:\Foo\bin.exe"),
9325 Path::new(r"C:\Foo\bin.exe"),
9326 ));
9327 }
9328
9329 #[cfg(windows)]
9330 #[test]
9331 fn path_prefix_check_is_case_insensitive() {
9332 assert!(path_starts_with_case_insensitive(
9335 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
9336 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
9337 ));
9338 }
9339
9340 #[cfg(windows)]
9341 #[test]
9342 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
9343 let query = PackageQuery {
9344 source: Some("winget".to_owned()),
9345 ..PackageQuery::default()
9346 };
9347 assert_eq!(
9348 portable_source_identifier(&query),
9349 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9350 );
9351 let query = PackageQuery {
9353 source: Some("WINGET".to_owned()),
9354 ..PackageQuery::default()
9355 };
9356 assert_eq!(
9357 portable_source_identifier(&query),
9358 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9359 );
9360 }
9361
9362 #[cfg(windows)]
9363 #[test]
9364 fn portable_source_identifier_passes_through_custom_sources() {
9365 let query = PackageQuery {
9366 source: Some("internal-feed".to_owned()),
9367 ..PackageQuery::default()
9368 };
9369 assert_eq!(portable_source_identifier(&query), "internal-feed");
9370 }
9371
9372 #[cfg(windows)]
9373 #[test]
9374 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
9375 let query = PackageQuery {
9376 source: None,
9377 ..PackageQuery::default()
9378 };
9379 assert_eq!(
9380 portable_source_identifier(&query),
9381 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9382 );
9383 let query = PackageQuery {
9384 source: Some(String::new()),
9385 ..PackageQuery::default()
9386 };
9387 assert_eq!(
9388 portable_source_identifier(&query),
9389 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9390 );
9391 }
9392
9393 #[cfg(windows)]
9394 #[test]
9395 fn portable_subkey_name_matches_winget_format() {
9396 assert_eq!(
9397 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
9398 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
9399 );
9400 }
9401
9402 #[cfg(windows)]
9403 #[test]
9404 fn determine_portable_alias_prefers_nested_installer_alias() {
9405 let installer = Installer {
9406 nested_installer_files: vec![NestedInstallerFile {
9407 relative_file_path: "ripgrep/rg.exe".to_owned(),
9408 portable_command_alias: Some("rg".to_owned()),
9409 }],
9410 commands: vec!["other-name".to_owned()],
9411 ..Installer::default()
9412 };
9413 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
9414 }
9415
9416 #[cfg(windows)]
9417 #[test]
9418 fn determine_portable_alias_falls_back_to_commands() {
9419 let installer = Installer {
9420 commands: vec!["nuget".to_owned()],
9422 ..Installer::default()
9423 };
9424 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
9425
9426 let installer = Installer {
9428 nested_installer_files: vec![NestedInstallerFile {
9429 relative_file_path: "subdir/bin.exe".to_owned(),
9430 portable_command_alias: None,
9431 }],
9432 commands: vec!["bin".to_owned()],
9433 ..Installer::default()
9434 };
9435 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
9436 }
9437
9438 #[cfg(windows)]
9439 #[test]
9440 fn determine_portable_alias_returns_none_without_alias_or_commands() {
9441 let installer = Installer::default();
9442 assert!(determine_portable_alias(&installer).is_none());
9443 }
9444
9445 #[cfg(windows)]
9446 #[test]
9447 fn resolve_portable_install_location_prefers_request_override() {
9448 let result = resolve_portable_install_location_pure(
9449 Some(r"D:\Tools\rg"),
9450 Some(r"C:\Software\rg-test"),
9451 "Sub_Source",
9452 Path::new(r"C:\default"),
9453 );
9454 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
9455 }
9456
9457 #[cfg(windows)]
9458 #[test]
9459 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
9460 let result = resolve_portable_install_location_pure(
9464 None,
9465 Some(r"C:\Software\rg-test"),
9466 "Sub_Source",
9467 Path::new(r"C:\default"),
9468 );
9469 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
9470 }
9471
9472 #[cfg(windows)]
9473 #[test]
9474 fn resolve_portable_install_location_falls_back_to_default_root() {
9475 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
9476 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9477 }
9478
9479 #[cfg(windows)]
9480 #[test]
9481 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
9482 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
9485 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9486 }
9487
9488 #[cfg(windows)]
9489 #[test]
9490 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
9491 let root = std::env::temp_dir().join(format!(
9492 "pinget-clean-test-{}",
9493 Utc::now().timestamp_nanos_opt().unwrap_or_default()
9494 ));
9495 fs::create_dir_all(&root).expect("create temp dir");
9496 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
9497 fs::create_dir_all(root.join("nested")).expect("create nested dir");
9498 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
9499
9500 clean_directory_contents(&root).expect("clean root contents");
9501
9502 assert!(root.is_dir(), "root dir itself must survive");
9503 assert_eq!(
9504 fs::read_dir(&root).expect("read root").count(),
9505 0,
9506 "root must be empty after clean"
9507 );
9508 let _ = fs::remove_dir_all(&root);
9509 }
9510
9511 #[cfg(windows)]
9512 #[test]
9513 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
9514 let bogus = std::env::temp_dir().join(format!(
9515 "pinget-clean-test-bogus-{}",
9516 Utc::now().timestamp_nanos_opt().unwrap_or_default()
9517 ));
9518 assert!(!bogus.exists());
9519 clean_directory_contents(&bogus).expect("clean nonexistent dir");
9520 assert!(!bogus.exists());
9522 }
9523
9524 #[cfg(windows)]
9525 #[test]
9526 fn path_prefix_check_handles_mixed_separators() {
9527 assert!(path_starts_with_case_insensitive(
9530 Path::new(r"C:\Foo\sub/bin.exe"),
9531 Path::new(r"C:\Foo"),
9532 ));
9533 }
9534
9535 #[test]
9536 fn repository_options_capture_custom_host_settings() {
9537 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
9538 .with_user_agent("pinget-rs-tests/1.0")
9539 .with_pre_indexed_source_auto_update_interval(None);
9540
9541 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
9542 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
9543 assert!(options.pre_indexed_source_auto_update_interval.is_none());
9544 }
9545
9546 #[test]
9547 fn dependency_install_requests_are_silent_and_not_forced() {
9548 let mut parent = InstallRequest::new(PackageQuery {
9549 source: Some("winget".to_owned()),
9550 ..PackageQuery::default()
9551 });
9552 parent.mode = InstallerMode::SilentWithProgress;
9553 parent.force = true;
9554 parent.accept_package_agreements = true;
9555 parent.ignore_security_hash = true;
9556 parent.dependency_source = Some("dependencies".to_owned());
9557
9558 let dependency =
9559 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
9560
9561 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
9562 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
9563 assert_eq!(dependency.mode, InstallerMode::Silent);
9564 assert!(!dependency.force);
9565 assert!(dependency.no_upgrade);
9566 assert!(dependency.accept_package_agreements);
9567 assert!(dependency.ignore_security_hash);
9568 }
9569
9570 #[test]
9571 fn storage_paths_use_configured_app_root() {
9572 let app_root = PathBuf::from(r"C:\temp\pinget-test");
9573 let source = SourceRecord {
9574 name: "winget/test".to_owned(),
9575 kind: SourceKind::PreIndexed,
9576 arg: "https://example.com/cache".to_owned(),
9577 identifier: "Test.Source".to_owned(),
9578 trust_level: "None".to_owned(),
9579 explicit: false,
9580 priority: 0,
9581 last_update: None,
9582 source_version: None,
9583 };
9584
9585 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
9586 assert_eq!(
9587 source_state_dir(&app_root, &source),
9588 app_root.join("sources").join("winget_test")
9589 );
9590 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
9591 }
9592
9593 #[cfg(windows)]
9594 #[test]
9595 fn packaged_layout_paths_resolve_from_packaged_app_root() {
9596 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
9597 let app_root = local_app_data
9598 .join("Packages")
9599 .join(PACKAGED_FAMILY_NAME)
9600 .join("LocalState");
9601 assert!(uses_packaged_layout(&app_root));
9602 assert!(
9603 user_settings_path(&app_root)
9604 .display()
9605 .to_string()
9606 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
9607 );
9608 assert!(
9609 packaged_file_cache_root(&app_root)
9610 .display()
9611 .to_string()
9612 .ends_with(&format!(
9613 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
9614 PACKAGED_FAMILY_NAME
9615 ))
9616 );
9617 }
9618
9619 #[cfg(windows)]
9620 #[test]
9621 fn default_app_root_outside_package_avoids_packaged_layout() {
9622 let prior = std::env::var_os("PINGET_APPROOT");
9626 unsafe { std::env::remove_var("PINGET_APPROOT") };
9628
9629 let result = default_app_root();
9630
9631 if let Some(prior) = prior {
9632 unsafe { std::env::set_var("PINGET_APPROOT", prior) };
9634 }
9635
9636 let app_root = result.expect("default app root");
9637 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
9638 assert!(
9639 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
9640 "unexpected fallback app root: {app_root:?}"
9641 );
9642 }
9643
9644 #[test]
9645 fn packaged_source_stream_overlays_defaults_and_metadata() {
9646 let store = parse_packaged_source_store(
9647 Some(
9648 "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",
9649 ),
9650 Some(
9651 "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n",
9652 ),
9653 )
9654 .expect("packaged store");
9655
9656 assert!(!store.sources.iter().any(|source| source.name == "winget"));
9657 let corp = store
9658 .sources
9659 .iter()
9660 .find(|source| source.name == "corp")
9661 .expect("corp source");
9662 assert_eq!(corp.kind, SourceKind::Rest);
9663 assert_eq!(corp.identifier, "Contoso.Rest");
9664 assert_eq!(corp.trust_level, "Trusted");
9665 assert!(corp.explicit);
9666 assert_eq!(corp.priority, 7);
9667 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
9668 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
9669 }
9670
9671 #[test]
9672 fn system_winget_export_parses_json_lines() {
9673 let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9674{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
9675"#;
9676
9677 let sources = parse_system_winget_source_export(output).expect("source export");
9678
9679 assert_eq!(
9680 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
9681 vec!["contoso", "contoso-cache"]
9682 );
9683 assert_eq!(sources[0].kind, SourceKind::Rest);
9684 assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
9685 assert_eq!(sources[0].identifier, "api.contoso.test");
9686 assert_eq!(sources[0].trust_level, "Trusted");
9687 assert!(!sources[0].explicit);
9688 assert_eq!(sources[1].kind, SourceKind::PreIndexed);
9689 assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
9690 assert!(sources[1].explicit);
9691 }
9692
9693 #[test]
9694 fn system_winget_export_parses_wrapped_sources() {
9695 let output = r#"{
9696 "Sources": [
9697 {
9698 "Name": "corp",
9699 "Type": "Microsoft.Rest",
9700 "Arg": "https://packages.contoso.test/api",
9701 "Identifier": "Contoso.Rest",
9702 "TrustLevel": "Trusted",
9703 "Explicit": true,
9704 "Priority": 9
9705 }
9706 ]
9707}"#;
9708
9709 let sources = parse_system_winget_source_export(output).expect("source export");
9710 let source = sources.first().expect("source");
9711 assert_eq!(sources.len(), 1);
9712 assert_eq!(source.name, "corp");
9713 assert_eq!(source.kind, SourceKind::Rest);
9714 assert_eq!(source.identifier, "Contoso.Rest");
9715 assert_eq!(source.trust_level, "Trusted");
9716 assert!(source.explicit);
9717 assert_eq!(source.priority, 9);
9718 }
9719
9720 #[test]
9721 fn system_winget_source_store_detects_secure_settings_stub() {
9722 assert!(is_secure_settings_stub(Some(
9723 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
9724 )));
9725 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
9726 }
9727
9728 #[test]
9729 fn system_winget_source_store_builds_add_arguments() {
9730 let args = build_winget_source_add_arguments(
9731 "corp",
9732 "https://packages.contoso.test/api",
9733 SourceKind::Rest,
9734 "Trusted",
9735 true,
9736 );
9737
9738 assert_eq!(
9739 args,
9740 vec![
9741 "source",
9742 "add",
9743 "--name",
9744 "corp",
9745 "--arg",
9746 "https://packages.contoso.test/api",
9747 "--type",
9748 "Microsoft.Rest",
9749 "--disable-interactivity",
9750 "--trust-level",
9751 "trusted",
9752 "--explicit",
9753 ]
9754 );
9755 }
9756
9757 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
9758 Ok(WingetSourceCommandResult {
9759 exit_code: 0,
9760 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9761"#
9762 .to_owned(),
9763 stderr: String::new(),
9764 })
9765 }
9766
9767 #[test]
9768 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
9769 let original_runner = {
9770 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9771 let original = *runner;
9772 *runner = fake_system_winget_source_export;
9773 original
9774 };
9775
9776 let result = load_packaged_store_from_streams(
9777 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
9778 None,
9779 );
9780
9781 {
9782 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9783 *runner = original_runner;
9784 }
9785
9786 let store = result.expect("packaged store").expect("packaged store");
9787 let source = store.sources.first().expect("source");
9788 assert_eq!(store.sources.len(), 1);
9789 assert_eq!(source.name, "contoso");
9790 assert_eq!(source.kind, SourceKind::Rest);
9791 }
9792
9793 #[test]
9794 fn source_metadata_and_named_reset_round_trip() {
9795 let app_root = temp_app_root("source-reset");
9796 let result = (|| -> Result<()> {
9797 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9798 repository.add_source_with_metadata(
9799 "test",
9800 "https://example.com/test",
9801 SourceKind::Rest,
9802 Some("trusted"),
9803 true,
9804 4,
9805 )?;
9806
9807 repository.edit_source("test", Some(false), None)?;
9808 let edited = repository
9809 .list_sources()
9810 .into_iter()
9811 .find(|source| source.name == "test")
9812 .expect("source");
9813 assert_eq!(edited.trust_level, "Trusted");
9814 assert!(!edited.explicit);
9815 assert_eq!(edited.priority, 4);
9816
9817 repository.reset_source("test")?;
9818 let reset = repository
9819 .list_sources()
9820 .into_iter()
9821 .find(|source| source.name == "test")
9822 .expect("source");
9823 assert_eq!(reset.trust_level, "Trusted");
9824 assert!(!reset.explicit);
9825 assert_eq!(reset.priority, 4);
9826 assert!(reset.last_update.is_none());
9827 assert!(reset.source_version.is_none());
9828 Ok(())
9829 })();
9830
9831 let _ = fs::remove_dir_all(&app_root);
9832 result.expect("source metadata");
9833 }
9834
9835 #[test]
9836 fn explicit_sources_are_skipped_by_default_search_resolution() {
9837 let app_root = temp_app_root("source-resolution");
9838 let result = (|| -> Result<()> {
9839 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9840 repository.add_source_with_metadata(
9841 "explicit-test",
9842 "https://example.com/explicit",
9843 SourceKind::Rest,
9844 Some("trusted"),
9845 true,
9846 10,
9847 )?;
9848
9849 let indexes = repository.resolve_source_indexes(None)?;
9850 let names: Vec<_> = indexes
9851 .into_iter()
9852 .map(|index| repository.list_sources()[index].name.clone())
9853 .collect();
9854 assert!(!names.iter().any(|name| name == "explicit-test"));
9855 Ok(())
9856 })();
9857
9858 let _ = fs::remove_dir_all(&app_root);
9859 result.expect("source resolution");
9860 }
9861
9862 #[test]
9863 fn user_and_admin_settings_round_trip() {
9864 let app_root = temp_app_root("settings");
9865 let result = (|| -> Result<()> {
9866 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9867 repository.set_user_settings(
9868 &serde_json::json!({
9869 "visual": {
9870 "progressBar": "retro"
9871 }
9872 }),
9873 false,
9874 )?;
9875 repository.set_user_settings(
9876 &serde_json::json!({
9877 "experimentalFeatures": {
9878 "directMSI": true
9879 }
9880 }),
9881 true,
9882 )?;
9883
9884 let user_settings = repository.get_user_settings()?;
9885 assert_eq!(
9886 user_settings["visual"]["progressBar"],
9887 serde_json::Value::String("retro".to_owned())
9888 );
9889 assert_eq!(
9890 user_settings["experimentalFeatures"]["directMSI"],
9891 serde_json::Value::Bool(true)
9892 );
9893 assert!(repository.test_user_settings(
9894 &serde_json::json!({
9895 "experimentalFeatures": {
9896 "directMSI": true
9897 }
9898 }),
9899 true
9900 )?);
9901
9902 repository.set_admin_setting("LocalManifestFiles", true)?;
9903 repository.set_admin_setting("InstallerHashOverride", true)?;
9904 let admin_settings = repository.get_admin_settings()?;
9905 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
9906 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
9907
9908 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
9909 let reset_one = repository.get_admin_settings()?;
9910 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
9911
9912 repository.reset_admin_setting(None, true)?;
9913 let reset_all = repository.get_admin_settings()?;
9914 for name in Repository::supported_admin_settings() {
9915 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
9916 }
9917 Ok(())
9918 })();
9919
9920 let _ = fs::remove_dir_all(&app_root);
9921 result.expect("settings");
9922 }
9923
9924 #[test]
9925 fn show_result_structured_document_is_manifest_oriented() {
9926 let result = ShowResult {
9927 package: SearchMatch {
9928 source_name: "winget".to_owned(),
9929 source_kind: SourceKind::PreIndexed,
9930 id: "Test.Package".to_owned(),
9931 name: "Test Package".to_owned(),
9932 moniker: Some("testpkg".to_owned()),
9933 version: Some("1.2.3".to_owned()),
9934 channel: Some("stable".to_owned()),
9935 match_criteria: Some("Id".to_owned()),
9936 },
9937 manifest: Manifest {
9938 id: "Test.Package".to_owned(),
9939 name: "Test Package".to_owned(),
9940 version: "1.2.3".to_owned(),
9941 channel: "stable".to_owned(),
9942 publisher: Some("Contoso".to_owned()),
9943 description: Some("Structured output".to_owned()),
9944 moniker: Some("testpkg".to_owned()),
9945 package_url: None,
9946 publisher_url: None,
9947 publisher_support_url: None,
9948 license: None,
9949 license_url: None,
9950 privacy_url: None,
9951 author: None,
9952 copyright: None,
9953 copyright_url: None,
9954 release_notes: None,
9955 release_notes_url: None,
9956 tags: vec!["utility".to_owned()],
9957 agreements: Vec::new(),
9958 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
9959 documentation: vec![Documentation {
9960 label: Some("Docs".to_owned()),
9961 url: "https://example.test/docs".to_owned(),
9962 }],
9963 icons: vec![PackageIcon {
9964 icon_url: Some("https://example.test/default-256.ico".to_owned()),
9965 icon_file_type: Some("ico".to_owned()),
9966 icon_resolution: Some("256x256".to_owned()),
9967 icon_theme: Some("default".to_owned()),
9968 icon_sha256: Some("DEF456".to_owned()),
9969 }],
9970 installers: vec![Installer {
9971 architecture: Some("x64".to_owned()),
9972 installer_type: Some("msix".to_owned()),
9973 url: Some("https://example.test/Test.Package.msix".to_owned()),
9974 sha256: Some("ABC123".to_owned()),
9975 product_code: None,
9976 locale: Some("en-US".to_owned()),
9977 scope: Some("machine".to_owned()),
9978 release_date: None,
9979 package_family_name: None,
9980 upgrade_code: None,
9981 platforms: Vec::new(),
9982 minimum_os_version: None,
9983 switches: InstallerSwitches {
9984 silent: Some("/quiet".to_owned()),
9985 ..InstallerSwitches::default()
9986 },
9987 commands: vec!["testpkg".to_owned()],
9988 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9989 require_explicit_upgrade: false,
9990 nested_installer_type: None,
9991 nested_installer_files: Vec::new(),
9992 }],
9993 require_explicit_upgrade: false,
9994 },
9995 selected_installer: Some(Installer {
9996 architecture: Some("x64".to_owned()),
9997 installer_type: Some("msix".to_owned()),
9998 url: Some("https://example.test/Test.Package.msix".to_owned()),
9999 sha256: Some("ABC123".to_owned()),
10000 product_code: None,
10001 locale: Some("en-US".to_owned()),
10002 scope: Some("machine".to_owned()),
10003 release_date: None,
10004 package_family_name: None,
10005 upgrade_code: None,
10006 platforms: Vec::new(),
10007 minimum_os_version: None,
10008 switches: InstallerSwitches {
10009 silent: Some("/quiet".to_owned()),
10010 ..InstallerSwitches::default()
10011 },
10012 commands: vec!["testpkg".to_owned()],
10013 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
10014 require_explicit_upgrade: false,
10015 nested_installer_type: None,
10016 nested_installer_files: Vec::new(),
10017 }),
10018 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
10019 warnings: vec!["cache warmed".to_owned()],
10020 manifest_documents: JsonValue::Array(vec![
10021 serde_json::json!({
10022 "PackageIdentifier": "Test.Package",
10023 "PackageVersion": "1.2.3",
10024 "DefaultLocale": "en-US",
10025 "ManifestType": "version",
10026 "ManifestVersion": "1.10.0"
10027 }),
10028 serde_json::json!({
10029 "PackageIdentifier": "Test.Package",
10030 "PackageVersion": "1.2.3",
10031 "PackageLocale": "en-US",
10032 "PackageName": "Test Package",
10033 "Publisher": "Example",
10034 "License": "MIT",
10035 "ShortDescription": "Structured output",
10036 "Icons": [
10037 {
10038 "IconUrl": "https://example.test/default-256.ico",
10039 "IconFileType": "ico",
10040 "IconResolution": "256x256",
10041 "IconTheme": "default",
10042 "IconSha256": "DEF456"
10043 }
10044 ],
10045 "ManifestType": "defaultLocale",
10046 "ManifestVersion": "1.10.0"
10047 }),
10048 serde_json::json!({
10049 "PackageIdentifier": "Test.Package",
10050 "PackageVersion": "1.2.3",
10051 "ManifestType": "installer",
10052 "ManifestVersion": "1.10.0",
10053 "Installers": [
10054 {
10055 "Architecture": "x64",
10056 "InstallerType": "msix",
10057 "InstallerUrl": "https://example.test/Test.Package.msix",
10058 "InstallerSha256": "ABC123",
10059 "Commands": ["testpkg"],
10060 "InstallerSwitches": { "Silent": "/quiet" },
10061 "Dependencies": {
10062 "PackageDependencies": [
10063 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
10064 ]
10065 }
10066 }
10067 ]
10068 }),
10069 ]),
10070 };
10071
10072 let document = result.structured_document();
10073
10074 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10075 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
10076 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
10077 assert_eq!(
10078 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
10079 Some("Microsoft.VCRedist.2015+.x64")
10080 );
10081 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
10082 assert_eq!(
10083 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
10084 Some("/quiet")
10085 );
10086 assert_eq!(
10087 document["IconUrl"].as_str(),
10088 Some("https://example.test/default-256.ico")
10089 );
10090 assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
10091 assert_eq!(
10092 document["Icons"][0]["IconUrl"].as_str(),
10093 Some("https://example.test/default-256.ico")
10094 );
10095 }
10096
10097 #[test]
10098 fn manifest_parses_require_explicit_upgrade_at_top_level() {
10099 let yaml = r#"
10104PackageIdentifier: Test.Package
10105PackageVersion: 1.2.3
10106DefaultLocale: en-US
10107ManifestType: singleton
10108ManifestVersion: 1.10.0
10109PackageLocale: en-US
10110PackageName: Test Package
10111Publisher: Example
10112License: MIT
10113ShortDescription: explicit-upgrade fixture
10114RequireExplicitUpgrade: true
10115Installers:
10116 - Architecture: x64
10117 InstallerType: exe
10118 InstallerUrl: https://example.test/Test.Package.exe
10119 InstallerSha256: ABC123
10120"#;
10121 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10122 assert!(manifest.require_explicit_upgrade);
10123 }
10124
10125 #[test]
10126 fn manifest_parses_require_explicit_upgrade_on_installer() {
10127 let yaml = r#"
10131PackageIdentifier: Test.Package
10132PackageVersion: 1.2.3
10133DefaultLocale: en-US
10134ManifestType: singleton
10135ManifestVersion: 1.10.0
10136PackageLocale: en-US
10137PackageName: Test Package
10138Publisher: Example
10139License: MIT
10140ShortDescription: explicit-upgrade fixture
10141Installers:
10142 - Architecture: x64
10143 InstallerType: exe
10144 InstallerUrl: https://example.test/Test.Package.x64.exe
10145 InstallerSha256: ABC123
10146 - Architecture: arm64
10147 InstallerType: exe
10148 InstallerUrl: https://example.test/Test.Package.arm64.exe
10149 InstallerSha256: DEF456
10150 RequireExplicitUpgrade: true
10151"#;
10152 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10153 assert!(manifest.require_explicit_upgrade);
10154 assert!(!manifest.installers[0].require_explicit_upgrade);
10155 assert!(manifest.installers[1].require_explicit_upgrade);
10156 }
10157
10158 #[test]
10159 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
10160 let yaml = r#"
10161PackageIdentifier: Test.Package
10162PackageVersion: 1.2.3
10163DefaultLocale: en-US
10164ManifestType: singleton
10165ManifestVersion: 1.10.0
10166PackageLocale: en-US
10167PackageName: Test Package
10168Publisher: Example
10169License: MIT
10170ShortDescription: baseline fixture
10171Installers:
10172 - Architecture: x64
10173 InstallerType: exe
10174 InstallerUrl: https://example.test/Test.Package.exe
10175 InstallerSha256: ABC123
10176"#;
10177 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10178 assert!(!manifest.require_explicit_upgrade);
10179 }
10180
10181 #[test]
10182 fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
10183 let yaml = r#"
10184PackageIdentifier: Test.Package
10185PackageVersion: 1.2.3
10186DefaultLocale: en-US
10187ManifestType: singleton
10188ManifestVersion: 1.10.0
10189PackageLocale: en-US
10190PackageName: Test Package
10191Publisher: Example
10192License: MIT
10193ShortDescription: icon fixture
10194Icons:
10195 - IconUrl: https://example.test/dark-512.png
10196 IconFileType: png
10197 IconResolution: 512x512
10198 IconTheme: dark
10199 IconSha256: DARK
10200 - IconUrl: https://example.test/default-64.png
10201 IconFileType: png
10202 IconResolution: 64x64
10203 IconTheme: default
10204 - IconUrl: https://example.test/default-256.ico
10205 IconFileType: ico
10206 IconResolution: 256x256
10207 IconTheme: default
10208 - IconFileType: png
10209 IconResolution: 1024x1024
10210Installers:
10211 - Architecture: x64
10212 InstallerType: exe
10213 InstallerUrl: https://example.test/Test.Package.exe
10214 InstallerSha256: ABC123
10215"#;
10216 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10217
10218 assert_eq!(manifest.icons.len(), 4);
10219 assert_eq!(
10220 manifest.icons[0].icon_url.as_deref(),
10221 Some("https://example.test/dark-512.png")
10222 );
10223 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
10224 let selected = manifest.icon().expect("selected icon");
10225 assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
10226 assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
10227 }
10228
10229 #[test]
10230 fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
10231 let yaml = r#"
10232PackageIdentifier: Test.Package
10233PackageVersion: 1.2.3
10234DefaultLocale: en-US
10235ManifestType: singleton
10236ManifestVersion: 1.10.0
10237PackageLocale: en-US
10238PackageName: Test Package
10239Publisher: Example
10240License: MIT
10241ShortDescription: no-icon fixture
10242Installers:
10243 - Architecture: x64
10244 InstallerType: exe
10245 InstallerUrl: https://example.test/Test.Package.exe
10246 InstallerSha256: ABC123
10247"#;
10248 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
10249
10250 assert!(manifest.icons.is_empty());
10251 assert!(manifest.icon().is_none());
10252 assert!(manifest.icon_url().is_none());
10253 }
10254
10255 #[test]
10256 fn rest_manifest_parses_icons_from_default_locale() {
10257 let response = serde_json::json!({
10258 "Data": {
10259 "PackageIdentifier": "Test.Package",
10260 "Versions": [
10261 {
10262 "PackageVersion": "1.2.3",
10263 "DefaultLocale": {
10264 "PackageName": "Test Package",
10265 "Publisher": "Example",
10266 "ShortDescription": "REST icon fixture",
10267 "Icons": [
10268 {
10269 "IconUrl": "https://example.test/default-128.png",
10270 "IconFileType": "png",
10271 "IconResolution": "128x128",
10272 "IconTheme": "default"
10273 },
10274 {
10275 "IconUrl": "https://example.test/default-256.ico",
10276 "IconFileType": "ico",
10277 "IconResolution": "256x256"
10278 }
10279 ]
10280 },
10281 "Installers": [
10282 {
10283 "Architecture": "x64",
10284 "InstallerType": "exe",
10285 "InstallerUrl": "https://example.test/Test.Package.exe",
10286 "InstallerSha256": "ABC123"
10287 }
10288 ],
10289 "ManifestVersion": "1.10.0"
10290 }
10291 ],
10292 "ManifestVersion": "1.10.0"
10293 }
10294 });
10295 let bytes = serde_json::to_vec(&response).expect("json");
10296
10297 let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
10298
10299 assert_eq!(manifest.icons.len(), 2);
10300 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
10301 assert_eq!(
10302 document["Icons"][0]["IconUrl"].as_str(),
10303 Some("https://example.test/default-128.png")
10304 );
10305 }
10306
10307 #[test]
10308 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
10309 let mut pkg = InstalledPackage {
10313 name: "Edge".to_owned(),
10314 local_id: r"ARP\Machine\X64\Edge".to_owned(),
10315 installed_version: "100.0".to_owned(),
10316 publisher: None,
10317 scope: Some("Machine".to_owned()),
10318 installer_category: Some("exe".to_owned()),
10319 install_location: None,
10320 package_family_names: Vec::new(),
10321 product_codes: Vec::new(),
10322 upgrade_codes: Vec::new(),
10323 correlated: Some(SearchMatch {
10324 source_name: "winget".to_owned(),
10325 source_kind: SourceKind::PreIndexed,
10326 id: "Microsoft.Edge".to_owned(),
10327 name: "Microsoft Edge".to_owned(),
10328 moniker: None,
10329 version: Some("110.0".to_owned()),
10330 channel: None,
10331 match_criteria: None,
10332 }),
10333 installed_version_canonical: false,
10334 correlated_requires_explicit_upgrade: true,
10335 correlated_lacks_compatible_installer: false,
10336 };
10337 let bulk_query = ListQuery {
10338 upgrade_only: true,
10339 ..ListQuery::default()
10340 };
10341 assert!(
10342 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
10343 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
10344 );
10345
10346 let filtered_query = ListQuery {
10349 upgrade_only: true,
10350 id: Some("Microsoft.Edge".to_owned()),
10351 ..ListQuery::default()
10352 };
10353 assert!(
10354 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
10355 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
10356 );
10357
10358 pkg.correlated_requires_explicit_upgrade = false;
10360 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10361 }
10362
10363 #[test]
10364 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
10365 let mut pkg = InstalledPackage {
10366 name: "Foo".to_owned(),
10367 local_id: r"ARP\Machine\X64\Foo".to_owned(),
10368 installed_version: "1.0".to_owned(),
10369 publisher: None,
10370 scope: Some("Machine".to_owned()),
10371 installer_category: Some("exe".to_owned()),
10372 install_location: None,
10373 package_family_names: Vec::new(),
10374 product_codes: Vec::new(),
10375 upgrade_codes: Vec::new(),
10376 correlated: Some(SearchMatch {
10377 source_name: "winget".to_owned(),
10378 source_kind: SourceKind::PreIndexed,
10379 id: "Test.Foo".to_owned(),
10380 name: "Foo".to_owned(),
10381 moniker: None,
10382 version: Some("2.0".to_owned()),
10383 channel: None,
10384 match_criteria: None,
10385 }),
10386 installed_version_canonical: false,
10387 correlated_requires_explicit_upgrade: false,
10388 correlated_lacks_compatible_installer: true,
10389 };
10390 let bulk_query = ListQuery {
10391 upgrade_only: true,
10392 ..ListQuery::default()
10393 };
10394 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10395
10396 let filtered_query = ListQuery {
10398 upgrade_only: true,
10399 id: Some("Test.Foo".to_owned()),
10400 ..ListQuery::default()
10401 };
10402 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
10403
10404 pkg.correlated_lacks_compatible_installer = false;
10406 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10407 }
10408
10409 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
10410 let installers = arches
10411 .iter()
10412 .map(|a| Installer {
10413 architecture: Some((*a).to_owned()),
10414 installer_type: None,
10415 url: None,
10416 sha256: None,
10417 product_code: None,
10418 locale: None,
10419 scope: None,
10420 release_date: None,
10421 package_family_name: None,
10422 upgrade_code: None,
10423 platforms: Vec::new(),
10424 minimum_os_version: None,
10425 switches: InstallerSwitches::default(),
10426 commands: Vec::new(),
10427 package_dependencies: Vec::new(),
10428 require_explicit_upgrade: false,
10429 nested_installer_type: None,
10430 nested_installer_files: Vec::new(),
10431 })
10432 .collect();
10433 Manifest {
10434 id: "Test".to_owned(),
10435 name: "Test".to_owned(),
10436 version: "1.0".to_owned(),
10437 channel: String::new(),
10438 publisher: None,
10439 description: None,
10440 moniker: None,
10441 package_url: None,
10442 publisher_url: None,
10443 publisher_support_url: None,
10444 license: None,
10445 license_url: None,
10446 privacy_url: None,
10447 author: None,
10448 copyright: None,
10449 copyright_url: None,
10450 release_notes: None,
10451 release_notes_url: None,
10452 tags: Vec::new(),
10453 agreements: Vec::new(),
10454 package_dependencies: Vec::new(),
10455 documentation: Vec::new(),
10456 icons: Vec::new(),
10457 installers,
10458 require_explicit_upgrade: false,
10459 }
10460 }
10461
10462 #[test]
10463 fn manifest_compatible_installer_neutral_always_matches() {
10464 assert!(manifest_has_compatible_installer(
10466 &synthetic_manifest_with_installer_arches(&["neutral"])
10467 ));
10468 }
10469
10470 #[test]
10471 fn manifest_compatible_installer_empty_installers_pass_through() {
10472 assert!(manifest_has_compatible_installer(
10475 &synthetic_manifest_with_installer_arches(&[])
10476 ));
10477 }
10478
10479 #[test]
10480 fn manifest_compatible_installer_rejects_alien_arch_only() {
10481 assert!(!manifest_has_compatible_installer(
10483 &synthetic_manifest_with_installer_arches(&["ppc"])
10484 ));
10485 }
10486
10487 #[test]
10488 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
10489 assert!(manifest_has_compatible_installer(
10491 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
10492 ));
10493 }
10494
10495 #[test]
10496 fn lookup_unique_normalized_identity_returns_unique_match() {
10497 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10499 execute_batch_sql(
10500 &connection,
10501 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10502 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10503 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
10504 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
10505 )
10506 .expect("seed schema");
10507
10508 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
10509 .expect("query")
10510 .expect("unique match");
10511 assert_eq!(rowid, 100);
10512 }
10513
10514 #[test]
10515 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
10516 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10521 execute_batch_sql(
10522 &connection,
10523 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10524 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10525 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10526 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
10527 )
10528 .expect("seed schema");
10529
10530 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10531 assert!(rowid.is_none(), "ambiguous match must not correlate");
10532 }
10533
10534 #[test]
10535 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
10536 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10540 execute_batch_sql(
10541 &connection,
10542 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10543 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10544 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10545 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
10546 )
10547 .expect("seed schema");
10548
10549 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10550 assert_eq!(rowid, Some(100));
10551 }
10552
10553 #[test]
10554 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
10555 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10558 execute_batch_sql(
10559 &connection,
10560 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10561 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10562 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
10563 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
10564 )
10565 .expect("seed schema");
10566
10567 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
10568 assert!(
10569 rowid.is_none(),
10570 "name matches package 100 but publisher matches 200 — no intersection"
10571 );
10572 }
10573
10574 #[test]
10575 fn parse_yaml_manifest_bundle_returns_singleton_document() {
10576 let yaml = r#"
10577PackageIdentifier: Test.Package
10578PackageVersion: 1.2.3
10579DefaultLocale: en-US
10580ManifestType: version
10581ManifestVersion: 1.10.0
10582---
10583PackageIdentifier: Test.Package
10584PackageVersion: 1.2.3
10585PackageLocale: en-US
10586PackageName: Test Package
10587Publisher: Example
10588License: MIT
10589ShortDescription: Structured output
10590ManifestType: defaultLocale
10591ManifestVersion: 1.10.0
10592---
10593PackageIdentifier: Test.Package
10594PackageVersion: 1.2.3
10595ManifestType: installer
10596ManifestVersion: 1.10.0
10597Installers:
10598 - Architecture: x64
10599 InstallerType: exe
10600 InstallerUrl: https://example.test/Test.Package.exe
10601 InstallerSha256: ABC123
10602"#;
10603
10604 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
10605
10606 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
10607 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
10608 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
10609 }
10610
10611 #[test]
10612 fn collapse_structured_documents_returns_plural_show_documents() {
10613 let documents = collapse_structured_documents(&[
10614 JsonValue::Array(vec![
10615 serde_json::json!({
10616 "PackageIdentifier": "Test.Package.One",
10617 "PackageVersion": "1.0.0",
10618 "DefaultLocale": "en-US",
10619 "ManifestType": "version",
10620 "ManifestVersion": "1.10.0"
10621 }),
10622 serde_json::json!({
10623 "PackageIdentifier": "Test.Package.One",
10624 "PackageVersion": "1.0.0",
10625 "PackageLocale": "en-US",
10626 "PackageName": "Test Package One",
10627 "ManifestType": "defaultLocale",
10628 "ManifestVersion": "1.10.0"
10629 }),
10630 serde_json::json!({
10631 "PackageIdentifier": "Test.Package.One",
10632 "PackageVersion": "1.0.0",
10633 "ManifestType": "installer",
10634 "ManifestVersion": "1.10.0",
10635 "Installers": [
10636 {
10637 "Architecture": "x64",
10638 "InstallerType": "exe",
10639 "InstallerUrl": "https://example.test/one.exe",
10640 "InstallerSha256": "ABC123"
10641 }
10642 ]
10643 }),
10644 ]),
10645 serde_json::json!({
10646 "PackageIdentifier": "Test.Package.Two",
10647 "PackageVersion": "2.0.0",
10648 "PackageLocale": "en-US",
10649 "PackageName": "Test Package Two",
10650 "ManifestType": "singleton",
10651 "ManifestVersion": "1.12.0"
10652 }),
10653 ]);
10654
10655 assert_eq!(documents.len(), 2);
10656 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
10657 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
10658 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
10659 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
10660 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
10661 }
10662
10663 #[test]
10664 fn unsupported_action_result_marks_noop_and_warning() {
10665 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
10666
10667 assert!(result.success);
10668 assert!(result.no_op);
10669 assert_eq!(result.exit_code, 0);
10670 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
10671 }
10672
10673 #[test]
10674 fn create_install_no_op_result_honors_no_upgrade() {
10675 let mut request = InstallRequest::new(PackageQuery {
10676 id: Some("Contoso.App".to_owned()),
10677 ..PackageQuery::default()
10678 });
10679 request.no_upgrade = true;
10680
10681 let manifest = Manifest {
10682 id: "Contoso.App".to_owned(),
10683 name: "Contoso App".to_owned(),
10684 version: "2.0.0".to_owned(),
10685 channel: String::new(),
10686 publisher: None,
10687 description: None,
10688 moniker: None,
10689 package_url: None,
10690 publisher_url: None,
10691 publisher_support_url: None,
10692 license: None,
10693 license_url: None,
10694 privacy_url: None,
10695 author: None,
10696 copyright: None,
10697 copyright_url: None,
10698 release_notes: None,
10699 release_notes_url: None,
10700 tags: Vec::new(),
10701 agreements: Vec::new(),
10702 package_dependencies: Vec::new(),
10703 documentation: Vec::new(),
10704 icons: Vec::new(),
10705 installers: Vec::new(),
10706 require_explicit_upgrade: false,
10707 };
10708 let existing = ListMatch {
10709 name: "Contoso App".to_owned(),
10710 id: "Contoso.App".to_owned(),
10711 local_id: "Contoso.App".to_owned(),
10712 installed_version: "1.0.0".to_owned(),
10713 available_version: Some("2.0.0".to_owned()),
10714 source_name: Some("winget".to_owned()),
10715 publisher: None,
10716 scope: None,
10717 installer_category: None,
10718 install_location: None,
10719 package_family_names: Vec::new(),
10720 product_codes: Vec::new(),
10721 upgrade_codes: Vec::new(),
10722 };
10723
10724 let result =
10725 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10726
10727 assert!(result.success);
10728 assert!(result.no_op);
10729 assert_eq!(result.version, "1.0.0");
10730 assert_eq!(
10731 result.warnings,
10732 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
10733 );
10734 }
10735
10736 #[test]
10737 fn create_install_no_op_result_skips_reinstall_when_current() {
10738 let request = InstallRequest::new(PackageQuery {
10739 id: Some("Contoso.App".to_owned()),
10740 ..PackageQuery::default()
10741 });
10742
10743 let manifest = Manifest {
10744 id: "Contoso.App".to_owned(),
10745 name: "Contoso App".to_owned(),
10746 version: "2.0.0".to_owned(),
10747 channel: String::new(),
10748 publisher: None,
10749 description: None,
10750 moniker: None,
10751 package_url: None,
10752 publisher_url: None,
10753 publisher_support_url: None,
10754 license: None,
10755 license_url: None,
10756 privacy_url: None,
10757 author: None,
10758 copyright: None,
10759 copyright_url: None,
10760 release_notes: None,
10761 release_notes_url: None,
10762 tags: Vec::new(),
10763 agreements: Vec::new(),
10764 package_dependencies: Vec::new(),
10765 documentation: Vec::new(),
10766 icons: Vec::new(),
10767 installers: Vec::new(),
10768 require_explicit_upgrade: false,
10769 };
10770 let existing = ListMatch {
10771 name: "Contoso App".to_owned(),
10772 id: "Contoso.App".to_owned(),
10773 local_id: "Contoso.App".to_owned(),
10774 installed_version: "2.0.0".to_owned(),
10775 available_version: Some("2.0.0".to_owned()),
10776 source_name: Some("winget".to_owned()),
10777 publisher: None,
10778 scope: None,
10779 installer_category: None,
10780 install_location: None,
10781 package_family_names: Vec::new(),
10782 product_codes: Vec::new(),
10783 upgrade_codes: Vec::new(),
10784 };
10785
10786 let result =
10787 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10788
10789 assert!(result.success);
10790 assert!(result.no_op);
10791 assert_eq!(result.version, "2.0.0");
10792 assert_eq!(
10793 result.warnings,
10794 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
10795 );
10796 }
10797
10798 #[test]
10799 fn collapse_structured_document_projects_merged_manifest() {
10800 let document = collapse_structured_document(&serde_json::json!({
10801 "PackageIdentifier": "Test.Package",
10802 "PackageVersion": "1.2.3",
10803 "PackageLocale": "en-US",
10804 "PackageName": "Test Package",
10805 "Publisher": "Example",
10806 "InstallerType": "exe",
10807 "Installers": [
10808 {
10809 "Architecture": "x64",
10810 "InstallerUrl": "https://example.test/Test.Package.exe",
10811 "InstallerSha256": "ABC123"
10812 }
10813 ],
10814 "ManifestType": "merged",
10815 "ManifestVersion": "1.10.0"
10816 }));
10817
10818 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10819 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
10820 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
10821 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
10822 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
10823 }
10824
10825 #[test]
10826 fn parses_fixture_manifest() {
10827 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
10828 .join("tests")
10829 .join("fixtures")
10830 .join("ManifestV1_28-Singleton.yaml");
10831 let bytes = fs::read(root).expect("fixture bytes");
10832 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
10833 assert_eq!(manifest.id, "microsoft.msixsdk");
10834 assert_eq!(manifest.version, "1.7.32");
10835 assert_eq!(manifest.name, "MSIX SDK");
10836 assert!(!manifest.installers.is_empty());
10837 }
10838
10839 #[test]
10840 fn parses_installer_switches_from_yaml_manifest() {
10841 let yaml = r#"
10842PackageIdentifier: Test.Package
10843PackageVersion: 1.2.3
10844PackageName: Test Package
10845InstallerSwitches:
10846 SilentWithProgress: /SILENT
10847Installers:
10848 - Architecture: x64
10849 InstallerType: inno
10850 InstallerUrl: https://example.test/Test.Package.exe
10851 InstallerSha256: ABC123
10852 InstallerSwitches:
10853 Silent: /VERYSILENT
10854 Interactive: /HELP
10855"#;
10856
10857 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10858 let installer = &manifest.installers[0];
10859
10860 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
10861 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
10862 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
10863 }
10864
10865 #[test]
10866 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
10867 let yaml = r#"
10868PackageIdentifier: Test.Package
10869PackageVersion: 1.2.3
10870PackageName: Test Package
10871Platform:
10872 - Windows.Desktop
10873MinimumOSVersion: 10.0.19041.0
10874Installers:
10875 - Architecture: x64
10876 InstallerType: exe
10877 InstallerUrl: https://example.test/Test.Package.exe
10878 InstallerSha256: ABC123
10879 - Architecture: x64
10880 InstallerType: msix
10881 Platform:
10882 - Windows.Universal
10883 MinimumOSVersion: 10.0.22621.0
10884 InstallerUrl: https://example.test/Test.Package.msix
10885 InstallerSha256: DEF456
10886"#;
10887
10888 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10889
10890 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
10891 assert_eq!(
10892 manifest.installers[0].minimum_os_version.as_deref(),
10893 Some("10.0.19041.0")
10894 );
10895 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
10896 assert_eq!(
10897 manifest.installers[1].minimum_os_version.as_deref(),
10898 Some("10.0.22621.0")
10899 );
10900 }
10901
10902 #[test]
10903 fn installer_switch_arguments_prefer_manifest_switches() {
10904 let installer = Installer {
10905 architecture: None,
10906 installer_type: Some("inno".to_owned()),
10907 url: None,
10908 sha256: None,
10909 product_code: None,
10910 locale: None,
10911 scope: None,
10912 release_date: None,
10913 package_family_name: None,
10914 upgrade_code: None,
10915 platforms: Vec::new(),
10916 minimum_os_version: None,
10917 switches: InstallerSwitches {
10918 silent: Some("/mysilent".to_owned()),
10919 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
10920 interactive: Some("/myinteractive".to_owned()),
10921 ..InstallerSwitches::default()
10922 },
10923 commands: Vec::new(),
10924 package_dependencies: Vec::new(),
10925 require_explicit_upgrade: false,
10926 nested_installer_type: None,
10927 nested_installer_files: Vec::new(),
10928 };
10929 let manifest = Manifest {
10930 id: "Test.Package".to_owned(),
10931 name: "Test Package".to_owned(),
10932 version: "1.0.0".to_owned(),
10933 channel: String::new(),
10934 publisher: None,
10935 description: None,
10936 moniker: None,
10937 package_url: None,
10938 publisher_url: None,
10939 publisher_support_url: None,
10940 license: None,
10941 license_url: None,
10942 privacy_url: None,
10943 author: None,
10944 copyright: None,
10945 copyright_url: None,
10946 release_notes: None,
10947 release_notes_url: None,
10948 tags: Vec::new(),
10949 agreements: Vec::new(),
10950 package_dependencies: Vec::new(),
10951 documentation: Vec::new(),
10952 icons: Vec::new(),
10953 installers: Vec::new(),
10954 require_explicit_upgrade: false,
10955 };
10956
10957 let mut silent_request = InstallRequest::new(PackageQuery::default());
10958 silent_request.mode = InstallerMode::Silent;
10959 let mut progress_request = InstallRequest::new(PackageQuery::default());
10960 progress_request.mode = InstallerMode::SilentWithProgress;
10961 let mut interactive_request = InstallRequest::new(PackageQuery::default());
10962 interactive_request.mode = InstallerMode::Interactive;
10963 assert_eq!(
10964 installer_command_arguments(
10965 "inno",
10966 &silent_request,
10967 &manifest,
10968 Path::new("installer.exe"),
10969 &installer
10970 ),
10971 vec!["/mysilent".to_owned()]
10972 );
10973 assert_eq!(
10974 installer_command_arguments(
10975 "inno",
10976 &progress_request,
10977 &manifest,
10978 Path::new("installer.exe"),
10979 &installer
10980 ),
10981 vec!["/mysilentwithprogress".to_owned()]
10982 );
10983 assert_eq!(
10984 installer_command_arguments(
10985 "inno",
10986 &interactive_request,
10987 &manifest,
10988 Path::new("installer.exe"),
10989 &installer
10990 ),
10991 vec!["/myinteractive".to_owned()]
10992 );
10993 }
10994
10995 #[test]
10996 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
10997 let installer = Installer {
10998 architecture: None,
10999 installer_type: Some("inno".to_owned()),
11000 url: None,
11001 sha256: None,
11002 product_code: None,
11003 locale: None,
11004 scope: None,
11005 release_date: None,
11006 package_family_name: None,
11007 upgrade_code: None,
11008 platforms: Vec::new(),
11009 minimum_os_version: None,
11010 switches: InstallerSwitches::default(),
11011 commands: Vec::new(),
11012 package_dependencies: Vec::new(),
11013 require_explicit_upgrade: false,
11014 nested_installer_type: None,
11015 nested_installer_files: Vec::new(),
11016 };
11017 let manifest = Manifest {
11018 id: "Test.Package".to_owned(),
11019 name: "Test Package".to_owned(),
11020 version: "1.0.0".to_owned(),
11021 channel: String::new(),
11022 publisher: None,
11023 description: None,
11024 moniker: None,
11025 package_url: None,
11026 publisher_url: None,
11027 publisher_support_url: None,
11028 license: None,
11029 license_url: None,
11030 privacy_url: None,
11031 author: None,
11032 copyright: None,
11033 copyright_url: None,
11034 release_notes: None,
11035 release_notes_url: None,
11036 tags: Vec::new(),
11037 agreements: Vec::new(),
11038 package_dependencies: Vec::new(),
11039 documentation: Vec::new(),
11040 icons: Vec::new(),
11041 installers: Vec::new(),
11042 require_explicit_upgrade: false,
11043 };
11044 let mut progress_request = InstallRequest::new(PackageQuery::default());
11045 progress_request.mode = InstallerMode::SilentWithProgress;
11046 let mut silent_request = InstallRequest::new(PackageQuery::default());
11047 silent_request.mode = InstallerMode::Silent;
11048
11049 assert_eq!(
11050 installer_command_arguments(
11051 "inno",
11052 &progress_request,
11053 &manifest,
11054 Path::new("installer.exe"),
11055 &installer
11056 ),
11057 vec![
11058 "/SP-".to_owned(),
11059 "/SILENT".to_owned(),
11060 "/SUPPRESSMSGBOXES".to_owned(),
11061 "/NORESTART".to_owned()
11062 ]
11063 );
11064 assert_eq!(
11065 installer_command_arguments(
11066 "inno",
11067 &silent_request,
11068 &manifest,
11069 Path::new("installer.exe"),
11070 &installer
11071 ),
11072 vec![
11073 "/SP-".to_owned(),
11074 "/VERYSILENT".to_owned(),
11075 "/SUPPRESSMSGBOXES".to_owned(),
11076 "/NORESTART".to_owned()
11077 ]
11078 );
11079 }
11080
11081 #[test]
11082 fn installer_command_arguments_append_manifest_and_cli_switches() {
11083 let installer = Installer {
11084 architecture: None,
11085 installer_type: Some("msi".to_owned()),
11086 url: None,
11087 sha256: None,
11088 product_code: None,
11089 locale: None,
11090 scope: None,
11091 release_date: None,
11092 package_family_name: None,
11093 upgrade_code: None,
11094 platforms: Vec::new(),
11095 minimum_os_version: None,
11096 switches: InstallerSwitches {
11097 custom: Some("ADDLOCAL=Core".to_owned()),
11098 log: Some("/log \"<LOGPATH>\"".to_owned()),
11099 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
11100 ..InstallerSwitches::default()
11101 },
11102 commands: Vec::new(),
11103 package_dependencies: Vec::new(),
11104 require_explicit_upgrade: false,
11105 nested_installer_type: None,
11106 nested_installer_files: Vec::new(),
11107 };
11108 let manifest = Manifest {
11109 id: "ShareX.ShareX".to_owned(),
11110 name: "ShareX".to_owned(),
11111 version: "19.0.2".to_owned(),
11112 channel: String::new(),
11113 publisher: None,
11114 description: None,
11115 moniker: None,
11116 package_url: None,
11117 publisher_url: None,
11118 publisher_support_url: None,
11119 license: None,
11120 license_url: None,
11121 privacy_url: None,
11122 author: None,
11123 copyright: None,
11124 copyright_url: None,
11125 release_notes: None,
11126 release_notes_url: None,
11127 tags: Vec::new(),
11128 agreements: Vec::new(),
11129 package_dependencies: Vec::new(),
11130 documentation: Vec::new(),
11131 icons: Vec::new(),
11132 installers: Vec::new(),
11133 require_explicit_upgrade: false,
11134 };
11135 let mut request = InstallRequest::new(PackageQuery::default());
11136 request.mode = InstallerMode::Silent;
11137 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
11138 request.custom = Some("REBOOT=ReallySuppress".to_owned());
11139 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
11140
11141 assert_eq!(
11142 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
11143 vec![
11144 "/i".to_owned(),
11145 r"C:\temp\ShareX.msi".to_owned(),
11146 "/quiet".to_owned(),
11147 "/norestart".to_owned(),
11148 "/log".to_owned(),
11149 r"C:\temp\winget.log".to_owned(),
11150 "ADDLOCAL=Core".to_owned(),
11151 "REBOOT=ReallySuppress".to_owned(),
11152 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
11153 ]
11154 );
11155 }
11156
11157 #[test]
11158 fn decompresses_mszyml_payload() {
11159 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
11160 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
11161 encoder.write_all(payload.as_bytes()).expect("write payload");
11162 let compressed = encoder.finish().expect("finish payload");
11163 let mut mszip = b"CK".to_vec();
11164 mszip.extend_from_slice(&compressed);
11165
11166 let decompressed = decompress_mszyml(&mszip).expect("decompress");
11167 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
11168 assert_eq!(parsed.versions[0].version, "1.2.3");
11169 }
11170
11171 #[test]
11172 fn decompresses_mszyml_payload_with_prefix() {
11173 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
11174 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
11175 encoder.write_all(payload.as_bytes()).expect("write payload");
11176 let compressed = encoder.finish().expect("finish payload");
11177 let mut prefixed = vec![0u8; 28];
11178 prefixed.extend_from_slice(b"CK");
11179 prefixed.extend_from_slice(&compressed);
11180
11181 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
11182 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
11183 assert_eq!(parsed.versions[0].version, "1.2.3");
11184 }
11185
11186 #[test]
11187 fn compares_versions_naturally() {
11188 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
11189 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
11190 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
11191 }
11192
11193 #[test]
11194 fn installer_hash_verification_fails_on_mismatch_without_override() {
11195 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
11196 assert!(result.is_err());
11197 assert!(
11198 result
11199 .expect_err("hash mismatch")
11200 .to_string()
11201 .contains("Installer hash mismatch")
11202 );
11203 }
11204
11205 #[test]
11206 fn installer_hash_verification_allows_mismatch_with_override() {
11207 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
11208 verify_installer_hash(None, b"pinget", false).expect("missing hash");
11209 }
11210
11211 #[test]
11212 fn search_query_many_includes_tag_and_command_conditions() {
11213 let query = PackageQuery {
11214 query: Some("terminal".to_owned()),
11215 ..PackageQuery::default()
11216 };
11217
11218 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
11219
11220 assert!(where_clause.contains("tags2"));
11221 assert!(where_clause.contains("commands2"));
11222 assert_eq!(params.len(), 5);
11223 }
11224
11225 #[test]
11226 fn tag_and_command_conditions_are_non_correlated() {
11227 let query = PackageQuery {
11234 query: Some("terminal".to_owned()),
11235 ..PackageQuery::default()
11236 };
11237
11238 let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
11239
11240 assert!(where_clause.contains("packages.rowid IN (SELECT"));
11241 assert!(!where_clause.contains("EXISTS"));
11242 assert!(!where_clause.contains("= packages.rowid"));
11243 }
11244
11245 #[test]
11246 fn show_query_single_omits_tag_and_command_conditions() {
11247 let query = PackageQuery {
11248 query: Some("terminal".to_owned()),
11249 ..PackageQuery::default()
11250 };
11251
11252 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
11253
11254 assert!(!where_clause.contains("tags2"));
11255 assert!(!where_clause.contains("commands2"));
11256 assert_eq!(params.len(), 3);
11257 }
11258
11259 #[test]
11260 fn explicit_tag_filter_uses_exact_match() {
11261 let query = PackageQuery {
11262 tag: Some("terminal".to_owned()),
11263 ..PackageQuery::default()
11264 };
11265
11266 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
11267
11268 assert!(where_clause.contains("tags2"));
11269 assert_eq!(params, vec!["terminal".to_owned()]);
11270 }
11271
11272 #[test]
11273 fn rest_tag_filter_uses_exact_match() {
11274 let query = PackageQuery {
11275 tag: Some("terminal".to_owned()),
11276 ..PackageQuery::default()
11277 };
11278 let info = RestInformation {
11279 required_package_match_fields: Vec::new(),
11280 unsupported_package_match_fields: Vec::new(),
11281 required_query_parameters: Vec::new(),
11282 ..RestInformation::default()
11283 };
11284
11285 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
11286 let filters = body["Filters"].as_array().expect("filters");
11287
11288 assert_eq!(filters.len(), 1);
11289 assert_eq!(filters[0]["PackageMatchField"], "Tag");
11290 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
11291 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
11292 }
11293
11294 #[test]
11295 fn selects_installer_using_requested_filters() {
11296 let installers = vec![
11297 Installer {
11298 architecture: Some("x86".to_owned()),
11299 installer_type: Some("zip".to_owned()),
11300 url: None,
11301 sha256: None,
11302 product_code: None,
11303 locale: Some("en-US".to_owned()),
11304 scope: Some("user".to_owned()),
11305 release_date: None,
11306 package_family_name: None,
11307 upgrade_code: None,
11308 platforms: Vec::new(),
11309 minimum_os_version: None,
11310 switches: InstallerSwitches::default(),
11311 commands: Vec::new(),
11312 package_dependencies: Vec::new(),
11313 require_explicit_upgrade: false,
11314 nested_installer_type: None,
11315 nested_installer_files: Vec::new(),
11316 },
11317 Installer {
11318 architecture: Some("x64".to_owned()),
11319 installer_type: Some("msix".to_owned()),
11320 url: None,
11321 sha256: None,
11322 product_code: None,
11323 locale: Some("en-US".to_owned()),
11324 scope: Some("user".to_owned()),
11325 release_date: None,
11326 package_family_name: None,
11327 upgrade_code: None,
11328 platforms: Vec::new(),
11329 minimum_os_version: None,
11330 switches: InstallerSwitches::default(),
11331 commands: vec!["demo".to_owned()],
11332 package_dependencies: Vec::new(),
11333 require_explicit_upgrade: false,
11334 nested_installer_type: None,
11335 nested_installer_files: Vec::new(),
11336 },
11337 ];
11338 let query = PackageQuery {
11339 installer_type: Some("msix".to_owned()),
11340 installer_architecture: Some("x64".to_owned()),
11341 install_scope: Some("user".to_owned()),
11342 locale: Some("en-US".to_owned()),
11343 ..PackageQuery::default()
11344 };
11345
11346 let selected = select_installer(&installers, &query).expect("selected installer");
11347 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
11348 assert_eq!(selected.architecture.as_deref(), Some("x64"));
11349 }
11350
11351 #[test]
11352 fn selects_first_installer_when_rank_ties() {
11353 let installers = vec![
11354 Installer {
11355 architecture: Some("x64".to_owned()),
11356 installer_type: Some("exe".to_owned()),
11357 url: None,
11358 sha256: None,
11359 product_code: None,
11360 locale: Some("en-US".to_owned()),
11361 scope: Some("machine".to_owned()),
11362 release_date: None,
11363 package_family_name: None,
11364 upgrade_code: None,
11365 platforms: Vec::new(),
11366 minimum_os_version: None,
11367 switches: InstallerSwitches::default(),
11368 commands: Vec::new(),
11369 package_dependencies: Vec::new(),
11370 require_explicit_upgrade: false,
11371 nested_installer_type: None,
11372 nested_installer_files: Vec::new(),
11373 },
11374 Installer {
11375 architecture: Some("x64".to_owned()),
11376 installer_type: Some("exe".to_owned()),
11377 url: None,
11378 sha256: None,
11379 product_code: None,
11380 locale: Some("en-US".to_owned()),
11381 scope: Some("user".to_owned()),
11382 release_date: None,
11383 package_family_name: None,
11384 upgrade_code: None,
11385 platforms: Vec::new(),
11386 minimum_os_version: None,
11387 switches: InstallerSwitches::default(),
11388 commands: Vec::new(),
11389 package_dependencies: Vec::new(),
11390 require_explicit_upgrade: false,
11391 nested_installer_type: None,
11392 nested_installer_files: Vec::new(),
11393 },
11394 ];
11395
11396 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
11397 assert_eq!(selected.scope.as_deref(), Some("machine"));
11398 }
11399
11400 #[test]
11401 fn selects_installer_using_requested_platform() {
11402 let installers = vec![
11403 Installer {
11404 architecture: Some("x64".to_owned()),
11405 installer_type: Some("exe".to_owned()),
11406 url: None,
11407 sha256: None,
11408 product_code: None,
11409 locale: Some("en-US".to_owned()),
11410 scope: Some("user".to_owned()),
11411 release_date: None,
11412 package_family_name: None,
11413 upgrade_code: None,
11414 platforms: vec!["Windows.Universal".to_owned()],
11415 minimum_os_version: None,
11416 switches: InstallerSwitches::default(),
11417 commands: Vec::new(),
11418 package_dependencies: Vec::new(),
11419 require_explicit_upgrade: false,
11420 nested_installer_type: None,
11421 nested_installer_files: Vec::new(),
11422 },
11423 Installer {
11424 architecture: Some("x64".to_owned()),
11425 installer_type: Some("exe".to_owned()),
11426 url: None,
11427 sha256: None,
11428 product_code: None,
11429 locale: Some("en-US".to_owned()),
11430 scope: Some("user".to_owned()),
11431 release_date: None,
11432 package_family_name: None,
11433 upgrade_code: None,
11434 platforms: vec!["Windows.Desktop".to_owned()],
11435 minimum_os_version: None,
11436 switches: InstallerSwitches::default(),
11437 commands: Vec::new(),
11438 package_dependencies: Vec::new(),
11439 require_explicit_upgrade: false,
11440 nested_installer_type: None,
11441 nested_installer_files: Vec::new(),
11442 },
11443 ];
11444 let query = PackageQuery {
11445 installer_type: Some("exe".to_owned()),
11446 platform: Some("Windows.Desktop".to_owned()),
11447 ..PackageQuery::default()
11448 };
11449
11450 let selected = select_installer(&installers, &query).expect("selected installer");
11451 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
11452 }
11453
11454 #[test]
11455 fn selects_installer_using_requested_os_version() {
11456 let installers = vec![
11457 Installer {
11458 architecture: Some("x64".to_owned()),
11459 installer_type: Some("exe".to_owned()),
11460 url: None,
11461 sha256: None,
11462 product_code: None,
11463 locale: Some("en-US".to_owned()),
11464 scope: Some("user".to_owned()),
11465 release_date: None,
11466 package_family_name: None,
11467 upgrade_code: None,
11468 platforms: Vec::new(),
11469 minimum_os_version: Some("10.0.22621.0".to_owned()),
11470 switches: InstallerSwitches::default(),
11471 commands: Vec::new(),
11472 package_dependencies: Vec::new(),
11473 require_explicit_upgrade: false,
11474 nested_installer_type: None,
11475 nested_installer_files: Vec::new(),
11476 },
11477 Installer {
11478 architecture: Some("x64".to_owned()),
11479 installer_type: Some("exe".to_owned()),
11480 url: None,
11481 sha256: None,
11482 product_code: None,
11483 locale: Some("en-US".to_owned()),
11484 scope: Some("user".to_owned()),
11485 release_date: None,
11486 package_family_name: None,
11487 upgrade_code: None,
11488 platforms: Vec::new(),
11489 minimum_os_version: Some("10.0.19041.0".to_owned()),
11490 switches: InstallerSwitches::default(),
11491 commands: Vec::new(),
11492 package_dependencies: Vec::new(),
11493 require_explicit_upgrade: false,
11494 nested_installer_type: None,
11495 nested_installer_files: Vec::new(),
11496 },
11497 ];
11498 let query = PackageQuery {
11499 installer_type: Some("exe".to_owned()),
11500 os_version: Some("10.0.19045.0".to_owned()),
11501 ..PackageQuery::default()
11502 };
11503
11504 let selected = select_installer(&installers, &query).expect("selected installer");
11505 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
11506 }
11507
11508 #[test]
11509 fn derives_correlation_name_candidates() {
11510 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
11511 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
11512 assert!(candidates.contains(&"PowerToys".to_owned()));
11513 }
11514
11515 #[test]
11516 fn correlates_installed_package_to_available_match() {
11517 let installed = InstalledPackage {
11518 name: "PowerToys (Preview) x64".to_owned(),
11519 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11520 installed_version: "0.98.1".to_owned(),
11521 publisher: None,
11522 scope: Some("Machine".to_owned()),
11523 installer_category: Some("exe".to_owned()),
11524 install_location: None,
11525 package_family_names: Vec::new(),
11526 product_codes: Vec::new(),
11527 upgrade_codes: Vec::new(),
11528 correlated: None,
11529 installed_version_canonical: false,
11530 correlated_requires_explicit_upgrade: false,
11531 correlated_lacks_compatible_installer: false,
11532 };
11533 let candidates = vec![SearchMatch {
11534 source_name: "winget".to_owned(),
11535 source_kind: SourceKind::PreIndexed,
11536 id: "Microsoft.PowerToys".to_owned(),
11537 name: "PowerToys".to_owned(),
11538 moniker: None,
11539 version: Some("0.98.1".to_owned()),
11540 channel: None,
11541 match_criteria: None,
11542 }];
11543
11544 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11545 assert_eq!(correlated.id, "Microsoft.PowerToys");
11546 }
11547
11548 #[test]
11549 fn list_query_uses_available_lookup_for_tag_filters() {
11550 let query = ListQuery {
11551 tag: Some("terminal".to_owned()),
11552 ..ListQuery::default()
11553 };
11554
11555 assert!(list_query_needs_available_lookup(&query));
11556 }
11557
11558 #[test]
11559 fn list_tag_filter_requires_correlated_match() {
11560 let package = InstalledPackage {
11561 name: "PowerToys (Preview) x64".to_owned(),
11562 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11563 installed_version: "0.98.1".to_owned(),
11564 publisher: None,
11565 scope: Some("Machine".to_owned()),
11566 installer_category: Some("exe".to_owned()),
11567 install_location: None,
11568 package_family_names: Vec::new(),
11569 product_codes: Vec::new(),
11570 upgrade_codes: Vec::new(),
11571 correlated: None,
11572 installed_version_canonical: false,
11573 correlated_requires_explicit_upgrade: false,
11574 correlated_lacks_compatible_installer: false,
11575 };
11576 let query = ListQuery {
11577 tag: Some("powertoys".to_owned()),
11578 ..ListQuery::default()
11579 };
11580
11581 assert!(!list_package_matches(&package, &query));
11582 }
11583
11584 #[test]
11585 fn list_lookup_ignores_display_count() {
11586 let query = ListQuery {
11587 query: Some("git".to_owned()),
11588 count: Some(5),
11589 ..ListQuery::default()
11590 };
11591
11592 let package_query = package_query_from_list_query(&query);
11593 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
11594 }
11595
11596 #[test]
11597 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
11598 let installed = InstalledPackage {
11603 name: "Notepad++ (ARM 64-bit)".to_owned(),
11604 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
11605 installed_version: "8.8.9".to_owned(),
11606 publisher: None,
11607 scope: Some("Machine".to_owned()),
11608 installer_category: Some("exe".to_owned()),
11609 install_location: None,
11610 package_family_names: Vec::new(),
11611 product_codes: Vec::new(),
11612 upgrade_codes: Vec::new(),
11613 correlated: None,
11614 installed_version_canonical: false,
11615 correlated_requires_explicit_upgrade: false,
11616 correlated_lacks_compatible_installer: false,
11617 };
11618 let candidates = vec![
11619 SearchMatch {
11620 source_name: "winget".to_owned(),
11621 source_kind: SourceKind::PreIndexed,
11622 id: "ndd.Notepad--".to_owned(),
11623 name: "Notepad--".to_owned(),
11624 moniker: None,
11625 version: Some("1.0.0".to_owned()),
11626 channel: None,
11627 match_criteria: None,
11628 },
11629 SearchMatch {
11630 source_name: "winget".to_owned(),
11631 source_kind: SourceKind::PreIndexed,
11632 id: "Notepad++.Notepad++".to_owned(),
11633 name: "Notepad++".to_owned(),
11634 moniker: None,
11635 version: Some("8.9.5".to_owned()),
11636 channel: None,
11637 match_criteria: None,
11638 },
11639 ];
11640
11641 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11642 assert_eq!(correlated.id, "Notepad++.Notepad++");
11643 }
11644
11645 #[test]
11646 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
11647 let installed = InstalledPackage {
11652 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
11653 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
11654 installed_version: "40.10.18029".to_owned(),
11655 publisher: None,
11656 scope: Some("Machine".to_owned()),
11657 installer_category: Some("msi".to_owned()),
11658 install_location: None,
11659 package_family_names: Vec::new(),
11660 product_codes: Vec::new(),
11661 upgrade_codes: Vec::new(),
11662 correlated: None,
11663 installed_version_canonical: false,
11664 correlated_requires_explicit_upgrade: false,
11665 correlated_lacks_compatible_installer: false,
11666 };
11667 let candidates = vec![
11668 SearchMatch {
11669 source_name: "winget".to_owned(),
11670 source_kind: SourceKind::PreIndexed,
11671 id: "ZeroBrane.Studio".to_owned(),
11672 name: "Studio".to_owned(),
11673 moniker: None,
11674 version: Some("1.0".to_owned()),
11675 channel: None,
11676 match_criteria: None,
11677 },
11678 SearchMatch {
11679 source_name: "winget".to_owned(),
11680 source_kind: SourceKind::PreIndexed,
11681 id: "BrickLink.Studio".to_owned(),
11682 name: "Studio".to_owned(),
11683 moniker: None,
11684 version: Some("1.0".to_owned()),
11685 channel: None,
11686 match_criteria: None,
11687 },
11688 SearchMatch {
11689 source_name: "winget".to_owned(),
11690 source_kind: SourceKind::PreIndexed,
11691 id: "Microsoft.DotNet.SDK.10".to_owned(),
11692 name: "Microsoft .NET SDK 10.0".to_owned(),
11693 moniker: None,
11694 version: Some("10.0.204".to_owned()),
11695 channel: None,
11696 match_criteria: None,
11697 },
11698 ];
11699
11700 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11701 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
11702 }
11703
11704 #[test]
11705 fn msix_packages_do_not_correlate_via_name() {
11706 let installed = InstalledPackage {
11714 name: "Microsoft Teams".to_owned(),
11715 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11716 installed_version: "25290.205.4069.4894".to_owned(),
11717 publisher: None,
11718 scope: Some("User".to_owned()),
11719 installer_category: Some("msix".to_owned()),
11720 install_location: None,
11721 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11722 product_codes: Vec::new(),
11723 upgrade_codes: Vec::new(),
11724 correlated: None,
11725 installed_version_canonical: false,
11726 correlated_requires_explicit_upgrade: false,
11727 correlated_lacks_compatible_installer: false,
11728 };
11729 let candidates = vec![SearchMatch {
11730 source_name: "winget".to_owned(),
11731 source_kind: SourceKind::PreIndexed,
11732 id: "Microsoft.Teams".to_owned(),
11733 name: "Microsoft Teams".to_owned(),
11734 moniker: None,
11735 version: Some("26106.1906.4665.7308".to_owned()),
11736 channel: None,
11737 match_criteria: None,
11738 }];
11739
11740 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11741 }
11742
11743 #[test]
11744 fn name_fallback_refuses_ambiguous_winners() {
11745 let installed = InstalledPackage {
11753 name: "Git".to_owned(),
11754 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
11755 installed_version: "2.53.0".to_owned(),
11756 publisher: Some("The Git Development Community".to_owned()),
11757 scope: Some("Machine".to_owned()),
11758 installer_category: Some("exe".to_owned()),
11759 install_location: None,
11760 package_family_names: Vec::new(),
11761 product_codes: Vec::new(),
11762 upgrade_codes: Vec::new(),
11763 correlated: None,
11764 installed_version_canonical: false,
11765 correlated_requires_explicit_upgrade: false,
11766 correlated_lacks_compatible_installer: false,
11767 };
11768 let candidates = vec![
11769 SearchMatch {
11770 source_name: "winget".to_owned(),
11771 source_kind: SourceKind::PreIndexed,
11772 id: "Git.Git".to_owned(),
11773 name: "Git".to_owned(),
11774 moniker: None,
11775 version: Some("2.54.0".to_owned()),
11776 channel: None,
11777 match_criteria: None,
11778 },
11779 SearchMatch {
11780 source_name: "winget".to_owned(),
11781 source_kind: SourceKind::PreIndexed,
11782 id: "Microsoft.Git".to_owned(),
11783 name: "Git".to_owned(),
11784 moniker: None,
11785 version: Some("2.53.0.0.7".to_owned()),
11786 channel: None,
11787 match_criteria: None,
11788 },
11789 ];
11790
11791 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11792 }
11793
11794 #[test]
11795 fn msix_with_resource_string_name_does_not_correlate() {
11796 let installed = InstalledPackage {
11800 name: "ms-resource:appDisplayName".to_owned(),
11801 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11802 installed_version: "1.28.239.0".to_owned(),
11803 publisher: None,
11804 scope: Some("User".to_owned()),
11805 installer_category: Some("msix".to_owned()),
11806 install_location: None,
11807 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11808 product_codes: Vec::new(),
11809 upgrade_codes: Vec::new(),
11810 correlated: None,
11811 installed_version_canonical: false,
11812 correlated_requires_explicit_upgrade: false,
11813 correlated_lacks_compatible_installer: false,
11814 };
11815 let candidates = vec![SearchMatch {
11816 source_name: "winget".to_owned(),
11817 source_kind: SourceKind::PreIndexed,
11818 id: "Microsoft.AppInstaller".to_owned(),
11819 name: "App Installer".to_owned(),
11820 moniker: None,
11821 version: Some("1.28.240.0".to_owned()),
11822 channel: None,
11823 match_criteria: None,
11824 }];
11825
11826 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11827 }
11828
11829 #[test]
11830 fn strict_list_correlation_avoids_short_substring_matches() {
11831 let installed = InstalledPackage {
11832 name: "GitHub".to_owned(),
11833 local_id: r"ARP\User\X64\GitHub".to_owned(),
11834 installed_version: "1.0.0".to_owned(),
11835 publisher: None,
11836 scope: Some("User".to_owned()),
11837 installer_category: Some("exe".to_owned()),
11838 install_location: None,
11839 package_family_names: Vec::new(),
11840 product_codes: Vec::new(),
11841 upgrade_codes: Vec::new(),
11842 correlated: None,
11843 installed_version_canonical: false,
11844 correlated_requires_explicit_upgrade: false,
11845 correlated_lacks_compatible_installer: false,
11846 };
11847 let candidates = vec![SearchMatch {
11848 source_name: "winget".to_owned(),
11849 source_kind: SourceKind::PreIndexed,
11850 id: "Git.Git.PreRelease".to_owned(),
11851 name: "Git".to_owned(),
11852 moniker: None,
11853 version: Some("2.54.0".to_owned()),
11854 channel: None,
11855 match_criteria: None,
11856 }];
11857
11858 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
11859 }
11860
11861 #[test]
11862 fn detects_available_upgrade_from_correlated_package() {
11863 let package = InstalledPackage {
11864 name: "AzCopy v10".to_owned(),
11865 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11866 installed_version: "10.32.2".to_owned(),
11867 publisher: None,
11868 scope: Some("Machine".to_owned()),
11869 installer_category: Some("exe".to_owned()),
11870 install_location: None,
11871 package_family_names: Vec::new(),
11872 product_codes: Vec::new(),
11873 upgrade_codes: Vec::new(),
11874 correlated: Some(SearchMatch {
11875 source_name: "winget".to_owned(),
11876 source_kind: SourceKind::PreIndexed,
11877 id: "Microsoft.Azure.AZCopy.10".to_owned(),
11878 name: "AzCopy v10".to_owned(),
11879 moniker: None,
11880 version: Some("10.32.3".to_owned()),
11881 channel: None,
11882 match_criteria: None,
11883 }),
11884 installed_version_canonical: false,
11885 correlated_requires_explicit_upgrade: false,
11886 correlated_lacks_compatible_installer: false,
11887 };
11888
11889 assert!(installed_package_has_upgrade(&package));
11890 }
11891
11892 #[test]
11893 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
11894 let package = InstalledPackage {
11895 name: "Example Tool".to_owned(),
11896 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
11897 installed_version: "Unknown".to_owned(),
11898 publisher: None,
11899 scope: Some("User".to_owned()),
11900 installer_category: Some("exe".to_owned()),
11901 install_location: None,
11902 package_family_names: Vec::new(),
11903 product_codes: Vec::new(),
11904 upgrade_codes: Vec::new(),
11905 correlated: Some(SearchMatch {
11906 source_name: "winget".to_owned(),
11907 source_kind: SourceKind::PreIndexed,
11908 id: "Contoso.ExampleTool".to_owned(),
11909 name: "Example Tool".to_owned(),
11910 moniker: None,
11911 version: Some("2.0.0".to_owned()),
11912 channel: None,
11913 match_criteria: None,
11914 }),
11915 installed_version_canonical: false,
11916 correlated_requires_explicit_upgrade: false,
11917 correlated_lacks_compatible_installer: false,
11918 };
11919 let query = ListQuery {
11920 upgrade_only: true,
11921 include_unknown: true,
11922 ..Default::default()
11923 };
11924
11925 assert!(installed_package_matches_upgrade_filter(&package, &query));
11926 assert_eq!(
11927 list_match_from_installed(package).available_version.as_deref(),
11928 Some("2.0.0")
11929 );
11930 }
11931
11932 #[test]
11933 fn create_repair_list_query_includes_installed_selectors() {
11934 let request = RepairRequest {
11935 query: PackageQuery {
11936 query: Some("powertoys".to_owned()),
11937 id: Some("Microsoft.PowerToys".to_owned()),
11938 name: Some("PowerToys".to_owned()),
11939 moniker: Some("powertoys".to_owned()),
11940 source: Some("winget".to_owned()),
11941 exact: true,
11942 version: Some("0.98.1".to_owned()),
11943 install_scope: Some("Machine".to_owned()),
11944 ..PackageQuery::default()
11945 },
11946 manifest_path: None,
11947 product_code: Some("{GUID}".to_owned()),
11948 mode: InstallerMode::Silent,
11949 log_path: Some(PathBuf::from("repair.log")),
11950 accept_package_agreements: true,
11951 force: true,
11952 ignore_security_hash: true,
11953 };
11954
11955 let query = Repository::create_repair_list_query(&request);
11956 assert_eq!(query.query.as_deref(), Some("powertoys"));
11957 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
11958 assert_eq!(query.name.as_deref(), Some("PowerToys"));
11959 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
11960 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
11961 assert_eq!(query.version.as_deref(), Some("0.98.1"));
11962 assert_eq!(query.source.as_deref(), Some("winget"));
11963 assert!(query.exact);
11964 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
11965 assert_eq!(query.count, Some(100));
11966 }
11967
11968 #[test]
11969 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
11970 let request = RepairRequest {
11971 query: PackageQuery {
11972 name: Some("PowerToys".to_owned()),
11973 source: Some("winget".to_owned()),
11974 locale: Some("en-US".to_owned()),
11975 installer_architecture: Some("x64".to_owned()),
11976 install_scope: Some("Machine".to_owned()),
11977 ..PackageQuery::default()
11978 },
11979 manifest_path: None,
11980 product_code: None,
11981 mode: InstallerMode::SilentWithProgress,
11982 log_path: Some(PathBuf::from("repair.log")),
11983 accept_package_agreements: true,
11984 force: false,
11985 ignore_security_hash: true,
11986 };
11987 let installed = ListMatch {
11988 name: "PowerToys".to_owned(),
11989 id: "Microsoft.PowerToys".to_owned(),
11990 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
11991 installed_version: "0.98.1".to_owned(),
11992 available_version: Some("0.98.2".to_owned()),
11993 source_name: Some("winget".to_owned()),
11994 publisher: None,
11995 scope: Some("Machine".to_owned()),
11996 installer_category: Some("exe".to_owned()),
11997 install_location: None,
11998 package_family_names: Vec::new(),
11999 product_codes: Vec::new(),
12000 upgrade_codes: Vec::new(),
12001 };
12002
12003 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
12004 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
12005 assert!(install_request.query.query.is_none());
12006 assert!(install_request.query.name.is_none());
12007 assert!(install_request.query.moniker.is_none());
12008 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
12009 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
12010 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
12011 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
12012 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
12013 assert!(install_request.query.exact);
12014 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
12015 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
12016 assert!(install_request.accept_package_agreements);
12017 assert!(install_request.force);
12018 assert!(install_request.ignore_security_hash);
12019 }
12020
12021 #[test]
12022 fn source_scoped_pin_operations_round_trip() {
12023 let app_root = temp_app_root("pins");
12024 let result = (|| -> Result<()> {
12025 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
12026 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
12027 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
12028 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
12029
12030 assert_eq!(repository.list_pins(None)?.len(), 3);
12031 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
12032 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
12033
12034 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
12035 assert_eq!(repository.list_pins(None)?.len(), 2);
12036 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
12037
12038 repository.reset_pins(Some("test"))?;
12039 let remaining = repository.list_pins(None)?;
12040 assert_eq!(remaining.len(), 1);
12041 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
12042
12043 repository.reset_pins(None)?;
12044 assert!(repository.list_pins(None)?.is_empty());
12045 Ok(())
12046 })();
12047
12048 let _ = fs::remove_dir_all(&app_root);
12049 result.expect("pin round trip");
12050 }
12051
12052 #[test]
12053 fn pin_operations_work_with_packaged_pin_schema() {
12054 let app_root = temp_app_root("pins_packaged_schema");
12055 let result = (|| -> Result<()> {
12056 let db_path = pins_db_path(&app_root);
12057 if let Some(parent) = db_path.parent() {
12058 fs::create_dir_all(parent)?;
12059 }
12060
12061 let conn = Repository::open_sqlite_connection(db_path.clone())?;
12062 execute_batch_sql(
12063 &conn,
12064 "CREATE TABLE pin (
12065 package_id TEXT NOT NULL,
12066 source_id TEXT NOT NULL,
12067 type INTEGER NOT NULL,
12068 version TEXT NOT NULL,
12069 PRIMARY KEY (package_id, source_id)
12070 )",
12071 )?;
12072 conn.cacheflush().context("failed to flush pin database")?;
12073 drop(conn);
12074
12075 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
12076 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
12077
12078 let pins = repository.list_pins(None)?;
12079 assert_eq!(pins.len(), 1);
12080 assert_eq!(pins[0].package_id, "Contoso.Tool");
12081 assert_eq!(pins[0].version, "1.2.*");
12082 assert_eq!(pins[0].source_id, "winget");
12083 assert_eq!(pins[0].pin_type, PinType::Gating);
12084
12085 let conn = Repository::open_sqlite_connection(db_path)?;
12086 let stored_pin = query_optional_value(
12087 &conn,
12088 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
12089 vec![
12090 SqlValue::Text("Contoso.Tool".to_owned()),
12091 SqlValue::Text("winget".to_owned()),
12092 ],
12093 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
12094 )?
12095 .ok_or_else(|| anyhow!("expected stored pin"))?;
12096 assert_eq!(stored_pin.0, 3);
12097 assert_eq!(stored_pin.1, "1.2.*");
12098
12099 Ok(())
12100 })();
12101
12102 let _ = fs::remove_dir_all(&app_root);
12103 result.expect("packaged pin schema round trip");
12104 }
12105
12106 #[test]
12107 fn version_pin_patterns_match_exact_and_prefix_values() {
12108 assert!(version_matches_pin_pattern("1.2.3", "*"));
12109 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
12110 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
12111 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
12112 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
12113 }
12114
12115 #[test]
12116 fn source_specific_pins_override_source_agnostic_pins() {
12117 let item = ListMatch {
12118 name: "Contoso Tool".to_owned(),
12119 id: "Contoso.Tool".to_owned(),
12120 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
12121 installed_version: "1.0.0".to_owned(),
12122 available_version: Some("2.0.0".to_owned()),
12123 source_name: Some("winget".to_owned()),
12124 publisher: None,
12125 scope: None,
12126 installer_category: None,
12127 install_location: None,
12128 package_family_names: Vec::new(),
12129 product_codes: Vec::new(),
12130 upgrade_codes: Vec::new(),
12131 };
12132 let pins = vec![
12133 PinRecord {
12134 package_id: "Contoso.Tool".to_owned(),
12135 version: "*".to_owned(),
12136 source_id: String::new(),
12137 pin_type: PinType::Blocking,
12138 },
12139 PinRecord {
12140 package_id: "Contoso.Tool".to_owned(),
12141 version: "2.0.*".to_owned(),
12142 source_id: "winget".to_owned(),
12143 pin_type: PinType::Pinning,
12144 },
12145 ];
12146
12147 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
12148 }
12149
12150 #[test]
12151 fn blocking_pin_blocks_upgrade() {
12152 let item = ListMatch {
12153 name: "Contoso Tool".to_owned(),
12154 id: "Contoso.Tool".to_owned(),
12155 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
12156 installed_version: "1.0.0".to_owned(),
12157 available_version: Some("2.0.0".to_owned()),
12158 source_name: Some("winget".to_owned()),
12159 publisher: None,
12160 scope: None,
12161 installer_category: None,
12162 install_location: None,
12163 package_family_names: Vec::new(),
12164 product_codes: Vec::new(),
12165 upgrade_codes: Vec::new(),
12166 };
12167 let pins = vec![PinRecord {
12168 package_id: "Contoso.Tool".to_owned(),
12169 version: "*".to_owned(),
12170 source_id: "winget".to_owned(),
12171 pin_type: PinType::Blocking,
12172 }];
12173
12174 assert!(is_upgrade_blocked_by_pin(&item, &pins));
12175 }
12176
12177 #[cfg(windows)]
12178 #[test]
12179 fn parses_msix_package_full_name_into_version_and_family() {
12180 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
12181 .expect("package metadata");
12182
12183 assert_eq!(parsed.version, "0.98.1.0");
12184 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
12185 }
12186
12187 #[cfg(windows)]
12188 #[test]
12189 fn recognizes_windows_system_paths() {
12190 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
12191 assert!(!is_windows_system_path(
12192 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
12193 ));
12194 }
12195
12196 #[test]
12197 fn search_ranking_prefers_exact_name_over_tag_match() {
12198 let query = PackageQuery {
12199 query: Some("PowerToys".to_owned()),
12200 ..Default::default()
12201 };
12202 let exact_name = SearchMatch {
12203 source_name: "winget".to_owned(),
12204 source_kind: SourceKind::PreIndexed,
12205 id: "Microsoft.PowerToys".to_owned(),
12206 name: "PowerToys".to_owned(),
12207 moniker: Some("powertoys".to_owned()),
12208 version: Some("0.98.1".to_owned()),
12209 channel: None,
12210 match_criteria: Some("Moniker: powertoys".to_owned()),
12211 };
12212 let tag_match = SearchMatch {
12213 source_name: "winget".to_owned(),
12214 source_kind: SourceKind::PreIndexed,
12215 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
12216 name: "QR Codes for Command Palette".to_owned(),
12217 moniker: None,
12218 version: Some("0.4.0.0".to_owned()),
12219 channel: None,
12220 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
12221 };
12222
12223 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
12224 }
12225
12226 #[test]
12227 fn select_best_text_match_prefers_exact_tag_value() {
12228 let values = vec![
12229 "microsoft-powertoys".to_owned(),
12230 "powertoys-run".to_owned(),
12231 "powertoys".to_owned(),
12232 ];
12233
12234 assert_eq!(
12235 select_best_text_match(values, "PowerToys", false).as_deref(),
12236 Some("powertoys")
12237 );
12238 }
12239
12240 #[test]
12241 fn search_match_criteria_is_blank_for_name_match() {
12242 let query = PackageQuery {
12243 query: Some("PowerToys".to_owned()),
12244 ..Default::default()
12245 };
12246
12247 let result = infer_match_criteria(
12248 "Microsoft.PowerToys",
12249 "PowerToys",
12250 Some("powertoys"),
12251 &query,
12252 SearchSemantics::Many,
12253 |_| Ok(Some("powertoys".to_owned())),
12254 |_| Ok(None),
12255 )
12256 .expect("match criteria");
12257
12258 assert_eq!(result, None);
12259 }
12260
12261 #[test]
12262 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
12263 let query = PackageQuery {
12264 query: Some("PowerToys".to_owned()),
12265 ..Default::default()
12266 };
12267 let unknown_version = SearchMatch {
12268 source_name: "msstore".to_owned(),
12269 source_kind: SourceKind::Rest,
12270 id: "XP89DCGQ3K6VLD".to_owned(),
12271 name: "Microsoft PowerToys".to_owned(),
12272 moniker: None,
12273 version: Some("Unknown".to_owned()),
12274 channel: None,
12275 match_criteria: None,
12276 };
12277 let tag_match = SearchMatch {
12278 source_name: "winget".to_owned(),
12279 source_kind: SourceKind::PreIndexed,
12280 id: "Riri.QRCodeforCmdPal".to_owned(),
12281 name: "QR Code for CmdPal".to_owned(),
12282 moniker: None,
12283 version: Some("0.0.1.0".to_owned()),
12284 channel: None,
12285 match_criteria: Some("Tag: powertoys".to_owned()),
12286 };
12287
12288 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
12289 }
12290
12291 #[test]
12292 fn search_source_fetch_results_ignore_small_display_count() {
12293 let query = PackageQuery {
12294 count: Some(5),
12295 ..Default::default()
12296 };
12297
12298 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
12299 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
12300 }
12301
12302 #[test]
12303 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
12304 let query = PackageQuery {
12305 query: Some("PowerToys".to_owned()),
12306 ..Default::default()
12307 };
12308 let prefix_name = SearchMatch {
12309 source_name: "winget".to_owned(),
12310 source_kind: SourceKind::PreIndexed,
12311 id: "advaith.CurrencyConverterPowerToys".to_owned(),
12312 name: "PowerToys-Run-Currency-Converter".to_owned(),
12313 moniker: None,
12314 version: Some("1.5.4".to_owned()),
12315 channel: None,
12316 match_criteria: None,
12317 };
12318 let exact_tag = SearchMatch {
12319 source_name: "winget".to_owned(),
12320 source_kind: SourceKind::PreIndexed,
12321 id: "Riri.QRCodeforCmdPal".to_owned(),
12322 name: "QR Code for CmdPal".to_owned(),
12323 moniker: None,
12324 version: Some("0.0.1.0".to_owned()),
12325 channel: None,
12326 match_criteria: Some("Tag: powertoys".to_owned()),
12327 };
12328
12329 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
12330 }
12331
12332 #[test]
12333 fn list_sort_prefers_main_package_then_sparse_app() {
12334 let main = InstalledPackage {
12335 name: "PowerToys (Preview) x64".to_owned(),
12336 local_id: r"ARP\User\X64\PowerToys".to_owned(),
12337 installed_version: "0.98.1".to_owned(),
12338 publisher: None,
12339 scope: Some("User".to_owned()),
12340 installer_category: Some("exe".to_owned()),
12341 install_location: None,
12342 package_family_names: Vec::new(),
12343 product_codes: Vec::new(),
12344 upgrade_codes: Vec::new(),
12345 correlated: None,
12346 installed_version_canonical: false,
12347 correlated_requires_explicit_upgrade: false,
12348 correlated_lacks_compatible_installer: false,
12349 };
12350 let sparse = InstalledPackage {
12351 name: "PowerToys.SparseApp".to_owned(),
12352 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12353 installed_version: "0.98.1.0".to_owned(),
12354 publisher: None,
12355 scope: Some("User".to_owned()),
12356 installer_category: Some("msix".to_owned()),
12357 install_location: None,
12358 package_family_names: Vec::new(),
12359 product_codes: Vec::new(),
12360 upgrade_codes: Vec::new(),
12361 correlated: None,
12362 installed_version_canonical: false,
12363 correlated_requires_explicit_upgrade: false,
12364 correlated_lacks_compatible_installer: false,
12365 };
12366 let extension = InstalledPackage {
12367 name: "PowerToys FileLocksmith Context Menu".to_owned(),
12368 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12369 installed_version: "0.98.1.0".to_owned(),
12370 publisher: None,
12371 scope: Some("User".to_owned()),
12372 installer_category: Some("msix".to_owned()),
12373 install_location: None,
12374 package_family_names: Vec::new(),
12375 product_codes: Vec::new(),
12376 upgrade_codes: Vec::new(),
12377 correlated: None,
12378 installed_version_canonical: false,
12379 correlated_requires_explicit_upgrade: false,
12380 correlated_lacks_compatible_installer: false,
12381 };
12382
12383 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
12384 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
12385 }
12386
12387 #[test]
12388 #[cfg(windows)]
12389 fn arp_subkey_name_extracts_registry_subkey() {
12390 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
12391 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
12392 }
12393
12394 #[test]
12395 #[cfg(windows)]
12396 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
12397 let installed = ListMatch {
12398 name: "ShareX".to_owned(),
12399 id: "ShareX.ShareX".to_owned(),
12400 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
12401 installed_version: "19.0.2".to_owned(),
12402 available_version: None,
12403 source_name: Some("winget".to_owned()),
12404 publisher: Some("ShareX Team".to_owned()),
12405 scope: Some("Machine".to_owned()),
12406 installer_category: Some("exe".to_owned()),
12407 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
12408 package_family_names: Vec::new(),
12409 product_codes: Vec::new(),
12410 upgrade_codes: Vec::new(),
12411 };
12412
12413 assert!(registry_entry_matches_installed_package(
12414 "ShareX", "ShareX", None, &installed
12415 ));
12416 assert!(!registry_entry_matches_installed_package(
12417 "ShareX.ShareX",
12418 "ShareX.ShareX",
12419 None,
12420 &installed
12421 ));
12422 }
12423
12424 #[test]
12425 #[cfg(windows)]
12426 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
12427 assert_eq!(
12428 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
12429 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
12430 );
12431 assert_eq!(
12432 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
12433 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
12434 );
12435 assert_eq!(
12436 r#""C:\Program Files\ShareX\unins000.exe""#,
12437 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
12438 );
12439 assert_eq!(
12440 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12441 build_uninstall_command(
12442 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12443 true,
12444 false,
12445 )
12446 );
12447 }
12448
12449 #[test]
12450 #[cfg(windows)]
12451 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
12452 let installed = ListMatch {
12453 name: "lazygit".to_owned(),
12454 id: "JesseDuffield.lazygit".to_owned(),
12455 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
12456 installed_version: "0.61.1".to_owned(),
12457 available_version: None,
12458 source_name: Some("winget".to_owned()),
12459 publisher: Some("Jesse Duffield".to_owned()),
12460 scope: Some("User".to_owned()),
12461 installer_category: Some("exe".to_owned()),
12462 install_location: Some(
12463 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
12464 ),
12465 package_family_names: Vec::new(),
12466 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
12467 upgrade_codes: Vec::new(),
12468 };
12469 let mut request = UninstallRequest::new(PackageQuery {
12470 id: Some("JesseDuffield.lazygit".to_owned()),
12471 install_scope: Some("user".to_owned()),
12472 ..PackageQuery::default()
12473 });
12474 request.mode = InstallerMode::Silent;
12475
12476 assert_eq!(
12477 Some(vec![
12478 "uninstall".to_owned(),
12479 "--id".to_owned(),
12480 "JesseDuffield.lazygit".to_owned(),
12481 "--exact".to_owned(),
12482 "--disable-interactivity".to_owned(),
12483 "--source".to_owned(),
12484 "winget".to_owned(),
12485 "--scope".to_owned(),
12486 "user".to_owned(),
12487 "--silent".to_owned(),
12488 ]),
12489 build_winget_uninstall_arguments(&installed, &request)
12490 );
12491
12492 assert_eq!(
12493 Some(vec![
12494 "uninstall".to_owned(),
12495 "--id".to_owned(),
12496 "JesseDuffield.lazygit".to_owned(),
12497 "--exact".to_owned(),
12498 "--disable-interactivity".to_owned(),
12499 "--source".to_owned(),
12500 "winget".to_owned(),
12501 "--silent".to_owned(),
12502 ]),
12503 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
12504 );
12505 }
12506
12507 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
12508 PackageVersionDataEntry {
12509 version: version.to_owned(),
12510 manifest_relative_path: format!("manifests/x/{version}"),
12511 manifest_hash: "00".repeat(32),
12512 arp_min_version: arp_min.map(str::to_owned),
12513 arp_max_version: arp_max.map(str::to_owned),
12514 }
12515 }
12516
12517 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
12518 V1VersionRow {
12519 version: version.to_owned(),
12520 channel: channel.to_owned(),
12521 pathpart_id: 0,
12522 manifest_hash: None,
12523 }
12524 }
12525
12526 #[derive(Clone)]
12527 struct TestHttpResponse {
12528 status: u16,
12529 body: Vec<u8>,
12530 source_version: Option<String>,
12531 }
12532
12533 struct TestHttpServer {
12534 addr: SocketAddr,
12535 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12536 counts: Arc<Mutex<HashMap<String, usize>>>,
12537 running: Arc<AtomicBool>,
12538 handle: Option<JoinHandle<()>>,
12539 }
12540
12541 impl TestHttpServer {
12542 fn start() -> Self {
12543 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
12544 let addr = listener.local_addr().expect("test HTTP server address");
12545 let routes = Arc::new(Mutex::new(HashMap::new()));
12546 let counts = Arc::new(Mutex::new(HashMap::new()));
12547 let running = Arc::new(AtomicBool::new(true));
12548 let thread_routes = Arc::clone(&routes);
12549 let thread_counts = Arc::clone(&counts);
12550 let thread_running = Arc::clone(&running);
12551 let handle = thread::spawn(move || {
12552 while thread_running.load(AtomicOrdering::SeqCst) {
12553 let Ok((stream, _)) = listener.accept() else {
12554 continue;
12555 };
12556 handle_test_http_request(stream, &thread_routes, &thread_counts);
12557 }
12558 });
12559
12560 Self {
12561 addr,
12562 routes,
12563 counts,
12564 running,
12565 handle: Some(handle),
12566 }
12567 }
12568
12569 fn url(&self) -> String {
12570 format!("http://{}", self.addr)
12571 }
12572
12573 fn set_bytes(&self, path: &str, body: Vec<u8>) {
12574 self.set_response(
12575 path,
12576 TestHttpResponse {
12577 status: 200,
12578 body,
12579 source_version: None,
12580 },
12581 );
12582 }
12583
12584 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
12585 self.set_response(
12586 "/source.msix",
12587 TestHttpResponse {
12588 status: 200,
12589 body,
12590 source_version: Some(source_version.to_owned()),
12591 },
12592 );
12593 }
12594
12595 fn set_status(&self, path: &str, status: u16) {
12596 self.set_response(
12597 path,
12598 TestHttpResponse {
12599 status,
12600 body: Vec::new(),
12601 source_version: None,
12602 },
12603 );
12604 }
12605
12606 fn set_response(&self, path: &str, response: TestHttpResponse) {
12607 self.routes
12608 .lock()
12609 .expect("test HTTP routes")
12610 .insert(path.to_owned(), response);
12611 }
12612
12613 fn request_count(&self, path: &str) -> usize {
12614 self.counts
12615 .lock()
12616 .expect("test HTTP counts")
12617 .get(path)
12618 .copied()
12619 .unwrap_or_default()
12620 }
12621 }
12622
12623 impl Drop for TestHttpServer {
12624 fn drop(&mut self) {
12625 self.running.store(false, AtomicOrdering::SeqCst);
12626 let _ = TcpStream::connect(self.addr);
12627 if let Some(handle) = self.handle.take() {
12628 let _ = handle.join();
12629 }
12630 }
12631 }
12632
12633 fn handle_test_http_request(
12634 mut stream: TcpStream,
12635 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12636 counts: &Arc<Mutex<HashMap<String, usize>>>,
12637 ) {
12638 let mut buffer = [0u8; 2048];
12639 let read = stream.read(&mut buffer).unwrap_or(0);
12640 let request = String::from_utf8_lossy(&buffer[..read]);
12641 let path = request
12642 .lines()
12643 .next()
12644 .and_then(|line| line.split_whitespace().nth(1))
12645 .unwrap_or("/");
12646 *counts
12647 .lock()
12648 .expect("test HTTP counts")
12649 .entry(path.to_owned())
12650 .or_default() += 1;
12651 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
12652 let response = response.unwrap_or(TestHttpResponse {
12653 status: 404,
12654 body: Vec::new(),
12655 source_version: None,
12656 });
12657 let reason = if response.status == 200 { "OK" } else { "Not Found" };
12658 let mut headers = format!(
12659 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
12660 response.status,
12661 reason,
12662 response.body.len()
12663 );
12664 if let Some(source_version) = response.source_version {
12665 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
12666 }
12667 headers.push_str("\r\n");
12668 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
12669 stream.write_all(&response.body).expect("write test HTTP body");
12670 }
12671
12672 fn test_source_record(source_url: String) -> SourceRecord {
12673 SourceRecord {
12674 name: "test-winget".to_owned(),
12675 kind: SourceKind::PreIndexed,
12676 arg: source_url,
12677 identifier: "Test.Source".to_owned(),
12678 trust_level: "Trusted".to_owned(),
12679 explicit: false,
12680 priority: 0,
12681 last_update: Some(Utc::now()),
12682 source_version: None,
12683 }
12684 }
12685
12686 fn open_test_preindexed_repository(
12687 app_root: &Path,
12688 source: &SourceRecord,
12689 interval: Option<Duration>,
12690 ) -> Repository {
12691 fs::create_dir_all(app_root).expect("create app root");
12692 save_store(
12693 app_root,
12694 &SourceStore {
12695 sources: vec![source.clone()],
12696 },
12697 )
12698 .expect("save source store");
12699 Repository::open_with_options(
12700 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
12701 )
12702 .expect("open repository")
12703 }
12704
12705 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
12706 let index_path = preindexed_index_path(app_root, source);
12707 if let Some(parent) = index_path.parent() {
12708 fs::create_dir_all(parent).expect("create source state directory");
12709 }
12710 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
12711 }
12712
12713 #[test]
12714 fn replace_file_failure_keeps_existing_target() {
12715 let root = temp_app_root("replace_file_failure");
12716 fs::create_dir_all(&root).expect("create replace root");
12717 let target = root.join("index.db");
12718 let missing_source = root.join("missing.tmp");
12719 fs::write(&target, b"old-index").expect("write existing target");
12720
12721 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
12722
12723 assert!(format!("{error:#}").contains("failed to replace"));
12724 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
12725 let _ = fs::remove_dir_all(root);
12726 }
12727
12728 #[cfg(windows)]
12729 #[test]
12730 fn replace_file_succeeds_while_target_is_open() {
12731 use std::os::windows::fs::OpenOptionsExt;
12732 const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
12736
12737 let root = temp_app_root("replace_file_open_target");
12738 fs::create_dir_all(&root).expect("create replace root");
12739 let target = root.join("index.db");
12740 let source = root.join("index.new.tmp");
12741 fs::write(&target, b"old-index").expect("write existing target");
12742 fs::write(&source, b"new-index").expect("write replacement");
12743
12744 let held = fs::OpenOptions::new()
12745 .read(true)
12746 .share_mode(SHARE_ALL)
12747 .open(&target)
12748 .expect("open target with share-delete");
12749
12750 replace_file(&source, &target).expect("stage-aside replace should succeed");
12751
12752 assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
12753 drop(held);
12754 let _ = fs::remove_dir_all(root);
12755 }
12756
12757 fn make_source_package(versions: &[&str]) -> Vec<u8> {
12758 let cursor = Cursor::new(Vec::new());
12759 let mut archive = zip::ZipWriter::new(cursor);
12760 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
12761 archive
12762 .start_file("Public/index.db", options)
12763 .expect("start index entry");
12764 archive
12765 .write_all(&make_v1_index_bytes(versions))
12766 .expect("write index entry");
12767 archive.finish().expect("finish source package").into_inner()
12768 }
12769
12770 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
12771 let root = temp_app_root("preindexed_index_bytes");
12772 fs::create_dir_all(&root).expect("create index temp root");
12773 let db_path = root.join("index.db");
12774 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
12775 let mut sql = String::from(
12776 "CREATE TABLE ids (id TEXT);
12777 CREATE TABLE names (name TEXT);
12778 CREATE TABLE monikers (moniker TEXT);
12779 CREATE TABLE versions (version TEXT);
12780 CREATE TABLE channels (channel TEXT);
12781 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
12782 CREATE TABLE manifest (
12783 id INT64,
12784 name INT64,
12785 moniker INT64,
12786 version INT64,
12787 channel INT64,
12788 pathpart INT64
12789 );
12790 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
12791 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
12792 INSERT INTO channels(rowid, channel) VALUES (1, '');
12793 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
12794 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
12795 );
12796 for (index, version) in versions.iter().enumerate() {
12797 let rowid = index + 1;
12798 sql.push_str(&format!(
12799 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
12800 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
12801 ));
12802 }
12803 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
12804 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
12805 .expect("checkpoint preindexed V1 database");
12806 connection.cacheflush().expect("flush preindexed V1 database");
12807 drop(connection);
12808 let bytes = fs::read(&db_path).expect("read index bytes");
12809 let _ = fs::remove_dir_all(root);
12810 bytes
12811 }
12812
12813 fn test_manifest_yaml(version: &str) -> Vec<u8> {
12814 format!(
12815 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
12816 )
12817 .into_bytes()
12818 }
12819
12820 fn show_test_package(version: Option<&str>) -> PackageQuery {
12821 PackageQuery {
12822 id: Some("Test.Package".to_owned()),
12823 source: Some("test-winget".to_owned()),
12824 exact: true,
12825 version: version.map(str::to_owned),
12826 ..PackageQuery::default()
12827 }
12828 }
12829
12830 #[test]
12831 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
12832 let app_root = temp_app_root("preindexed_explicit_refresh");
12833 let server = TestHttpServer::start();
12834 server.set_status("/source2.msix", 404);
12835 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
12836 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12837 let source = test_source_record(server.url());
12838 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12839 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12840
12841 let result = repository
12842 .show(&show_test_package(Some("2.0.0")))
12843 .expect("show after refresh");
12844
12845 assert_eq!(result.manifest.version, "2.0.0");
12846 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
12847 let _ = fs::remove_dir_all(app_root);
12848 }
12849
12850 #[test]
12851 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
12852 let app_root = temp_app_root("preindexed_explicit_still_missing");
12853 let server = TestHttpServer::start();
12854 server.set_status("/source2.msix", 404);
12855 server.set_source_package(make_source_package(&["1.0.0"]), "same");
12856 let source = test_source_record(server.url());
12857 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12858 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12859
12860 let error = repository
12861 .show(&show_test_package(Some("2.0.0")))
12862 .expect_err("missing explicit version must fail");
12863
12864 assert!(format!("{error:#}").contains("2.0.0"));
12865 let _ = fs::remove_dir_all(app_root);
12866 }
12867
12868 #[test]
12869 fn preindexed_stale_index_refreshes_for_latest_requests() {
12870 let app_root = temp_app_root("preindexed_ttl_refresh");
12871 let server = TestHttpServer::start();
12872 server.set_status("/source2.msix", 404);
12873 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12874 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12875 let mut source = test_source_record(server.url());
12876 source.last_update = Some(Utc::now() - Duration::days(2));
12877 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12878 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
12879
12880 let result = repository
12881 .show(&show_test_package(None))
12882 .expect("show latest after TTL refresh");
12883
12884 assert_eq!(result.manifest.version, "2.0.0");
12885 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
12886 let _ = fs::remove_dir_all(app_root);
12887 }
12888
12889 #[test]
12890 fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
12891 let app_root = temp_app_root("preindexed_fresh_mtime");
12892 let server = TestHttpServer::start();
12893 server.set_status("/source2.msix", 404);
12894 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12895 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12896 let mut source = test_source_record(server.url());
12897 source.last_update = Some(Utc::now() - Duration::days(2));
12898 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12899 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12900
12901 let result = repository
12902 .show(&show_test_package(None))
12903 .expect("show latest from fresh index mtime");
12904
12905 assert_eq!(result.manifest.version, "1.0.0");
12906 assert_eq!(server.request_count("/source.msix"), 0);
12907 let _ = fs::remove_dir_all(app_root);
12908 }
12909
12910 #[test]
12911 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
12912 let app_root = temp_app_root("preindexed_ttl_fallback");
12913 let server = TestHttpServer::start();
12914 server.set_status("/source2.msix", 404);
12915 server.set_status("/source.msix", 404);
12916 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12917 let mut source = test_source_record(server.url());
12918 source.last_update = Some(Utc::now() - Duration::days(2));
12919 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12920 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
12921
12922 let result = repository
12923 .show(&show_test_package(None))
12924 .expect("show latest from stale cache after refresh failure");
12925
12926 assert_eq!(result.manifest.version, "1.0.0");
12927 let _ = fs::remove_dir_all(app_root);
12928 }
12929
12930 #[test]
12931 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
12932 let app_root = temp_app_root("preindexed_no_double_refresh");
12933 let server = TestHttpServer::start();
12934 server.set_status("/source2.msix", 404);
12935 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12936 let mut source = test_source_record(server.url());
12937 source.last_update = Some(Utc::now() - Duration::days(2));
12938 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12939 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
12940
12941 let error = repository
12942 .show(&show_test_package(Some("3.0.0")))
12943 .expect_err("still-missing explicit version must fail after one stale refresh");
12944
12945 assert!(format!("{error:#}").contains("3.0.0"));
12946 assert_eq!(server.request_count("/source.msix"), 1);
12947 let _ = fs::remove_dir_all(app_root);
12948 }
12949
12950 #[test]
12951 fn select_v2_version_without_explicit_filters_returns_latest() {
12952 let versions = vec![
12953 version_entry("1.0.0", None, None),
12954 version_entry("1.2.0", None, None),
12955 version_entry("1.1.0", None, None),
12956 ];
12957
12958 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
12959
12960 assert_eq!(selected.version, "1.2.0");
12961 }
12962
12963 #[test]
12964 fn select_v2_version_with_missing_requested_version_throws() {
12965 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
12966
12967 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
12968 .expect_err("missing requested version");
12969
12970 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12971 assert!(error.to_string().contains("9.9.9"));
12972 }
12973
12974 #[test]
12975 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
12976 let versions = vec![version_entry("1.2.0", None, None)];
12977
12978 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12979 .expect_err("unsupported channel request");
12980
12981 assert!(error.to_string().contains("beta"));
12982 assert!(error.to_string().contains("channel metadata"));
12983 }
12984
12985 #[test]
12986 fn select_v1_version_channel_only_returns_latest_matching_channel() {
12987 let versions = vec![
12988 v1_version("1.2.0", "stable"),
12989 v1_version("1.3.0", "stable"),
12990 v1_version("2.0.0", "preview"),
12991 ];
12992
12993 let selected =
12994 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
12995
12996 assert_eq!(selected.version, "1.3.0");
12997 }
12998
12999 #[test]
13000 fn select_rest_version_channel_mismatch_throws() {
13001 let versions = vec![
13002 VersionKey {
13003 version: "1.2.0".to_owned(),
13004 channel: "stable".to_owned(),
13005 },
13006 VersionKey {
13007 version: "1.1.0".to_owned(),
13008 channel: "beta".to_owned(),
13009 },
13010 ];
13011
13012 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
13013 .expect_err("missing version and channel pair");
13014
13015 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
13016 }
13017
13018 #[test]
13019 fn map_arp_version_returns_catalog_version_inside_range() {
13020 let entries = vec![
13025 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
13026 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
13027 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
13028 ];
13029
13030 assert_eq!(
13031 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
13032 Some("10.0.108")
13033 );
13034 }
13035
13036 #[test]
13037 fn map_arp_version_returns_none_when_no_range_matches() {
13038 let entries = vec![
13043 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
13044 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
13045 ];
13046
13047 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
13048 }
13049
13050 #[test]
13051 fn map_arp_version_skips_entries_missing_arp_bounds() {
13052 let entries = vec![
13056 version_entry("9.0.300", None, None),
13057 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
13058 ];
13059
13060 assert_eq!(
13061 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
13062 Some("9.0.117")
13063 );
13064 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
13065 }
13066
13067 #[test]
13068 fn map_arp_version_handles_inclusive_range_endpoints() {
13069 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
13072
13073 assert_eq!(
13074 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
13075 Some("3.14.5")
13076 );
13077 assert_eq!(
13078 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
13079 Some("3.14.5")
13080 );
13081 assert_eq!(
13082 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
13083 Some("3.14.5")
13084 );
13085 }
13086
13087 #[test]
13088 fn map_arp_version_ignores_unknown_marker() {
13089 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
13093
13094 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
13095 assert!(map_arp_version_to_catalog(&entries, "").is_none());
13096 }
13097
13098 #[test]
13099 fn latest_arp_anchored_skips_internal_versions() {
13100 let entries = vec![
13106 version_entry("8000.836.2153.0", None, None),
13107 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
13108 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
13109 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
13110 ];
13111
13112 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
13113 }
13114
13115 #[test]
13116 fn latest_arp_anchored_returns_none_without_bounds() {
13117 let entries = vec![
13121 version_entry("1.28.240.0", None, None),
13122 version_entry("1.27.470.0", None, None),
13123 ];
13124
13125 assert!(latest_arp_anchored_version(&entries).is_none());
13126 }
13127
13128 #[test]
13129 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
13130 let package = InstalledPackage {
13131 name: "AzCopy v10".to_owned(),
13132 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
13133 installed_version: "10.32.2".to_owned(),
13134 publisher: Some("Microsoft".to_owned()),
13135 scope: Some("Machine".to_owned()),
13136 installer_category: Some("exe".to_owned()),
13137 install_location: None,
13138 package_family_names: Vec::new(),
13139 product_codes: Vec::new(),
13140 upgrade_codes: Vec::new(),
13141 correlated: Some(SearchMatch {
13142 source_name: "winget".to_owned(),
13143 source_kind: SourceKind::PreIndexed,
13144 id: "Microsoft.Azure.AZCopy.10".to_owned(),
13145 name: "AzCopy v10".to_owned(),
13146 moniker: None,
13147 version: Some("10.32.3".to_owned()),
13148 channel: None,
13149 match_criteria: None,
13150 }),
13151 installed_version_canonical: false,
13152 correlated_requires_explicit_upgrade: false,
13153 correlated_lacks_compatible_installer: false,
13154 };
13155
13156 let item = list_match_from_installed(package);
13157
13158 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
13159 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
13160 assert_eq!(item.source_name.as_deref(), Some("winget"));
13161 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
13162 }
13163
13164 #[test]
13165 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
13166 let package = InstalledPackage {
13167 name: "Atlassian CLI".to_owned(),
13168 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
13169 installed_version: "1.3.18-stable".to_owned(),
13170 publisher: Some("Atlassian".to_owned()),
13171 scope: Some("User".to_owned()),
13172 installer_category: Some("exe".to_owned()),
13173 install_location: None,
13174 package_family_names: Vec::new(),
13175 product_codes: Vec::new(),
13176 upgrade_codes: Vec::new(),
13177 correlated: Some(SearchMatch {
13178 source_name: "winget".to_owned(),
13179 source_kind: SourceKind::PreIndexed,
13180 id: "Atlassian.AtlassianCLI".to_owned(),
13181 name: "Atlassian CLI".to_owned(),
13182 moniker: None,
13183 version: Some("1.3.18-stable".to_owned()),
13184 channel: None,
13185 match_criteria: Some("ProductCode".to_owned()),
13186 }),
13187 installed_version_canonical: false,
13188 correlated_requires_explicit_upgrade: false,
13189 correlated_lacks_compatible_installer: false,
13190 };
13191
13192 let item = list_match_from_installed(package);
13193
13194 assert_eq!(item.id, "Atlassian.AtlassianCLI");
13195 assert_eq!(
13196 item.local_id,
13197 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
13198 );
13199 assert_ne!(item.id, item.local_id);
13200 assert_eq!(item.source_name.as_deref(), Some("winget"));
13201 }
13202
13203 #[test]
13204 fn list_response_serialization_keeps_pascal_case_fields() {
13205 let response = ListResponse {
13206 matches: vec![ListMatch {
13207 name: "PowerToys".to_owned(),
13208 id: "Microsoft.PowerToys".to_owned(),
13209 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13210 installed_version: "0.98.1".to_owned(),
13211 available_version: Some("0.99.0".to_owned()),
13212 source_name: Some("winget".to_owned()),
13213 publisher: Some("Microsoft".to_owned()),
13214 scope: Some("Machine".to_owned()),
13215 installer_category: Some("msi".to_owned()),
13216 install_location: None,
13217 package_family_names: Vec::new(),
13218 product_codes: Vec::new(),
13219 upgrade_codes: Vec::new(),
13220 }],
13221 warnings: Vec::new(),
13222 truncated: false,
13223 };
13224
13225 let value = serde_json::to_value(&response).expect("serialize list response");
13226 let match_value = &value["Matches"][0];
13227
13228 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
13229 assert_eq!(match_value["InstalledVersion"], "0.98.1");
13230 assert_eq!(match_value["AvailableVersion"], "0.99.0");
13231 assert_eq!(match_value["SourceName"], "winget");
13232 assert!(match_value.get("local_id").is_none());
13233 assert!(match_value.get("installed_version").is_none());
13234 assert!(match_value.get("available_version").is_none());
13235 assert!(match_value.get("source_name").is_none());
13236 }
13237
13238 #[test]
13239 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
13240 let response = SearchResponse {
13241 matches: vec![SearchMatch {
13242 source_name: "msstore".to_owned(),
13243 source_kind: SourceKind::Rest,
13244 id: "9WZDNCRFJBMP".to_owned(),
13245 name: "Microsoft To Do".to_owned(),
13246 moniker: None,
13247 version: Some("2.123.456.0".to_owned()),
13248 channel: None,
13249 match_criteria: None,
13250 }],
13251 warnings: Vec::new(),
13252 truncated: false,
13253 };
13254
13255 let value = serde_json::to_value(&response).expect("serialize search response");
13256 let match_value = &value["Matches"][0];
13257
13258 assert_eq!(match_value["Name"], "Microsoft To Do");
13259 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
13260 assert_eq!(match_value["Version"], "2.123.456.0");
13261 assert_eq!(match_value["SourceName"], "msstore");
13262 assert!(match_value.get("source_name").is_none());
13263 }
13264
13265 #[test]
13266 fn versions_result_serialization_keeps_pascal_case_fields() {
13267 let result = VersionsResult {
13268 package: SearchMatch {
13269 source_name: "winget".to_owned(),
13270 source_kind: SourceKind::PreIndexed,
13271 id: "Microsoft.PowerToys".to_owned(),
13272 name: "PowerToys".to_owned(),
13273 moniker: None,
13274 version: Some("0.99.0".to_owned()),
13275 channel: None,
13276 match_criteria: None,
13277 },
13278 versions: vec![
13279 VersionKey {
13280 version: "0.99.0".to_owned(),
13281 channel: "stable".to_owned(),
13282 },
13283 VersionKey {
13284 version: "0.100.0-preview".to_owned(),
13285 channel: "preview".to_owned(),
13286 },
13287 ],
13288 warnings: Vec::new(),
13289 };
13290
13291 let value = serde_json::to_value(&result).expect("serialize versions result");
13292
13293 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
13294 assert_eq!(value["Versions"][0]["Channel"], "stable");
13295 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
13296 assert_eq!(value["Versions"][1]["Channel"], "preview");
13297 assert!(value.get("versions").is_none());
13298 }
13299
13300 #[test]
13301 fn version_data_parses_arp_bounds_from_winget_payload() {
13302 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";
13306
13307 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
13308 assert_eq!(document.versions.len(), 2);
13309 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
13310 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
13311 assert!(document.versions[1].arp_min_version.is_none());
13312 assert!(document.versions[1].arp_max_version.is_none());
13313 }
13314
13315 #[test]
13316 fn msix_resource_string_name_resolves_to_catalog_name() {
13317 let mut package = InstalledPackage {
13322 name: "ms-resource:appDisplayName".to_owned(),
13323 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
13324 installed_version: "1.28.239.0".to_owned(),
13325 publisher: None,
13326 scope: Some("User".to_owned()),
13327 installer_category: Some("msix".to_owned()),
13328 install_location: None,
13329 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
13330 product_codes: Vec::new(),
13331 upgrade_codes: Vec::new(),
13332 correlated: Some(SearchMatch {
13333 source_name: "winget".to_owned(),
13334 source_kind: SourceKind::PreIndexed,
13335 id: "Microsoft.AppInstaller".to_owned(),
13336 name: "App Installer".to_owned(),
13337 moniker: None,
13338 version: Some("1.28.240.0".to_owned()),
13339 channel: None,
13340 match_criteria: Some("PackageFamilyName".to_owned()),
13341 }),
13342 installed_version_canonical: false,
13343 correlated_requires_explicit_upgrade: false,
13344 correlated_lacks_compatible_installer: false,
13345 };
13346 apply_msix_resource_string_name_fix(&mut package);
13347 assert_eq!(package.name, "App Installer");
13348 }
13349
13350 #[test]
13351 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
13352 let mut package = InstalledPackage {
13357 name: "ms-resource:appDisplayName".to_owned(),
13358 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
13359 installed_version: "1.0".to_owned(),
13360 publisher: None,
13361 scope: Some("Machine".to_owned()),
13362 installer_category: Some("msi".to_owned()),
13363 install_location: None,
13364 package_family_names: Vec::new(),
13365 product_codes: Vec::new(),
13366 upgrade_codes: Vec::new(),
13367 correlated: Some(SearchMatch {
13368 source_name: "winget".to_owned(),
13369 source_kind: SourceKind::PreIndexed,
13370 id: "Some.Package".to_owned(),
13371 name: "Should Not Apply".to_owned(),
13372 moniker: None,
13373 version: Some("1.0".to_owned()),
13374 channel: None,
13375 match_criteria: None,
13376 }),
13377 installed_version_canonical: false,
13378 correlated_requires_explicit_upgrade: false,
13379 correlated_lacks_compatible_installer: false,
13380 };
13381 apply_msix_resource_string_name_fix(&mut package);
13382 assert_eq!(package.name, "ms-resource:appDisplayName");
13383 }
13384
13385 #[test]
13386 fn msix_resource_string_fix_skips_resolved_names() {
13387 let mut package = InstalledPackage {
13392 name: "Microsoft Teams".to_owned(),
13393 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13394 installed_version: "25290.205.4069.4894".to_owned(),
13395 publisher: None,
13396 scope: Some("User".to_owned()),
13397 installer_category: Some("msix".to_owned()),
13398 install_location: None,
13399 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13400 product_codes: Vec::new(),
13401 upgrade_codes: Vec::new(),
13402 correlated: Some(SearchMatch {
13403 source_name: "winget".to_owned(),
13404 source_kind: SourceKind::PreIndexed,
13405 id: "Microsoft.Teams".to_owned(),
13406 name: "Microsoft Teams Catalog Name".to_owned(),
13407 moniker: None,
13408 version: Some("26106.1906.4665.7308".to_owned()),
13409 channel: None,
13410 match_criteria: Some("PackageFamilyName".to_owned()),
13411 }),
13412 installed_version_canonical: false,
13413 correlated_requires_explicit_upgrade: false,
13414 correlated_lacks_compatible_installer: false,
13415 };
13416 apply_msix_resource_string_name_fix(&mut package);
13417 assert_eq!(package.name, "Microsoft Teams");
13418 }
13419
13420 #[cfg(windows)]
13421 #[test]
13422 fn unflip_packed_guid_reverses_msi_installer_packing() {
13423 assert_eq!(
13430 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
13431 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
13432 );
13433 assert_eq!(
13436 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
13437 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
13438 );
13439 assert!(unflip_packed_guid("nothex").is_none());
13441 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
13443 }
13444
13445 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
13446 InstalledPackage {
13447 name: format!("{id} install"),
13448 local_id: format!(r"ARP\Machine\X64\{id}"),
13449 installed_version: installed_version.to_owned(),
13450 publisher: None,
13451 scope: Some("Machine".to_owned()),
13452 installer_category: Some("msi".to_owned()),
13453 install_location: None,
13454 package_family_names: Vec::new(),
13455 product_codes: Vec::new(),
13456 upgrade_codes: Vec::new(),
13457 correlated: Some(SearchMatch {
13458 source_name: "winget".to_owned(),
13459 source_kind: SourceKind::PreIndexed,
13460 id: id.to_owned(),
13461 name: id.to_owned(),
13462 moniker: None,
13463 version: Some("99.0.0".to_owned()),
13464 channel: None,
13465 match_criteria: None,
13466 }),
13467 installed_version_canonical: canonical,
13468 correlated_requires_explicit_upgrade: false,
13469 correlated_lacks_compatible_installer: false,
13470 }
13471 }
13472
13473 #[test]
13474 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
13475 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
13482 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
13483
13484 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
13485 assert_eq!(result.len(), 1);
13486 assert_eq!(result[0].installed_version, "10.0.108");
13487 assert!(result[0].installed_version_canonical);
13488 }
13489
13490 #[test]
13491 fn dedupe_keeps_highest_among_canonical_rows() {
13492 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
13493 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
13494
13495 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
13496 assert_eq!(result.len(), 1);
13497 assert_eq!(result[0].installed_version, "1.7.9");
13498 }
13499
13500 #[test]
13501 fn dedupe_leaves_uncorrelated_rows_alone() {
13502 let uncorrelated = InstalledPackage {
13506 name: "Foo".to_owned(),
13507 local_id: r"ARP\Machine\X64\Foo".to_owned(),
13508 installed_version: "1.0".to_owned(),
13509 publisher: None,
13510 scope: Some("Machine".to_owned()),
13511 installer_category: Some("exe".to_owned()),
13512 install_location: None,
13513 package_family_names: Vec::new(),
13514 product_codes: Vec::new(),
13515 upgrade_codes: Vec::new(),
13516 correlated: None,
13517 installed_version_canonical: false,
13518 correlated_requires_explicit_upgrade: false,
13519 correlated_lacks_compatible_installer: false,
13520 };
13521 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
13522 assert_eq!(result.len(), 1);
13523 }
13524}