1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, 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::OsStringExt;
12use std::path::{Path, PathBuf};
13use std::sync::RwLock;
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use rusqlite::types::{Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, Row as SqlRow, params_from_iter};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map as JsonMap, Value as JsonValue};
22use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
23use sha2::{Digest, Sha256};
24#[cfg(windows)]
25use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
26#[cfg(windows)]
27use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
28#[cfg(windows)]
29use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
30#[cfg(windows)]
31use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
32#[cfg(windows)]
33use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
34#[cfg(windows)]
35use winreg::{RegKey, enums::*};
36use zip::ZipArchive;
37
38const DEFAULT_MARKET: &str = "US";
39const DEFAULT_MAX_RESULTS: usize = 50;
40const LIST_LOOKUP_MAX_RESULTS: usize = 500;
41const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
42const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
43#[cfg(windows)]
44const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
45#[cfg(windows)]
46const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
47const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
48 "Installed package discovery is not supported on this platform; returning no installed packages.";
49const INSTALL_UNSUPPORTED_WARNING: &str =
50 "Installing packages is not supported on this platform; no changes were made.";
51const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
52const REPAIR_REINSTALL_WARNING: &str =
53 "Pinget repair currently re-runs the package install flow for the selected package.";
54const UNINSTALL_UNSUPPORTED_WARNING: &str =
55 "Uninstalling packages is not supported on this platform; no changes were made.";
56#[cfg(windows)]
57const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
58const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
59 "LocalManifestFiles",
60 "BypassCertificatePinningForMicrosoftStore",
61 "InstallerHashOverride",
62 "LocalArchiveMalwareScanOverride",
63 "ProxyCommandLineOptions",
64];
65const REST_SUPPORTED_CONTRACTS: &[&str] = &[
66 "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",
67];
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70pub enum SourceKind {
71 #[serde(rename = "PreIndexed", alias = "preIndexed")]
72 PreIndexed,
73 #[serde(rename = "Rest", alias = "rest")]
74 Rest,
75}
76
77impl Display for SourceKind {
78 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
79 match self {
80 SourceKind::PreIndexed => f.write_str("preindexed"),
81 SourceKind::Rest => f.write_str("rest"),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SourceRecord {
88 pub name: String,
89 pub kind: SourceKind,
90 pub arg: String,
91 pub identifier: String,
92 #[serde(default = "default_source_trust_level")]
93 pub trust_level: String,
94 #[serde(default)]
95 pub explicit: bool,
96 #[serde(default)]
97 pub priority: i32,
98 #[serde(default)]
99 pub last_update: Option<DateTime<Utc>>,
100 #[serde(default)]
101 pub source_version: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105struct SourceStore {
106 sources: Vec<SourceRecord>,
107}
108
109impl Default for SourceStore {
110 fn default() -> Self {
111 Self {
112 sources: vec![
113 SourceRecord {
114 name: "winget".to_owned(),
115 kind: SourceKind::PreIndexed,
116 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
117 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
118 trust_level: "Trusted".to_owned(),
119 explicit: false,
120 priority: 0,
121 last_update: None,
122 source_version: None,
123 },
124 SourceRecord {
125 name: "msstore".to_owned(),
126 kind: SourceKind::Rest,
127 arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
128 identifier: "StoreEdgeFD".to_owned(),
129 trust_level: "Trusted".to_owned(),
130 explicit: false,
131 priority: 0,
132 last_update: None,
133 source_version: None,
134 },
135 ],
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
142pub struct RepositoryOptions {
143 pub app_root: PathBuf,
144 pub user_agent: String,
145}
146
147impl RepositoryOptions {
148 pub fn new(app_root: impl Into<PathBuf>) -> Self {
150 Self {
151 app_root: app_root.into(),
152 user_agent: DEFAULT_USER_AGENT.to_owned(),
153 }
154 }
155
156 pub fn for_current_user() -> Result<Self> {
158 Ok(Self::new(default_app_root()?))
159 }
160
161 #[must_use]
163 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
164 self.user_agent = user_agent.into();
165 self
166 }
167}
168
169#[derive(Debug, Clone, Default)]
170pub struct PackageQuery {
171 pub query: Option<String>,
172 pub id: Option<String>,
173 pub name: Option<String>,
174 pub moniker: Option<String>,
175 pub tag: Option<String>,
176 pub command: Option<String>,
177 pub source: Option<String>,
178 pub count: Option<usize>,
179 pub exact: bool,
180 pub version: Option<String>,
181 pub channel: Option<String>,
182 pub locale: Option<String>,
183 pub installer_type: Option<String>,
184 pub installer_architecture: Option<String>,
185 pub platform: Option<String>,
186 pub os_version: Option<String>,
187 pub install_scope: Option<String>,
188}
189
190#[derive(Debug, Clone, Default)]
191pub struct ListQuery {
192 pub query: Option<String>,
193 pub id: Option<String>,
194 pub name: Option<String>,
195 pub moniker: Option<String>,
196 pub tag: Option<String>,
197 pub command: Option<String>,
198 pub product_code: Option<String>,
199 pub version: Option<String>,
200 pub source: Option<String>,
201 pub count: Option<usize>,
202 pub exact: bool,
203 pub install_scope: Option<String>,
204 pub upgrade_only: bool,
205 pub include_unknown: bool,
206 pub include_pinned: bool,
207}
208
209#[derive(Debug, Clone, serde::Serialize)]
210#[serde(rename_all = "PascalCase")]
211pub struct SearchMatch {
212 pub source_name: String,
213 pub source_kind: SourceKind,
214 pub id: String,
215 pub name: String,
216 pub moniker: Option<String>,
217 pub version: Option<String>,
218 pub channel: Option<String>,
219 pub match_criteria: Option<String>,
220}
221
222#[derive(Debug, Clone, serde::Serialize)]
223#[serde(rename_all = "PascalCase")]
224pub struct SearchResponse {
225 pub matches: Vec<SearchMatch>,
226 pub warnings: Vec<String>,
227 pub truncated: bool,
228}
229
230#[derive(Debug, Clone, serde::Serialize)]
231#[serde(rename_all = "PascalCase")]
232pub struct ListMatch {
233 pub name: String,
234 pub id: String,
235 pub local_id: String,
236 pub installed_version: String,
237 pub available_version: Option<String>,
238 pub source_name: Option<String>,
239 pub publisher: Option<String>,
240 pub scope: Option<String>,
241 pub installer_category: Option<String>,
242 pub install_location: Option<String>,
243 pub package_family_names: Vec<String>,
244 pub product_codes: Vec<String>,
245 pub upgrade_codes: Vec<String>,
246}
247
248#[derive(Debug, Clone, serde::Serialize)]
249#[serde(rename_all = "PascalCase")]
250pub struct ListResponse {
251 pub matches: Vec<ListMatch>,
252 pub warnings: Vec<String>,
253 pub truncated: bool,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
257#[serde(rename_all = "PascalCase")]
258pub struct VersionKey {
259 pub version: String,
260 pub channel: String,
261}
262
263#[derive(Debug, Clone, serde::Serialize)]
264pub struct Manifest {
265 pub id: String,
266 pub name: String,
267 pub version: String,
268 pub channel: String,
269 pub publisher: Option<String>,
270 pub description: Option<String>,
271 pub moniker: Option<String>,
272 pub package_url: Option<String>,
273 pub publisher_url: Option<String>,
274 pub publisher_support_url: Option<String>,
275 pub license: Option<String>,
276 pub license_url: Option<String>,
277 pub privacy_url: Option<String>,
278 pub author: Option<String>,
279 pub copyright: Option<String>,
280 pub copyright_url: Option<String>,
281 pub release_notes: Option<String>,
282 pub release_notes_url: Option<String>,
283 pub tags: Vec<String>,
284 pub agreements: Vec<PackageAgreement>,
285 pub package_dependencies: Vec<String>,
286 pub documentation: Vec<Documentation>,
287 pub installers: Vec<Installer>,
288 pub require_explicit_upgrade: bool,
293}
294
295#[derive(Debug, Clone, serde::Serialize)]
296pub struct Documentation {
297 pub label: Option<String>,
298 pub url: String,
299}
300
301#[derive(Debug, Clone, serde::Serialize)]
302pub struct PackageAgreement {
303 pub label: Option<String>,
304 pub text: Option<String>,
305 pub url: Option<String>,
306}
307
308#[derive(Debug, Clone, Default, serde::Serialize)]
309pub struct Installer {
310 pub architecture: Option<String>,
311 pub installer_type: Option<String>,
312 pub nested_installer_type: Option<String>,
313 pub nested_installer_files: Vec<NestedInstallerFile>,
314 pub url: Option<String>,
315 pub sha256: Option<String>,
316 pub product_code: Option<String>,
317 pub locale: Option<String>,
318 pub scope: Option<String>,
319 pub release_date: Option<String>,
320 pub package_family_name: Option<String>,
321 pub upgrade_code: Option<String>,
322 pub platforms: Vec<String>,
323 pub minimum_os_version: Option<String>,
324 pub switches: InstallerSwitches,
325 pub commands: Vec<String>,
326 pub package_dependencies: Vec<String>,
327 pub require_explicit_upgrade: bool,
328}
329
330#[derive(Debug, Clone, Default, serde::Serialize)]
331pub struct NestedInstallerFile {
332 pub relative_file_path: String,
334 pub portable_command_alias: Option<String>,
338}
339
340#[derive(Debug, Clone, Default, serde::Serialize)]
341pub struct InstallerSwitches {
342 pub silent: Option<String>,
343 pub silent_with_progress: Option<String>,
344 pub interactive: Option<String>,
345 pub custom: Option<String>,
346 pub log: Option<String>,
347 pub install_location: Option<String>,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub enum InstallerMode {
352 Interactive,
353 SilentWithProgress,
354 Silent,
355}
356
357#[derive(Debug, Clone)]
358pub struct InstallRequest {
359 pub query: PackageQuery,
360 pub manifest_path: Option<PathBuf>,
361 pub mode: InstallerMode,
362 pub log_path: Option<PathBuf>,
363 pub custom: Option<String>,
364 pub override_args: Option<String>,
365 pub install_location: Option<String>,
366 pub skip_dependencies: bool,
367 pub dependencies_only: bool,
368 pub accept_package_agreements: bool,
369 pub force: bool,
370 pub rename: Option<String>,
371 pub uninstall_previous: bool,
372 pub ignore_security_hash: bool,
373 pub dependency_source: Option<String>,
374 pub no_upgrade: bool,
375}
376
377impl InstallRequest {
378 pub fn new(query: PackageQuery) -> Self {
379 Self {
380 query,
381 manifest_path: None,
382 mode: InstallerMode::SilentWithProgress,
383 log_path: None,
384 custom: None,
385 override_args: None,
386 install_location: None,
387 skip_dependencies: false,
388 dependencies_only: false,
389 accept_package_agreements: false,
390 force: false,
391 rename: None,
392 uninstall_previous: false,
393 ignore_security_hash: false,
394 dependency_source: None,
395 no_upgrade: false,
396 }
397 }
398}
399
400#[derive(Debug, Clone)]
401pub struct UninstallRequest {
402 pub query: PackageQuery,
403 pub manifest_path: Option<PathBuf>,
404 pub product_code: Option<String>,
405 pub mode: InstallerMode,
406 pub all_versions: bool,
407 pub force: bool,
408 pub purge: bool,
409 pub preserve: bool,
410 pub log_path: Option<PathBuf>,
411}
412
413impl UninstallRequest {
414 pub fn new(query: PackageQuery) -> Self {
415 Self {
416 query,
417 manifest_path: None,
418 product_code: None,
419 mode: InstallerMode::SilentWithProgress,
420 all_versions: false,
421 force: false,
422 purge: false,
423 preserve: false,
424 log_path: None,
425 }
426 }
427}
428
429#[derive(Debug, Clone)]
430pub struct RepairRequest {
431 pub query: PackageQuery,
432 pub manifest_path: Option<PathBuf>,
433 pub product_code: Option<String>,
434 pub mode: InstallerMode,
435 pub log_path: Option<PathBuf>,
436 pub accept_package_agreements: bool,
437 pub force: bool,
438 pub ignore_security_hash: bool,
439}
440
441impl RepairRequest {
442 pub fn new(query: PackageQuery) -> Self {
443 Self {
444 query,
445 manifest_path: None,
446 product_code: None,
447 mode: InstallerMode::SilentWithProgress,
448 log_path: None,
449 accept_package_agreements: false,
450 force: false,
451 ignore_security_hash: false,
452 }
453 }
454}
455
456#[derive(Debug, Clone, serde::Serialize)]
457pub struct ShowResult {
458 pub package: SearchMatch,
459 pub manifest: Manifest,
460 pub selected_installer: Option<Installer>,
461 pub cached_files: Vec<PathBuf>,
462 pub warnings: Vec<String>,
463 #[serde(skip_serializing)]
464 manifest_documents: JsonValue,
465}
466
467impl ShowResult {
468 pub fn structured_document(&self) -> JsonValue {
469 collapse_structured_document(&self.manifest_documents)
470 }
471}
472
473#[derive(Debug, Clone, serde::Serialize)]
474#[serde(rename_all = "PascalCase")]
475pub struct VersionsResult {
476 pub package: SearchMatch,
477 pub versions: Vec<VersionKey>,
478 pub warnings: Vec<String>,
479}
480
481#[derive(Debug, Clone, serde::Serialize)]
482#[serde(rename_all = "PascalCase")]
483pub struct CacheWarmResult {
484 pub package: SearchMatch,
485 pub cached_files: Vec<PathBuf>,
486 pub warnings: Vec<String>,
487}
488
489#[derive(Debug, Clone, serde::Serialize)]
490#[serde(rename_all = "PascalCase")]
491pub struct SourceUpdateResult {
492 pub name: String,
493 pub kind: SourceKind,
494 pub detail: String,
495}
496
497#[derive(Debug, Clone, serde::Serialize)]
498#[serde(rename_all = "PascalCase")]
499pub struct PinRecord {
500 pub package_id: String,
501 pub version: String,
502 pub source_id: String,
503 pub pin_type: PinType,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
507pub enum PinType {
508 Pinning,
509 Blocking,
510 Gating,
511}
512
513impl Display for PinType {
514 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
515 match self {
516 PinType::Pinning => f.write_str("Pinning"),
517 PinType::Blocking => f.write_str("Blocking"),
518 PinType::Gating => f.write_str("Gating"),
519 }
520 }
521}
522
523#[derive(Debug, Clone, serde::Serialize)]
525pub struct InstallResult {
526 pub package_id: String,
527 pub version: String,
528 pub installer_path: PathBuf,
529 pub installer_type: String,
530 pub exit_code: i32,
531 pub success: bool,
532 pub no_op: bool,
533 pub warnings: Vec<String>,
534}
535
536impl InstallerSwitches {
537 fn with_fallback(&self, fallback: &Self) -> Self {
538 Self {
539 silent: self.silent.clone().or_else(|| fallback.silent.clone()),
540 silent_with_progress: self
541 .silent_with_progress
542 .clone()
543 .or_else(|| fallback.silent_with_progress.clone()),
544 interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
545 custom: self.custom.clone().or_else(|| fallback.custom.clone()),
546 log: self.log.clone().or_else(|| fallback.log.clone()),
547 install_location: self
548 .install_location
549 .clone()
550 .or_else(|| fallback.install_location.clone()),
551 }
552 }
553}
554
555fn installed_package_discovery_supported() -> bool {
556 cfg!(windows)
557}
558
559fn package_actions_supported() -> bool {
560 cfg!(windows)
561}
562
563fn unsupported_action_result(
564 package_id: impl Into<String>,
565 version: impl Into<String>,
566 installer_type: impl Into<String>,
567 warning: &str,
568) -> InstallResult {
569 InstallResult {
570 package_id: package_id.into(),
571 version: version.into(),
572 installer_path: PathBuf::new(),
573 installer_type: installer_type.into(),
574 exit_code: 0,
575 success: true,
576 no_op: true,
577 warnings: vec![warning.to_owned()],
578 }
579}
580
581#[derive(Debug, Clone)]
582struct InstalledPackage {
583 name: String,
584 local_id: String,
585 installed_version: String,
586 publisher: Option<String>,
587 scope: Option<String>,
588 installer_category: Option<String>,
589 install_location: Option<String>,
590 package_family_names: Vec<String>,
591 product_codes: Vec<String>,
592 upgrade_codes: Vec<String>,
593 correlated: Option<SearchMatch>,
594 installed_version_canonical: bool,
601 correlated_requires_explicit_upgrade: bool,
605 correlated_lacks_compatible_installer: bool,
609}
610
611#[derive(Debug, Clone)]
612struct LocatedMatch {
613 display: SearchMatch,
614 source_index: usize,
615 locator: MatchLocator,
616}
617
618#[derive(Debug)]
619struct SearchSourceMatches {
620 matches: Vec<LocatedMatch>,
621 truncated: bool,
622}
623
624#[derive(Debug, Clone)]
625enum MatchLocator {
626 PreIndexedV1 {
627 package_rowid: i64,
628 },
629 PreIndexedV2 {
630 package_rowid: i64,
631 package_hash: String,
632 },
633 Rest {
634 package_id: String,
635 versions: Vec<VersionKey>,
636 },
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640enum SearchSemantics {
641 Many,
642 Single,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
646struct RestInfoCache {
647 expires_at: DateTime<Utc>,
648 value: RestInformation,
649}
650
651#[derive(Debug, Clone, Default, Serialize, Deserialize)]
652struct RestInformation {
653 #[serde(rename = "SourceIdentifier")]
654 source_identifier: String,
655 #[serde(rename = "ServerSupportedVersions", default)]
656 server_supported_versions: Vec<String>,
657 #[serde(rename = "RequiredPackageMatchFields", default)]
658 required_package_match_fields: Vec<String>,
659 #[serde(rename = "UnsupportedPackageMatchFields", default)]
660 unsupported_package_match_fields: Vec<String>,
661 #[serde(rename = "RequiredQueryParameters", default)]
662 required_query_parameters: Vec<String>,
663 #[serde(rename = "UnsupportedQueryParameters", default)]
664 unsupported_query_parameters: Vec<String>,
665}
666
667#[derive(Debug, Clone, Deserialize)]
668struct PackageVersionDataDocument {
669 #[serde(rename = "vD", default)]
670 versions: Vec<PackageVersionDataEntry>,
671}
672
673#[derive(Debug, Clone, Deserialize)]
674struct PackageVersionDataEntry {
675 #[serde(rename = "v")]
676 version: String,
677 #[serde(rename = "rP")]
678 manifest_relative_path: String,
679 #[serde(rename = "s256H")]
680 manifest_hash: String,
681 #[serde(rename = "aMiV", default)]
686 arp_min_version: Option<String>,
687 #[serde(rename = "aMaV", default)]
688 arp_max_version: Option<String>,
689}
690
691pub struct Repository {
692 app_root: PathBuf,
693 client: Client,
694 store: SourceStore,
695 use_system_winget_sources: bool,
696}
697
698impl Repository {
699 pub fn open() -> Result<Self> {
701 Self::open_with_options(RepositoryOptions::for_current_user()?)
702 }
703
704 pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
706 ensure_app_dirs(&options.app_root)?;
707 let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
708 let store = load_store(&options.app_root)?;
709 let client = Client::builder()
710 .user_agent(&options.user_agent)
711 .build()
712 .context("failed to build HTTP client")?;
713 Ok(Self {
714 app_root: options.app_root,
715 client,
716 store,
717 use_system_winget_sources,
718 })
719 }
720
721 pub fn app_root(&self) -> &Path {
722 &self.app_root
723 }
724
725 pub fn list_sources(&self) -> Vec<SourceRecord> {
726 self.store.sources.clone()
727 }
728
729 pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
730 self.add_source_with_metadata(name, arg, kind, None, false, 0)
731 }
732
733 pub fn add_source_with_metadata(
734 &mut self,
735 name: &str,
736 arg: &str,
737 kind: SourceKind,
738 trust_level: Option<&str>,
739 explicit: bool,
740 priority: i32,
741 ) -> Result<()> {
742 if self.use_system_winget_sources {
743 system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
744 self.refresh_system_winget_sources()?;
745 return Ok(());
746 }
747
748 if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
749 bail!("A source with name '{name}' already exists.");
750 }
751 if self.store.sources.iter().any(|s| s.arg == arg) {
752 bail!("A source with argument '{arg}' already exists.");
753 }
754 let identifier = name.to_owned();
755 self.store.sources.push(SourceRecord {
756 name: name.to_owned(),
757 kind,
758 arg: arg.to_owned(),
759 identifier,
760 trust_level: normalize_source_trust_level(trust_level)?,
761 explicit,
762 priority,
763 last_update: None,
764 source_version: None,
765 });
766 self.save_store()?;
767 Ok(())
768 }
769
770 pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
771 if self.use_system_winget_sources {
772 bail!("Editing system WinGet sources is not supported by winget source commands.");
773 }
774
775 let source = self
776 .store
777 .sources
778 .iter_mut()
779 .find(|source| source.name.eq_ignore_ascii_case(name))
780 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
781
782 if let Some(explicit) = explicit {
783 source.explicit = explicit;
784 }
785
786 if trust_level.is_some() {
787 source.trust_level = normalize_source_trust_level(trust_level)?;
788 }
789
790 self.save_store()?;
791 Ok(())
792 }
793
794 pub fn remove_source(&mut self, name: &str) -> Result<()> {
795 if self.use_system_winget_sources {
796 system_winget_remove_source(name)?;
797 self.refresh_system_winget_sources()?;
798 return Ok(());
799 }
800
801 let idx = self
802 .store
803 .sources
804 .iter()
805 .position(|s| s.name.eq_ignore_ascii_case(name))
806 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
807 let source = self.store.sources.remove(idx);
808 let state_dir = self.source_state_dir(&source);
810 if state_dir.exists() {
811 let _ = fs::remove_dir_all(&state_dir);
812 }
813 self.save_store()?;
814 Ok(())
815 }
816
817 pub fn reset_source(&mut self, name: &str) -> Result<()> {
818 if self.use_system_winget_sources {
819 system_winget_reset_source(name)?;
820 self.refresh_system_winget_sources()?;
821 return Ok(());
822 }
823
824 let idx = self
825 .store
826 .sources
827 .iter()
828 .position(|source| source.name.eq_ignore_ascii_case(name))
829 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
830
831 let source = self.store.sources[idx].clone();
832 let state_dir = self.source_state_dir(&source);
833 if state_dir.exists() {
834 let _ = fs::remove_dir_all(&state_dir);
835 }
836
837 if let Some(default_source) = SourceStore::default()
838 .sources
839 .into_iter()
840 .find(|source| source.name.eq_ignore_ascii_case(name))
841 {
842 self.store.sources[idx] = default_source;
843 } else {
844 self.store.sources[idx].last_update = None;
845 self.store.sources[idx].source_version = None;
846 }
847
848 self.save_store()?;
849 Ok(())
850 }
851
852 pub fn reset_sources(&mut self) -> Result<()> {
853 if self.use_system_winget_sources {
854 system_winget_reset_sources()?;
855 self.refresh_system_winget_sources()?;
856 return Ok(());
857 }
858
859 for source in &self.store.sources {
861 let state_dir = self.source_state_dir(source);
862 if state_dir.exists() {
863 let _ = fs::remove_dir_all(&state_dir);
864 }
865 }
866 self.store = SourceStore::default();
867 self.save_store()?;
868 Ok(())
869 }
870
871 pub fn supported_admin_settings() -> &'static [&'static str] {
872 SUPPORTED_ADMIN_SETTINGS
873 }
874
875 pub fn get_user_settings(&self) -> Result<JsonValue> {
876 Ok(JsonValue::Object(load_json_object(&user_settings_path(
877 &self.app_root,
878 ))?))
879 }
880
881 pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
882 let update = match user_settings {
883 JsonValue::Object(object) => object.clone(),
884 _ => bail!("user settings must be a JSON object"),
885 };
886
887 let effective = if merge {
888 merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
889 } else {
890 update
891 };
892 save_json_object(user_settings_path(&self.app_root), &effective)?;
893 Ok(JsonValue::Object(effective))
894 }
895
896 pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
897 let current = self.get_user_settings()?;
898 Ok(if ignore_not_set {
899 json_contains(¤t, expected)
900 } else {
901 current == *expected
902 })
903 }
904
905 pub fn get_admin_settings(&self) -> Result<JsonValue> {
906 let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
907 for name in SUPPORTED_ADMIN_SETTINGS {
908 settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
909 }
910
911 Ok(JsonValue::Object(settings))
912 }
913
914 pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
915 let normalized = normalize_admin_setting_name(name)?;
916 let mut settings = match self.get_admin_settings()? {
917 JsonValue::Object(settings) => settings,
918 _ => JsonMap::new(),
919 };
920 settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
921 save_json_object(admin_settings_path(&self.app_root), &settings)
922 }
923
924 pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
925 if !reset_all && name.is_none() {
926 bail!("resetting admin settings requires a setting name or reset-all");
927 }
928
929 let mut settings = match self.get_admin_settings()? {
930 JsonValue::Object(settings) => settings,
931 _ => JsonMap::new(),
932 };
933
934 if reset_all {
935 for setting_name in SUPPORTED_ADMIN_SETTINGS {
936 settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
937 }
938 } else {
939 settings.insert(
940 normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
941 JsonValue::Bool(false),
942 );
943 }
944
945 save_json_object(admin_settings_path(&self.app_root), &settings)
946 }
947
948 pub fn ensure_settings_files(&self) -> Result<()> {
949 let user_settings = user_settings_path(&self.app_root);
950 if !user_settings.exists() {
951 save_json_object(user_settings, &JsonMap::new())?;
952 }
953
954 let admin_settings = admin_settings_path(&self.app_root);
955 if !admin_settings.exists() {
956 let mut values = JsonMap::new();
957 for name in SUPPORTED_ADMIN_SETTINGS {
958 values.insert((*name).to_owned(), JsonValue::Bool(false));
959 }
960 save_json_object(admin_settings, &values)?;
961 }
962
963 Ok(())
964 }
965
966 pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
967 if self.use_system_winget_sources {
968 return self.update_system_winget_sources(source_name);
969 }
970
971 let indexes = self.resolve_source_indexes(source_name)?;
972 let mut results = Vec::new();
973
974 for index in indexes {
975 let detail = match self.store.sources[index].kind {
976 SourceKind::PreIndexed => self.update_preindexed(index)?,
977 SourceKind::Rest => self.update_rest(index)?,
978 };
979
980 results.push(SourceUpdateResult {
981 name: self.store.sources[index].name.clone(),
982 kind: self.store.sources[index].kind,
983 detail,
984 });
985 }
986
987 self.save_store()?;
988 Ok(results)
989 }
990
991 pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
992 let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
993 Ok(SearchResponse {
994 matches: matches.into_iter().map(|item| item.display).collect(),
995 warnings,
996 truncated,
997 })
998 }
999
1000 pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1001 let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
1002 let mut structured_documents = Vec::with_capacity(matches.len());
1003 for located in matches {
1004 let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1005 structured_documents.push(manifest_documents);
1006 }
1007
1008 Ok(collapse_structured_documents(&structured_documents))
1009 }
1010
1011 pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1012 if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1013 bail!("--include-unknown and --include-pinned require --upgrade-available");
1014 }
1015 if query.source.is_some()
1016 && query.query.is_none()
1017 && query.id.is_none()
1018 && query.name.is_none()
1019 && query.moniker.is_none()
1020 && query.tag.is_none()
1021 && query.command.is_none()
1022 {
1023 bail!("list --source currently requires a query or explicit filter");
1024 }
1025
1026 let has_filter = list_query_needs_available_lookup(query);
1027 let needs_available = true;
1032
1033 let mut warnings = Vec::new();
1034 if !installed_package_discovery_supported() {
1035 warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1036 }
1037 let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1038
1039 if needs_available {
1040 warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1046
1047 warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1056 }
1057
1058 if needs_available && has_filter {
1059 let available_query = package_query_from_list_query(query);
1062 let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1063 warnings.extend(source_warnings);
1064 let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1065 for package in &mut installed {
1066 if package.correlated.is_some() {
1067 continue;
1068 }
1069 package.correlated =
1070 correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1071 }
1072 } else if needs_available {
1073 warnings.extend(self.correlate_all_installed(&mut installed)?);
1075 }
1076
1077 if needs_available {
1078 warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1089 }
1090
1091 if query.upgrade_only {
1098 installed = dedupe_correlated_for_upgrade(installed);
1099 }
1100
1101 let mut matches = installed
1102 .into_iter()
1103 .filter(|package| {
1104 list_package_matches(package, query)
1105 && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1106 })
1107 .collect::<Vec<_>>();
1108
1109 matches.sort_by(|left, right| {
1110 list_sort_weight(left)
1111 .cmp(&list_sort_weight(right))
1112 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1113 .then_with(|| left.local_id.cmp(&right.local_id))
1114 });
1115
1116 let pins = if query.upgrade_only {
1117 self.list_pins(query.source.as_deref())?
1118 } else {
1119 Vec::new()
1120 };
1121 let mut list_matches = matches
1122 .into_iter()
1123 .map(list_match_from_installed)
1124 .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1125 .collect::<Vec<_>>();
1126 let truncated = if let Some(limit) = query.count {
1127 let was_truncated = list_matches.len() > limit;
1128 list_matches.truncate(limit);
1129 was_truncated
1130 } else {
1131 false
1132 };
1133
1134 Ok(ListResponse {
1135 matches: list_matches,
1136 warnings,
1137 truncated,
1138 })
1139 }
1140
1141 fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1145 if installed.iter().all(|p| p.correlated.is_some()) {
1146 return Ok(Vec::new());
1147 }
1148 let all_query = PackageQuery {
1149 query: None,
1150 id: None,
1151 name: None,
1152 moniker: None,
1153 tag: None,
1154 command: None,
1155 source: None,
1156 count: Some(100_000), exact: false,
1158 version: None,
1159 channel: None,
1160 locale: None,
1161 installer_type: None,
1162 installer_architecture: None,
1163 platform: None,
1164 os_version: None,
1165 install_scope: None,
1166 };
1167 let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1168 let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1169
1170 for package in installed.iter_mut() {
1171 if package.correlated.is_some() {
1172 continue;
1173 }
1174 package.correlated = correlate_installed_package(package, &candidates, true);
1175 }
1176
1177 Ok(warnings)
1178 }
1179
1180 fn correlate_installed_via_index(
1189 &mut self,
1190 installed: &mut [InstalledPackage],
1191 requested_source: Option<&str>,
1192 ) -> Result<Vec<String>> {
1193 let source_indices: Vec<usize> = self
1194 .store
1195 .sources
1196 .iter()
1197 .enumerate()
1198 .filter(|(_, source)| {
1199 source.kind == SourceKind::PreIndexed
1200 && match requested_source {
1201 Some(name) => source.name.eq_ignore_ascii_case(name),
1202 None => true,
1203 }
1204 })
1205 .map(|(index, _)| index)
1206 .collect();
1207
1208 for source_index in source_indices {
1209 let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1210 let connection = match self.open_preindexed_connection(source_index) {
1211 Ok(c) => c,
1212 Err(_) => continue,
1213 };
1214 if !v2_identity_tables_present(&connection) {
1215 continue;
1216 }
1217 let mut acc = Vec::new();
1218 for (idx, package) in installed.iter().enumerate() {
1219 if package.correlated.is_some() {
1220 continue;
1221 }
1222 let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1223 continue;
1224 };
1225 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1226 continue;
1227 };
1228 acc.push((idx, meta, by));
1229 }
1230 acc
1231 };
1232
1233 let source = self.source_clone(source_index);
1234 for (idx, meta, by) in resolved {
1235 let (canonical_installed, anchored_latest) =
1236 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1237 Ok((entries, _)) => (
1238 map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1239 latest_arp_anchored_version(&entries),
1240 ),
1241 Err(_) => (None, None),
1242 };
1243 let available_version = match (&canonical_installed, anchored_latest) {
1250 (Some(_), Some(anchored)) => anchored,
1251 _ => meta.latest_version,
1252 };
1253 let installed_pkg = &mut installed[idx];
1254 installed_pkg.correlated = Some(SearchMatch {
1255 source_name: source.name.clone(),
1256 source_kind: source.kind,
1257 id: meta.id,
1258 name: meta.name,
1259 moniker: meta.moniker,
1260 version: Some(available_version),
1261 channel: None,
1262 match_criteria: Some(by.to_owned()),
1263 });
1264 if let Some(version) = canonical_installed {
1265 installed_pkg.installed_version = version;
1266 installed_pkg.installed_version_canonical = true;
1267 }
1268 }
1269 }
1270
1271 Ok(Vec::new())
1272 }
1273
1274 fn correlate_installed_by_normalized_identity(
1283 &mut self,
1284 installed: &mut [InstalledPackage],
1285 requested_source: Option<&str>,
1286 ) -> Result<Vec<String>> {
1287 let source_indices: Vec<usize> = self
1288 .store
1289 .sources
1290 .iter()
1291 .enumerate()
1292 .filter(|(_, source)| {
1293 source.kind == SourceKind::PreIndexed
1294 && match requested_source {
1295 Some(name) => source.name.eq_ignore_ascii_case(name),
1296 None => true,
1297 }
1298 })
1299 .map(|(index, _)| index)
1300 .collect();
1301
1302 for source_index in source_indices {
1303 let resolved: Vec<(usize, V2PackageMetadata)> = {
1304 let connection = match self.open_preindexed_connection(source_index) {
1305 Ok(c) => c,
1306 Err(_) => continue,
1307 };
1308 if !v2_normalized_identity_tables_present(&connection) {
1309 continue;
1310 }
1311 let mut acc = Vec::new();
1312 for (idx, pkg) in installed.iter().enumerate() {
1313 if pkg.correlated.is_some() {
1314 continue;
1315 }
1316 if pkg.local_id.starts_with("MSIX\\") {
1321 continue;
1322 }
1323 let Some(publisher) = pkg.publisher.as_deref() else {
1324 continue;
1325 };
1326 let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1327 if normalized_name.is_empty() {
1328 continue;
1329 }
1330 let normalized_publisher = name_normalization::normalize_publisher(publisher);
1331 if normalized_publisher.is_empty() {
1332 continue;
1333 }
1334 let Some(rowid) =
1335 lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1336 else {
1337 continue;
1338 };
1339 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1340 continue;
1341 };
1342 acc.push((idx, meta));
1343 }
1344 acc
1345 };
1346
1347 let source = self.source_clone(source_index);
1348 for (idx, meta) in resolved {
1349 let (canonical_installed, anchored_latest) =
1350 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1351 Ok((entries, _)) => (
1352 map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1353 latest_arp_anchored_version(&entries),
1354 ),
1355 Err(_) => (None, None),
1356 };
1357 let available_version = match (&canonical_installed, anchored_latest) {
1358 (Some(_), Some(anchored)) => anchored,
1359 _ => meta.latest_version,
1360 };
1361 let installed_pkg = &mut installed[idx];
1362 installed_pkg.correlated = Some(SearchMatch {
1363 source_name: source.name.clone(),
1364 source_kind: source.kind,
1365 id: meta.id,
1366 name: meta.name,
1367 moniker: meta.moniker,
1368 version: Some(available_version),
1369 channel: None,
1370 match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1371 });
1372 if let Some(version) = canonical_installed {
1373 installed_pkg.installed_version = version;
1374 installed_pkg.installed_version_canonical = true;
1375 }
1376 }
1377 }
1378
1379 Ok(Vec::new())
1380 }
1381
1382 fn enrich_correlated_via_index(
1394 &mut self,
1395 installed: &mut [InstalledPackage],
1396 requested_source: Option<&str>,
1397 ) -> Result<Vec<String>> {
1398 for package in installed.iter_mut() {
1400 apply_msix_resource_string_name_fix(package);
1401 }
1402
1403 let source_indices: Vec<usize> = self
1406 .store
1407 .sources
1408 .iter()
1409 .enumerate()
1410 .filter(|(_, source)| {
1411 source.kind == SourceKind::PreIndexed
1412 && match requested_source {
1413 Some(name) => source.name.eq_ignore_ascii_case(name),
1414 None => true,
1415 }
1416 })
1417 .map(|(index, _)| index)
1418 .collect();
1419
1420 for source_index in source_indices {
1421 let needs: Vec<(usize, V2PackageMetadata)> = {
1422 let connection = match self.open_preindexed_connection(source_index) {
1423 Ok(c) => c,
1424 Err(_) => continue,
1425 };
1426 if !v2_identity_tables_present(&connection) {
1427 continue;
1428 }
1429 let mut acc = Vec::new();
1430 for (idx, pkg) in installed.iter().enumerate() {
1431 let Some(candidate) = pkg.correlated.as_ref() else {
1432 continue;
1433 };
1434 if !candidate
1438 .source_name
1439 .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1440 {
1441 continue;
1442 }
1443 let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1444 continue;
1445 };
1446 acc.push((idx, meta));
1447 }
1448 acc
1449 };
1450
1451 let source = self.source_clone(source_index);
1452 for (idx, meta) in needs {
1453 let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1458 Ok((entries, _)) => entries,
1459 Err(_) => continue,
1460 };
1461
1462 if !installed[idx].installed_version_canonical {
1463 let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1464 let anchored_latest = latest_arp_anchored_version(&entries);
1465 if let Some(canonical) = canonical_installed {
1466 installed[idx].installed_version = canonical;
1467 installed[idx].installed_version_canonical = true;
1468 if let (Some(correlated), Some(anchored)) =
1469 (installed[idx].correlated.as_mut(), anchored_latest)
1470 {
1471 correlated.version = Some(anchored);
1472 }
1473 }
1474 }
1475
1476 if let Some(latest) = entries.first() {
1482 let bytes_result = self.get_cached_source_file(
1483 "V2_M",
1484 &source,
1485 &latest.manifest_relative_path,
1486 Some(latest.manifest_hash.as_str()),
1487 );
1488 if let Ok(bytes) = bytes_result
1489 && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1490 {
1491 installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1492 installed[idx].correlated_lacks_compatible_installer =
1493 !manifest_has_compatible_installer(&manifest);
1494 }
1495 }
1496 }
1497 }
1498
1499 Ok(Vec::new())
1500 }
1501
1502 pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1503 let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1504 let versions = self.versions_for_match(&located, query)?;
1505 Ok(VersionsResult {
1506 package: located.display,
1507 versions,
1508 warnings,
1509 })
1510 }
1511
1512 pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1513 let (located, warnings) = self.find_single_match(query)?;
1514 let versions = self.versions_for_match(&located, query)?;
1515 Ok(VersionsResult {
1516 package: located.display,
1517 versions,
1518 warnings,
1519 })
1520 }
1521
1522 pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1523 let (located, warnings) = self.find_single_match(query)?;
1524 let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1525 let selected_installer = select_installer(&manifest.installers, query);
1526
1527 Ok(ShowResult {
1528 package: located.display,
1529 manifest,
1530 selected_installer,
1531 cached_files,
1532 warnings,
1533 manifest_documents,
1534 })
1535 }
1536
1537 pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1538 let (located, warnings) = self.find_single_match(query)?;
1539 let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1540
1541 Ok(CacheWarmResult {
1542 package: located.display,
1543 cached_files,
1544 warnings,
1545 })
1546 }
1547
1548 pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1551 let db_path = pins_db_path(&self.app_root);
1552 if !db_path.exists() {
1553 return Ok(Vec::new());
1554 }
1555 let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
1556 let pin_type_column = match resolve_pin_type_column(&conn)? {
1557 Some(column) => column,
1558 None => return Ok(Vec::new()),
1559 };
1560 let sql = if source_id.is_some() {
1561 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1562 } else {
1563 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1564 };
1565 let mut stmt = conn.prepare(&sql)?;
1566 let rows = if let Some(source_id) = source_id {
1567 stmt.query_map([source_id], |row| {
1568 let pin_type_int: i64 = row.get(3)?;
1569 Ok(PinRecord {
1570 package_id: row.get(0)?,
1571 version: row.get(1)?,
1572 source_id: row.get(2)?,
1573 pin_type: decode_pin_type(pin_type_int),
1574 })
1575 })?
1576 .filter_map(|r| r.ok())
1577 .collect()
1578 } else {
1579 stmt.query_map([], |row| {
1580 let pin_type_int: i64 = row.get(3)?;
1581 Ok(PinRecord {
1582 package_id: row.get(0)?,
1583 version: row.get(1)?,
1584 source_id: row.get(2)?,
1585 pin_type: decode_pin_type(pin_type_int),
1586 })
1587 })?
1588 .filter_map(|r| r.ok())
1589 .collect()
1590 };
1591 Ok(rows)
1592 }
1593
1594 pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1595 let db_path = pins_db_path(&self.app_root);
1596 let conn = Connection::open(&db_path)?;
1597 conn.execute_batch(
1598 "CREATE TABLE IF NOT EXISTS pin (
1599 package_id TEXT NOT NULL,
1600 version TEXT NOT NULL DEFAULT '*',
1601 source_id TEXT NOT NULL DEFAULT '',
1602 type INTEGER NOT NULL DEFAULT 0,
1603 PRIMARY KEY (package_id, source_id)
1604 )",
1605 )?;
1606 let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1607 let type_int: i64 = match pin_type {
1608 PinType::Pinning => 2,
1609 PinType::Blocking => 4,
1610 PinType::Gating => 3,
1611 };
1612 conn.execute(
1613 &format!(
1614 "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1615 ),
1616 rusqlite::params![package_id, version, source_id, type_int],
1617 )?;
1618 Ok(())
1619 }
1620
1621 pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1622 let db_path = pins_db_path(&self.app_root);
1623 if !db_path.exists() {
1624 return Ok(false);
1625 }
1626 let conn = Connection::open(&db_path)?;
1627 let count = if let Some(source_id) = source_id {
1628 conn.execute(
1629 "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1630 rusqlite::params![package_id, source_id],
1631 )?
1632 } else {
1633 conn.execute("DELETE FROM pin WHERE package_id = ?1", rusqlite::params![package_id])?
1634 };
1635 Ok(count > 0)
1636 }
1637
1638 pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1639 let db_path = pins_db_path(&self.app_root);
1640 if !db_path.exists() {
1641 return Ok(());
1642 }
1643
1644 if let Some(source_id) = source_id {
1645 let conn = Connection::open(&db_path)?;
1646 conn.execute("DELETE FROM pin WHERE source_id = ?1", rusqlite::params![source_id])?;
1647 } else {
1648 fs::remove_file(&db_path)?;
1649 }
1650 Ok(())
1651 }
1652
1653 fn save_store(&self) -> Result<()> {
1654 save_store(&self.app_root, &self.store)
1655 }
1656
1657 fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1658 source_state_dir(&self.app_root, source)
1659 }
1660
1661 pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<(Manifest, PathBuf)> {
1664 self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1665 }
1666
1667 pub fn download_installer_for_request(
1668 &mut self,
1669 request: &InstallRequest,
1670 download_dir: &Path,
1671 ) -> Result<(Manifest, PathBuf)> {
1672 let manifest = self.resolve_manifest_for_install(request)?;
1673 let installer = select_installer(&manifest.installers, &request.query)
1674 .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1675 let url = installer
1676 .url
1677 .as_deref()
1678 .ok_or_else(|| anyhow!("Installer has no URL"))?;
1679
1680 fs::create_dir_all(download_dir)?;
1681 let filename = request.rename.as_deref().unwrap_or_else(|| {
1682 url.rsplit('/')
1683 .next()
1684 .unwrap_or("installer")
1685 .split('?')
1686 .next()
1687 .unwrap_or("installer")
1688 });
1689 let dest = download_dir.join(filename);
1690
1691 let response = self.client.get(url).send().context("failed to download installer")?;
1692 if !response.status().is_success() {
1693 bail!("Download failed: HTTP {}", response.status());
1694 }
1695 let bytes = response.bytes()?;
1696 fs::write(&dest, &bytes)?;
1697
1698 if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1699 let _ = fs::remove_file(&dest);
1700 return Err(error);
1701 }
1702
1703 Ok((manifest, dest))
1704 }
1705
1706 pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1707 let mut request = InstallRequest::new(query.clone());
1708 request.mode = if silent {
1709 InstallerMode::Silent
1710 } else {
1711 InstallerMode::SilentWithProgress
1712 };
1713 self.install_request(&request)
1714 }
1715
1716 pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1717 let mut request = InstallRequest::new(query.clone());
1718 request.mode = mode;
1719 self.install_request(&request)
1720 }
1721
1722 pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1723 let manifest = self.resolve_manifest_for_install(request)?;
1724 if !package_actions_supported() {
1725 let installer_type = select_installer(&manifest.installers, &request.query)
1726 .and_then(|installer| installer.installer_type)
1727 .unwrap_or_else(|| "install".to_owned())
1728 .to_lowercase();
1729 return Ok(unsupported_action_result(
1730 manifest.id.clone(),
1731 manifest.version.clone(),
1732 installer_type,
1733 INSTALL_UNSUPPORTED_WARNING,
1734 ));
1735 }
1736
1737 let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1738 if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1739 return Ok(no_op_result);
1740 }
1741
1742 Self::ensure_package_agreements_accepted(&manifest, request)?;
1743 self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1744
1745 if request.dependencies_only {
1746 return Ok(InstallResult {
1747 package_id: manifest.id.clone(),
1748 version: manifest.version.clone(),
1749 installer_path: PathBuf::new(),
1750 installer_type: "dependencies".to_owned(),
1751 exit_code: 0,
1752 success: true,
1753 no_op: false,
1754 warnings: Vec::new(),
1755 });
1756 }
1757
1758 let installer = select_installer(&manifest.installers, &request.query)
1759 .ok_or_else(|| anyhow!("No applicable installer found"))?;
1760
1761 if request.uninstall_previous {
1762 let mut uninstall_request = UninstallRequest::new(PackageQuery {
1763 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1764 query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1765 source: request.query.source.clone(),
1766 exact: true,
1767 version: request.query.version.clone(),
1768 ..PackageQuery::default()
1769 });
1770 uninstall_request.product_code = installer.product_code.clone();
1771 uninstall_request.mode = InstallerMode::Silent;
1772 uninstall_request.all_versions = true;
1773 uninstall_request.force = true;
1774 let _ = self.uninstall_request(&uninstall_request);
1775 }
1776
1777 let temp_dir = std::env::temp_dir().join("pinget-install");
1778 let (_, installer_path) = self.download_installer_for_request(request, &temp_dir)?;
1779
1780 let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1781
1782 let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1783
1784 Ok(InstallResult {
1785 package_id: manifest.id.clone(),
1786 version: manifest.version.clone(),
1787 installer_path,
1788 installer_type,
1789 exit_code,
1790 success: exit_code == 0,
1791 no_op: false,
1792 warnings: Vec::new(),
1793 })
1794 }
1795
1796 fn find_installed_package_for_install(
1797 &mut self,
1798 request: &InstallRequest,
1799 manifest: &Manifest,
1800 ) -> Result<Option<ListMatch>> {
1801 let installed_matches = self.list(&ListQuery {
1802 query: request.query.query.clone(),
1803 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1804 name: request.query.name.clone(),
1805 moniker: request.query.moniker.clone(),
1806 tag: None,
1807 command: None,
1808 product_code: None,
1809 version: None,
1810 source: request.query.source.clone(),
1811 count: Some(100),
1812 exact: request.query.exact || !manifest.id.trim().is_empty(),
1813 install_scope: request.query.install_scope.clone(),
1814 upgrade_only: false,
1815 include_unknown: false,
1816 include_pinned: false,
1817 })?;
1818
1819 Ok(installed_matches.matches.into_iter().find(|candidate| {
1820 candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
1821 }))
1822 }
1823
1824 fn create_install_no_op_result(
1825 request: &InstallRequest,
1826 manifest: &Manifest,
1827 existing_match: Option<&ListMatch>,
1828 ) -> Option<InstallResult> {
1829 let existing_match = existing_match?;
1830
1831 if request.no_upgrade {
1832 return Some(InstallResult {
1833 package_id: manifest.id.clone(),
1834 version: existing_match.installed_version.clone(),
1835 installer_path: PathBuf::new(),
1836 installer_type: "install".to_owned(),
1837 exit_code: 0,
1838 success: true,
1839 no_op: true,
1840 warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
1841 });
1842 }
1843
1844 if !request.force
1845 && !existing_match.installed_version.trim().is_empty()
1846 && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
1847 {
1848 return Some(InstallResult {
1849 package_id: manifest.id.clone(),
1850 version: existing_match.installed_version.clone(),
1851 installer_path: PathBuf::new(),
1852 installer_type: "install".to_owned(),
1853 exit_code: 0,
1854 success: true,
1855 no_op: true,
1856 warnings: vec![
1857 "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
1858 ],
1859 });
1860 }
1861
1862 None
1863 }
1864
1865 pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
1866 if request.manifest_path.is_none() && !package_actions_supported() {
1867 let package_id = request
1868 .query
1869 .id
1870 .clone()
1871 .or_else(|| request.query.name.clone())
1872 .or_else(|| request.query.moniker.clone())
1873 .or_else(|| request.query.query.clone())
1874 .or_else(|| request.product_code.clone())
1875 .unwrap_or_else(|| "repair".to_owned());
1876 return Ok(unsupported_action_result(
1877 package_id,
1878 request.query.version.clone().unwrap_or_default(),
1879 "repair".to_owned(),
1880 REPAIR_UNSUPPORTED_WARNING,
1881 ));
1882 }
1883
1884 let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
1885 let installed_match = if request.manifest_path.is_some() {
1886 None
1887 } else {
1888 let installed = self.list(&Self::create_repair_list_query(request))?;
1889 warnings.extend(installed.warnings);
1890 if installed.matches.is_empty() {
1891 bail!("No installed package matched the supplied repair query.");
1892 }
1893 if installed.matches.len() > 1 {
1894 bail!("Multiple installed packages matched the supplied repair query.");
1895 }
1896 installed.matches.into_iter().next()
1897 };
1898
1899 let mut result =
1900 self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
1901 warnings.extend(result.warnings);
1902 result.warnings = warnings;
1903 Ok(result)
1904 }
1905
1906 pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1907 let mut request = UninstallRequest::new(query.clone());
1908 request.mode = if silent {
1909 InstallerMode::Silent
1910 } else {
1911 InstallerMode::SilentWithProgress
1912 };
1913 self.uninstall_request(&request)
1914 }
1915
1916 pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
1917 if !package_actions_supported() {
1918 let (package_id, version) = if let Some(path) = &request.manifest_path {
1919 let manifest = Self::load_manifest_from_path(path)?;
1920 (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
1921 } else {
1922 (
1923 request
1924 .query
1925 .id
1926 .clone()
1927 .or_else(|| request.query.query.clone())
1928 .or_else(|| request.query.name.clone())
1929 .or_else(|| request.query.moniker.clone())
1930 .or_else(|| request.product_code.clone())
1931 .unwrap_or_else(|| "unknown".to_owned()),
1932 request.query.version.clone().unwrap_or_default(),
1933 )
1934 };
1935
1936 return Ok(unsupported_action_result(
1937 package_id,
1938 version,
1939 "uninstall",
1940 UNINSTALL_UNSUPPORTED_WARNING,
1941 ));
1942 }
1943
1944 let matches = self.resolve_uninstall_matches(request)?;
1945 let mut exit_code = 0;
1946
1947 for installed in &matches {
1948 exit_code = uninstall_package(installed, request)?;
1949 if exit_code != 0 && !request.all_versions {
1950 break;
1951 }
1952 }
1953
1954 let installed = matches
1955 .first()
1956 .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
1957
1958 Ok(InstallResult {
1959 package_id: installed.id.clone(),
1960 version: installed.installed_version.clone(),
1961 installer_path: PathBuf::new(),
1962 installer_type: "uninstall".to_owned(),
1963 exit_code,
1964 success: exit_code == 0,
1965 no_op: false,
1966 warnings: Vec::new(),
1967 })
1968 }
1969
1970 fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
1971 if let Some(path) = &request.manifest_path {
1972 return Self::load_manifest_from_path(path);
1973 }
1974
1975 let (located, _warnings) = self.find_single_match(&request.query)?;
1976 let (manifest, _, _cached_files) = self.manifest_for_match(&located, &request.query)?;
1977 Ok(manifest)
1978 }
1979
1980 fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
1981 if !request.accept_package_agreements && !manifest.agreements.is_empty() {
1982 bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
1983 }
1984 Ok(())
1985 }
1986
1987 fn install_dependencies(
1988 &mut self,
1989 manifest: &Manifest,
1990 request: &InstallRequest,
1991 visited: &mut HashSet<String>,
1992 ) -> Result<()> {
1993 if request.skip_dependencies {
1994 return Ok(());
1995 }
1996
1997 let dependency_ids = manifest
1998 .package_dependencies
1999 .iter()
2000 .chain(
2001 manifest
2002 .installers
2003 .iter()
2004 .flat_map(|installer| installer.package_dependencies.iter()),
2005 )
2006 .filter(|value| !value.trim().is_empty())
2007 .cloned()
2008 .collect::<BTreeSet<_>>();
2009
2010 for dependency_id in dependency_ids {
2011 if !visited.insert(dependency_id.clone()) {
2012 continue;
2013 }
2014
2015 let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2016 self.install_request(&dependency_request)?;
2017 }
2018
2019 Ok(())
2020 }
2021
2022 fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2023 let mut dependency_request = InstallRequest::new(PackageQuery {
2024 id: Some(dependency_id),
2025 source: request
2026 .dependency_source
2027 .clone()
2028 .or_else(|| request.query.source.clone()),
2029 exact: true,
2030 ..PackageQuery::default()
2031 });
2032 dependency_request.mode = match request.mode {
2033 InstallerMode::Interactive => InstallerMode::Interactive,
2034 InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2035 };
2036 dependency_request.accept_package_agreements = request.accept_package_agreements;
2037 dependency_request.no_upgrade = true;
2038 dependency_request.ignore_security_hash = request.ignore_security_hash;
2039 dependency_request.dependency_source = request.dependency_source.clone();
2040 dependency_request
2041 }
2042
2043 fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2044 ListQuery {
2045 query: request.query.query.clone(),
2046 id: request.query.id.clone(),
2047 name: request.query.name.clone(),
2048 moniker: request.query.moniker.clone(),
2049 product_code: request.product_code.clone(),
2050 version: request.query.version.clone(),
2051 source: request.query.source.clone(),
2052 count: Some(100),
2053 exact: request.query.exact,
2054 install_scope: request.query.install_scope.clone(),
2055 ..ListQuery::default()
2056 }
2057 }
2058
2059 fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2060 let mut query = request.query.clone();
2061 query.query = if installed_match.is_none() {
2062 request.query.query.clone()
2063 } else {
2064 None
2065 };
2066 query.id = installed_match
2067 .map(|item| item.id.clone())
2068 .or_else(|| request.query.id.clone());
2069 query.name = if installed_match.is_none() {
2070 request.query.name.clone()
2071 } else {
2072 None
2073 };
2074 query.moniker = if installed_match.is_none() {
2075 request.query.moniker.clone()
2076 } else {
2077 None
2078 };
2079 query.source = request
2080 .query
2081 .source
2082 .clone()
2083 .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2084 query.exact = true;
2085 query.version = request
2086 .query
2087 .version
2088 .clone()
2089 .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2090
2091 InstallRequest {
2092 query,
2093 manifest_path: request.manifest_path.clone(),
2094 mode: request.mode,
2095 log_path: request.log_path.clone(),
2096 custom: None,
2097 override_args: None,
2098 install_location: None,
2099 skip_dependencies: false,
2100 dependencies_only: false,
2101 accept_package_agreements: request.accept_package_agreements,
2102 force: true,
2103 rename: None,
2104 uninstall_previous: false,
2105 ignore_security_hash: request.ignore_security_hash,
2106 dependency_source: None,
2107 no_upgrade: false,
2108 }
2109 }
2110
2111 fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2112 let mut effective_query = request.query.clone();
2113 let mut effective_product_code = request.product_code.clone();
2114
2115 if let Some(path) = &request.manifest_path {
2116 let manifest = Self::load_manifest_from_path(path)?;
2117 effective_query = PackageQuery {
2118 query: Some(manifest.id.clone()),
2119 id: Some(manifest.id.clone()),
2120 name: Some(manifest.name.clone()),
2121 exact: true,
2122 version: effective_query.version.clone().or(Some(manifest.version)),
2123 ..PackageQuery::default()
2124 };
2125 effective_product_code = effective_product_code.or_else(|| {
2126 manifest
2127 .installers
2128 .iter()
2129 .find_map(|installer| installer.product_code.clone())
2130 });
2131 }
2132
2133 let list_query = ListQuery {
2134 query: effective_query.query.clone(),
2135 id: effective_query.id.clone(),
2136 name: effective_query.name.clone(),
2137 moniker: effective_query.moniker.clone(),
2138 tag: None,
2139 command: None,
2140 product_code: effective_product_code,
2141 version: effective_query.version.clone(),
2142 source: effective_query.source.clone(),
2143 count: if request.all_versions { None } else { Some(100) },
2144 exact: effective_query.exact,
2145 install_scope: effective_query.install_scope,
2146 upgrade_only: false,
2147 include_unknown: false,
2148 include_pinned: false,
2149 };
2150
2151 let list_result = self.list(&list_query)?;
2152 if list_result.matches.is_empty() {
2153 bail!("No installed package found matching the query");
2154 }
2155 if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2156 bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2157 }
2158
2159 Ok(if request.all_versions {
2160 list_result.matches
2161 } else {
2162 vec![list_result.matches[0].clone()]
2163 })
2164 }
2165
2166 fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2167 let resolved = resolve_manifest_path(manifest_path)?;
2168 let bytes =
2169 fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2170 parse_yaml_manifest(&bytes)
2171 }
2172
2173 fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2174 self.find_single_match_with_semantics(query, SearchSemantics::Single)
2175 }
2176
2177 fn find_single_match_with_semantics(
2178 &mut self,
2179 query: &PackageQuery,
2180 semantics: SearchSemantics,
2181 ) -> Result<(LocatedMatch, Vec<String>)> {
2182 let (matches, warnings, _) = self.search_located(query, semantics)?;
2183
2184 if matches.is_empty() {
2185 bail!("no package matched the supplied query");
2186 }
2187
2188 if matches.len() > 1 {
2189 let choices = matches
2190 .iter()
2191 .take(10)
2192 .map(|item| {
2193 format!(
2194 "{} [{}] ({})",
2195 item.display.name, item.display.id, item.display.source_name
2196 )
2197 })
2198 .collect::<Vec<_>>()
2199 .join(", ");
2200 bail!("multiple packages matched: {choices}");
2201 }
2202
2203 Ok((matches.into_iter().next().expect("one match"), warnings))
2204 }
2205
2206 fn search_located(
2207 &mut self,
2208 query: &PackageQuery,
2209 semantics: SearchSemantics,
2210 ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2211 let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2212 let mut matches = Vec::new();
2213 let mut warnings = Vec::new();
2214 let mut truncated = false;
2215
2216 for index in indexes {
2217 match self.search_source(index, query, semantics) {
2218 Ok(mut source_matches) => {
2219 truncated |= source_matches.truncated;
2220 matches.append(&mut source_matches.matches);
2221 }
2222 Err(_error) => warnings.push(format!(
2223 "Failed when searching source; results will not be included: {}",
2224 self.store.sources[index].name
2225 )),
2226 }
2227 }
2228
2229 if semantics == SearchSemantics::Many {
2230 matches.sort_by(|left, right| {
2231 search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2232 });
2233 let limit = max_results(query);
2234 if matches.len() > limit {
2235 truncated = true;
2236 matches.truncate(limit);
2237 }
2238 }
2239
2240 Ok((matches, warnings, truncated))
2241 }
2242
2243 fn search_source(
2244 &mut self,
2245 source_index: usize,
2246 query: &PackageQuery,
2247 semantics: SearchSemantics,
2248 ) -> Result<SearchSourceMatches> {
2249 match self.store.sources[source_index].kind {
2250 SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2251 SourceKind::Rest => self.search_rest(source_index, query, semantics),
2252 }
2253 }
2254
2255 fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2256 match &located.locator {
2257 MatchLocator::PreIndexedV1 { package_rowid } => {
2258 let source = self.source_clone(located.source_index);
2259 let connection = self.open_preindexed_connection(located.source_index)?;
2260 let versions = query_v1_versions(&connection, *package_rowid)?;
2261 let mut keys = versions
2262 .into_iter()
2263 .map(|row| VersionKey {
2264 version: row.version,
2265 channel: row.channel,
2266 })
2267 .collect::<Vec<_>>();
2268 sort_versions_desc(&mut keys);
2269 if keys.is_empty() {
2270 bail!("no versions found for {} in {}", located.display.id, source.name);
2271 }
2272 Ok(keys)
2273 }
2274 MatchLocator::PreIndexedV2 {
2275 package_rowid,
2276 package_hash,
2277 } => {
2278 let source = self.source_clone(located.source_index);
2279 let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2280 let mut keys = entries
2281 .into_iter()
2282 .map(|entry| VersionKey {
2283 version: entry.version,
2284 channel: String::new(),
2285 })
2286 .collect::<Vec<_>>();
2287 sort_versions_desc(&mut keys);
2288 Ok(keys)
2289 }
2290 MatchLocator::Rest { versions, .. } => {
2291 let mut keys = versions.clone();
2292 sort_versions_desc(&mut keys);
2293 Ok(keys)
2294 }
2295 }
2296 }
2297
2298 fn manifest_for_match(
2299 &mut self,
2300 located: &LocatedMatch,
2301 query: &PackageQuery,
2302 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2303 match &located.locator {
2304 MatchLocator::PreIndexedV1 { package_rowid } => {
2305 let source = self.source_clone(located.source_index);
2306 let connection = self.open_preindexed_connection(located.source_index)?;
2307 let versions = query_v1_versions(&connection, *package_rowid)?;
2308 let selected = select_v1_version(&versions, query.version.as_deref(), query.channel.as_deref())?;
2309 let relative_path = resolve_v1_relative_path(&connection, selected.pathpart_id)?;
2310 let bytes =
2311 self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2312 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2313 manifest.version = selected.version.clone();
2314 manifest.channel = selected.channel;
2315 Ok((manifest, manifest_documents, vec![bytes.path]))
2316 }
2317 MatchLocator::PreIndexedV2 {
2318 package_rowid,
2319 package_hash,
2320 } => {
2321 let source = self.source_clone(located.source_index);
2322 let (entries, version_data_file) =
2323 self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2324 let selected = select_v2_version(&entries, query.version.as_deref())?;
2325 let manifest_bytes = self.get_cached_source_file(
2326 "V2_M",
2327 &source,
2328 &selected.manifest_relative_path,
2329 Some(selected.manifest_hash.as_str()),
2330 )?;
2331 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2332 manifest.version = selected.version;
2333 Ok((
2334 manifest,
2335 manifest_documents,
2336 vec![version_data_file, manifest_bytes.path],
2337 ))
2338 }
2339 MatchLocator::Rest { package_id, versions } => {
2340 let source = self.source_clone(located.source_index);
2341 let selected = select_rest_version(versions, query.version.as_deref(), query.channel.as_deref())?;
2342 let (bytes, cache_path) =
2343 self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2344 let (manifest, manifest_documents) =
2345 parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2346 Ok((manifest, manifest_documents, vec![cache_path]))
2347 }
2348 }
2349 }
2350
2351 fn search_preindexed(
2352 &mut self,
2353 source_index: usize,
2354 query: &PackageQuery,
2355 semantics: SearchSemantics,
2356 ) -> Result<SearchSourceMatches> {
2357 let connection = self.open_preindexed_connection(source_index)?;
2358 let source = self.source_clone(source_index);
2359
2360 match query_v2_matches(&connection, query, semantics) {
2361 Ok((rows, truncated)) => Ok(SearchSourceMatches {
2362 truncated,
2363 matches: rows
2364 .into_iter()
2365 .map(|row| LocatedMatch {
2366 display: SearchMatch {
2367 source_name: source.name.clone(),
2368 source_kind: source.kind,
2369 id: row.id.clone(),
2370 name: row.name.clone(),
2371 moniker: row.moniker.clone(),
2372 version: Some(row.version.clone()),
2373 channel: None,
2374 match_criteria: row.match_criteria.clone(),
2375 },
2376 source_index,
2377 locator: MatchLocator::PreIndexedV2 {
2378 package_rowid: row.package_rowid,
2379 package_hash: row.package_hash,
2380 },
2381 })
2382 .collect(),
2383 }),
2384 Err(error) if can_fallback_to_v1(&error) => {
2385 let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2386 let grouped = group_v1_rows(rows);
2387
2388 Ok(SearchSourceMatches {
2389 truncated,
2390 matches: grouped
2391 .into_iter()
2392 .map(|row| LocatedMatch {
2393 display: SearchMatch {
2394 source_name: source.name.clone(),
2395 source_kind: source.kind,
2396 id: row.id.clone(),
2397 name: row.name.clone(),
2398 moniker: row.moniker.clone(),
2399 version: Some(row.version.clone()),
2400 channel: Some(row.channel.clone()),
2401 match_criteria: row.match_criteria.clone(),
2402 },
2403 source_index,
2404 locator: MatchLocator::PreIndexedV1 {
2405 package_rowid: row.package_rowid,
2406 },
2407 })
2408 .collect(),
2409 })
2410 }
2411 Err(error) => Err(error),
2412 }
2413 }
2414
2415 fn search_rest(
2416 &mut self,
2417 source_index: usize,
2418 query: &PackageQuery,
2419 semantics: SearchSemantics,
2420 ) -> Result<SearchSourceMatches> {
2421 let source = self.source_clone(source_index);
2422 let info = self.load_rest_information(source_index)?;
2423 let contract = choose_contract(&info.server_supported_versions)
2424 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2425
2426 let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2427 let body = build_rest_search_body(query, &info, semantics)?;
2428 let response = self
2429 .client
2430 .post(url)
2431 .header("Version", contract)
2432 .json(&body)
2433 .send()
2434 .context("REST search request failed")?
2435 .error_for_status()
2436 .context("REST search request returned an error")?;
2437 let json = response
2438 .json::<JsonValue>()
2439 .context("failed to parse REST search response")?;
2440 let data = json
2441 .get("Data")
2442 .and_then(JsonValue::as_array)
2443 .cloned()
2444 .unwrap_or_default();
2445 let max_results = source_fetch_results(query, semantics);
2446
2447 let mut results = Vec::new();
2448 for item in data {
2449 let package_id = json_string(&item, "PackageIdentifier")
2450 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2451 let package_name =
2452 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2453 let mut versions = parse_rest_versions(&item)?;
2454 sort_versions_desc(&mut versions);
2455 let latest = versions
2456 .first()
2457 .cloned()
2458 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2459
2460 results.push(LocatedMatch {
2461 display: SearchMatch {
2462 source_name: source.name.clone(),
2463 source_kind: source.kind,
2464 id: package_id.clone(),
2465 name: package_name,
2466 moniker: json_string(&item, "Moniker"),
2467 version: Some(latest.version.clone()),
2468 channel: if latest.channel.is_empty() {
2469 None
2470 } else {
2471 Some(latest.channel.clone())
2472 },
2473 match_criteria: rest_match_criteria(&item, query, semantics),
2474 },
2475 source_index,
2476 locator: MatchLocator::Rest { package_id, versions },
2477 });
2478 }
2479
2480 Ok(SearchSourceMatches {
2481 truncated: results.len() >= max_results,
2482 matches: results,
2483 })
2484 }
2485
2486 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2487 let source = self.source_clone(source_index);
2488 let index_path = preindexed_index_path(&self.app_root, &source);
2489 if !index_path.exists() {
2490 let _ = self.update_preindexed(source_index)?;
2491 if !self.use_system_winget_sources {
2492 self.save_store()?;
2493 }
2494 }
2495
2496 Self::open_sqlite_connection(index_path).context("failed to open preindexed index")
2497 }
2498
2499 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2500 let path_text = path.to_string_lossy().into_owned();
2501 Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI)
2502 .with_context(|| format!("failed to open SQLite database at {path_text}"))
2503 }
2504
2505 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2506 let source = &mut self.store.sources[source_index];
2507 let state_dir = source_state_dir(&self.app_root, source);
2508 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2509
2510 let mut last_error = None;
2511 for candidate in PREINDEXED_CANDIDATES {
2512 let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2513 match self.client.get(&url).send() {
2514 Ok(response) if response.status().is_success() => {
2515 let header_version = response
2516 .headers()
2517 .get("x-ms-meta-sourceversion")
2518 .and_then(|value| value.to_str().ok())
2519 .map(str::to_owned);
2520 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2521 let payload = bytes.to_vec();
2522 let index_bytes = extract_zip_member(&payload, "Public/index.db")
2523 .context("preindexed package did not contain Public/index.db")?;
2524
2525 fs::write(preindexed_package_path(&self.app_root, source), &payload)
2526 .context("failed to persist source package")?;
2527 fs::write(preindexed_index_path(&self.app_root, source), index_bytes)
2528 .context("failed to persist source index")?;
2529 source.last_update = Some(Utc::now());
2530 source.source_version = header_version;
2531 return Ok(format!("downloaded {}", candidate));
2532 }
2533 Ok(response) => {
2534 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2535 }
2536 Err(error) => {
2537 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2538 }
2539 }
2540 }
2541
2542 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2543 }
2544
2545 fn update_rest(&mut self, source_index: usize) -> Result<String> {
2546 let info = self.fetch_rest_information(source_index, true)?;
2547 let source = &mut self.store.sources[source_index];
2548 if !info.source_identifier.is_empty() {
2549 source.identifier = info.source_identifier.clone();
2550 }
2551 source.last_update = Some(Utc::now());
2552 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2553 Ok("refreshed information cache".to_owned())
2554 }
2555
2556 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2557 let source = self.source_clone(source_index);
2558 let cache_path = rest_information_cache_path(&self.app_root, &source);
2559
2560 if cache_path.exists() {
2561 let cache = serde_json::from_slice::<RestInfoCache>(
2562 &fs::read(&cache_path).context("failed to read REST information cache")?,
2563 )
2564 .context("failed to parse REST information cache")?;
2565 if cache.expires_at > Utc::now() {
2566 return Ok(cache.value);
2567 }
2568 }
2569
2570 self.fetch_rest_information(source_index, true)
2571 }
2572
2573 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2574 let source = self.source_clone(source_index);
2575 let url = format!("{}/information", source.arg.trim_end_matches('/'));
2576 let response = self
2577 .client
2578 .get(url)
2579 .send()
2580 .context("REST information request failed")?
2581 .error_for_status()
2582 .context("REST information request returned an error")?;
2583
2584 let max_age = cache_control_max_age(&response);
2585 let json = response
2586 .json::<JsonValue>()
2587 .context("failed to parse REST information response")?;
2588 let data = json
2589 .get("Data")
2590 .cloned()
2591 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2592 let info = serde_json::from_value::<RestInformation>(data)
2593 .context("failed to deserialize REST information payload")?;
2594
2595 if persist {
2596 let cache = RestInfoCache {
2597 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2598 value: info.clone(),
2599 };
2600 write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2601 }
2602
2603 Ok(info)
2604 }
2605
2606 fn load_v2_version_data(
2607 &self,
2608 source: &SourceRecord,
2609 package_rowid: i64,
2610 package_hash: &str,
2611 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2612 let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2613 .context("failed to reopen preindexed index for V2 version data")?;
2614 let package_hash = package_hash.to_ascii_lowercase();
2615 let package_id = query_optional_value(
2616 &connection,
2617 "SELECT id FROM packages WHERE rowid = ?1",
2618 vec![SqlValue::Integer(package_rowid)],
2619 |row| row_string(row, 0),
2620 )?
2621 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2622 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2623 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2624 let yaml = decompress_mszyml(&file.bytes)?;
2625 let document =
2626 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2627 Ok((document.versions, file.path))
2628 }
2629
2630 fn get_cached_source_file(
2631 &self,
2632 bucket: &str,
2633 source: &SourceRecord,
2634 relative_path: &str,
2635 expected_hash: Option<&str>,
2636 ) -> Result<CachedBytes> {
2637 let normalized_relative = relative_path.replace('\\', "/");
2638 let cache_path =
2639 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
2640
2641 if cache_path.exists() {
2642 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
2643 if hash_matches(expected_hash, &cached) {
2644 return Ok(CachedBytes {
2645 path: cache_path,
2646 bytes: cached,
2647 });
2648 }
2649 }
2650
2651 let url = format!(
2652 "{}/{}",
2653 source.arg.trim_end_matches('/'),
2654 normalized_relative.trim_start_matches('/')
2655 );
2656 let response = self
2657 .client
2658 .get(url)
2659 .send()
2660 .context("failed to fetch source file")?
2661 .error_for_status()
2662 .context("source file request returned an error")?;
2663 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
2664
2665 if let Some(hash) = expected_hash {
2666 verify_hash(hash, &bytes)?;
2667 }
2668
2669 if let Some(parent) = cache_path.parent() {
2670 fs::create_dir_all(parent).context("failed to create cache directory")?;
2671 }
2672 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
2673
2674 Ok(CachedBytes {
2675 path: cache_path,
2676 bytes,
2677 })
2678 }
2679
2680 fn get_or_fetch_rest_manifest(
2681 &mut self,
2682 source: &SourceRecord,
2683 package_id: &str,
2684 version: &str,
2685 channel: &str,
2686 ) -> Result<(Vec<u8>, PathBuf)> {
2687 let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
2688 if cache_path.exists() {
2689 return Ok((
2690 fs::read(&cache_path).context("failed to read cached REST manifest")?,
2691 cache_path,
2692 ));
2693 }
2694
2695 let info = self.load_rest_information_by_name(&source.name)?;
2696 let contract = choose_contract(&info.server_supported_versions)
2697 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2698 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
2699
2700 let mut params = vec![("Version", version.to_owned())];
2701 if !channel.is_empty() {
2702 params.push(("Channel", channel.to_owned()));
2703 }
2704 if info
2705 .required_query_parameters
2706 .iter()
2707 .any(|value| value.eq_ignore_ascii_case("Market"))
2708 {
2709 params.push(("Market", default_market()));
2710 }
2711
2712 let response = self
2713 .client
2714 .get(url)
2715 .header("Version", contract)
2716 .query(¶ms)
2717 .send()
2718 .context("REST manifest request failed")?
2719 .error_for_status()
2720 .context("REST manifest request returned an error")?;
2721 let bytes = response
2722 .bytes()
2723 .context("failed to read REST manifest response")?
2724 .to_vec();
2725
2726 if let Some(parent) = cache_path.parent() {
2727 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
2728 }
2729 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
2730
2731 Ok((bytes, cache_path))
2732 }
2733
2734 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
2735 let index = self
2736 .store
2737 .sources
2738 .iter()
2739 .position(|source| source.name.eq_ignore_ascii_case(name))
2740 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2741 self.load_rest_information(index)
2742 }
2743
2744 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
2745 if let Some(name) = source_name {
2746 let index = self
2747 .store
2748 .sources
2749 .iter()
2750 .position(|source| source.name.eq_ignore_ascii_case(name))
2751 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
2752 return Ok(vec![index]);
2753 }
2754
2755 let mut indexes: Vec<_> = self
2756 .store
2757 .sources
2758 .iter()
2759 .enumerate()
2760 .filter(|(_, source)| !source.explicit)
2761 .collect();
2762 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
2763 right_source
2764 .priority
2765 .cmp(&left_source.priority)
2766 .then_with(|| left_index.cmp(right_index))
2767 });
2768 Ok(indexes.into_iter().map(|(index, _)| index).collect())
2769 }
2770
2771 fn source_clone(&self, index: usize) -> SourceRecord {
2772 self.store.sources[index].clone()
2773 }
2774
2775 fn refresh_system_winget_sources(&mut self) -> Result<()> {
2776 if self.use_system_winget_sources {
2777 self.store = load_system_winget_source_store()?;
2778 }
2779 Ok(())
2780 }
2781
2782 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
2783 self.refresh_system_winget_sources()?;
2784 let selected_names: Vec<_> = if source_name.is_none() {
2785 self.store.sources.iter().map(|source| source.name.clone()).collect()
2786 } else {
2787 self.resolve_source_indexes(source_name)?
2788 .into_iter()
2789 .map(|index| self.store.sources[index].name.clone())
2790 .collect()
2791 };
2792
2793 let detail = system_winget_update_sources(source_name)?;
2794 self.refresh_system_winget_sources()?;
2795
2796 Ok(selected_names
2797 .into_iter()
2798 .filter_map(|name| {
2799 self.store
2800 .sources
2801 .iter()
2802 .find(|source| source.name.eq_ignore_ascii_case(&name))
2803 .map(|source| SourceUpdateResult {
2804 name: source.name.clone(),
2805 kind: source.kind,
2806 detail: detail.clone(),
2807 })
2808 })
2809 .collect())
2810 }
2811}
2812
2813#[derive(Debug)]
2814struct CachedBytes {
2815 path: PathBuf,
2816 bytes: Vec<u8>,
2817}
2818
2819#[derive(Debug, Clone)]
2820struct V1SearchRow {
2821 manifest_rowid: i64,
2822 package_rowid: i64,
2823 version: String,
2824 channel: String,
2825 id: String,
2826 name: String,
2827 moniker: Option<String>,
2828 match_criteria: Option<String>,
2829}
2830
2831#[derive(Debug, Clone)]
2832struct V1VersionRow {
2833 version: String,
2834 channel: String,
2835 pathpart_id: i64,
2836 manifest_hash: Option<String>,
2837}
2838
2839#[derive(Debug, Clone)]
2840struct V2SearchRow {
2841 package_rowid: i64,
2842 package_hash: String,
2843 id: String,
2844 name: String,
2845 moniker: Option<String>,
2846 version: String,
2847 match_criteria: Option<String>,
2848}
2849
2850fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
2851 query.query.is_some()
2852 || query.id.is_some()
2853 || query.name.is_some()
2854 || query.moniker.is_some()
2855 || query.tag.is_some()
2856 || query.command.is_some()
2857 || query.source.is_some()
2858}
2859
2860fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
2861 PackageQuery {
2862 query: query.query.clone(),
2863 id: query.id.clone(),
2864 name: query.name.clone(),
2865 moniker: query.moniker.clone(),
2866 tag: query.tag.clone(),
2867 command: query.command.clone(),
2868 source: query.source.clone(),
2869 count: Some(LIST_LOOKUP_MAX_RESULTS),
2870 exact: query.exact,
2871 version: None,
2872 channel: None,
2873 locale: None,
2874 installer_type: None,
2875 installer_architecture: None,
2876 platform: None,
2877 os_version: None,
2878 install_scope: query.install_scope.clone(),
2879 }
2880}
2881
2882fn allow_loose_list_correlation(query: &ListQuery) -> bool {
2883 query.query.is_some() || query.id.is_some() || query.name.is_some()
2884}
2885
2886fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
2887 package
2888 .correlated
2889 .as_ref()
2890 .and_then(|candidate| candidate.version.as_deref())
2891 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
2892}
2893
2894fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
2895 package.installed_version.eq_ignore_ascii_case("Unknown")
2896}
2897
2898fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
2899 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
2902 return false;
2903 }
2904 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
2907 return false;
2908 }
2909 installed_package_has_upgrade(package)
2910 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
2911}
2912
2913fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
2919 use std::collections::HashMap;
2920 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
2921 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
2922
2923 for package in packages {
2924 match package.correlated.as_ref() {
2925 Some(candidate) => {
2926 let key = (
2927 candidate.id.to_ascii_lowercase(),
2928 candidate.source_name.to_ascii_lowercase(),
2929 );
2930 let keep_new = match by_id.get(&key) {
2931 Some(existing) => {
2932 match (
2938 package.installed_version_canonical,
2939 existing.installed_version_canonical,
2940 ) {
2941 (true, false) => true,
2942 (false, true) => false,
2943 _ => {
2944 compare_version(&package.installed_version, &existing.installed_version)
2945 == Ordering::Greater
2946 }
2947 }
2948 }
2949 None => true,
2950 };
2951 if keep_new {
2952 by_id.insert(key, package);
2953 }
2954 }
2955 None => uncorrelated.push(package),
2956 }
2957 }
2958
2959 let mut result = uncorrelated;
2960 result.extend(by_id.into_values());
2961 result
2962}
2963
2964fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
2965 let mut source_specific = None;
2966 let mut source_agnostic = None;
2967
2968 for pin in pins {
2969 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
2970 continue;
2971 }
2972
2973 if pin.source_id.is_empty() {
2974 if source_agnostic.is_none() {
2975 source_agnostic = Some(pin);
2976 }
2977 } else if item
2978 .source_name
2979 .as_deref()
2980 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
2981 {
2982 source_specific = Some(pin);
2983 break;
2984 }
2985 }
2986
2987 source_specific.or(source_agnostic)
2988}
2989
2990fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
2991 let Some(available_version) = item.available_version.as_deref() else {
2992 return false;
2993 };
2994 let Some(pin) = find_applicable_pin(item, pins) else {
2995 return false;
2996 };
2997
2998 match pin.pin_type {
2999 PinType::Blocking => true,
3000 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3001 }
3002}
3003
3004fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3005 if pattern.trim().is_empty() || pattern == "*" {
3006 return true;
3007 }
3008
3009 if !pattern.contains('*') {
3010 return version.eq_ignore_ascii_case(pattern);
3011 }
3012
3013 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3014 let prefix = &pattern[..pattern.len() - 1];
3015 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3016 }
3017
3018 version.eq_ignore_ascii_case(pattern)
3019}
3020
3021fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3022 let available_version = package.correlated.as_ref().and_then(|candidate| {
3023 candidate.version.as_ref().and_then(|candidate_version| {
3024 if installed_package_has_unknown_version(&package)
3025 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3026 {
3027 Some(candidate_version.clone())
3028 } else {
3029 None
3030 }
3031 })
3032 });
3033 let source_name = package
3034 .correlated
3035 .as_ref()
3036 .map(|candidate| candidate.source_name.clone())
3037 .filter(|value| !value.is_empty());
3038 let id = package
3039 .correlated
3040 .as_ref()
3041 .map(|candidate| candidate.id.clone())
3042 .unwrap_or_else(|| package.local_id.clone());
3043
3044 ListMatch {
3045 name: package.name,
3046 id,
3047 local_id: package.local_id,
3048 installed_version: package.installed_version,
3049 available_version,
3050 source_name,
3051 publisher: package.publisher,
3052 scope: package.scope,
3053 installer_category: package.installer_category,
3054 install_location: package.install_location,
3055 package_family_names: package.package_family_names,
3056 product_codes: package.product_codes,
3057 upgrade_codes: package.upgrade_codes,
3058 }
3059}
3060
3061fn list_sort_weight(package: &InstalledPackage) -> usize {
3062 if package.local_id.starts_with("ARP\\") {
3063 0
3064 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3065 1
3066 } else {
3067 2
3068 }
3069}
3070
3071fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3072 let correlated = package.correlated.as_ref();
3073
3074 if let Some(value) = &query.id {
3075 let local_match = matches_text(&package.local_id, value, query.exact);
3076 let correlated_match = correlated
3077 .map(|candidate| matches_text(&candidate.id, value, query.exact))
3078 .unwrap_or(false);
3079 if !local_match && !correlated_match {
3080 return false;
3081 }
3082 }
3083
3084 if let Some(value) = &query.name
3085 && !matches_text(&package.name, value, query.exact)
3086 {
3087 return false;
3088 }
3089
3090 if let Some(value) = &query.product_code
3091 && !package
3092 .product_codes
3093 .iter()
3094 .any(|code| code.eq_ignore_ascii_case(value))
3095 {
3096 return false;
3097 }
3098
3099 if let Some(value) = &query.version
3100 && !package.installed_version.eq_ignore_ascii_case(value)
3101 {
3102 return false;
3103 }
3104
3105 if let Some(value) = &query.query {
3106 let local_match =
3107 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3108 let correlated_match = correlated
3109 .map(|candidate| {
3110 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3111 })
3112 .unwrap_or(false);
3113 if !local_match && !correlated_match {
3114 return false;
3115 }
3116 }
3117
3118 if let Some(source) = &query.source
3119 && correlated
3120 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3121 .unwrap_or(true)
3122 {
3123 return false;
3124 }
3125
3126 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3127 return false;
3128 }
3129
3130 true
3131}
3132
3133fn correlate_installed_package(
3134 package: &InstalledPackage,
3135 candidates: &[SearchMatch],
3136 allow_loose_name_match: bool,
3137) -> Option<SearchMatch> {
3138 if package.local_id.starts_with("MSIX\\") {
3146 return None;
3147 }
3148
3149 let installed_name = normalize_correlation_name(&package.name);
3150 let installed_name_lower = package.name.to_ascii_lowercase();
3151 let candidate_names = correlation_name_candidates(&package.name);
3152
3153 let mut scored: Vec<(i32, SearchMatch)> = candidates
3154 .iter()
3155 .filter_map(|candidate| {
3156 let candidate_name = normalize_correlation_name(&candidate.name);
3157 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3158 1000
3159 } else if candidate_names.iter().any(|name| {
3160 let normalized = normalize_correlation_name(name);
3161 normalized == candidate_name
3162 }) {
3163 900
3164 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3165 700
3166 } else {
3167 0
3168 };
3169
3170 if base_score == 0 {
3171 return None;
3172 }
3173
3174 let candidate_lower = candidate.name.to_ascii_lowercase();
3187 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3188 100
3189 } else if installed_name_lower.contains(&candidate_lower) {
3190 50
3191 } else {
3192 0
3193 };
3194
3195 Some((base_score + prefix_bonus, candidate.clone()))
3196 })
3197 .collect();
3198
3199 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3200 let mut iter = scored.into_iter();
3201 let (top_score, top_match) = iter.next()?;
3202
3203 if let Some((next_score, _)) = iter.next()
3211 && next_score == top_score
3212 {
3213 return None;
3214 }
3215 Some(top_match)
3216}
3217
3218fn correlation_name_candidates(name: &str) -> Vec<String> {
3219 let mut candidates = Vec::new();
3220 candidates.push(name.trim().to_owned());
3221
3222 let mut trimmed = name.trim().to_owned();
3223 if let Some(index) = trimmed.find(" (") {
3224 trimmed.truncate(index);
3225 }
3226
3227 let mut words = Vec::new();
3228 for token in trimmed.split_whitespace() {
3229 let lower = token
3230 .trim_matches(|ch: char| ch == '(' || ch == ')')
3231 .to_ascii_lowercase();
3232 if !words.is_empty()
3233 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3234 {
3235 break;
3236 }
3237 words.push(token);
3238 }
3239
3240 if !words.is_empty() {
3241 candidates.push(words.join(" "));
3242 }
3243
3244 candidates.sort();
3245 candidates.dedup();
3246 candidates
3247}
3248
3249fn normalize_correlation_name(value: &str) -> String {
3250 value
3251 .chars()
3252 .filter(|ch| ch.is_ascii_alphanumeric())
3253 .flat_map(|ch| ch.to_lowercase())
3254 .collect()
3255}
3256
3257struct V2PackageMetadata {
3258 rowid: i64,
3259 id: String,
3260 name: String,
3261 moniker: Option<String>,
3262 latest_version: String,
3263 package_hash: String,
3264}
3265
3266fn lookup_identity_match_v2(
3272 connection: &Connection,
3273 package: &InstalledPackage,
3274) -> Result<Option<(i64, &'static str)>> {
3275 for pfn in &package.package_family_names {
3276 if let Some(rowid) = query_optional_value(
3277 connection,
3278 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3279 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3280 |row| row_i64(row, 0),
3281 )? {
3282 return Ok(Some((rowid, "PackageFamilyName")));
3283 }
3284 }
3285 for code in &package.upgrade_codes {
3294 if let Some(rowid) = query_optional_value(
3295 connection,
3296 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3297 vec![SqlValue::Text(code.to_ascii_lowercase())],
3298 |row| row_i64(row, 0),
3299 )? {
3300 return Ok(Some((rowid, "UpgradeCode")));
3301 }
3302 }
3303 for code in &package.product_codes {
3304 if let Some(rowid) = query_optional_value(
3305 connection,
3306 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3307 vec![SqlValue::Text(code.to_ascii_lowercase())],
3308 |row| row_i64(row, 0),
3309 )? {
3310 return Ok(Some((rowid, "ProductCode")));
3311 }
3312 }
3313 Ok(None)
3314}
3315
3316fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3320 query_optional_value(
3321 connection,
3322 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3323 vec![SqlValue::Text(package_id.to_owned())],
3324 |row| {
3325 Ok(V2PackageMetadata {
3326 rowid: row_i64(row, 0)?,
3327 id: row_string(row, 1)?,
3328 name: row_string(row, 2)?,
3329 moniker: row_opt_string(row, 3)?,
3330 latest_version: row_string(row, 4)?,
3331 package_hash: row_hex_string(row, 5)?,
3332 })
3333 },
3334 )
3335}
3336
3337fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3345 if !package.local_id.starts_with("MSIX\\") {
3346 return;
3347 }
3348 if !package.name.starts_with("ms-resource:") {
3349 return;
3350 }
3351 let Some(candidate) = package.correlated.as_ref() else {
3352 return;
3353 };
3354 if !candidate.name.is_empty() {
3355 package.name = candidate.name.clone();
3356 }
3357}
3358
3359fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3360 query_optional_value(
3361 connection,
3362 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3363 vec![SqlValue::Integer(package_rowid)],
3364 |row| {
3365 Ok(V2PackageMetadata {
3366 rowid: package_rowid,
3367 id: row_string(row, 0)?,
3368 name: row_string(row, 1)?,
3369 moniker: row_opt_string(row, 2)?,
3370 latest_version: row_string(row, 3)?,
3371 package_hash: row_hex_string(row, 4)?,
3372 })
3373 },
3374 )
3375}
3376
3377fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3378 query_optional_value(
3379 connection,
3380 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3381 Vec::new(),
3382 |row| row_i64(row, 0),
3383 )
3384 .ok()
3385 .flatten()
3386 .is_some()
3387}
3388
3389fn lookup_unique_normalized_identity(
3396 connection: &Connection,
3397 norm_name: &str,
3398 norm_publisher: &str,
3399) -> Result<Option<i64>> {
3400 let rows: Vec<i64> = query_rows(
3401 connection,
3402 "SELECT DISTINCT n.package FROM norm_names2 n \
3403 WHERE n.norm_name = ?1 \
3404 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3405 LIMIT 2",
3406 vec![
3407 SqlValue::Text(norm_name.to_owned()),
3408 SqlValue::Text(norm_publisher.to_owned()),
3409 ],
3410 |row| row_i64(row, 0),
3411 )?;
3412 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3413}
3414
3415fn v2_identity_tables_present(connection: &Connection) -> bool {
3416 query_optional_value(
3417 connection,
3418 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3419 Vec::new(),
3420 |row| row_i64(row, 0),
3421 )
3422 .ok()
3423 .flatten()
3424 .is_some()
3425}
3426
3427fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3434 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3435 return None;
3436 }
3437 for entry in entries {
3438 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3439 continue;
3440 };
3441 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3442 {
3443 return Some(entry.version.clone());
3444 }
3445 }
3446 None
3447}
3448
3449fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3458 entries
3459 .iter()
3460 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3461 .max_by(|a, b| compare_version(&a.version, &b.version))
3462 .map(|entry| entry.version.clone())
3463}
3464
3465#[cfg(windows)]
3466fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3467 let mut packages = Vec::new();
3468 let mut seen = BTreeSet::new();
3469
3470 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3471 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3472
3473 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3481
3482 if machine {
3483 collect_uninstall_view(
3484 &mut packages,
3485 &mut seen,
3486 &upgrade_codes,
3487 RegKey::predef(HKEY_LOCAL_MACHINE),
3488 "Machine",
3489 "X64",
3490 KEY_READ | KEY_WOW64_64KEY,
3491 )?;
3492 collect_uninstall_view(
3493 &mut packages,
3494 &mut seen,
3495 &upgrade_codes,
3496 RegKey::predef(HKEY_LOCAL_MACHINE),
3497 "Machine",
3498 "X86",
3499 KEY_READ | KEY_WOW64_32KEY,
3500 )?;
3501 collect_appmodel_packages(
3502 &mut packages,
3503 &mut seen,
3504 RegKey::predef(HKEY_LOCAL_MACHINE),
3505 "Machine",
3506 KEY_READ | KEY_WOW64_64KEY,
3507 )?;
3508 }
3509
3510 if user {
3511 collect_uninstall_view(
3512 &mut packages,
3513 &mut seen,
3514 &upgrade_codes,
3515 RegKey::predef(HKEY_CURRENT_USER),
3516 "User",
3517 "X64",
3518 KEY_READ,
3519 )?;
3520 collect_appmodel_packages(
3521 &mut packages,
3522 &mut seen,
3523 RegKey::predef(HKEY_CURRENT_USER),
3524 "User",
3525 KEY_READ,
3526 )?;
3527 }
3528
3529 Ok(packages)
3530}
3531
3532#[cfg(windows)]
3538fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> std::collections::HashMap<String, String> {
3539 use std::collections::HashMap;
3540 let mut map = HashMap::new();
3541 if include_machine {
3542 collect_msi_upgrade_codes_from(
3543 RegKey::predef(HKEY_LOCAL_MACHINE),
3544 r"SOFTWARE\Classes\Installer\UpgradeCodes",
3545 KEY_READ | KEY_WOW64_64KEY,
3546 &mut map,
3547 );
3548 }
3549 if include_user {
3550 collect_msi_upgrade_codes_from(
3551 RegKey::predef(HKEY_CURRENT_USER),
3552 r"Software\Microsoft\Installer\UpgradeCodes",
3553 KEY_READ,
3554 &mut map,
3555 );
3556 }
3557 map
3558}
3559
3560#[cfg(windows)]
3561fn collect_msi_upgrade_codes_from(
3562 root: RegKey,
3563 path: &str,
3564 flags: u32,
3565 map: &mut std::collections::HashMap<String, String>,
3566) {
3567 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3568 return;
3569 };
3570 for key_name in upgrade_codes.enum_keys().flatten() {
3571 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3572 continue;
3573 };
3574 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3575 continue;
3576 };
3577 for (value_name, _) in subkey.enum_values().flatten() {
3578 if let Some(product_code) = unflip_packed_guid(&value_name) {
3579 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3583 }
3584 }
3585 }
3586}
3587
3588#[cfg(windows)]
3593fn unflip_packed_guid(packed: &str) -> Option<String> {
3594 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3595 return None;
3596 }
3597 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3598 let bytes = packed.as_bytes();
3599 let mut reversed = String::with_capacity(32);
3600 let mut offset = 0;
3601 for size in CHUNKS {
3602 let chunk = &bytes[offset..offset + size];
3603 for &c in chunk.iter().rev() {
3604 reversed.push(c as char);
3605 }
3606 offset += size;
3607 }
3608 let r = reversed.to_ascii_lowercase();
3609 Some(format!(
3610 "{{{}-{}-{}-{}-{}}}",
3611 &r[0..8],
3612 &r[8..12],
3613 &r[12..16],
3614 &r[16..20],
3615 &r[20..32]
3616 ))
3617}
3618
3619#[cfg(not(windows))]
3620fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3621 Ok(Vec::new())
3622}
3623
3624#[cfg(windows)]
3625fn collect_uninstall_view(
3626 packages: &mut Vec<InstalledPackage>,
3627 seen: &mut BTreeSet<String>,
3628 upgrade_code_map: &std::collections::HashMap<String, String>,
3629 root: RegKey,
3630 scope: &str,
3631 arch: &str,
3632 flags: u32,
3633) -> Result<()> {
3634 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
3635
3636 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
3637 Ok(key) => key,
3638 Err(_) => return Ok(()),
3639 };
3640
3641 for key_name in uninstall.enum_keys().flatten() {
3642 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
3643 Ok(key) => key,
3644 Err(_) => continue,
3645 };
3646 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
3647 {
3648 continue;
3649 }
3650
3651 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
3652 continue;
3653 };
3654
3655 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
3656 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
3657 let publisher = read_reg_string(&subkey, "Publisher");
3658 let install_location = read_reg_string(&subkey, "InstallLocation");
3659 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
3660 .into_iter()
3661 .collect::<Vec<_>>();
3662 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
3663 if product_codes.is_empty() && looks_like_product_code(&key_name) {
3664 product_codes.push(key_name.to_ascii_lowercase());
3665 }
3666 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
3667 if upgrade_codes.is_empty() {
3668 for code in &product_codes {
3672 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
3673 upgrade_codes.push(uc.clone());
3674 break;
3675 }
3676 }
3677 }
3678 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
3679 .as_deref()
3680 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
3681 {
3682 Some("portable".to_owned())
3688 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
3689 Some("msi".to_owned())
3690 } else if key_name.starts_with("MSIX\\") {
3691 Some("msix".to_owned())
3692 } else {
3693 Some("exe".to_owned())
3694 };
3695
3696 let dedupe_key = format!(
3697 "{}|{}|{}|{}",
3698 local_id,
3699 name.to_ascii_lowercase(),
3700 installed_version.to_ascii_lowercase(),
3701 publisher.clone().unwrap_or_default().to_ascii_lowercase()
3702 );
3703 if !seen.insert(dedupe_key) {
3704 continue;
3705 }
3706
3707 packages.push(InstalledPackage {
3708 name,
3709 local_id,
3710 installed_version,
3711 publisher,
3712 scope: Some(scope.to_owned()),
3713 installer_category,
3714 install_location,
3715 package_family_names,
3716 product_codes,
3717 upgrade_codes,
3718 correlated: None,
3719 installed_version_canonical: false,
3720 correlated_requires_explicit_upgrade: false,
3721 correlated_lacks_compatible_installer: false,
3722 });
3723 }
3724
3725 Ok(())
3726}
3727
3728#[cfg(windows)]
3729fn collect_appmodel_packages(
3730 packages: &mut Vec<InstalledPackage>,
3731 seen: &mut BTreeSet<String>,
3732 root: RegKey,
3733 scope: &str,
3734 flags: u32,
3735) -> Result<()> {
3736 const APPMODEL_PACKAGES_PATH: &str =
3737 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
3738
3739 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
3740 Ok(key) => key,
3741 Err(_) => return Ok(()),
3742 };
3743
3744 for key_name in appmodel.enum_keys().flatten() {
3745 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
3746 Ok(key) => key,
3747 Err(_) => continue,
3748 };
3749
3750 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
3751 continue;
3752 };
3753 let install_location = read_reg_string(&subkey, "PackageRootFolder");
3754 if install_location.as_deref().is_some_and(is_windows_system_path) {
3755 continue;
3756 }
3757
3758 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
3759 continue;
3760 };
3761
3762 let local_id = format!(r"MSIX\{key_name}");
3763 let dedupe_key = format!(
3764 "{}|{}|{}",
3765 local_id,
3766 name.to_ascii_lowercase(),
3767 metadata.version.to_ascii_lowercase()
3768 );
3769 if !seen.insert(dedupe_key) {
3770 continue;
3771 }
3772
3773 packages.push(InstalledPackage {
3774 name,
3775 local_id,
3776 installed_version: metadata.version,
3777 publisher: None,
3778 scope: Some(scope.to_owned()),
3779 installer_category: Some("msix".to_owned()),
3780 install_location,
3781 package_family_names: vec![metadata.family_name],
3782 product_codes: Vec::new(),
3783 upgrade_codes: Vec::new(),
3784 correlated: None,
3785 installed_version_canonical: false,
3786 correlated_requires_explicit_upgrade: false,
3787 correlated_lacks_compatible_installer: false,
3788 });
3789 }
3790
3791 Ok(())
3792}
3793
3794#[cfg(windows)]
3795struct ParsedMsixPackageFullName {
3796 version: String,
3797 family_name: String,
3798}
3799
3800#[cfg(windows)]
3801fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
3802 let segments = value.split('_').collect::<Vec<_>>();
3803 if segments.len() < 5 {
3804 return None;
3805 }
3806
3807 let name = segments[..segments.len() - 4].join("_");
3808 if name.is_empty() {
3809 return None;
3810 }
3811
3812 let version = segments[segments.len() - 4].trim();
3813 let resource_id = segments[segments.len() - 2].trim();
3814 let publisher_id = segments[segments.len() - 1].trim();
3815 if version.is_empty() || publisher_id.is_empty() {
3816 return None;
3817 }
3818
3819 let family_name = if resource_id.is_empty() {
3820 format!("{name}_{publisher_id}")
3821 } else {
3822 format!("{name}_{resource_id}_{publisher_id}")
3823 };
3824
3825 Some(ParsedMsixPackageFullName {
3826 version: version.to_owned(),
3827 family_name,
3828 })
3829}
3830
3831#[cfg(windows)]
3832fn is_windows_system_path(path: &str) -> bool {
3833 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
3834}
3835
3836#[cfg(windows)]
3837fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
3838 key.get_value::<String, _>(value_name)
3839 .ok()
3840 .map(|value| value.trim().to_owned())
3841 .filter(|value| !value.is_empty())
3842}
3843
3844#[cfg(windows)]
3845fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
3846 key.get_value::<u32, _>(value_name).ok()
3847}
3848
3849#[cfg(windows)]
3850fn looks_like_product_code(value: &str) -> bool {
3851 value.starts_with('{') && value.ends_with('}')
3852}
3853
3854fn ensure_app_dirs(app_root: &Path) -> Result<()> {
3855 fs::create_dir_all(app_root).context("failed to create app root directory")?;
3856 if uses_packaged_layout(app_root) {
3857 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
3858 let user_sources_path = packaged_user_sources_path(app_root)?;
3859 if let Some(parent) = user_sources_path.parent() {
3860 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
3861 }
3862 } else {
3863 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
3864 }
3865 Ok(())
3866}
3867
3868fn default_app_root() -> Result<PathBuf> {
3869 if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
3870 return Ok(PathBuf::from(app_root));
3871 }
3872
3873 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
3874
3875 #[cfg(windows)]
3876 {
3877 if has_current_package_identity() {
3878 return Ok(local_app_data
3879 .join("Packages")
3880 .join(PACKAGED_FAMILY_NAME)
3881 .join("LocalState"));
3882 }
3883 Ok(local_app_data.join("Devolutions").join("Pinget"))
3884 }
3885
3886 #[cfg(not(windows))]
3887 {
3888 Ok(local_app_data.join("pinget"))
3889 }
3890}
3891
3892#[cfg(windows)]
3893fn has_current_package_identity() -> bool {
3894 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
3897
3898 let mut length: u32 = 0;
3899 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
3902 rc != APPMODEL_ERROR_NO_PACKAGE
3903}
3904
3905fn store_path(app_root: &Path) -> PathBuf {
3906 app_root.join("sources.json")
3907}
3908
3909fn user_settings_path(app_root: &Path) -> PathBuf {
3910 if uses_packaged_layout(app_root) {
3911 app_root.join("settings.json")
3912 } else {
3913 app_root.join("user-settings.json")
3914 }
3915}
3916
3917fn admin_settings_path(app_root: &Path) -> PathBuf {
3918 app_root.join("admin-settings.json")
3919}
3920
3921fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
3922 if uses_packaged_layout(app_root) {
3923 return app_root
3924 .join(packaged_source_type(source.kind))
3925 .join(sanitize_path_segment(&source.identifier));
3926 }
3927
3928 app_root.join("sources").join(sanitize_path_segment(&source.name))
3929}
3930
3931fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3932 source_state_dir(app_root, source).join("source.msix")
3933}
3934
3935fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3936 source_state_dir(app_root, source).join("index.db")
3937}
3938
3939fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
3940 source_state_dir(app_root, source).join("rest-information.json")
3941}
3942
3943fn rest_manifest_cache_path_with_root(
3944 app_root: &Path,
3945 source: &SourceRecord,
3946 package_id: &str,
3947 version: &str,
3948 channel: &str,
3949) -> PathBuf {
3950 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
3951 let digest = sha256_hex(key.as_bytes());
3952 cache_root(app_root)
3953 .join("REST_M")
3954 .join(sanitize_path_segment(&source.identifier))
3955 .join(format!("{digest}.json"))
3956}
3957
3958fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
3959 cache_root(app_root)
3960 .join(bucket)
3961 .join(sanitize_path_segment(identifier))
3962}
3963
3964fn load_store(app_root: &Path) -> Result<SourceStore> {
3965 if uses_packaged_layout(app_root)
3966 && let Some(store) = load_packaged_store(app_root)?
3967 {
3968 return Ok(store);
3969 }
3970
3971 let path = store_path(app_root);
3972 if !path.exists() {
3973 let store = SourceStore::default();
3974 save_store(app_root, &store)?;
3975 return Ok(store);
3976 }
3977
3978 let bytes = fs::read(path).context("failed to read source store")?;
3979 serde_json::from_slice(&bytes).context("failed to parse source store")
3980}
3981
3982fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
3983 if uses_system_winget_source_commands(app_root) {
3984 bail!("System WinGet source settings must be modified through winget.");
3985 }
3986
3987 if uses_packaged_layout(app_root) {
3988 return save_packaged_store(app_root, store);
3989 }
3990
3991 write_json(store_path(app_root), store)
3992}
3993
3994fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
3995 let user_sources_path = packaged_user_sources_path(app_root)?;
3996 let metadata_path = app_root.join("sources_metadata");
3997 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
3998 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
3999 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4000}
4001
4002fn load_packaged_store_from_streams(
4003 user_sources_yaml: Option<&str>,
4004 metadata_yaml: Option<&str>,
4005) -> Result<Option<SourceStore>> {
4006 if is_secure_settings_stub(user_sources_yaml) {
4007 return load_system_winget_source_store().map(Some);
4008 }
4009
4010 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4011}
4012
4013fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4014 let user_sources_path = packaged_user_sources_path(app_root)?;
4015 if let Some(parent) = user_sources_path.parent() {
4016 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4017 }
4018
4019 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4020 .context("failed to write packaged user_sources stream")?;
4021 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4022 .context("failed to write packaged source metadata")?;
4023 Ok(())
4024}
4025
4026fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4027 if !uses_packaged_layout(app_root) {
4028 return false;
4029 }
4030
4031 #[cfg(windows)]
4032 {
4033 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4034 return false;
4035 };
4036
4037 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4038 is_secure_settings_stub(user_sources_yaml.as_deref())
4039 }
4040
4041 #[cfg(not(windows))]
4042 {
4043 false
4044 }
4045}
4046
4047fn is_secure_settings_stub(value: Option<&str>) -> bool {
4048 value
4049 .map(str::trim_start)
4050 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4051}
4052
4053fn load_system_winget_source_store() -> Result<SourceStore> {
4054 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4055 Ok(SourceStore {
4056 sources: parse_system_winget_source_export(&result.stdout)?,
4057 })
4058}
4059
4060fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4061 run_checked_winget_source_command(
4062 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4063 &format!("add WinGet source '{name}'"),
4064 )?;
4065 Ok(())
4066}
4067
4068fn system_winget_remove_source(name: &str) -> Result<()> {
4069 run_checked_winget_source_command(
4070 vec![
4071 "source".to_owned(),
4072 "remove".to_owned(),
4073 "--name".to_owned(),
4074 name.to_owned(),
4075 "--disable-interactivity".to_owned(),
4076 ],
4077 &format!("remove WinGet source '{name}'"),
4078 )?;
4079 Ok(())
4080}
4081
4082fn system_winget_reset_source(name: &str) -> Result<()> {
4083 run_checked_winget_source_command(
4084 vec![
4085 "source".to_owned(),
4086 "reset".to_owned(),
4087 "--name".to_owned(),
4088 name.to_owned(),
4089 "--force".to_owned(),
4090 "--disable-interactivity".to_owned(),
4091 ],
4092 &format!("reset WinGet source '{name}'"),
4093 )?;
4094 Ok(())
4095}
4096
4097fn system_winget_reset_sources() -> Result<()> {
4098 run_checked_winget_source_command(
4099 vec![
4100 "source".to_owned(),
4101 "reset".to_owned(),
4102 "--force".to_owned(),
4103 "--disable-interactivity".to_owned(),
4104 ],
4105 "reset WinGet sources",
4106 )?;
4107 Ok(())
4108}
4109
4110fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4111 let mut args = vec!["source".to_owned(), "update".to_owned()];
4112 if let Some(name) = name {
4113 args.push("--name".to_owned());
4114 args.push(name.to_owned());
4115 }
4116
4117 args.push("--disable-interactivity".to_owned());
4118 let result = run_checked_winget_source_command(
4119 args,
4120 if name.is_some() {
4121 "update WinGet source"
4122 } else {
4123 "update WinGet sources"
4124 },
4125 )?;
4126 let detail = result.stdout.trim();
4127 Ok(if detail.is_empty() {
4128 "Done".to_owned()
4129 } else {
4130 detail.to_owned()
4131 })
4132}
4133
4134fn build_winget_source_export_arguments() -> Vec<String> {
4135 vec![
4136 "source".to_owned(),
4137 "export".to_owned(),
4138 "--disable-interactivity".to_owned(),
4139 ]
4140}
4141
4142fn build_winget_source_add_arguments(
4143 name: &str,
4144 arg: &str,
4145 kind: SourceKind,
4146 trust_level: &str,
4147 explicit: bool,
4148) -> Vec<String> {
4149 let mut args = vec![
4150 "source".to_owned(),
4151 "add".to_owned(),
4152 "--name".to_owned(),
4153 name.to_owned(),
4154 "--arg".to_owned(),
4155 arg.to_owned(),
4156 "--type".to_owned(),
4157 packaged_source_type(kind).to_owned(),
4158 "--disable-interactivity".to_owned(),
4159 ];
4160
4161 if !trust_level.trim().is_empty() {
4162 args.push("--trust-level".to_owned());
4163 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4164 "trusted".to_owned()
4165 } else {
4166 "none".to_owned()
4167 });
4168 }
4169
4170 if explicit {
4171 args.push("--explicit".to_owned());
4172 }
4173
4174 args
4175}
4176
4177#[derive(Debug)]
4178struct WingetSourceCommandResult {
4179 exit_code: i32,
4180 stdout: String,
4181 stderr: String,
4182}
4183
4184type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4185
4186static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4187 RwLock::new(run_winget_source_command_process);
4188
4189fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4190 let result = run_winget_source_command(&args)?;
4191 if result.exit_code == 0 {
4192 return Ok(result);
4193 }
4194
4195 let detail = [result.stderr.trim(), result.stdout.trim()]
4196 .into_iter()
4197 .filter(|value| !value.is_empty())
4198 .collect::<Vec<_>>()
4199 .join("\n");
4200 if detail.is_empty() {
4201 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4202 }
4203
4204 bail!(
4205 "Failed to {action}; winget exited with code {}:\n{}",
4206 result.exit_code,
4207 detail
4208 );
4209}
4210
4211fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4212 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4213 .read()
4214 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4215 runner(args)
4216}
4217
4218fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4219 use std::process::Command;
4220
4221 let output = Command::new("winget")
4222 .args(args)
4223 .output()
4224 .context("failed to run winget source command")?;
4225
4226 Ok(WingetSourceCommandResult {
4227 exit_code: output.status.code().unwrap_or(-1),
4228 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4229 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4230 })
4231}
4232
4233fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4234 let trimmed = output.trim();
4235 if trimmed.is_empty() {
4236 return Ok(Vec::new());
4237 }
4238
4239 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4240 && let Some(sources) = parse_system_winget_source_export_value(&value)?
4241 {
4242 return Ok(order_sources(sources));
4243 }
4244
4245 let mut sources = Vec::new();
4246 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4247 if line.is_empty() {
4248 continue;
4249 }
4250
4251 let value: JsonValue = serde_json::from_str(line)
4252 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4253 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4254 bail!("WinGet source export returned a non-source line: {line}");
4255 };
4256 sources.append(&mut parsed);
4257 }
4258
4259 Ok(order_sources(sources))
4260}
4261
4262fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4263 match value {
4264 JsonValue::Array(items) => items
4265 .iter()
4266 .map(parse_system_winget_source_record)
4267 .collect::<Result<Vec<_>>>()
4268 .map(Some),
4269 JsonValue::Object(object) => {
4270 if let Some(JsonValue::Array(items)) = object.get("Sources") {
4271 items
4272 .iter()
4273 .map(parse_system_winget_source_record)
4274 .collect::<Result<Vec<_>>>()
4275 .map(Some)
4276 } else if object.contains_key("Name") {
4277 Ok(Some(vec![parse_system_winget_source_record(value)?]))
4278 } else {
4279 Ok(None)
4280 }
4281 }
4282 _ => Ok(None),
4283 }
4284}
4285
4286fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4287 let object = value
4288 .as_object()
4289 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4290 let name = required_json_string(object, "Name")?;
4291 let source_type = required_json_string(object, "Type")?;
4292 let arg = required_json_string(object, "Arg")?;
4293 let kind = parse_system_winget_source_kind(&source_type)?;
4294 let identifier = optional_json_string(object, "Identifier")
4295 .or_else(|| optional_json_string(object, "Data"))
4296 .unwrap_or_else(|| name.clone());
4297
4298 Ok(SourceRecord {
4299 name,
4300 kind,
4301 arg,
4302 identifier,
4303 trust_level: parse_system_winget_trust_level(object),
4304 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4305 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4306 last_update: None,
4307 source_version: None,
4308 })
4309}
4310
4311fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4312 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4313}
4314
4315fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4316 match object.get(key)? {
4317 JsonValue::String(value) => Some(value.clone()),
4318 JsonValue::Number(value) => Some(value.to_string()),
4319 _ => None,
4320 }
4321}
4322
4323fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4324 match object.get(key)? {
4325 JsonValue::Bool(value) => Some(*value),
4326 JsonValue::String(value) => value.parse().ok(),
4327 _ => None,
4328 }
4329}
4330
4331fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4332 match object.get(key)? {
4333 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4334 JsonValue::String(value) => value.parse().ok(),
4335 _ => None,
4336 }
4337}
4338
4339fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4340 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4341 Ok(SourceKind::PreIndexed)
4342 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4343 Ok(SourceKind::Rest)
4344 } else {
4345 bail!("Unsupported WinGet source type '{value}'.")
4346 }
4347}
4348
4349fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4350 let Some(value) = object.get("TrustLevel") else {
4351 return "None".to_owned();
4352 };
4353
4354 match value {
4355 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4356 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4357 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4358 _ => "None".to_owned(),
4359 }
4360}
4361
4362fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4363 value
4364 .as_str()
4365 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4366}
4367
4368fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4369 sources.sort_by(|left, right| {
4370 left.explicit
4371 .cmp(&right.explicit)
4372 .then_with(|| right.priority.cmp(&left.priority))
4373 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4374 });
4375 sources
4376}
4377
4378fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4379 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4380 return None;
4381 }
4382
4383 let mut sources = SourceStore::default().sources;
4384 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4385 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4386 continue;
4387 };
4388
4389 if yaml_bool(entry.get("IsTombstone")) {
4390 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4391 continue;
4392 }
4393
4394 let Some(mapped) = map_packaged_source_entry(&entry) else {
4395 continue;
4396 };
4397
4398 if let Some(existing) = sources
4399 .iter_mut()
4400 .find(|source| source.name.eq_ignore_ascii_case(&name))
4401 {
4402 *existing = mapped;
4403 } else {
4404 sources.push(mapped);
4405 }
4406 }
4407
4408 for entry in parse_packaged_yaml_entries(metadata_yaml) {
4409 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4410 continue;
4411 };
4412
4413 let Some(source) = sources
4414 .iter_mut()
4415 .find(|source| source.name.eq_ignore_ascii_case(&name))
4416 else {
4417 continue;
4418 };
4419
4420 if let Some(last_update) = entry
4421 .get("LastUpdate")
4422 .and_then(|value| value.as_deref())
4423 .and_then(parse_packaged_datetime)
4424 {
4425 source.last_update = Some(last_update);
4426 }
4427
4428 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4429 && !source_version.is_empty()
4430 {
4431 source.source_version = Some(source_version);
4432 }
4433 }
4434
4435 Some(SourceStore { sources })
4436}
4437
4438fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4439 let Some(yaml) = yaml else {
4440 return Vec::new();
4441 };
4442
4443 let mut entries = Vec::new();
4444 let mut current: Option<BTreeMap<String, Option<String>>> = None;
4445 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4446 let line = raw_line.trim_end();
4447 let trimmed = line.trim();
4448 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4449 continue;
4450 }
4451
4452 if let Some(remainder) = trimmed.strip_prefix("- ") {
4453 if let Some(entry) = current.take() {
4454 entries.push(entry);
4455 }
4456
4457 let mut entry = BTreeMap::new();
4458 add_packaged_yaml_key_value(&mut entry, remainder);
4459 current = Some(entry);
4460 continue;
4461 }
4462
4463 if let Some(entry) = current.as_mut() {
4464 add_packaged_yaml_key_value(entry, trimmed);
4465 }
4466 }
4467
4468 if let Some(entry) = current {
4469 entries.push(entry);
4470 }
4471
4472 entries
4473}
4474
4475fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4476 let Some((key, value)) = line.split_once(':') else {
4477 return;
4478 };
4479
4480 entry.insert(
4481 key.trim().to_owned(),
4482 Some(unquote_yaml_scalar(value.trim()).to_owned()),
4483 );
4484}
4485
4486fn unquote_yaml_scalar(value: &str) -> &str {
4487 if value.len() >= 2 {
4488 let bytes = value.as_bytes();
4489 let first = bytes[0] as char;
4490 let last = bytes[value.len() - 1] as char;
4491 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4492 return &value[1..value.len() - 1];
4493 }
4494 }
4495
4496 value
4497}
4498
4499fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4500 let name = entry.get("Name").and_then(|value| value.clone())?;
4501 let source_type = entry.get("Type").and_then(|value| value.clone())?;
4502 let arg = entry.get("Arg").and_then(|value| value.clone())?;
4503 let kind = parse_packaged_source_kind(&source_type)?;
4504
4505 Some(SourceRecord {
4506 name: name.clone(),
4507 kind,
4508 arg,
4509 identifier: entry
4510 .get("Identifier")
4511 .and_then(|value| value.clone())
4512 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4513 .unwrap_or(name),
4514 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4515 explicit: yaml_bool(entry.get("Explicit")),
4516 priority: entry
4517 .get("Priority")
4518 .and_then(|value| value.as_deref())
4519 .and_then(|value| value.parse::<i32>().ok())
4520 .unwrap_or_default(),
4521 last_update: None,
4522 source_version: None,
4523 })
4524}
4525
4526fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4527 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4528 Some(SourceKind::PreIndexed)
4529 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4530 Some(SourceKind::Rest)
4531 } else {
4532 None
4533 }
4534}
4535
4536fn parse_packaged_trust_level(value: Option<&str>) -> String {
4537 match value {
4538 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
4539 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
4540 _ => "None".to_owned(),
4541 }
4542}
4543
4544fn yaml_bool(value: Option<&Option<String>>) -> bool {
4545 value
4546 .and_then(|raw| raw.as_deref())
4547 .and_then(|raw| raw.parse::<bool>().ok())
4548 .unwrap_or(false)
4549}
4550
4551fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
4552 if let Ok(unix_seconds) = value.parse::<i64>() {
4553 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
4554 }
4555
4556 DateTime::parse_from_rfc3339(value)
4557 .ok()
4558 .map(|value| value.with_timezone(&Utc))
4559}
4560
4561fn render_packaged_sources_yaml(store: &SourceStore) -> String {
4562 let mut lines = vec!["Sources:".to_owned()];
4563 for source in &store.sources {
4564 lines.push(format!(" - Name: {}", source.name));
4565 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
4566 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
4567 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
4568 lines.push(format!(
4569 " TrustLevel: {}",
4570 if source.trust_level.eq_ignore_ascii_case("Trusted") {
4571 1
4572 } else {
4573 0
4574 }
4575 ));
4576 lines.push(format!(" Explicit: {}", source.explicit));
4577 lines.push(format!(" Priority: {}", source.priority));
4578 lines.push(" IsTombstone: false".to_owned());
4579 }
4580 lines.push(String::new());
4581 lines.join("\n")
4582}
4583
4584fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
4585 let mut lines = vec!["Sources:".to_owned()];
4586 for source in &store.sources {
4587 if source.last_update.is_none() && source.source_version.is_none() {
4588 continue;
4589 }
4590
4591 lines.push(format!(" - Name: {}", source.name));
4592 if let Some(last_update) = source.last_update {
4593 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
4594 }
4595 if let Some(source_version) = &source.source_version {
4596 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
4597 }
4598 }
4599 lines.push(String::new());
4600 lines.join("\n")
4601}
4602
4603fn yaml_scalar(value: &str) -> String {
4604 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
4605 format!("\"{}\"", value.replace('"', "\\\""))
4606 } else {
4607 value.to_owned()
4608 }
4609}
4610
4611fn uses_packaged_layout(app_root: &Path) -> bool {
4612 #[cfg(windows)]
4613 {
4614 try_get_packaged_local_state_root(app_root).is_some()
4615 }
4616
4617 #[cfg(not(windows))]
4618 {
4619 let _ = app_root;
4620 false
4621 }
4622}
4623
4624#[cfg(windows)]
4625fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
4626 let local_state = app_root.file_name()?.to_string_lossy();
4627 if !local_state.eq_ignore_ascii_case("LocalState") {
4628 return None;
4629 }
4630
4631 let family = app_root.parent()?.file_name()?.to_string_lossy();
4632 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
4633 return None;
4634 }
4635
4636 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
4637 if !packages.eq_ignore_ascii_case("Packages") {
4638 return None;
4639 }
4640
4641 Some(app_root.to_path_buf())
4642}
4643
4644#[cfg(windows)]
4645fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
4646 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
4647 Ok(packaged_secure_settings_root()?.join("user_sources"))
4648}
4649
4650#[cfg(not(windows))]
4651fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
4652 bail!("packaged WinGet source streams are only available on Windows")
4653}
4654
4655#[cfg(windows)]
4656fn packaged_secure_settings_root() -> Result<PathBuf> {
4657 let program_data = std::env::var_os("PROGRAMDATA")
4658 .map(PathBuf::from)
4659 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
4660 Ok(program_data
4661 .join("Microsoft")
4662 .join("WinGet")
4663 .join(current_user_sid()?)
4664 .join("settings")
4665 .join("pkg")
4666 .join(PACKAGED_NAME))
4667}
4668
4669#[cfg(windows)]
4670#[allow(clippy::multiple_unsafe_ops_per_block)]
4671fn current_user_sid() -> Result<String> {
4672 fn close_token(token: HANDLE) {
4673 if !token.is_null() {
4674 unsafe {
4676 CloseHandle(token);
4677 }
4678 }
4679 }
4680
4681 let mut token: HANDLE = std::ptr::null_mut();
4682 let current_process = unsafe { GetCurrentProcess() };
4684 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
4686 bail!("failed to open current process token")
4687 }
4688
4689 let mut length = 0;
4690 unsafe {
4692 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
4693 }
4694
4695 let element_size = size_of::<TOKEN_USER>();
4696 let element_count = (length as usize).div_ceil(element_size);
4697 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
4698 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
4700 close_token(token);
4701 bail!("failed to get current user token information")
4702 }
4703
4704 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
4706 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
4707 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
4709 close_token(token);
4710 bail!("failed to convert current user SID")
4711 }
4712
4713 let mut len = 0usize;
4714 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
4716 len += 1;
4717 }
4718
4719 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
4721 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
4722 unsafe {
4724 LocalFree(sid_ptr.cast());
4725 }
4726 close_token(token);
4727 Ok(sid)
4728}
4729
4730fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
4731 app_root.join("Microsoft").join("Windows Package Manager")
4732}
4733
4734fn cache_root(app_root: &Path) -> PathBuf {
4735 if uses_packaged_layout(app_root) {
4736 packaged_file_cache_root(app_root)
4737 } else {
4738 default_cache_root_fallback()
4739 }
4740}
4741
4742fn default_cache_root_fallback() -> PathBuf {
4743 std::env::temp_dir().join("cache")
4744}
4745
4746fn packaged_source_type(kind: SourceKind) -> &'static str {
4747 match kind {
4748 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
4749 SourceKind::Rest => "Microsoft.Rest",
4750 }
4751}
4752
4753fn sanitize_path_segment(value: &str) -> String {
4754 value
4755 .chars()
4756 .map(|ch| match ch {
4757 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
4758 _ => ch,
4759 })
4760 .collect()
4761}
4762
4763fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
4764 if !path.exists() {
4765 return Ok(JsonMap::new());
4766 }
4767
4768 let bytes = fs::read(path).context("failed to read JSON file")?;
4769 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
4770 Ok(match value {
4771 JsonValue::Object(object) => object,
4772 _ => JsonMap::new(),
4773 })
4774}
4775
4776fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
4777 write_json(path, value)
4778}
4779
4780fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
4781 if let Some(parent) = path.parent() {
4782 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
4783 }
4784
4785 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
4786 fs::write(path, bytes).context("failed to write JSON file")
4787}
4788
4789fn merge_json_objects(
4790 current: &JsonMap<String, JsonValue>,
4791 update: &JsonMap<String, JsonValue>,
4792) -> JsonMap<String, JsonValue> {
4793 let mut merged = current.clone();
4794 for (key, value) in update {
4795 match (merged.get(key), value) {
4796 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
4797 merged.insert(
4798 key.clone(),
4799 JsonValue::Object(merge_json_objects(current_object, update_object)),
4800 );
4801 }
4802 _ => {
4803 merged.insert(key.clone(), value.clone());
4804 }
4805 }
4806 }
4807
4808 merged
4809}
4810
4811fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
4812 match expected {
4813 JsonValue::Object(expected_object) => match current {
4814 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
4815 current_object
4816 .get(key)
4817 .is_some_and(|current_child| json_contains(current_child, expected_child))
4818 }),
4819 _ => false,
4820 },
4821 JsonValue::Array(expected_array) => match current {
4822 JsonValue::Array(current_array) => {
4823 current_array.len() == expected_array.len()
4824 && current_array
4825 .iter()
4826 .zip(expected_array.iter())
4827 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
4828 }
4829 _ => false,
4830 },
4831 _ => current == expected,
4832 }
4833}
4834
4835fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
4836 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
4837 "none" | "default" => Ok("None".to_owned()),
4838 "trusted" => Ok("Trusted".to_owned()),
4839 other => bail!("unsupported source trust level: {other}"),
4840 }
4841}
4842
4843fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
4844 SUPPORTED_ADMIN_SETTINGS
4845 .iter()
4846 .copied()
4847 .find(|setting| setting.eq_ignore_ascii_case(name))
4848 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
4849}
4850
4851fn default_source_trust_level() -> String {
4852 "None".to_owned()
4853}
4854
4855fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
4856where
4857 F: FnMut(&SqlRow<'_>) -> Result<T>,
4858{
4859 let mut statement = connection
4860 .prepare(sql)
4861 .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4862 let mut rows = statement
4863 .query(params_from_iter(params))
4864 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4865 let mut result = Vec::new();
4866
4867 while let Some(row) = rows.next().context("failed to read SQL row")? {
4868 result.push(map(row)?);
4869 }
4870
4871 Ok(result)
4872}
4873
4874fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
4875where
4876 F: FnOnce(&SqlRow<'_>) -> Result<T>,
4877{
4878 let mut statement = connection
4879 .prepare(sql)
4880 .with_context(|| format!("failed to prepare SQL query: {sql}"))?;
4881 let mut rows = statement
4882 .query(params_from_iter(params))
4883 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
4884
4885 match rows.next().context("failed to read SQL row")? {
4886 Some(row) => Ok(Some(map(row)?)),
4887 None => Ok(None),
4888 }
4889}
4890
4891fn row_ref<'a>(row: &'a SqlRow<'_>, index: usize) -> Result<ValueRef<'a>> {
4892 row.get_ref(index)
4893 .with_context(|| format!("failed to read SQL column {index}"))
4894}
4895
4896fn row_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4897 match row_ref(row, index)? {
4898 ValueRef::Text(value) => std::str::from_utf8(value)
4899 .context("SQL text column was not valid UTF-8")
4900 .map(str::to_owned),
4901 ValueRef::Integer(value) => Ok(value.to_string()),
4902 ValueRef::Real(value) => Ok(value.to_string()),
4903 ValueRef::Blob(value) => String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8"),
4904 ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4905 }
4906}
4907
4908fn row_opt_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4909 match row_ref(row, index)? {
4910 ValueRef::Null => Ok(None),
4911 ValueRef::Text(value) => Ok(Some(
4912 std::str::from_utf8(value)
4913 .context("SQL text column was not valid UTF-8")?
4914 .to_owned(),
4915 )),
4916 ValueRef::Integer(value) => Ok(Some(value.to_string())),
4917 ValueRef::Real(value) => Ok(Some(value.to_string())),
4918 ValueRef::Blob(value) => Ok(Some(
4919 String::from_utf8(value.to_vec()).context("SQL blob column was not valid UTF-8")?,
4920 )),
4921 }
4922}
4923
4924fn row_i64(row: &SqlRow<'_>, index: usize) -> Result<i64> {
4925 match row_ref(row, index)? {
4926 ValueRef::Integer(value) => Ok(value),
4927 ValueRef::Text(value) => std::str::from_utf8(value)
4928 .context("SQL text column was not valid UTF-8")?
4929 .parse::<i64>()
4930 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
4931 ValueRef::Real(value) => f64_to_i64_checked(value, index),
4932 ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4933 ValueRef::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
4934 }
4935}
4936
4937fn row_hex_string(row: &SqlRow<'_>, index: usize) -> Result<String> {
4938 match row_ref(row, index)? {
4939 ValueRef::Blob(value) => Ok(bytes_to_hex(value)),
4940 ValueRef::Text(value) => Ok(std::str::from_utf8(value)
4941 .context("SQL text column was not valid UTF-8")?
4942 .to_ascii_lowercase()),
4943 ValueRef::Null => bail!("SQL column {index} was unexpectedly NULL"),
4944 ValueRef::Integer(value) => Ok(format!("{value:x}")),
4945 ValueRef::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
4946 }
4947}
4948
4949fn row_opt_hex_string(row: &SqlRow<'_>, index: usize) -> Result<Option<String>> {
4950 match row_ref(row, index)? {
4951 ValueRef::Null => Ok(None),
4952 ValueRef::Blob(value) => Ok(Some(bytes_to_hex(value))),
4953 ValueRef::Text(value) => Ok(Some(
4954 std::str::from_utf8(value)
4955 .context("SQL text column was not valid UTF-8")?
4956 .to_ascii_lowercase(),
4957 )),
4958 ValueRef::Integer(value) => Ok(Some(format!("{value:x}"))),
4959 ValueRef::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
4960 }
4961}
4962
4963fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
4964 if !value.is_finite() {
4965 bail!("SQL column {index} contained a non-finite floating-point value");
4966 }
4967
4968 if value.fract() != 0.0 {
4969 bail!("SQL column {index} contained a non-integer floating-point value");
4970 }
4971
4972 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
4973 bail!("SQL column {index} contained a floating-point value outside the i64 range");
4974 }
4975
4976 format!("{value:.0}")
4977 .parse::<i64>()
4978 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
4979}
4980
4981fn bytes_to_hex(bytes: &[u8]) -> String {
4982 let mut result = String::with_capacity(bytes.len() * 2);
4983 for byte in bytes {
4984 result.push_str(&format!("{byte:02x}"));
4985 }
4986 result
4987}
4988
4989fn query_v2_matches(
4990 connection: &Connection,
4991 query: &PackageQuery,
4992 semantics: SearchSemantics,
4993) -> Result<(Vec<V2SearchRow>, bool)> {
4994 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
4995 let limit = source_fetch_results(query, semantics);
4996 let sql = format!(
4997 "SELECT rowid, id, name, moniker, latest_version, hash \
4998 FROM packages WHERE {where_clause} LIMIT {}",
4999 limit + 1
5000 );
5001 let mut rows = query_rows(
5002 connection,
5003 &sql,
5004 params.into_iter().map(SqlValue::Text).collect(),
5005 |row| {
5006 Ok(V2SearchRow {
5007 package_rowid: row_i64(row, 0)?,
5008 id: row_string(row, 1)?,
5009 name: row_string(row, 2)?,
5010 moniker: row_opt_string(row, 3)?,
5011 version: row_string(row, 4)?,
5012 package_hash: row_hex_string(row, 5)?,
5013 match_criteria: None,
5014 })
5015 },
5016 )?;
5017 for row in &mut rows {
5018 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5019 }
5020 let truncated = rows.len() > limit;
5021 rows.truncate(limit);
5022 Ok((rows, truncated))
5023}
5024
5025fn query_v1_matches(
5026 connection: &Connection,
5027 query: &PackageQuery,
5028 semantics: SearchSemantics,
5029) -> Result<(Vec<V1SearchRow>, bool)> {
5030 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5031 let limit = source_fetch_results(query, semantics);
5032 let sql = format!(
5033 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5034 FROM manifest \
5035 JOIN ids ON manifest.id = ids.rowid \
5036 JOIN names ON manifest.name = names.rowid \
5037 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5038 JOIN versions ON manifest.version = versions.rowid \
5039 JOIN channels ON manifest.channel = channels.rowid \
5040 WHERE {where_clause} LIMIT {}",
5041 limit + 1
5042 );
5043 let mut rows = query_rows(
5044 connection,
5045 &sql,
5046 params.into_iter().map(SqlValue::Text).collect(),
5047 |row| {
5048 Ok(V1SearchRow {
5049 manifest_rowid: row_i64(row, 0)?,
5050 package_rowid: row_i64(row, 1)?,
5051 version: row_string(row, 2)?,
5052 channel: row_string(row, 3)?,
5053 id: row_string(row, 4)?,
5054 name: row_string(row, 5)?,
5055 moniker: row_opt_string(row, 6)?,
5056 match_criteria: None,
5057 })
5058 },
5059 )?;
5060 for row in &mut rows {
5061 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5062 }
5063 let truncated = rows.len() > limit;
5064 rows.truncate(limit);
5065 Ok((rows, truncated))
5066}
5067
5068fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5069 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5070
5071 for row in rows {
5072 match grouped.get(&row.package_rowid) {
5073 Some(existing)
5074 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5075 != Ordering::Less => {}
5076 _ => {
5077 grouped.insert(row.package_rowid, row);
5078 }
5079 }
5080 }
5081
5082 grouped.into_values().collect()
5083}
5084
5085fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5086 let has_hash = table_has_column(connection, "manifest", "hash")?;
5087 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5088 let sql = format!(
5089 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5090 FROM manifest \
5091 JOIN versions ON manifest.version = versions.rowid \
5092 JOIN channels ON manifest.channel = channels.rowid \
5093 WHERE manifest.id = ?1"
5094 );
5095 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5096 Ok(V1VersionRow {
5097 version: row_string(row, 0)?,
5098 channel: row_string(row, 1)?,
5099 pathpart_id: row_i64(row, 2)?,
5100 manifest_hash: row_opt_hex_string(row, 3)?,
5101 })
5102 })
5103}
5104
5105fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5106 let mut parts = Vec::new();
5107 let mut current = Some(pathpart_id);
5108
5109 while let Some(id) = current {
5110 let (parent, pathpart) = query_optional_value(
5111 connection,
5112 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5113 vec![SqlValue::Integer(id)],
5114 |row| {
5115 Ok((
5116 row_opt_string(row, 0)?
5117 .and_then(|value| value.parse::<i64>().ok())
5118 .filter(|value| *value != 0),
5119 row_string(row, 1)?,
5120 ))
5121 },
5122 )?
5123 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5124 parts.push(pathpart);
5125 current = parent;
5126 }
5127
5128 parts.reverse();
5129 Ok(parts.join("/"))
5130}
5131
5132fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5133 format!(
5134 "packages/{}/{}/versionData.mszyml",
5135 package_id,
5136 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5137 )
5138}
5139
5140fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5141 let message = format!("{error:#}").to_ascii_lowercase();
5142 message.contains("no such table") && message.contains("packages")
5143}
5144
5145fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5146 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5147 row_string(row, 1)
5148 })? {
5149 if value.eq_ignore_ascii_case(column) {
5150 return Ok(true);
5151 }
5152 }
5153
5154 Ok(false)
5155}
5156
5157fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5158 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5159 let id_column = if v2 { "id" } else { "ids.id" };
5160 let name_column = if v2 { "name" } else { "names.name" };
5161 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5162 let exact_match = query.exact || semantics == SearchSemantics::Single;
5163 let mut params = Vec::new();
5164
5165 if let Some(value) = &query.id {
5166 return (
5167 single_field_condition(id_column, value, exact_match, &mut params),
5168 params,
5169 );
5170 }
5171 if let Some(value) = &query.name {
5172 return (
5173 single_field_condition(name_column, value, exact_match, &mut params),
5174 params,
5175 );
5176 }
5177 if let Some(value) = &query.moniker {
5178 return (
5179 single_field_condition(moniker_column, value, exact_match, &mut params),
5180 params,
5181 );
5182 }
5183 if let Some(value) = &query.tag {
5184 return (
5185 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5186 params,
5187 );
5188 }
5189 if let Some(value) = &query.command {
5190 return (
5191 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5192 params,
5193 );
5194 }
5195 if let Some(value) = &query.query {
5196 if exact_match {
5197 let conditions = [
5198 single_field_condition(id_column, value, true, &mut params),
5199 single_field_condition(name_column, value, true, &mut params),
5200 single_field_condition(moniker_column, value, true, &mut params),
5201 ];
5202 return (format!("({})", conditions.join(" OR ")), params);
5203 }
5204
5205 let mut conditions = vec![
5206 single_field_condition(id_column, value, false, &mut params),
5207 single_field_condition(name_column, value, false, &mut params),
5208 single_field_condition(moniker_column, value, false, &mut params),
5209 ];
5210 if semantics == SearchSemantics::Many {
5211 conditions.push(mapped_field_condition(
5212 v2,
5213 "tag",
5214 value,
5215 rowid_column,
5216 false,
5217 &mut params,
5218 ));
5219 conditions.push(mapped_field_condition(
5220 v2,
5221 "command",
5222 value,
5223 rowid_column,
5224 false,
5225 &mut params,
5226 ));
5227 }
5228 return (format!("({})", conditions.join(" OR ")), params);
5229 }
5230
5231 ("1 = 1".to_owned(), Vec::new())
5232}
5233
5234fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5235 params.push(match_parameter(value, exact));
5236 format!("{column} LIKE ?{}", params.len())
5237}
5238
5239fn mapped_field_condition(
5240 v2: bool,
5241 value_name: &str,
5242 value: &str,
5243 rowid_column: &str,
5244 exact: bool,
5245 params: &mut Vec<String>,
5246) -> String {
5247 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5248 (
5249 format!("{value_name}s2"),
5250 format!("{value_name}s2_map"),
5251 value_name.to_owned(),
5252 "package".to_owned(),
5253 )
5254 } else {
5255 (
5256 format!("{value_name}s"),
5257 format!("{value_name}s_map"),
5258 value_name.to_owned(),
5259 "manifest".to_owned(),
5260 )
5261 };
5262 params.push(match_parameter(value, exact));
5263 let parameter = params.len();
5264 format!(
5265 "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
5266 {map_table_name}.{value_name} = {table_name}.rowid \
5267 WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
5268 AND {table_name}.{map_value_column} LIKE ?{parameter})"
5269 )
5270}
5271
5272fn match_parameter(value: &str, exact: bool) -> String {
5273 if exact { value.to_owned() } else { format!("%{value}%") }
5274}
5275
5276fn build_rest_search_body(
5277 query: &PackageQuery,
5278 info: &RestInformation,
5279 semantics: SearchSemantics,
5280) -> Result<JsonValue> {
5281 let mut root = serde_json::Map::new();
5282 root.insert(
5283 "MaximumResults".to_owned(),
5284 JsonValue::from(source_fetch_results(query, semantics) as u64),
5285 );
5286 let exact_match = query.exact || semantics == SearchSemantics::Single;
5287
5288 if let Some(value) = &query.query {
5289 if semantics == SearchSemantics::Single {
5290 let mut filters = vec![
5291 rest_filter("PackageIdentifier", value, true),
5292 rest_filter("PackageName", value, true),
5293 rest_filter("Moniker", value, true),
5294 ];
5295 append_required_rest_filters(&mut filters, info);
5296 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5297 return Ok(JsonValue::Object(root));
5298 }
5299
5300 root.insert(
5301 "Query".to_owned(),
5302 serde_json::json!({
5303 "KeyWord": value,
5304 "MatchType": if exact_match { "Exact" } else { "Substring" },
5305 }),
5306 );
5307 }
5308
5309 let mut filters = Vec::new();
5310 if let Some(value) = &query.id {
5311 filters.push(rest_filter("PackageIdentifier", value, exact_match));
5312 }
5313 if let Some(value) = &query.name {
5314 filters.push(rest_filter("PackageName", value, exact_match));
5315 }
5316 if let Some(value) = &query.moniker {
5317 filters.push(rest_filter("Moniker", value, exact_match));
5318 }
5319 if let Some(value) = &query.tag {
5320 filters.push(rest_filter("Tag", value, true));
5321 }
5322 if let Some(value) = &query.command {
5323 filters.push(rest_filter("Command", value, true));
5324 }
5325 append_required_rest_filters(&mut filters, info);
5326
5327 if !filters.is_empty() {
5328 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5329 }
5330
5331 Ok(JsonValue::Object(root))
5332}
5333
5334fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5335 if info
5336 .required_package_match_fields
5337 .iter()
5338 .any(|field| field.eq_ignore_ascii_case("Market"))
5339 {
5340 filters.push(rest_filter("Market", &default_market(), true));
5341 }
5342}
5343
5344fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5345 serde_json::json!({
5346 "PackageMatchField": field,
5347 "RequestMatch": {
5348 "KeyWord": value,
5349 "MatchType": if exact { "Exact" } else { "Substring" },
5350 }
5351 })
5352}
5353
5354fn max_results(query: &PackageQuery) -> usize {
5355 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5356}
5357
5358fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5359 match semantics {
5360 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5361 SearchSemantics::Single => max_results(query),
5362 }
5363}
5364
5365fn infer_preindexed_match_criteria_v2(
5366 connection: &Connection,
5367 row: &V2SearchRow,
5368 query: &PackageQuery,
5369 semantics: SearchSemantics,
5370) -> Result<Option<String>> {
5371 infer_match_criteria(
5372 &row.id,
5373 &row.name,
5374 row.moniker.as_deref(),
5375 query,
5376 semantics,
5377 |value| {
5378 find_mapped_value_v2(
5379 connection,
5380 "tags2",
5381 "tags2_map",
5382 "tag",
5383 row.package_rowid,
5384 value,
5385 query.exact,
5386 )
5387 },
5388 |value| {
5389 find_mapped_value_v2(
5390 connection,
5391 "commands2",
5392 "commands2_map",
5393 "command",
5394 row.package_rowid,
5395 value,
5396 query.exact,
5397 )
5398 },
5399 )
5400}
5401
5402fn infer_preindexed_match_criteria_v1(
5403 connection: &Connection,
5404 row: &V1SearchRow,
5405 query: &PackageQuery,
5406 semantics: SearchSemantics,
5407) -> Result<Option<String>> {
5408 infer_match_criteria(
5409 &row.id,
5410 &row.name,
5411 row.moniker.as_deref(),
5412 query,
5413 semantics,
5414 |value| {
5415 find_mapped_value_v1(
5416 connection,
5417 "tags",
5418 "tags_map",
5419 "tag",
5420 row.manifest_rowid,
5421 value,
5422 query.exact,
5423 )
5424 },
5425 |value| {
5426 find_mapped_value_v1(
5427 connection,
5428 "commands",
5429 "commands_map",
5430 "command",
5431 row.manifest_rowid,
5432 value,
5433 query.exact,
5434 )
5435 },
5436 )
5437}
5438
5439fn infer_match_criteria<FTag, FCommand>(
5440 id: &str,
5441 name: &str,
5442 moniker: Option<&str>,
5443 query: &PackageQuery,
5444 semantics: SearchSemantics,
5445 find_tag: FTag,
5446 find_command: FCommand,
5447) -> Result<Option<String>>
5448where
5449 FTag: FnOnce(&str) -> Result<Option<String>>,
5450 FCommand: FnOnce(&str) -> Result<Option<String>>,
5451{
5452 if let Some(value) = &query.tag {
5453 return Ok(Some(format_match_criteria("Tag", value)));
5454 }
5455 if let Some(value) = &query.command {
5456 return Ok(Some(format_match_criteria("Command", value)));
5457 }
5458 if let Some(value) = &query.moniker {
5459 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5460 }
5461 if semantics == SearchSemantics::Single {
5462 return Ok(None);
5463 }
5464 if let Some(value) = &query.query {
5465 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5466 return Ok(None);
5467 }
5468 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5469 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5470 }
5471 if let Some(tag) = find_tag(value)? {
5472 return Ok(Some(format_match_criteria("Tag", &tag)));
5473 }
5474 if let Some(command) = find_command(value)? {
5475 return Ok(Some(format_match_criteria("Command", &command)));
5476 }
5477 }
5478 Ok(None)
5479}
5480
5481fn find_mapped_value_v2(
5482 connection: &Connection,
5483 table_name: &str,
5484 map_table_name: &str,
5485 value_name: &str,
5486 package_rowid: i64,
5487 query: &str,
5488 exact: bool,
5489) -> Result<Option<String>> {
5490 let sql = format!(
5491 "SELECT {table_name}.{value_name} FROM {map_table_name} \
5492 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5493 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5494 );
5495 let values = query_rows(
5496 connection,
5497 &sql,
5498 vec![
5499 SqlValue::Integer(package_rowid),
5500 SqlValue::Text(match_parameter(query, exact)),
5501 ],
5502 |row| row_string(row, 0),
5503 )?;
5504 Ok(select_best_text_match(values, query, exact))
5505}
5506
5507fn find_mapped_value_v1(
5508 connection: &Connection,
5509 table_name: &str,
5510 map_table_name: &str,
5511 value_name: &str,
5512 manifest_rowid: i64,
5513 query: &str,
5514 exact: bool,
5515) -> Result<Option<String>> {
5516 let sql = format!(
5517 "SELECT {table_name}.{value_name} FROM {map_table_name} \
5518 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5519 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5520 );
5521 let values = query_rows(
5522 connection,
5523 &sql,
5524 vec![
5525 SqlValue::Integer(manifest_rowid),
5526 SqlValue::Text(match_parameter(query, exact)),
5527 ],
5528 |row| row_string(row, 0),
5529 )?;
5530 Ok(select_best_text_match(values, query, exact))
5531}
5532
5533fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5534 if let Some(value) = &query.tag {
5535 return Some(format_match_criteria("Tag", value));
5536 }
5537 if let Some(value) = &query.command {
5538 return Some(format_match_criteria("Command", value));
5539 }
5540 if let Some(value) = &query.moniker {
5541 return Some(format_match_criteria("Moniker", value));
5542 }
5543 if semantics == SearchSemantics::Single {
5544 return None;
5545 }
5546 let value = query.query.as_deref()?;
5547 if json_string(item, "PackageIdentifier")
5548 .as_deref()
5549 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5550 || json_string(item, "PackageName")
5551 .as_deref()
5552 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5553 {
5554 return None;
5555 }
5556 json_string(item, "Moniker")
5557 .filter(|candidate| matches_text(candidate, value, query.exact))
5558 .map(|candidate| format_match_criteria("Moniker", &candidate))
5559}
5560
5561fn format_match_criteria(field: &str, value: &str) -> String {
5562 format!("{field}: {value}")
5563}
5564
5565fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
5566 if exact {
5567 candidate.eq_ignore_ascii_case(query)
5568 } else {
5569 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
5570 }
5571}
5572
5573fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
5574 let mut score = 0;
5575
5576 if let Some(value) = &query.query {
5577 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
5578 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
5579 if let Some(moniker) = candidate.moniker.as_deref() {
5580 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
5581 }
5582 }
5583
5584 if let Some(value) = &query.id {
5585 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
5586 }
5587 if let Some(value) = &query.name {
5588 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
5589 }
5590 if let Some(value) = &query.moniker
5591 && let Some(moniker) = candidate.moniker.as_deref()
5592 {
5593 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
5594 }
5595 if let Some(value) = &query.tag
5596 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5597 {
5598 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
5599 }
5600 if let Some(value) = &query.command
5601 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5602 {
5603 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
5604 }
5605
5606 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
5607 score += 5;
5608 }
5609 if search_match_has_unknown_version(candidate) {
5610 score = score.saturating_sub(10);
5611 }
5612
5613 score
5614}
5615
5616fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
5617 candidate
5618 .version
5619 .as_deref()
5620 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
5621}
5622
5623fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
5624 if manifest_path.is_file() {
5625 return Ok(manifest_path.to_path_buf());
5626 }
5627
5628 if !manifest_path.is_dir() {
5629 bail!("Manifest path not found: {}", manifest_path.display());
5630 }
5631
5632 let candidate = fs::read_dir(manifest_path)?
5633 .filter_map(|entry| entry.ok())
5634 .map(|entry| entry.path())
5635 .filter(|path| {
5636 path.extension()
5637 .and_then(|value| value.to_str())
5638 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
5639 })
5640 .min();
5641
5642 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
5643}
5644
5645fn score_text_match(
5646 candidate: &str,
5647 query: &str,
5648 exact: bool,
5649 exact_score: usize,
5650 prefix_score: usize,
5651 substring_score: usize,
5652) -> usize {
5653 if candidate.eq_ignore_ascii_case(query) {
5654 return exact_score;
5655 }
5656 if exact {
5657 return 0;
5658 }
5659
5660 let candidate_lower = candidate.to_ascii_lowercase();
5661 let query_lower = query.to_ascii_lowercase();
5662 if candidate_lower.starts_with(&query_lower) {
5663 prefix_score
5664 } else if candidate_lower.contains(&query_lower) {
5665 substring_score
5666 } else {
5667 0
5668 }
5669}
5670
5671fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
5672where
5673 I: IntoIterator<Item = String>,
5674{
5675 values
5676 .into_iter()
5677 .filter_map(|candidate| {
5678 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
5679 (score > 0).then_some((score, candidate))
5680 })
5681 .max_by(|(left_score, left_value), (right_score, right_value)| {
5682 left_score
5683 .cmp(right_score)
5684 .then_with(|| right_value.len().cmp(&left_value.len()))
5685 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
5686 })
5687 .map(|(_, candidate)| candidate)
5688}
5689
5690fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
5691 let (field, value) = criteria.split_once(": ")?;
5692 Some((field, value))
5693}
5694
5695fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
5696 let versions = item
5697 .get("Versions")
5698 .and_then(JsonValue::as_array)
5699 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
5700 let mut result = Vec::new();
5701 for version in versions {
5702 let value = json_string(version, "PackageVersion")
5703 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
5704 let channel = json_string(version, "Channel").unwrap_or_default();
5705 result.push(VersionKey {
5706 version: value,
5707 channel,
5708 });
5709 }
5710 Ok(result)
5711}
5712
5713fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
5714 let mut merged = YamlMapping::new();
5715 let mut documents = Vec::new();
5716 for document in serde_yaml::Deserializer::from_slice(bytes) {
5717 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
5718 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
5719 if let Some(mapping) = value.as_mapping() {
5720 merge_yaml_mapping(&mut merged, mapping);
5721 }
5722 }
5723
5724 let id = yaml_string_from_root(&merged, "PackageIdentifier")
5725 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
5726 let version =
5727 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
5728 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
5729
5730 let installers = parse_yaml_installers(&merged);
5731 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
5732 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
5733
5734 Ok((
5735 Manifest {
5736 id,
5737 name,
5738 version,
5739 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
5740 publisher: yaml_localized_string(&merged, "Publisher"),
5741 description: yaml_localized_string(&merged, "Description")
5742 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
5743 moniker: yaml_string_from_root(&merged, "Moniker"),
5744 package_url: yaml_localized_string(&merged, "PackageUrl"),
5745 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
5746 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
5747 license: yaml_localized_string(&merged, "License"),
5748 license_url: yaml_localized_string(&merged, "LicenseUrl"),
5749 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
5750 author: yaml_localized_string(&merged, "Author"),
5751 copyright: yaml_localized_string(&merged, "Copyright"),
5752 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
5753 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
5754 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
5755 tags: yaml_string_list(&merged, "Tags"),
5756 agreements: yaml_agreement_list(&merged),
5757 package_dependencies: yaml_package_dependencies(&merged),
5758 documentation: yaml_documentation_list(&merged),
5759 installers,
5760 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
5761 },
5762 collapse_structured_document(&JsonValue::Array(documents)),
5763 ))
5764}
5765
5766fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
5767 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
5768}
5769
5770fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
5771 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
5772 let data = root
5773 .get("Data")
5774 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
5775 let versions = data
5776 .get("Versions")
5777 .and_then(JsonValue::as_array)
5778 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
5779 let selected = versions
5780 .iter()
5781 .find(|item| {
5782 json_string(item, "PackageVersion").as_deref() == Some(version)
5783 && json_string(item, "Channel").unwrap_or_default() == channel
5784 })
5785 .or_else(|| versions.first())
5786 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
5787
5788 let default_locale = selected
5789 .get("DefaultLocale")
5790 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
5791 let name = json_string(default_locale, "PackageName")
5792 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
5793 let installer_switch_defaults = json_installer_switches(selected);
5794 let top_platforms = json_string_list(selected, "Platform");
5795 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
5796 let top_installer_type = json_string(selected, "InstallerType");
5797 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
5798 let top_nested_installer_files = json_nested_installer_files(selected);
5799
5800 let installers = selected
5801 .get("Installers")
5802 .and_then(JsonValue::as_array)
5803 .map(|items| {
5804 items
5805 .iter()
5806 .map(|item| Installer {
5807 platforms: {
5808 let platforms = json_string_list(item, "Platform");
5809 if platforms.is_empty() {
5810 top_platforms.clone()
5811 } else {
5812 platforms
5813 }
5814 },
5815 minimum_os_version: json_string(item, "MinimumOSVersion")
5816 .or_else(|| top_minimum_os_version.clone()),
5817 architecture: json_string(item, "Architecture"),
5818 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
5819 nested_installer_type: json_string(item, "NestedInstallerType")
5820 .or_else(|| top_nested_installer_type.clone()),
5821 nested_installer_files: {
5822 let files = json_nested_installer_files(item);
5823 if files.is_empty() {
5824 top_nested_installer_files.clone()
5825 } else {
5826 files
5827 }
5828 },
5829 url: json_string(item, "InstallerUrl"),
5830 sha256: json_string(item, "InstallerSha256"),
5831 product_code: json_string(item, "ProductCode"),
5832 locale: json_string(item, "InstallerLocale"),
5833 scope: json_string(item, "Scope"),
5834 release_date: json_string(item, "ReleaseDate"),
5835 package_family_name: json_string(item, "PackageFamilyName"),
5836 upgrade_code: json_string(item, "UpgradeCode"),
5837 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
5838 commands: json_string_list(item, "Commands"),
5839 package_dependencies: json_package_dependencies(item),
5840 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
5841 })
5842 .collect::<Vec<_>>()
5843 })
5844 .unwrap_or_default();
5845 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
5846 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
5847
5848 let manifest = Manifest {
5849 id: package_id.to_owned(),
5850 name,
5851 version: version.to_owned(),
5852 channel: channel.to_owned(),
5853 publisher: json_string(default_locale, "Publisher"),
5854 description: json_string(default_locale, "Description")
5855 .or_else(|| json_string(default_locale, "ShortDescription")),
5856 moniker: json_string(default_locale, "Moniker"),
5857 package_url: json_string(default_locale, "PackageUrl"),
5858 publisher_url: json_string(default_locale, "PublisherUrl"),
5859 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
5860 license: json_string(default_locale, "License"),
5861 license_url: json_string(default_locale, "LicenseUrl"),
5862 privacy_url: json_string(default_locale, "PrivacyUrl"),
5863 author: json_string(default_locale, "Author"),
5864 copyright: json_string(default_locale, "Copyright"),
5865 copyright_url: json_string(default_locale, "CopyrightUrl"),
5866 release_notes: json_string(default_locale, "ReleaseNotes"),
5867 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
5868 tags: json_string_list(default_locale, "Tags"),
5869 agreements: json_agreement_list(default_locale),
5870 package_dependencies: json_package_dependencies(selected),
5871 documentation: json_documentation_list(default_locale),
5872 installers,
5873 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
5874 };
5875
5876 Ok((
5877 manifest,
5878 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
5879 ))
5880}
5881
5882fn collapse_structured_document(document: &JsonValue) -> JsonValue {
5883 match document {
5884 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
5885 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
5886 }
5887 JsonValue::Array(documents) => merge_manifest_documents(documents),
5888 JsonValue::Object(_) if is_merged_manifest_document(document) => {
5889 merge_manifest_documents(&split_merged_manifest_document(document))
5890 }
5891 _ => document.clone(),
5892 }
5893}
5894
5895fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
5896 documents.iter().map(collapse_structured_document).collect()
5897}
5898
5899fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
5900 let version = documents.iter().find(|document| {
5901 document
5902 .get("ManifestType")
5903 .and_then(JsonValue::as_str)
5904 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
5905 });
5906 let default_locale = documents.iter().find(|document| {
5907 document
5908 .get("ManifestType")
5909 .and_then(JsonValue::as_str)
5910 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
5911 });
5912 let installer = documents.iter().find(|document| {
5913 document
5914 .get("ManifestType")
5915 .and_then(JsonValue::as_str)
5916 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
5917 });
5918
5919 let mut singleton = JsonMap::new();
5920 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
5921 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
5922 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
5923
5924 if !singleton.contains_key("PackageLocale")
5925 && let Some(package_locale) = default_locale
5926 .and_then(|document| json_string(document, "PackageLocale"))
5927 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
5928 {
5929 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
5930 }
5931
5932 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
5933 singleton.insert(
5934 "ManifestVersion".to_owned(),
5935 JsonValue::String(
5936 installer
5937 .and_then(|document| json_string(document, "ManifestVersion"))
5938 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
5939 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
5940 .unwrap_or_else(|| "1.10.0".to_owned()),
5941 ),
5942 );
5943 singleton.remove("DefaultLocale");
5944
5945 JsonValue::Object(singleton)
5946}
5947
5948fn is_merged_manifest_document(document: &JsonValue) -> bool {
5949 document
5950 .get("ManifestType")
5951 .and_then(JsonValue::as_str)
5952 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
5953}
5954
5955fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
5956 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
5957 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
5958 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
5959 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
5960
5961 let version_document = serde_json::json!({
5962 "PackageIdentifier": package_identifier,
5963 "PackageVersion": package_version,
5964 "DefaultLocale": package_locale,
5965 "ManifestType": "version",
5966 "ManifestVersion": manifest_version,
5967 });
5968
5969 let mut default_locale_document = project_manifest_document(
5970 merged,
5971 &[
5972 "PackageIdentifier",
5973 "PackageVersion",
5974 "PackageLocale",
5975 "Publisher",
5976 "PublisherUrl",
5977 "PublisherSupportUrl",
5978 "PrivacyUrl",
5979 "Author",
5980 "PackageName",
5981 "PackageUrl",
5982 "License",
5983 "LicenseUrl",
5984 "Copyright",
5985 "CopyrightUrl",
5986 "ShortDescription",
5987 "Description",
5988 "Moniker",
5989 "Tags",
5990 "Agreements",
5991 "ReleaseNotes",
5992 "ReleaseNotesUrl",
5993 "PurchaseUrl",
5994 "InstallationNotes",
5995 "Documentations",
5996 "Icons",
5997 ],
5998 );
5999 default_locale_document.insert(
6000 "PackageIdentifier".to_owned(),
6001 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6002 );
6003 default_locale_document.insert(
6004 "PackageVersion".to_owned(),
6005 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6006 );
6007 default_locale_document.insert(
6008 "PackageLocale".to_owned(),
6009 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6010 );
6011 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6012 default_locale_document.insert(
6013 "ManifestVersion".to_owned(),
6014 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6015 );
6016
6017 let mut installer_document = project_manifest_document(
6018 merged,
6019 &[
6020 "PackageIdentifier",
6021 "PackageVersion",
6022 "Channel",
6023 "InstallerLocale",
6024 "Platform",
6025 "MinimumOSVersion",
6026 "InstallerType",
6027 "NestedInstallerType",
6028 "NestedInstallerFiles",
6029 "Scope",
6030 "InstallModes",
6031 "InstallerSwitches",
6032 "InstallerSuccessCodes",
6033 "ExpectedReturnCodes",
6034 "UpgradeBehavior",
6035 "Commands",
6036 "Protocols",
6037 "FileExtensions",
6038 "Dependencies",
6039 "PackageFamilyName",
6040 "ProductCode",
6041 "Capabilities",
6042 "RestrictedCapabilities",
6043 "Markets",
6044 "InstallerAbortsTerminal",
6045 "ReleaseDate",
6046 "InstallLocationRequired",
6047 "RequireExplicitUpgrade",
6048 "DisplayInstallWarnings",
6049 "UnsupportedOSArchitectures",
6050 "UnsupportedArguments",
6051 "AppsAndFeaturesEntries",
6052 "ElevationRequirement",
6053 "InstallationMetadata",
6054 "DownloadCommandProhibited",
6055 "RepairBehavior",
6056 "ArchiveBinariesDependOnPath",
6057 "Authentication",
6058 "Installers",
6059 ],
6060 );
6061 installer_document.insert(
6062 "PackageIdentifier".to_owned(),
6063 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6064 );
6065 installer_document.insert(
6066 "PackageVersion".to_owned(),
6067 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6068 );
6069 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6070 installer_document.insert(
6071 "ManifestVersion".to_owned(),
6072 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6073 );
6074
6075 vec![
6076 version_document,
6077 JsonValue::Object(default_locale_document),
6078 JsonValue::Object(installer_document),
6079 ]
6080}
6081
6082fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6083 let mut result = JsonMap::new();
6084 if let Some(source) = source.as_object() {
6085 for key in keys {
6086 if let Some(value) = source.get(*key) {
6087 result.insert((*key).to_owned(), value.clone());
6088 }
6089 }
6090 }
6091
6092 result
6093}
6094
6095fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6096 if let Some(source) = source.and_then(JsonValue::as_object) {
6097 for key in keys {
6098 if let Some(value) = source.get(*key) {
6099 target.insert((*key).to_owned(), value.clone());
6100 }
6101 }
6102 }
6103}
6104
6105fn copy_all_manifest_keys_except(
6106 target: &mut JsonMap<String, JsonValue>,
6107 source: Option<&JsonValue>,
6108 excluded_keys: &[&str],
6109) {
6110 if let Some(source) = source.and_then(JsonValue::as_object) {
6111 for (key, value) in source {
6112 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6113 target.insert(key.clone(), value.clone());
6114 }
6115 }
6116 }
6117}
6118
6119fn build_rest_manifest_documents(
6120 data: &JsonValue,
6121 installer_source: &JsonValue,
6122 default_locale: &JsonValue,
6123 package_id: &str,
6124 version: &str,
6125 channel: &str,
6126) -> JsonValue {
6127 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6128 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6129 let manifest_version = json_string(installer_source, "ManifestVersion")
6130 .or_else(|| json_string(default_locale, "ManifestVersion"))
6131 .or_else(|| json_string(data, "ManifestVersion"))
6132 .unwrap_or_else(|| "1.10.0".to_owned());
6133
6134 let mut version_document = JsonMap::new();
6135 version_document.insert(
6136 "PackageIdentifier".to_owned(),
6137 JsonValue::String(package_identifier.clone()),
6138 );
6139 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6140 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6141 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6142 version_document.insert(
6143 "ManifestVersion".to_owned(),
6144 JsonValue::String(manifest_version.clone()),
6145 );
6146
6147 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6148 default_locale_document.insert(
6149 "PackageIdentifier".to_owned(),
6150 JsonValue::String(package_identifier.clone()),
6151 );
6152 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6153 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6154 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6155 default_locale_document.insert(
6156 "ManifestVersion".to_owned(),
6157 JsonValue::String(manifest_version.clone()),
6158 );
6159
6160 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6161 installer_document.remove("DefaultLocale");
6162 installer_document.remove("Locales");
6163 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6164 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6165 if !channel.is_empty() {
6166 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6167 }
6168 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6169 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6170
6171 let documents = vec![
6172 JsonValue::Object(version_document),
6173 JsonValue::Object(default_locale_document),
6174 JsonValue::Object(installer_document),
6175 ];
6176
6177 merge_manifest_documents(&documents)
6178}
6179
6180fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6181 let base = installer_defaults(root);
6182 let base_switches = yaml_installer_switches(root);
6183 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6184 let installers = items
6185 .iter()
6186 .filter_map(YamlValue::as_mapping)
6187 .map(|item| {
6188 let mut merged = base.clone();
6189 merge_yaml_mapping(&mut merged, item);
6190 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6191 installer_from_yaml(&merged, switches)
6192 })
6193 .collect::<Vec<_>>();
6194 if !installers.is_empty() {
6195 return installers;
6196 }
6197 }
6198
6199 if base.is_empty() {
6200 Vec::new()
6201 } else {
6202 vec![installer_from_yaml(&base, base_switches)]
6203 }
6204}
6205
6206fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6207 let keys = [
6208 "Architecture",
6209 "InstallerType",
6210 "NestedInstallerType",
6211 "NestedInstallerFiles",
6212 "InstallerUrl",
6213 "InstallerSha256",
6214 "ProductCode",
6215 "InstallerLocale",
6216 "Platform",
6217 "MinimumOSVersion",
6218 "Scope",
6219 "ReleaseDate",
6220 "PackageFamilyName",
6221 "UpgradeCode",
6222 "Commands",
6223 ];
6224 let mut defaults = YamlMapping::new();
6225 for key in keys {
6226 if let Some(value) = root.get(YamlValue::from(key)) {
6227 defaults.insert(YamlValue::from(key), value.clone());
6228 }
6229 }
6230 defaults
6231}
6232
6233fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6234 Installer {
6235 architecture: yaml_string(root, "Architecture"),
6236 installer_type: yaml_string(root, "InstallerType"),
6237 nested_installer_type: yaml_string(root, "NestedInstallerType"),
6238 nested_installer_files: yaml_nested_installer_files(root),
6239 url: yaml_string(root, "InstallerUrl"),
6240 sha256: yaml_string(root, "InstallerSha256"),
6241 product_code: yaml_string(root, "ProductCode"),
6242 locale: yaml_string(root, "InstallerLocale"),
6243 scope: yaml_string(root, "Scope"),
6244 release_date: yaml_string(root, "ReleaseDate"),
6245 package_family_name: yaml_string(root, "PackageFamilyName"),
6246 upgrade_code: yaml_string(root, "UpgradeCode"),
6247 platforms: yaml_string_list(root, "Platform"),
6248 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6249 switches,
6250 commands: yaml_string_list(root, "Commands"),
6251 package_dependencies: yaml_package_dependencies(root),
6252 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6253 }
6254}
6255
6256fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6259 let Some(value) = root.get(YamlValue::from(key)) else {
6260 return false;
6261 };
6262 match value {
6263 YamlValue::Bool(b) => *b,
6264 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6265 _ => false,
6266 }
6267}
6268
6269fn json_bool(value: &JsonValue, key: &str) -> bool {
6270 match value.get(key) {
6271 Some(JsonValue::Bool(b)) => *b,
6272 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6273 _ => false,
6274 }
6275}
6276
6277fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6278 let mapping = root
6279 .get(YamlValue::from("InstallerSwitches"))
6280 .or_else(|| root.get(YamlValue::from("Switches")))
6281 .and_then(YamlValue::as_mapping);
6282 yaml_installer_switches_from_mapping(mapping)
6283}
6284
6285fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6286 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6287
6288 InstallerSwitches {
6289 silent: string("Silent"),
6290 silent_with_progress: string("SilentWithProgress"),
6291 interactive: string("Interactive"),
6292 custom: string("Custom"),
6293 log: string("Log"),
6294 install_location: string("InstallLocation"),
6295 }
6296}
6297
6298fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6299 yaml_string(root, key).or_else(|| {
6300 root.get(YamlValue::from("DefaultLocale"))
6301 .and_then(YamlValue::as_mapping)
6302 .and_then(|mapping| yaml_string(mapping, key))
6303 })
6304}
6305
6306fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6307 yaml_string(root, key)
6308}
6309
6310fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6311 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6312}
6313
6314fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6315 match value {
6316 YamlValue::Null => None,
6317 YamlValue::Bool(value) => Some(value.to_string()),
6318 YamlValue::Number(value) => Some(value.to_string()),
6319 YamlValue::String(value) => Some(value.clone()),
6320 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6321 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6322 }
6323}
6324
6325fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6326 root.get(YamlValue::from(key))
6327 .and_then(YamlValue::as_sequence)
6328 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6329 .unwrap_or_default()
6330}
6331
6332fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6333 root.get(YamlValue::from("Documentations"))
6334 .and_then(YamlValue::as_sequence)
6335 .map(|items| {
6336 items
6337 .iter()
6338 .filter_map(YamlValue::as_mapping)
6339 .filter_map(|item| {
6340 let url = yaml_string(item, "DocumentUrl")?;
6341 Some(Documentation {
6342 label: yaml_string(item, "DocumentLabel"),
6343 url,
6344 })
6345 })
6346 .collect()
6347 })
6348 .unwrap_or_default()
6349}
6350
6351fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6352 root.get(YamlValue::from("Agreements"))
6353 .and_then(YamlValue::as_sequence)
6354 .map(|items| {
6355 items
6356 .iter()
6357 .filter_map(YamlValue::as_mapping)
6358 .map(|item| PackageAgreement {
6359 label: yaml_string(item, "AgreementLabel"),
6360 text: yaml_string(item, "Agreement"),
6361 url: yaml_string(item, "AgreementUrl"),
6362 })
6363 .collect()
6364 })
6365 .unwrap_or_default()
6366}
6367
6368fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6369 root.get(YamlValue::from("Dependencies"))
6370 .and_then(YamlValue::as_mapping)
6371 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6372 .and_then(YamlValue::as_sequence)
6373 .map(|items| {
6374 items
6375 .iter()
6376 .filter_map(YamlValue::as_mapping)
6377 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6378 .collect()
6379 })
6380 .unwrap_or_default()
6381}
6382
6383fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
6384 root.get(YamlValue::from("NestedInstallerFiles"))
6385 .and_then(YamlValue::as_sequence)
6386 .map(|items| {
6387 items
6388 .iter()
6389 .filter_map(YamlValue::as_mapping)
6390 .filter_map(|item| {
6391 let relative = yaml_string(item, "RelativeFilePath")?;
6392 Some(NestedInstallerFile {
6393 relative_file_path: relative,
6394 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
6395 })
6396 })
6397 .collect()
6398 })
6399 .unwrap_or_default()
6400}
6401
6402fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6403 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6404}
6405
6406fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6407 value
6408 .get(key)
6409 .and_then(JsonValue::as_array)
6410 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
6411 .unwrap_or_default()
6412}
6413
6414fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
6415 value
6416 .get("Documentations")
6417 .and_then(JsonValue::as_array)
6418 .map(|items| {
6419 items
6420 .iter()
6421 .filter_map(|item| {
6422 let url = json_string(item, "DocumentUrl")?;
6423 Some(Documentation {
6424 label: json_string(item, "DocumentLabel"),
6425 url,
6426 })
6427 })
6428 .collect()
6429 })
6430 .unwrap_or_default()
6431}
6432
6433fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
6434 value
6435 .get("Agreements")
6436 .and_then(JsonValue::as_array)
6437 .map(|items| {
6438 items
6439 .iter()
6440 .map(|item| PackageAgreement {
6441 label: json_string(item, "AgreementLabel"),
6442 text: json_string(item, "Agreement"),
6443 url: json_string(item, "AgreementUrl"),
6444 })
6445 .collect()
6446 })
6447 .unwrap_or_default()
6448}
6449
6450fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
6451 value
6452 .get("Dependencies")
6453 .and_then(JsonValue::as_object)
6454 .and_then(|deps| deps.get("PackageDependencies"))
6455 .and_then(JsonValue::as_array)
6456 .map(|items| {
6457 items
6458 .iter()
6459 .filter_map(|item| json_string(item, "PackageIdentifier"))
6460 .collect()
6461 })
6462 .unwrap_or_default()
6463}
6464
6465fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
6466 value
6467 .get("NestedInstallerFiles")
6468 .and_then(JsonValue::as_array)
6469 .map(|items| {
6470 items
6471 .iter()
6472 .filter_map(|item| {
6473 let relative = json_string(item, "RelativeFilePath")?;
6474 Some(NestedInstallerFile {
6475 relative_file_path: relative,
6476 portable_command_alias: json_string(item, "PortableCommandAlias"),
6477 })
6478 })
6479 .collect()
6480 })
6481 .unwrap_or_default()
6482}
6483
6484fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
6485 let switches = value
6486 .get("InstallerSwitches")
6487 .or_else(|| value.get("Switches"))
6488 .and_then(JsonValue::as_object);
6489
6490 let string = |key: &str| {
6491 switches
6492 .and_then(|switches| switches.get(key))
6493 .and_then(JsonValue::as_str)
6494 .map(str::to_owned)
6495 };
6496
6497 InstallerSwitches {
6498 silent: string("Silent"),
6499 silent_with_progress: string("SilentWithProgress"),
6500 interactive: string("Interactive"),
6501 custom: string("Custom"),
6502 log: string("Log"),
6503 install_location: string("InstallLocation"),
6504 }
6505}
6506
6507fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
6508 for (key, value) in source {
6509 target.insert(key.clone(), value.clone());
6510 }
6511}
6512
6513fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
6514 let reader = Cursor::new(bytes.to_vec());
6515 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
6516 let mut file = archive
6517 .by_name(member_name)
6518 .with_context(|| format!("zip payload missing {member_name}"))?;
6519 let mut output = Vec::new();
6520 file.read_to_end(&mut output)
6521 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
6522 Ok(output)
6523}
6524
6525fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
6526 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
6527 return Ok(output);
6528 }
6529
6530 if !bytes.starts_with(b"CK")
6531 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
6532 {
6533 return decompress_mszyml_payload(&bytes[offset + 2..]);
6534 }
6535
6536 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
6537}
6538
6539fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
6540 let mut decoder = flate2::read::DeflateDecoder::new(payload);
6541 let mut output = String::new();
6542 decoder.read_to_string(&mut output)?;
6543 Ok(output)
6544}
6545
6546fn cache_control_max_age(response: &Response) -> u64 {
6547 response
6548 .headers()
6549 .get(reqwest::header::CACHE_CONTROL)
6550 .and_then(|value| value.to_str().ok())
6551 .and_then(|header| {
6552 header.split(',').find_map(|part| {
6553 let trimmed = part.trim();
6554 trimmed
6555 .strip_prefix("max-age=")
6556 .and_then(|value| value.parse::<u64>().ok())
6557 })
6558 })
6559 .unwrap_or(60)
6560}
6561
6562fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
6563 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
6564 let (major, minor) = major_minor(candidate);
6565 server_versions.iter().any(|version| {
6566 let (server_major, server_minor) = major_minor(version);
6567 server_major == major && server_minor == minor
6568 })
6569 })
6570}
6571
6572fn major_minor(version: &str) -> (String, String) {
6573 let mut parts = version.split('.');
6574 (
6575 parts.next().unwrap_or_default().to_owned(),
6576 parts.next().unwrap_or_default().to_owned(),
6577 )
6578}
6579
6580fn default_market() -> String {
6581 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
6582}
6583
6584fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
6585 let requested_locale = query.locale.as_deref();
6586 let requested_architecture = query.installer_architecture.as_deref();
6587 let requested_type = query.installer_type.as_deref();
6588 let requested_scope = query.install_scope.as_deref();
6589 let requested_platform = query.platform.as_deref();
6590 let requested_os_version = query.os_version.as_deref();
6591 let system_architecture = current_architecture();
6592 let system_platform = current_platform();
6593 let current_os_version = current_os_version();
6594
6595 installers
6596 .iter()
6597 .enumerate()
6598 .filter(|(_, installer)| {
6599 installer_matches_requested(
6600 installer,
6601 requested_type,
6602 requested_scope,
6603 requested_platform,
6604 requested_os_version,
6605 system_platform,
6606 current_os_version.as_deref(),
6607 )
6608 })
6609 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
6610 .max_by_key(|(index, installer)| {
6611 (
6612 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
6613 std::cmp::Reverse(*index),
6614 )
6615 })
6616 .map(|(_, installer)| installer)
6617 .cloned()
6618}
6619
6620fn installer_matches_requested(
6621 installer: &Installer,
6622 requested_type: Option<&str>,
6623 requested_scope: Option<&str>,
6624 requested_platform: Option<&str>,
6625 requested_os_version: Option<&str>,
6626 system_platform: Option<&str>,
6627 current_os_version: Option<&str>,
6628) -> bool {
6629 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
6630 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
6631 && installer_matches_platform(installer, requested_platform, system_platform)
6632 && installer_matches_os_version(installer, requested_os_version, current_os_version)
6633}
6634
6635fn installer_matches_platform(
6636 installer: &Installer,
6637 requested_platform: Option<&str>,
6638 system_platform: Option<&str>,
6639) -> bool {
6640 if installer.platforms.is_empty() {
6641 return true;
6642 }
6643
6644 let Some(effective_platform) = requested_platform.or(system_platform) else {
6645 return true;
6646 };
6647
6648 installer
6649 .platforms
6650 .iter()
6651 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
6652}
6653
6654fn installer_matches_os_version(
6655 installer: &Installer,
6656 requested_os_version: Option<&str>,
6657 current_os_version: Option<&str>,
6658) -> bool {
6659 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
6660 return true;
6661 };
6662
6663 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
6664 return true;
6665 };
6666
6667 match (
6668 parse_dotted_version(minimum_os_version),
6669 parse_dotted_version(actual_os_version),
6670 ) {
6671 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
6672 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
6673 }
6674}
6675
6676fn installer_matches_architecture(
6677 installer: &Installer,
6678 requested_architecture: Option<&str>,
6679 system_architecture: &str,
6680) -> bool {
6681 let Some(architecture) = installer.architecture.as_deref() else {
6682 return true;
6683 };
6684
6685 let architecture = architecture.to_ascii_lowercase();
6686 if let Some(requested) = requested_architecture {
6687 return architecture.eq_ignore_ascii_case(requested);
6688 }
6689
6690 preferred_architectures(system_architecture)
6691 .iter()
6692 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
6693}
6694
6695fn installer_rank(
6696 installer: &Installer,
6697 requested_locale: Option<&str>,
6698 requested_architecture: Option<&str>,
6699 system_architecture: &str,
6700) -> (i32, i32, i32) {
6701 let architecture_rank = architecture_rank(
6702 installer.architecture.as_deref(),
6703 requested_architecture.unwrap_or(system_architecture),
6704 requested_architecture.is_some(),
6705 system_architecture,
6706 );
6707 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
6708 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
6709 (architecture_rank, locale_rank, command_rank)
6710}
6711
6712fn architecture_rank(
6713 installer_architecture: Option<&str>,
6714 preferred_architecture: &str,
6715 strict: bool,
6716 system_architecture: &str,
6717) -> i32 {
6718 let Some(value) = installer_architecture else {
6719 return 0;
6720 };
6721 if value.eq_ignore_ascii_case(preferred_architecture) {
6722 return 5;
6723 }
6724 if value.eq_ignore_ascii_case("neutral") {
6725 return 4;
6726 }
6727 if strict {
6728 return -1;
6729 }
6730
6731 preferred_architectures(system_architecture)
6732 .iter()
6733 .rev()
6734 .position(|candidate| value.eq_ignore_ascii_case(candidate))
6735 .and_then(|index| i32::try_from(index).ok())
6736 .and_then(|index| index.checked_add(1))
6737 .unwrap_or(-1)
6738}
6739
6740fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
6744 if manifest.installers.is_empty() {
6745 return true;
6746 }
6747 let allowed = preferred_architectures(current_architecture());
6748 manifest.installers.iter().any(|installer| {
6749 let arch = installer.architecture.as_deref().unwrap_or("neutral");
6750 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
6751 })
6752}
6753
6754fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
6755 match system_architecture {
6756 "arm64" => &["arm64", "neutral", "x64", "x86"],
6757 "x64" => &["x64", "neutral", "x86"],
6758 "x86" => &["x86", "neutral"],
6759 _ => &["neutral"],
6760 }
6761}
6762
6763fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
6764 match (installer_locale, requested_locale) {
6765 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
6766 (Some(installer), Some(requested))
6767 if installer
6768 .split('-')
6769 .next()
6770 .zip(requested.split('-').next())
6771 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
6772 {
6773 2
6774 }
6775 (None, Some(_)) => 1,
6776 (Some(_), None) => 1,
6777 (None, None) => 0,
6778 _ => -1,
6779 }
6780}
6781
6782fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
6783 match requested {
6784 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
6785 None => true,
6786 }
6787}
6788
6789#[cfg(windows)]
6790fn current_architecture() -> &'static str {
6791 use std::sync::OnceLock;
6792 static CELL: OnceLock<&'static str> = OnceLock::new();
6793 CELL.get_or_init(|| {
6794 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
6799 let mut process_machine: u16 = 0;
6800 let mut native_machine: u16 = 0;
6801 let handle = unsafe { GetCurrentProcess() };
6804 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
6806 if ok != 0 {
6807 match native_machine {
6808 0xAA64 => return "arm64",
6809 0x8664 => return "x64",
6810 0x014C => return "x86",
6811 _ => {}
6812 }
6813 }
6814 compiled_architecture()
6815 })
6816}
6817
6818#[cfg(not(windows))]
6819fn current_architecture() -> &'static str {
6820 compiled_architecture()
6821}
6822
6823fn compiled_architecture() -> &'static str {
6824 match std::env::consts::ARCH {
6825 "x86_64" => "x64",
6826 "x86" => "x86",
6827 "aarch64" => "arm64",
6828 _ => "neutral",
6829 }
6830}
6831
6832fn current_platform() -> Option<&'static str> {
6833 if cfg!(windows) { Some("Windows.Desktop") } else { None }
6834}
6835
6836#[cfg(windows)]
6837fn current_os_version() -> Option<String> {
6838 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
6839 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
6840 let build: String = key.get_value("CurrentBuildNumber").ok()?;
6841 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
6842 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
6843 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
6844 Some(format!("{major}.{minor}.{build}.{ubr}"))
6845}
6846
6847#[cfg(not(windows))]
6848fn current_os_version() -> Option<String> {
6849 None
6850}
6851
6852fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
6853 let mut parsed = Vec::new();
6854 for component in version.split('.') {
6855 if component.is_empty() {
6856 continue;
6857 }
6858 parsed.push(component.parse().ok()?);
6859 }
6860
6861 if parsed.is_empty() { None } else { Some(parsed) }
6862}
6863
6864fn select_v1_version(
6865 versions: &[V1VersionRow],
6866 requested_version: Option<&str>,
6867 requested_channel: Option<&str>,
6868) -> Result<V1VersionRow> {
6869 let mut ordered = versions.to_vec();
6870 ordered.sort_by(|left, right| {
6871 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
6872 });
6873
6874 if let Some(version) = requested_version {
6875 let channel = requested_channel.unwrap_or_default();
6876 return ordered
6877 .iter()
6878 .find(|candidate| {
6879 candidate.version.eq_ignore_ascii_case(version)
6880 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
6881 })
6882 .cloned()
6883 .ok_or_else(|| anyhow!("requested version {version} was not found"));
6884 }
6885
6886 ordered
6887 .into_iter()
6888 .next()
6889 .ok_or_else(|| anyhow!("package had no versions"))
6890}
6891
6892fn select_v2_version(
6893 versions: &[PackageVersionDataEntry],
6894 requested_version: Option<&str>,
6895) -> Result<PackageVersionDataEntry> {
6896 let mut ordered = versions.to_vec();
6897 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
6898
6899 if let Some(version) = requested_version {
6900 return ordered
6901 .iter()
6902 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
6903 .cloned()
6904 .ok_or_else(|| anyhow!("requested version {version} was not found"));
6905 }
6906
6907 ordered
6908 .into_iter()
6909 .next()
6910 .ok_or_else(|| anyhow!("package had no V2 versions"))
6911}
6912
6913fn select_rest_version<'a>(
6914 versions: &'a [VersionKey],
6915 requested_version: Option<&str>,
6916 requested_channel: Option<&str>,
6917) -> Result<&'a VersionKey> {
6918 if let Some(version) = requested_version {
6919 let channel = requested_channel.unwrap_or_default();
6920 return versions
6921 .iter()
6922 .find(|candidate| {
6923 candidate.version.eq_ignore_ascii_case(version)
6924 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
6925 })
6926 .ok_or_else(|| anyhow!("requested version {version} was not found"));
6927 }
6928
6929 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
6930}
6931
6932fn sort_versions_desc(versions: &mut [VersionKey]) {
6933 versions.sort_by(|left, right| {
6934 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
6935 });
6936}
6937
6938fn compare_version_and_channel(
6939 left_version: &str,
6940 left_channel: &str,
6941 right_version: &str,
6942 right_channel: &str,
6943) -> Ordering {
6944 compare_version(left_version, right_version).then_with(|| {
6945 left_channel
6946 .to_ascii_lowercase()
6947 .cmp(&right_channel.to_ascii_lowercase())
6948 })
6949}
6950
6951fn compare_version(left: &str, right: &str) -> Ordering {
6952 let left_parts = tokenize_version(left);
6953 let right_parts = tokenize_version(right);
6954 let max_len = left_parts.len().max(right_parts.len());
6955
6956 for index in 0..max_len {
6957 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
6958 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
6959 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
6960
6961 let ordering = if let Some((left_number, right_number)) = numeric {
6962 left_number.cmp(&right_number)
6963 } else {
6964 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
6965 };
6966
6967 if ordering != Ordering::Equal {
6968 return ordering;
6969 }
6970 }
6971
6972 Ordering::Equal
6973}
6974
6975fn tokenize_version(value: &str) -> Vec<String> {
6976 let mut parts = Vec::new();
6977 let mut current = String::new();
6978 let mut current_is_digit = None;
6979
6980 for ch in value.chars() {
6981 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
6982 if !current.is_empty() {
6983 parts.push(std::mem::take(&mut current));
6984 }
6985 current_is_digit = None;
6986 continue;
6987 }
6988
6989 let is_digit = ch.is_ascii_digit();
6990 match current_is_digit {
6991 Some(previous) if previous != is_digit => {
6992 parts.push(std::mem::take(&mut current));
6993 current.push(ch);
6994 current_is_digit = Some(is_digit);
6995 }
6996 Some(_) => current.push(ch),
6997 None => {
6998 current.push(ch);
6999 current_is_digit = Some(is_digit);
7000 }
7001 }
7002 }
7003
7004 if !current.is_empty() {
7005 parts.push(current);
7006 }
7007
7008 parts
7009}
7010
7011fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7012 if !hash_matches(Some(expected_hash), bytes) {
7013 bail!("downloaded file hash did not match expected SHA256");
7014 }
7015 Ok(())
7016}
7017
7018fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7019 let Some(expected_hash) = expected_hash else {
7020 return Ok(());
7021 };
7022 if ignore_security_hash {
7023 return Ok(());
7024 }
7025
7026 if !hash_matches(Some(expected_hash), bytes) {
7027 let actual = sha256_hex(bytes);
7028 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7029 }
7030 Ok(())
7031}
7032
7033fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7034 expected_hash
7035 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7036 .unwrap_or(true)
7037}
7038
7039fn sha256_hex(bytes: &[u8]) -> String {
7040 let digest = Sha256::digest(bytes);
7041 let mut output = String::with_capacity(digest.len() * 2);
7042 for byte in digest {
7043 output.push_str(&format!("{byte:02X}"));
7044 }
7045 output
7046}
7047
7048fn pins_db_path(app_root: &Path) -> PathBuf {
7049 if uses_packaged_layout(app_root) {
7050 app_root.join("pinning.db")
7051 } else {
7052 app_root.join("pins.db")
7053 }
7054}
7055
7056fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7057 let mut stmt = conn.prepare("PRAGMA table_info(pin)")?;
7058 let mut has_current_column = false;
7059 let mut has_legacy_column = false;
7060
7061 let column_names = stmt.query_map([], |row| row.get::<_, String>(1))?;
7062 for column_name in column_names {
7063 match column_name?.as_str() {
7064 "type" => has_current_column = true,
7065 "pin_type" => has_legacy_column = true,
7066 _ => {}
7067 }
7068 }
7069
7070 Ok(if has_current_column {
7071 Some("type")
7072 } else if has_legacy_column {
7073 Some("pin_type")
7074 } else {
7075 None
7076 })
7077}
7078
7079fn decode_pin_type(pin_type_int: i64) -> PinType {
7080 match pin_type_int {
7081 4 => PinType::Blocking,
7082 3 => PinType::Gating,
7083 _ => PinType::Pinning,
7084 }
7085}
7086
7087#[cfg(windows)]
7088fn dispatch_installer(
7089 installer_path: &Path,
7090 installer_type: &str,
7091 request: &InstallRequest,
7092 manifest: &Manifest,
7093 installer: &Installer,
7094) -> Result<i32> {
7095 use std::process::Command;
7096
7097 match installer_type {
7098 "msi" | "wix" => {
7099 let mut cmd = Command::new("msiexec");
7100 cmd.args(installer_command_arguments(
7101 installer_type,
7102 request,
7103 manifest,
7104 installer_path,
7105 installer,
7106 ));
7107 let status = cmd.status().context("failed to run msiexec")?;
7108 Ok(status.code().unwrap_or(-1))
7109 }
7110 "msix" | "appx" => {
7111 let mut cmd = Command::new("powershell");
7112 cmd.arg("-NoProfile")
7113 .arg("-Command")
7114 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7115 let status = cmd.status().context("failed to run Add-AppxPackage")?;
7116 Ok(status.code().unwrap_or(-1))
7117 }
7118 "zip" => {
7119 if installer
7125 .nested_installer_type
7126 .as_deref()
7127 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7128 {
7129 return install_portable(installer_path, request, manifest, installer);
7130 }
7131 let target = dirs::data_local_dir()
7132 .unwrap_or_else(|| PathBuf::from("."))
7133 .join("Programs");
7134 fs::create_dir_all(&target)?;
7135 let file = fs::File::open(installer_path)?;
7136 let mut archive = ZipArchive::new(file)?;
7137 archive.extract(&target)?;
7138 Ok(0)
7139 }
7140 "portable" => install_portable(installer_path, request, manifest, installer),
7141 _ => {
7143 let mut cmd = Command::new(installer_path);
7144 cmd.args(installer_command_arguments(
7145 installer_type,
7146 request,
7147 manifest,
7148 installer_path,
7149 installer,
7150 ));
7151 let status = cmd.status().context("failed to run installer")?;
7152 Ok(status.code().unwrap_or(-1))
7153 }
7154 }
7155}
7156
7157#[cfg(not(windows))]
7158fn dispatch_installer(
7159 _installer_path: &Path,
7160 _installer_type: &str,
7161 _request: &InstallRequest,
7162 _manifest: &Manifest,
7163 _installer: &Installer,
7164) -> Result<i32> {
7165 bail!("Installing packages is only supported on Windows")
7166}
7167
7168#[cfg(any(windows, test))]
7169fn installer_command_arguments(
7170 installer_type: &str,
7171 request: &InstallRequest,
7172 _manifest: &Manifest,
7173 installer_path: &Path,
7174 installer: &Installer,
7175) -> Vec<String> {
7176 if let Some(override_args) = request.override_args.as_deref() {
7177 return split_installer_switches(override_args);
7178 }
7179
7180 let mut args = Vec::new();
7181 match installer_type {
7182 "msi" | "wix" => {
7183 args.push("/i".to_owned());
7184 args.push(installer_path.display().to_string());
7185 }
7186 "msix" | "appx" | "zip" => return Vec::new(),
7187 _ => {}
7188 }
7189
7190 let configured = match request.mode {
7191 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7192 InstallerMode::SilentWithProgress => installer
7193 .switches
7194 .silent_with_progress
7195 .as_deref()
7196 .or(installer.switches.silent.as_deref()),
7197 InstallerMode::Silent => installer
7198 .switches
7199 .silent
7200 .as_deref()
7201 .or(installer.switches.silent_with_progress.as_deref()),
7202 };
7203
7204 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7205 args.extend(split_installer_switches(value));
7206 } else {
7207 args.extend(default_installer_arguments(installer_type, request.mode));
7208 }
7209
7210 append_switches(
7211 &mut args,
7212 resolve_template_switch(
7213 installer.switches.log.as_deref(),
7214 default_log_switch(installer_type),
7215 request.log_path.as_ref().map(|path| path.display().to_string()),
7216 "<LOGPATH>",
7217 ),
7218 );
7219 append_switches(&mut args, installer.switches.custom.clone());
7220 append_switches(&mut args, request.custom.clone());
7221 append_switches(
7222 &mut args,
7223 resolve_template_switch(
7224 installer.switches.install_location.as_deref(),
7225 default_install_location_switch(installer_type),
7226 request.install_location.clone(),
7227 "<INSTALLPATH>",
7228 ),
7229 );
7230 args
7231}
7232
7233#[cfg(any(windows, test))]
7234fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7235 match mode {
7236 InstallerMode::Interactive => Vec::new(),
7237 InstallerMode::SilentWithProgress => match installer_type {
7238 "inno" => vec![
7239 "/SP-".to_owned(),
7240 "/SILENT".to_owned(),
7241 "/SUPPRESSMSGBOXES".to_owned(),
7242 "/NORESTART".to_owned(),
7243 ],
7244 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7245 "nullsoft" | "nsis" => vec!["/S".to_owned()],
7246 _ => vec!["/SILENT".to_owned()],
7247 },
7248 InstallerMode::Silent => match installer_type {
7249 "inno" => vec![
7250 "/SP-".to_owned(),
7251 "/VERYSILENT".to_owned(),
7252 "/SUPPRESSMSGBOXES".to_owned(),
7253 "/NORESTART".to_owned(),
7254 ],
7255 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7256 "nullsoft" | "nsis" => vec!["/S".to_owned()],
7257 _ => vec!["/S".to_owned()],
7258 },
7259 }
7260}
7261
7262#[cfg(any(windows, test))]
7263fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7264 match installer_type {
7265 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7266 "inno" => Some("/LOG=\"<LOGPATH>\""),
7267 _ => None,
7268 }
7269}
7270
7271#[cfg(any(windows, test))]
7272fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7273 match installer_type {
7274 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7275 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7276 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7277 _ => None,
7278 }
7279}
7280
7281#[cfg(any(windows, test))]
7282fn resolve_template_switch(
7283 manifest_value: Option<&str>,
7284 fallback: Option<&str>,
7285 replacement: Option<String>,
7286 token: &str,
7287) -> Option<String> {
7288 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7289 let replacement = replacement?;
7290 Some(template.replace(token, &replacement))
7291}
7292
7293#[cfg(any(windows, test))]
7294fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7295 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7296 args.extend(split_installer_switches(&value));
7297 }
7298}
7299
7300#[cfg(any(windows, test))]
7301fn split_installer_switches(value: &str) -> Vec<String> {
7302 let mut args = Vec::new();
7303 let mut current = String::new();
7304 let mut in_quotes = false;
7305
7306 for ch in value.chars() {
7307 match ch {
7308 '"' => in_quotes = !in_quotes,
7309 c if c.is_whitespace() && !in_quotes => {
7310 if !current.is_empty() {
7311 args.push(std::mem::take(&mut current));
7312 }
7313 }
7314 _ => current.push(ch),
7315 }
7316 }
7317
7318 if !current.is_empty() {
7319 args.push(current);
7320 }
7321
7322 args
7323}
7324
7325#[cfg(windows)]
7326fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7327 if request.purge && request.preserve {
7328 bail!("--purge and --preserve cannot be used together.");
7329 }
7330
7331 if installed
7332 .installer_category
7333 .as_deref()
7334 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7335 {
7336 return uninstall_portable(installed, request);
7337 }
7338
7339 if (request.purge || request.preserve) && !request.force {
7340 bail!("--purge and --preserve are currently only supported for portable packages.");
7341 }
7342
7343 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7344 return Ok(exit_code);
7345 }
7346
7347 if let Some(exit_code) = try_uninstall_msix(installed)? {
7348 return Ok(exit_code);
7349 }
7350
7351 bail!(
7352 "No uninstall command found for installed package '{}' ({})",
7353 installed.name,
7354 installed.local_id
7355 );
7356}
7357
7358#[cfg(windows)]
7359fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7360 use std::os::windows::process::CommandExt;
7361 use std::process::Command;
7362
7363 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7364 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
7365
7366 let arp_paths = [
7367 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
7368 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
7369 ];
7370
7371 for root in [&hklm, &hkcu] {
7372 for arp_path in &arp_paths {
7373 if let Ok(key) = root.open_subkey(arp_path) {
7374 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
7375 if let Ok(subkey) = key.open_subkey(&subkey_name) {
7376 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
7377 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
7378 if !registry_entry_matches_installed_package(
7379 &subkey_name,
7380 &display_name,
7381 product_code.as_deref(),
7382 installed,
7383 ) {
7384 continue;
7385 }
7386
7387 if let Some(exit_code) =
7388 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
7389 {
7390 return Ok(Some(exit_code));
7391 }
7392
7393 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
7394 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
7395 subkey.get_value("UninstallString").ok()
7396 } else {
7397 quiet_uninstall_cmd
7398 .clone()
7399 .or_else(|| subkey.get_value("UninstallString").ok())
7400 }
7401 .context("No uninstall command found in registry")?;
7402
7403 if is_winget_uninstall_command(&uninstall_cmd)
7404 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
7405 {
7406 return Ok(Some(exit_code));
7407 }
7408
7409 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
7410 let uninstall_line = build_uninstall_command_with_mode(
7411 &uninstall_cmd,
7412 request.mode,
7413 quiet_uninstall_cmd.is_some(),
7414 log_path.as_deref(),
7415 );
7416 let mut cmd = Command::new("cmd");
7425 cmd.raw_arg("/S /C");
7426 cmd.raw_arg(format!("\"{uninstall_line}\""));
7427 let status = cmd.status().context("failed to run uninstaller")?;
7428 return Ok(Some(status.code().unwrap_or(-1)));
7429 }
7430 }
7431 }
7432 }
7433 }
7434
7435 Ok(None)
7436}
7437
7438#[cfg(windows)]
7439fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
7440 use std::process::Command;
7441
7442 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
7443 return Ok(None);
7444 }
7445
7446 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
7447 let mut cmd = Command::new("powershell");
7448 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
7449 package_full_name,
7450 &installed.package_family_names,
7451 ));
7452 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
7453 Ok(Some(status.code().unwrap_or(-1)))
7454}
7455
7456#[cfg(windows)]
7457fn arp_subkey_name(local_id: &str) -> Option<&str> {
7458 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
7459}
7460
7461#[cfg(windows)]
7462fn registry_entry_matches_installed_package(
7463 subkey_name: &str,
7464 display_name: &str,
7465 product_code: Option<&str>,
7466 installed: &ListMatch,
7467) -> bool {
7468 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
7469 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
7470 {
7471 return true;
7472 }
7473
7474 if installed.product_codes.iter().any(|code| {
7475 code.eq_ignore_ascii_case(subkey_name)
7476 || product_code
7477 .map(|value| code.eq_ignore_ascii_case(value))
7478 .unwrap_or(false)
7479 }) {
7480 return true;
7481 }
7482
7483 display_name.eq_ignore_ascii_case(&installed.name)
7484}
7485
7486#[cfg(all(windows, test))]
7487fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
7488 build_uninstall_command_with_mode(
7489 uninstall_cmd,
7490 if silent {
7491 InstallerMode::Silent
7492 } else {
7493 InstallerMode::Interactive
7494 },
7495 has_quiet_command,
7496 None,
7497 )
7498}
7499
7500#[cfg(windows)]
7501fn build_uninstall_command_with_mode(
7502 uninstall_cmd: &str,
7503 mode: InstallerMode,
7504 has_quiet_command: bool,
7505 log_path: Option<&str>,
7506) -> String {
7507 let uninstall_cmd = log_path
7508 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
7509 .unwrap_or_else(|| uninstall_cmd.to_owned());
7510 if mode == InstallerMode::Interactive || has_quiet_command {
7511 return uninstall_cmd;
7512 }
7513
7514 let lower = uninstall_cmd.to_ascii_lowercase();
7515 if is_winget_uninstall_command_lower(&lower) {
7516 return uninstall_cmd;
7517 }
7518 if lower.contains("/quiet")
7519 || lower.contains("/passive")
7520 || lower.contains("/verysilent")
7521 || lower.contains("/silent")
7522 || lower.contains(" /s")
7523 {
7524 return uninstall_cmd;
7525 }
7526
7527 format!("{uninstall_cmd} /S")
7528}
7529
7530#[cfg(windows)]
7531fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7532 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
7533 return Ok(None);
7534 };
7535
7536 let exit_code = run_winget_uninstall(&args)?;
7537 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
7538 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
7539 return Ok(Some(exit_code));
7540 };
7541
7542 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
7543 }
7544
7545 Ok(Some(exit_code))
7546}
7547
7548#[cfg(windows)]
7549fn run_winget_uninstall(args: &[String]) -> Result<i32> {
7550 use std::process::Command;
7551
7552 let status = Command::new("winget")
7553 .args(args)
7554 .status()
7555 .context("failed to run delegated winget uninstall")?;
7556 Ok(status.code().unwrap_or(-1))
7557}
7558
7559#[cfg(windows)]
7560fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
7561 build_winget_uninstall_arguments_with_scope(installed, request, true)
7562}
7563
7564#[cfg(windows)]
7565fn build_winget_uninstall_arguments_with_scope(
7566 installed: &ListMatch,
7567 request: &UninstallRequest,
7568 include_scope: bool,
7569) -> Option<Vec<String>> {
7570 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
7571 return None;
7572 }
7573
7574 let mut args = vec![
7575 "uninstall".to_owned(),
7576 "--id".to_owned(),
7577 installed.id.clone(),
7578 "--exact".to_owned(),
7579 "--disable-interactivity".to_owned(),
7580 ];
7581
7582 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
7583 args.push("--source".to_owned());
7584 args.push(source_name.to_owned());
7585 }
7586
7587 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
7588 args.push("--scope".to_owned());
7589 args.push(scope.to_owned());
7590 }
7591
7592 if request.mode == InstallerMode::Silent {
7593 args.push("--silent".to_owned());
7594 }
7595
7596 Some(args)
7597}
7598
7599#[cfg(windows)]
7600fn is_winget_uninstall_command(command: &str) -> bool {
7601 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
7602}
7603
7604#[cfg(windows)]
7605fn is_winget_uninstall_command_lower(command: &str) -> bool {
7606 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
7607}
7608
7609#[cfg(windows)]
7610fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
7611 let full_name_literal = package_full_name
7612 .map(|value| format!("'{}'", value.replace('\'', "''")))
7613 .unwrap_or_else(|| "$null".to_owned());
7614 let family_names_literal = if package_family_names.is_empty() {
7615 "@()".to_owned()
7616 } else {
7617 format!(
7618 "@({})",
7619 package_family_names
7620 .iter()
7621 .map(|value| format!("'{}'", value.replace('\'', "''")))
7622 .collect::<Vec<_>>()
7623 .join(", ")
7624 )
7625 };
7626
7627 format!(
7628 "$fullName = {full_name_literal}; \
7629 $familyNames = {family_names_literal}; \
7630 $targets = Get-AppxPackage | Where-Object {{ \
7631 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
7632 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
7633 if (-not $targets) {{ exit 1 }}; \
7634 $targets | Remove-AppxPackage"
7635 )
7636}
7637
7638#[cfg(windows)]
7639fn try_run_msi_uninstall(
7640 installed: &ListMatch,
7641 subkey_name: &str,
7642 product_code: Option<&str>,
7643 request: &UninstallRequest,
7644) -> Result<Option<i32>> {
7645 use std::process::Command;
7646
7647 let uninstall_code = installed
7648 .product_codes
7649 .iter()
7650 .map(String::as_str)
7651 .chain(product_code)
7652 .chain(Some(subkey_name))
7653 .find(|value| is_product_code_like(value));
7654
7655 let Some(uninstall_code) = uninstall_code else {
7656 return Ok(None);
7657 };
7658
7659 let mut cmd = Command::new("msiexec");
7660 cmd.arg("/x").arg(uninstall_code);
7661 match request.mode {
7662 InstallerMode::Interactive => {}
7663 InstallerMode::SilentWithProgress => {
7664 cmd.arg("/passive").arg("/norestart");
7665 }
7666 InstallerMode::Silent => {
7667 cmd.arg("/quiet").arg("/norestart");
7668 }
7669 }
7670 if let Some(log_path) = &request.log_path {
7671 cmd.arg("/log").arg(log_path);
7672 }
7673
7674 let status = cmd.status().context("failed to run msiexec uninstall")?;
7675 Ok(Some(status.code().unwrap_or(-1)))
7676}
7677
7678#[cfg(windows)]
7679fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7680 let known_symlink = read_portable_symlink_full_path(installed);
7686
7687 let Some(location) = installed.install_location.as_deref() else {
7688 if request.force {
7689 try_remove_portable_symlinks(installed, &known_symlink, None);
7690 try_remove_portable_registry_entry(installed);
7691 return Ok(0);
7692 }
7693 bail!(
7694 "Portable package '{}' does not expose an install location.",
7695 installed.name
7696 );
7697 };
7698
7699 if request.preserve {
7700 return Ok(0);
7701 }
7702
7703 let path = Path::new(location);
7704 let removed = if path.is_dir() {
7705 fs::remove_dir_all(path)?;
7706 true
7707 } else if path.is_file() {
7708 fs::remove_file(path)?;
7709 true
7710 } else {
7711 false
7712 };
7713
7714 if removed || request.force {
7715 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
7718 try_remove_portable_registry_entry(installed);
7719 return Ok(0);
7720 }
7721
7722 bail!("Portable package location not found: {location}")
7723}
7724
7725#[cfg(windows)]
7731fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
7732 const ARP_PREFIX: &str = r"ARP\";
7733 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
7734 let mut parts = rest.splitn(3, '\\');
7735 let scope = parts.next().unwrap_or("");
7736 let _arch = parts.next();
7737 let subkey_name = parts.next().unwrap_or("");
7738 if subkey_name.is_empty() {
7739 return None;
7740 }
7741 let hive = if scope.eq_ignore_ascii_case("User") {
7742 RegKey::predef(HKEY_CURRENT_USER)
7743 } else {
7744 RegKey::predef(HKEY_LOCAL_MACHINE)
7745 };
7746 let subkey = hive
7747 .open_subkey(format!(
7748 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
7749 ))
7750 .ok()?;
7751 return read_reg_string(&subkey, "PortableSymlinkFullPath");
7752 }
7753 None
7754}
7755
7756#[cfg(windows)]
7762fn try_remove_portable_symlinks(
7763 _installed: &ListMatch,
7764 known_symlink: &Option<String>,
7765 install_location: Option<&Path>,
7766) {
7767 if let Some(path) = known_symlink.as_deref() {
7768 let _ = fs::remove_file(path);
7769 }
7770
7771 let Some(install_dir) = install_location else { return };
7776
7777 let Some(local) = dirs::data_local_dir() else { return };
7778 let links_root = local.join("Microsoft").join("WinGet").join("Links");
7779 let Ok(entries) = fs::read_dir(&links_root) else { return };
7780 for entry in entries.flatten() {
7781 let path = entry.path();
7782 let Ok(target) = fs::read_link(&path) else { continue };
7783 if path_starts_with_case_insensitive(&target, install_dir) {
7784 let _ = fs::remove_file(&path);
7785 }
7786 }
7787}
7788
7789#[cfg(windows)]
7796fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
7797 let lower_components = |p: &Path| -> Vec<String> {
7798 p.components()
7799 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
7800 .collect()
7801 };
7802 let parent_components = lower_components(parent);
7803 let child_components = lower_components(child);
7804 if parent_components.is_empty() || parent_components.len() > child_components.len() {
7805 return false;
7806 }
7807 parent_components
7808 .iter()
7809 .zip(child_components.iter())
7810 .all(|(a, b)| a == b)
7811}
7812
7813#[cfg(windows)]
7820fn try_remove_portable_registry_entry(installed: &ListMatch) {
7821 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
7824 let mut parts = rest.splitn(3, '\\');
7826 let scope = parts.next().unwrap_or("");
7827 let _arch = parts.next();
7828 let subkey_name = parts.next().unwrap_or("");
7829 if subkey_name.is_empty() {
7830 return;
7831 }
7832 let is_hkcu = scope.eq_ignore_ascii_case("User");
7833 vec![(
7834 is_hkcu,
7835 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
7836 )]
7837 } else {
7838 let mut found = Vec::new();
7840 for (is_hkcu, hive) in [
7841 (true, RegKey::predef(HKEY_CURRENT_USER)),
7842 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
7843 ] {
7844 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
7845 continue;
7846 };
7847 for name in uninstall.enum_keys().flatten() {
7848 let Ok(subkey) = uninstall.open_subkey(&name) else {
7849 continue;
7850 };
7851 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
7852 found.push((
7853 is_hkcu,
7854 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
7855 ));
7856 }
7857 }
7858 }
7859 found
7860 };
7861
7862 for (is_hkcu, path) in candidates {
7863 let hive = if is_hkcu {
7864 RegKey::predef(HKEY_CURRENT_USER)
7865 } else {
7866 RegKey::predef(HKEY_LOCAL_MACHINE)
7867 };
7868 let _ = hive.delete_subkey_all(&path);
7869 }
7870}
7871
7872#[cfg(windows)]
7873fn is_product_code_like(value: &str) -> bool {
7874 value.starts_with('{') && value.ends_with('}')
7875}
7876
7877#[cfg(not(windows))]
7878fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
7879 bail!("Uninstalling packages is only supported on Windows")
7880}
7881
7882#[cfg(windows)]
7904fn install_portable(
7905 installer_path: &Path,
7906 request: &InstallRequest,
7907 manifest: &Manifest,
7908 installer: &Installer,
7909) -> Result<i32> {
7910 let source_identifier = portable_source_identifier(&request.query);
7911 let existing_entry = read_existing_portable_entry(&manifest.id);
7912 let subkey_name = existing_entry
7915 .as_ref()
7916 .map(|entry| entry.subkey_name.clone())
7917 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
7918
7919 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
7920
7921 let prev_dir_created = existing_entry
7926 .as_ref()
7927 .and_then(|entry| entry.install_directory_created)
7928 == Some(1);
7929 let dir_existed = target_dir.is_dir();
7930 if prev_dir_created && dir_existed {
7931 clean_directory_contents(&target_dir).with_context(|| {
7932 format!(
7933 "failed to clean existing portable install directory: {}",
7934 target_dir.display()
7935 )
7936 })?;
7937 }
7938
7939 fs::create_dir_all(&target_dir)
7940 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
7941 let install_directory_created = !dir_existed || prev_dir_created;
7945
7946 let installer_type = installer.installer_type.as_deref().unwrap_or("");
7947 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
7948 let file = fs::File::open(installer_path)
7949 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
7950 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
7951 archive
7952 .extract(&target_dir)
7953 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
7954 installer
7958 .nested_installer_files
7959 .first()
7960 .map(|file| target_dir.join(&file.relative_file_path))
7961 .unwrap_or_else(|| target_dir.clone())
7962 } else {
7963 let basename = installer_path
7967 .file_name()
7968 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
7969 let copied = target_dir.join(basename);
7970 fs::copy(installer_path, &copied)
7971 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
7972 copied
7973 };
7974
7975 let alias = determine_portable_alias(installer);
7980 let symlink_full_path = match alias.as_deref() {
7981 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
7982 _ => None,
7983 };
7984
7985 let added_to_path = if symlink_full_path.is_some() {
7989 try_add_links_dir_to_user_path()
7990 } else {
7991 false
7992 };
7993
7994 write_portable_arp_entry(&PortableArpEntry {
7995 subkey_name: &subkey_name,
7996 install_location: &target_dir,
7997 portable_target_full_path: &portable_target_full_path,
7998 portable_symlink_full_path: symlink_full_path.as_deref(),
7999 install_directory_created,
8000 added_to_path,
8001 source_identifier: &source_identifier,
8002 manifest,
8003 })?;
8004
8005 Ok(0)
8006}
8007
8008#[cfg(windows)]
8009struct PortableArpEntry<'a> {
8010 subkey_name: &'a str,
8011 install_location: &'a Path,
8012 portable_target_full_path: &'a Path,
8013 portable_symlink_full_path: Option<&'a Path>,
8014 install_directory_created: bool,
8015 added_to_path: bool,
8016 source_identifier: &'a str,
8017 manifest: &'a Manifest,
8018}
8019
8020#[cfg(windows)]
8026fn determine_portable_alias(installer: &Installer) -> Option<String> {
8027 installer
8028 .nested_installer_files
8029 .iter()
8030 .find_map(|file| file.portable_command_alias.clone())
8031 .or_else(|| installer.commands.first().cloned())
8032}
8033
8034#[cfg(windows)]
8035fn winget_links_dir() -> PathBuf {
8036 dirs::data_local_dir()
8037 .unwrap_or_else(|| PathBuf::from("."))
8038 .join("Microsoft")
8039 .join("WinGet")
8040 .join("Links")
8041}
8042
8043#[cfg(windows)]
8048fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8049 use std::os::windows::fs::symlink_file;
8050 let links_dir = winget_links_dir();
8051 if fs::create_dir_all(&links_dir).is_err() {
8052 return None;
8053 }
8054 let link_path = links_dir.join(format!("{alias}.exe"));
8055
8056 if link_path.symlink_metadata().is_ok() {
8059 let _ = fs::remove_file(&link_path);
8060 }
8061
8062 symlink_file(target, &link_path).ok().map(|()| link_path)
8067}
8068
8069#[cfg(windows)]
8075fn try_add_links_dir_to_user_path() -> bool {
8076 let links_dir = winget_links_dir();
8077 let links_str = links_dir.to_string_lossy().to_string();
8078 let normalized_links = links_str.to_ascii_lowercase();
8079
8080 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8081 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8082 return false;
8083 };
8084 let existing: String = env_key.get_value("Path").unwrap_or_default();
8085 let already_present = existing
8086 .split(';')
8087 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8088 if already_present {
8089 return false;
8090 }
8091
8092 let new_path = if existing.is_empty() {
8093 links_str
8094 } else if existing.ends_with(';') {
8095 format!("{existing}{links_str}")
8096 } else {
8097 format!("{existing};{links_str}")
8098 };
8099
8100 if env_key.set_value("Path", &new_path).is_err() {
8101 return false;
8102 }
8103
8104 broadcast_environment_change();
8105 true
8106}
8107
8108#[cfg(windows)]
8109fn broadcast_environment_change() {
8110 use std::ffi::OsStr;
8111 use std::os::windows::ffi::OsStrExt;
8112
8113 use windows_sys::Win32::UI::WindowsAndMessaging::{
8114 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8115 };
8116
8117 let param: Vec<u16> = OsStr::new("Environment")
8118 .encode_wide()
8119 .chain(std::iter::once(0))
8120 .collect();
8121 unsafe {
8125 let mut result: usize = 0;
8126 SendMessageTimeoutW(
8127 HWND_BROADCAST,
8128 WM_SETTINGCHANGE,
8129 0usize,
8130 param.as_ptr() as isize,
8131 SMTO_ABORTIFHUNG,
8132 5000,
8133 &mut result,
8134 );
8135 }
8136}
8137
8138#[cfg(windows)]
8143fn portable_source_identifier(query: &PackageQuery) -> String {
8144 match query.source.as_deref() {
8145 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8146 Some(name) if !name.is_empty() => name.to_owned(),
8147 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8148 }
8149}
8150
8151#[cfg(windows)]
8152fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8153 format!("{package_id}_{source_identifier}")
8154}
8155
8156#[cfg(windows)]
8157struct ExistingPortableEntry {
8158 install_location: Option<PathBuf>,
8159 install_directory_created: Option<u32>,
8160 subkey_name: String,
8161}
8162
8163#[cfg(windows)]
8164fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8165 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8166 let uninstall = hkcu
8167 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8168 .ok()?;
8169 for name in uninstall.enum_keys().flatten() {
8170 let Ok(subkey) = uninstall.open_subkey(&name) else {
8171 continue;
8172 };
8173 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8174 continue;
8175 }
8176 if !read_reg_string(&subkey, "WinGetInstallerType")
8177 .as_deref()
8178 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8179 {
8180 continue;
8181 }
8182 return Some(ExistingPortableEntry {
8183 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
8184 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
8185 subkey_name: name,
8186 });
8187 }
8188 None
8189}
8190
8191#[cfg(windows)]
8192fn resolve_portable_install_location(
8193 request: &InstallRequest,
8194 existing: &Option<ExistingPortableEntry>,
8195 subkey_name: &str,
8196) -> PathBuf {
8197 resolve_portable_install_location_pure(
8198 request.install_location.as_deref(),
8199 existing
8200 .as_ref()
8201 .and_then(|e| e.install_location.as_deref())
8202 .and_then(Path::to_str),
8203 subkey_name,
8204 &default_user_portable_root(),
8205 )
8206}
8207
8208#[cfg(windows)]
8213fn resolve_portable_install_location_pure(
8214 request_location: Option<&str>,
8215 existing_location: Option<&str>,
8216 subkey_name: &str,
8217 default_user_portable_root: &Path,
8218) -> PathBuf {
8219 if let Some(custom) = request_location
8220 && !custom.is_empty()
8221 {
8222 return PathBuf::from(custom);
8223 }
8224 if let Some(loc) = existing_location
8225 && !loc.is_empty()
8226 {
8227 return PathBuf::from(loc);
8228 }
8229 default_user_portable_root.join(subkey_name)
8230}
8231
8232#[cfg(windows)]
8233fn default_user_portable_root() -> PathBuf {
8234 dirs::data_local_dir()
8235 .unwrap_or_else(|| PathBuf::from("."))
8236 .join("Microsoft")
8237 .join("WinGet")
8238 .join("Packages")
8239}
8240
8241#[cfg(windows)]
8242fn clean_directory_contents(dir: &Path) -> Result<()> {
8243 if !dir.is_dir() {
8244 return Ok(());
8245 }
8246 for entry in fs::read_dir(dir)? {
8247 let entry = entry?;
8248 let path = entry.path();
8249 if path.is_dir() {
8250 fs::remove_dir_all(&path)?;
8251 } else {
8252 fs::remove_file(&path)?;
8253 }
8254 }
8255 Ok(())
8256}
8257
8258#[cfg(windows)]
8262fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
8263 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8264 let uninstall_path = format!(
8265 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
8266 entry.subkey_name
8267 );
8268 let (subkey, _) = hkcu
8269 .create_subkey(&uninstall_path)
8270 .context("failed to create portable ARP registry subkey")?;
8271
8272 let manifest = entry.manifest;
8273 let install_location_str = entry.install_location.to_string_lossy().to_string();
8274 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
8275 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
8276 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
8277 subkey.set_value("InstallLocation", &install_location_str)?;
8278 subkey.set_value(
8279 "PortableTargetFullPath",
8280 &entry.portable_target_full_path.to_string_lossy().to_string(),
8281 )?;
8282 if let Some(symlink_path) = entry.portable_symlink_full_path {
8283 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
8284 }
8285 subkey.set_value(
8286 "InstallDirectoryAddedToPath",
8287 &(if entry.added_to_path { 1u32 } else { 0u32 }),
8288 )?;
8289 subkey.set_value(
8290 "InstallDirectoryCreated",
8291 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
8292 )?;
8293 subkey.set_value(
8294 "DisplayName",
8295 &if manifest.name.is_empty() {
8296 manifest.id.clone()
8297 } else {
8298 manifest.name.clone()
8299 },
8300 )?;
8301 subkey.set_value("DisplayVersion", &manifest.version)?;
8302 if let Some(publisher) = manifest.publisher.as_ref() {
8303 subkey.set_value("Publisher", publisher)?;
8304 }
8305 subkey.set_value(
8306 "UninstallString",
8307 &format!("winget uninstall --product-code {}", entry.subkey_name),
8308 )?;
8309 let today = Utc::now().format("%Y%m%d").to_string();
8310 subkey.set_value("InstallDate", &today)?;
8311 if let Some(url) = manifest.package_url.as_ref() {
8312 subkey.set_value("URLInfoAbout", url)?;
8313 }
8314
8315 Ok(())
8316}
8317
8318#[cfg(test)]
8319mod tests {
8320 use std::io::Write;
8321 use std::path::{Path, PathBuf};
8322
8323 use flate2::Compression;
8324 use flate2::write::DeflateEncoder;
8325
8326 use super::*;
8327
8328 fn temp_app_root(label: &str) -> PathBuf {
8329 std::env::temp_dir().join(format!(
8330 "pinget-rs-tests-{label}-{}",
8331 Utc::now().timestamp_nanos_opt().unwrap_or_default()
8332 ))
8333 }
8334
8335 #[cfg(windows)]
8336 #[test]
8337 fn path_prefix_check_rejects_string_prefix_collisions() {
8338 assert!(!path_starts_with_case_insensitive(
8342 Path::new(r"C:\Foobar\bin.exe"),
8343 Path::new(r"C:\Foo"),
8344 ));
8345 assert!(!path_starts_with_case_insensitive(
8346 Path::new(r"C:\Foo\bin.exe.bak"),
8347 Path::new(r"C:\Foo\bin.exe"),
8348 ));
8349 }
8350
8351 #[cfg(windows)]
8352 #[test]
8353 fn path_prefix_check_accepts_real_children_and_self() {
8354 assert!(path_starts_with_case_insensitive(
8356 Path::new(r"C:\Foo\sub\bin.exe"),
8357 Path::new(r"C:\Foo"),
8358 ));
8359 assert!(path_starts_with_case_insensitive(
8361 Path::new(r"C:\Foo\bin.exe"),
8362 Path::new(r"C:\Foo\bin.exe"),
8363 ));
8364 }
8365
8366 #[cfg(windows)]
8367 #[test]
8368 fn path_prefix_check_is_case_insensitive() {
8369 assert!(path_starts_with_case_insensitive(
8372 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
8373 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
8374 ));
8375 }
8376
8377 #[cfg(windows)]
8378 #[test]
8379 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
8380 let query = PackageQuery {
8381 source: Some("winget".to_owned()),
8382 ..PackageQuery::default()
8383 };
8384 assert_eq!(
8385 portable_source_identifier(&query),
8386 "Microsoft.Winget.Source_8wekyb3d8bbwe"
8387 );
8388 let query = PackageQuery {
8390 source: Some("WINGET".to_owned()),
8391 ..PackageQuery::default()
8392 };
8393 assert_eq!(
8394 portable_source_identifier(&query),
8395 "Microsoft.Winget.Source_8wekyb3d8bbwe"
8396 );
8397 }
8398
8399 #[cfg(windows)]
8400 #[test]
8401 fn portable_source_identifier_passes_through_custom_sources() {
8402 let query = PackageQuery {
8403 source: Some("internal-feed".to_owned()),
8404 ..PackageQuery::default()
8405 };
8406 assert_eq!(portable_source_identifier(&query), "internal-feed");
8407 }
8408
8409 #[cfg(windows)]
8410 #[test]
8411 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
8412 let query = PackageQuery {
8413 source: None,
8414 ..PackageQuery::default()
8415 };
8416 assert_eq!(
8417 portable_source_identifier(&query),
8418 "Microsoft.Winget.Source_8wekyb3d8bbwe"
8419 );
8420 let query = PackageQuery {
8421 source: Some(String::new()),
8422 ..PackageQuery::default()
8423 };
8424 assert_eq!(
8425 portable_source_identifier(&query),
8426 "Microsoft.Winget.Source_8wekyb3d8bbwe"
8427 );
8428 }
8429
8430 #[cfg(windows)]
8431 #[test]
8432 fn portable_subkey_name_matches_winget_format() {
8433 assert_eq!(
8434 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
8435 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
8436 );
8437 }
8438
8439 #[cfg(windows)]
8440 #[test]
8441 fn determine_portable_alias_prefers_nested_installer_alias() {
8442 let installer = Installer {
8443 nested_installer_files: vec![NestedInstallerFile {
8444 relative_file_path: "ripgrep/rg.exe".to_owned(),
8445 portable_command_alias: Some("rg".to_owned()),
8446 }],
8447 commands: vec!["other-name".to_owned()],
8448 ..Installer::default()
8449 };
8450 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
8451 }
8452
8453 #[cfg(windows)]
8454 #[test]
8455 fn determine_portable_alias_falls_back_to_commands() {
8456 let installer = Installer {
8457 commands: vec!["nuget".to_owned()],
8459 ..Installer::default()
8460 };
8461 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
8462
8463 let installer = Installer {
8465 nested_installer_files: vec![NestedInstallerFile {
8466 relative_file_path: "subdir/bin.exe".to_owned(),
8467 portable_command_alias: None,
8468 }],
8469 commands: vec!["bin".to_owned()],
8470 ..Installer::default()
8471 };
8472 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
8473 }
8474
8475 #[cfg(windows)]
8476 #[test]
8477 fn determine_portable_alias_returns_none_without_alias_or_commands() {
8478 let installer = Installer::default();
8479 assert!(determine_portable_alias(&installer).is_none());
8480 }
8481
8482 #[cfg(windows)]
8483 #[test]
8484 fn resolve_portable_install_location_prefers_request_override() {
8485 let result = resolve_portable_install_location_pure(
8486 Some(r"D:\Tools\rg"),
8487 Some(r"C:\Software\rg-test"),
8488 "Sub_Source",
8489 Path::new(r"C:\default"),
8490 );
8491 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
8492 }
8493
8494 #[cfg(windows)]
8495 #[test]
8496 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
8497 let result = resolve_portable_install_location_pure(
8501 None,
8502 Some(r"C:\Software\rg-test"),
8503 "Sub_Source",
8504 Path::new(r"C:\default"),
8505 );
8506 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
8507 }
8508
8509 #[cfg(windows)]
8510 #[test]
8511 fn resolve_portable_install_location_falls_back_to_default_root() {
8512 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
8513 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
8514 }
8515
8516 #[cfg(windows)]
8517 #[test]
8518 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
8519 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
8522 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
8523 }
8524
8525 #[cfg(windows)]
8526 #[test]
8527 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
8528 let root = std::env::temp_dir().join(format!(
8529 "pinget-clean-test-{}",
8530 Utc::now().timestamp_nanos_opt().unwrap_or_default()
8531 ));
8532 fs::create_dir_all(&root).expect("create temp dir");
8533 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
8534 fs::create_dir_all(root.join("nested")).expect("create nested dir");
8535 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
8536
8537 clean_directory_contents(&root).expect("clean root contents");
8538
8539 assert!(root.is_dir(), "root dir itself must survive");
8540 assert_eq!(
8541 fs::read_dir(&root).expect("read root").count(),
8542 0,
8543 "root must be empty after clean"
8544 );
8545 let _ = fs::remove_dir_all(&root);
8546 }
8547
8548 #[cfg(windows)]
8549 #[test]
8550 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
8551 let bogus = std::env::temp_dir().join(format!(
8552 "pinget-clean-test-bogus-{}",
8553 Utc::now().timestamp_nanos_opt().unwrap_or_default()
8554 ));
8555 assert!(!bogus.exists());
8556 clean_directory_contents(&bogus).expect("clean nonexistent dir");
8557 assert!(!bogus.exists());
8559 }
8560
8561 #[cfg(windows)]
8562 #[test]
8563 fn path_prefix_check_handles_mixed_separators() {
8564 assert!(path_starts_with_case_insensitive(
8567 Path::new(r"C:\Foo\sub/bin.exe"),
8568 Path::new(r"C:\Foo"),
8569 ));
8570 }
8571
8572 #[test]
8573 fn repository_options_capture_custom_host_settings() {
8574 let options =
8575 RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test")).with_user_agent("pinget-rs-tests/1.0");
8576
8577 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
8578 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
8579 }
8580
8581 #[test]
8582 fn dependency_install_requests_are_silent_and_not_forced() {
8583 let mut parent = InstallRequest::new(PackageQuery {
8584 source: Some("winget".to_owned()),
8585 ..PackageQuery::default()
8586 });
8587 parent.mode = InstallerMode::SilentWithProgress;
8588 parent.force = true;
8589 parent.accept_package_agreements = true;
8590 parent.ignore_security_hash = true;
8591 parent.dependency_source = Some("dependencies".to_owned());
8592
8593 let dependency =
8594 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
8595
8596 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
8597 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
8598 assert_eq!(dependency.mode, InstallerMode::Silent);
8599 assert!(!dependency.force);
8600 assert!(dependency.no_upgrade);
8601 assert!(dependency.accept_package_agreements);
8602 assert!(dependency.ignore_security_hash);
8603 }
8604
8605 #[test]
8606 fn storage_paths_use_configured_app_root() {
8607 let app_root = PathBuf::from(r"C:\temp\pinget-test");
8608 let source = SourceRecord {
8609 name: "winget/test".to_owned(),
8610 kind: SourceKind::PreIndexed,
8611 arg: "https://example.com/cache".to_owned(),
8612 identifier: "Test.Source".to_owned(),
8613 trust_level: "None".to_owned(),
8614 explicit: false,
8615 priority: 0,
8616 last_update: None,
8617 source_version: None,
8618 };
8619
8620 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
8621 assert_eq!(
8622 source_state_dir(&app_root, &source),
8623 app_root.join("sources").join("winget_test")
8624 );
8625 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
8626 }
8627
8628 #[cfg(windows)]
8629 #[test]
8630 fn packaged_layout_paths_resolve_from_packaged_app_root() {
8631 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
8632 let app_root = local_app_data
8633 .join("Packages")
8634 .join(PACKAGED_FAMILY_NAME)
8635 .join("LocalState");
8636 assert!(uses_packaged_layout(&app_root));
8637 assert!(
8638 user_settings_path(&app_root)
8639 .display()
8640 .to_string()
8641 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
8642 );
8643 assert!(
8644 packaged_file_cache_root(&app_root)
8645 .display()
8646 .to_string()
8647 .ends_with(&format!(
8648 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
8649 PACKAGED_FAMILY_NAME
8650 ))
8651 );
8652 }
8653
8654 #[cfg(windows)]
8655 #[test]
8656 fn default_app_root_outside_package_avoids_packaged_layout() {
8657 let prior = std::env::var_os("PINGET_APPROOT");
8661 unsafe { std::env::remove_var("PINGET_APPROOT") };
8663
8664 let result = default_app_root();
8665
8666 if let Some(prior) = prior {
8667 unsafe { std::env::set_var("PINGET_APPROOT", prior) };
8669 }
8670
8671 let app_root = result.expect("default app root");
8672 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
8673 assert!(
8674 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
8675 "unexpected fallback app root: {app_root:?}"
8676 );
8677 }
8678
8679 #[test]
8680 fn packaged_source_stream_overlays_defaults_and_metadata() {
8681 let store = parse_packaged_source_store(
8682 Some(
8683 "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",
8684 ),
8685 Some(
8686 "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n",
8687 ),
8688 )
8689 .expect("packaged store");
8690
8691 assert!(!store.sources.iter().any(|source| source.name == "winget"));
8692 let corp = store
8693 .sources
8694 .iter()
8695 .find(|source| source.name == "corp")
8696 .expect("corp source");
8697 assert_eq!(corp.kind, SourceKind::Rest);
8698 assert_eq!(corp.identifier, "Contoso.Rest");
8699 assert_eq!(corp.trust_level, "Trusted");
8700 assert!(corp.explicit);
8701 assert_eq!(corp.priority, 7);
8702 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
8703 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
8704 }
8705
8706 #[test]
8707 fn system_winget_export_parses_json_lines() {
8708 let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
8709{"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"}
8710"#;
8711
8712 let sources = parse_system_winget_source_export(output).expect("source export");
8713
8714 assert_eq!(
8715 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
8716 vec!["contoso", "contoso-cache"]
8717 );
8718 assert_eq!(sources[0].kind, SourceKind::Rest);
8719 assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
8720 assert_eq!(sources[0].identifier, "api.contoso.test");
8721 assert_eq!(sources[0].trust_level, "Trusted");
8722 assert!(!sources[0].explicit);
8723 assert_eq!(sources[1].kind, SourceKind::PreIndexed);
8724 assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
8725 assert!(sources[1].explicit);
8726 }
8727
8728 #[test]
8729 fn system_winget_export_parses_wrapped_sources() {
8730 let output = r#"{
8731 "Sources": [
8732 {
8733 "Name": "corp",
8734 "Type": "Microsoft.Rest",
8735 "Arg": "https://packages.contoso.test/api",
8736 "Identifier": "Contoso.Rest",
8737 "TrustLevel": "Trusted",
8738 "Explicit": true,
8739 "Priority": 9
8740 }
8741 ]
8742}"#;
8743
8744 let sources = parse_system_winget_source_export(output).expect("source export");
8745 let source = sources.first().expect("source");
8746 assert_eq!(sources.len(), 1);
8747 assert_eq!(source.name, "corp");
8748 assert_eq!(source.kind, SourceKind::Rest);
8749 assert_eq!(source.identifier, "Contoso.Rest");
8750 assert_eq!(source.trust_level, "Trusted");
8751 assert!(source.explicit);
8752 assert_eq!(source.priority, 9);
8753 }
8754
8755 #[test]
8756 fn system_winget_source_store_detects_secure_settings_stub() {
8757 assert!(is_secure_settings_stub(Some(
8758 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
8759 )));
8760 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
8761 }
8762
8763 #[test]
8764 fn system_winget_source_store_builds_add_arguments() {
8765 let args = build_winget_source_add_arguments(
8766 "corp",
8767 "https://packages.contoso.test/api",
8768 SourceKind::Rest,
8769 "Trusted",
8770 true,
8771 );
8772
8773 assert_eq!(
8774 args,
8775 vec![
8776 "source",
8777 "add",
8778 "--name",
8779 "corp",
8780 "--arg",
8781 "https://packages.contoso.test/api",
8782 "--type",
8783 "Microsoft.Rest",
8784 "--disable-interactivity",
8785 "--trust-level",
8786 "trusted",
8787 "--explicit",
8788 ]
8789 );
8790 }
8791
8792 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
8793 Ok(WingetSourceCommandResult {
8794 exit_code: 0,
8795 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
8796"#
8797 .to_owned(),
8798 stderr: String::new(),
8799 })
8800 }
8801
8802 #[test]
8803 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
8804 let original_runner = {
8805 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
8806 let original = *runner;
8807 *runner = fake_system_winget_source_export;
8808 original
8809 };
8810
8811 let result = load_packaged_store_from_streams(
8812 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
8813 None,
8814 );
8815
8816 {
8817 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
8818 *runner = original_runner;
8819 }
8820
8821 let store = result.expect("packaged store").expect("packaged store");
8822 let source = store.sources.first().expect("source");
8823 assert_eq!(store.sources.len(), 1);
8824 assert_eq!(source.name, "contoso");
8825 assert_eq!(source.kind, SourceKind::Rest);
8826 }
8827
8828 #[test]
8829 fn source_metadata_and_named_reset_round_trip() {
8830 let app_root = temp_app_root("source-reset");
8831 let result = (|| -> Result<()> {
8832 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8833 repository.add_source_with_metadata(
8834 "test",
8835 "https://example.com/test",
8836 SourceKind::Rest,
8837 Some("trusted"),
8838 true,
8839 4,
8840 )?;
8841
8842 repository.edit_source("test", Some(false), None)?;
8843 let edited = repository
8844 .list_sources()
8845 .into_iter()
8846 .find(|source| source.name == "test")
8847 .expect("source");
8848 assert_eq!(edited.trust_level, "Trusted");
8849 assert!(!edited.explicit);
8850 assert_eq!(edited.priority, 4);
8851
8852 repository.reset_source("test")?;
8853 let reset = repository
8854 .list_sources()
8855 .into_iter()
8856 .find(|source| source.name == "test")
8857 .expect("source");
8858 assert_eq!(reset.trust_level, "Trusted");
8859 assert!(!reset.explicit);
8860 assert_eq!(reset.priority, 4);
8861 assert!(reset.last_update.is_none());
8862 assert!(reset.source_version.is_none());
8863 Ok(())
8864 })();
8865
8866 let _ = fs::remove_dir_all(&app_root);
8867 result.expect("source metadata");
8868 }
8869
8870 #[test]
8871 fn explicit_sources_are_skipped_by_default_search_resolution() {
8872 let app_root = temp_app_root("source-resolution");
8873 let result = (|| -> Result<()> {
8874 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8875 repository.add_source_with_metadata(
8876 "explicit-test",
8877 "https://example.com/explicit",
8878 SourceKind::Rest,
8879 Some("trusted"),
8880 true,
8881 10,
8882 )?;
8883
8884 let indexes = repository.resolve_source_indexes(None)?;
8885 let names: Vec<_> = indexes
8886 .into_iter()
8887 .map(|index| repository.list_sources()[index].name.clone())
8888 .collect();
8889 assert!(!names.iter().any(|name| name == "explicit-test"));
8890 Ok(())
8891 })();
8892
8893 let _ = fs::remove_dir_all(&app_root);
8894 result.expect("source resolution");
8895 }
8896
8897 #[test]
8898 fn user_and_admin_settings_round_trip() {
8899 let app_root = temp_app_root("settings");
8900 let result = (|| -> Result<()> {
8901 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
8902 repository.set_user_settings(
8903 &serde_json::json!({
8904 "visual": {
8905 "progressBar": "retro"
8906 }
8907 }),
8908 false,
8909 )?;
8910 repository.set_user_settings(
8911 &serde_json::json!({
8912 "experimentalFeatures": {
8913 "directMSI": true
8914 }
8915 }),
8916 true,
8917 )?;
8918
8919 let user_settings = repository.get_user_settings()?;
8920 assert_eq!(
8921 user_settings["visual"]["progressBar"],
8922 serde_json::Value::String("retro".to_owned())
8923 );
8924 assert_eq!(
8925 user_settings["experimentalFeatures"]["directMSI"],
8926 serde_json::Value::Bool(true)
8927 );
8928 assert!(repository.test_user_settings(
8929 &serde_json::json!({
8930 "experimentalFeatures": {
8931 "directMSI": true
8932 }
8933 }),
8934 true
8935 )?);
8936
8937 repository.set_admin_setting("LocalManifestFiles", true)?;
8938 repository.set_admin_setting("InstallerHashOverride", true)?;
8939 let admin_settings = repository.get_admin_settings()?;
8940 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
8941 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
8942
8943 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
8944 let reset_one = repository.get_admin_settings()?;
8945 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
8946
8947 repository.reset_admin_setting(None, true)?;
8948 let reset_all = repository.get_admin_settings()?;
8949 for name in Repository::supported_admin_settings() {
8950 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
8951 }
8952 Ok(())
8953 })();
8954
8955 let _ = fs::remove_dir_all(&app_root);
8956 result.expect("settings");
8957 }
8958
8959 #[test]
8960 fn show_result_structured_document_is_manifest_oriented() {
8961 let result = ShowResult {
8962 package: SearchMatch {
8963 source_name: "winget".to_owned(),
8964 source_kind: SourceKind::PreIndexed,
8965 id: "Test.Package".to_owned(),
8966 name: "Test Package".to_owned(),
8967 moniker: Some("testpkg".to_owned()),
8968 version: Some("1.2.3".to_owned()),
8969 channel: Some("stable".to_owned()),
8970 match_criteria: Some("Id".to_owned()),
8971 },
8972 manifest: Manifest {
8973 id: "Test.Package".to_owned(),
8974 name: "Test Package".to_owned(),
8975 version: "1.2.3".to_owned(),
8976 channel: "stable".to_owned(),
8977 publisher: Some("Contoso".to_owned()),
8978 description: Some("Structured output".to_owned()),
8979 moniker: Some("testpkg".to_owned()),
8980 package_url: None,
8981 publisher_url: None,
8982 publisher_support_url: None,
8983 license: None,
8984 license_url: None,
8985 privacy_url: None,
8986 author: None,
8987 copyright: None,
8988 copyright_url: None,
8989 release_notes: None,
8990 release_notes_url: None,
8991 tags: vec!["utility".to_owned()],
8992 agreements: Vec::new(),
8993 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
8994 documentation: vec![Documentation {
8995 label: Some("Docs".to_owned()),
8996 url: "https://example.test/docs".to_owned(),
8997 }],
8998 installers: vec![Installer {
8999 architecture: Some("x64".to_owned()),
9000 installer_type: Some("msix".to_owned()),
9001 url: Some("https://example.test/Test.Package.msix".to_owned()),
9002 sha256: Some("ABC123".to_owned()),
9003 product_code: None,
9004 locale: Some("en-US".to_owned()),
9005 scope: Some("machine".to_owned()),
9006 release_date: None,
9007 package_family_name: None,
9008 upgrade_code: None,
9009 platforms: Vec::new(),
9010 minimum_os_version: None,
9011 switches: InstallerSwitches {
9012 silent: Some("/quiet".to_owned()),
9013 ..InstallerSwitches::default()
9014 },
9015 commands: vec!["testpkg".to_owned()],
9016 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9017 require_explicit_upgrade: false,
9018 nested_installer_type: None,
9019 nested_installer_files: Vec::new(),
9020 }],
9021 require_explicit_upgrade: false,
9022 },
9023 selected_installer: Some(Installer {
9024 architecture: Some("x64".to_owned()),
9025 installer_type: Some("msix".to_owned()),
9026 url: Some("https://example.test/Test.Package.msix".to_owned()),
9027 sha256: Some("ABC123".to_owned()),
9028 product_code: None,
9029 locale: Some("en-US".to_owned()),
9030 scope: Some("machine".to_owned()),
9031 release_date: None,
9032 package_family_name: None,
9033 upgrade_code: None,
9034 platforms: Vec::new(),
9035 minimum_os_version: None,
9036 switches: InstallerSwitches {
9037 silent: Some("/quiet".to_owned()),
9038 ..InstallerSwitches::default()
9039 },
9040 commands: vec!["testpkg".to_owned()],
9041 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9042 require_explicit_upgrade: false,
9043 nested_installer_type: None,
9044 nested_installer_files: Vec::new(),
9045 }),
9046 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
9047 warnings: vec!["cache warmed".to_owned()],
9048 manifest_documents: JsonValue::Array(vec![
9049 serde_json::json!({
9050 "PackageIdentifier": "Test.Package",
9051 "PackageVersion": "1.2.3",
9052 "DefaultLocale": "en-US",
9053 "ManifestType": "version",
9054 "ManifestVersion": "1.10.0"
9055 }),
9056 serde_json::json!({
9057 "PackageIdentifier": "Test.Package",
9058 "PackageVersion": "1.2.3",
9059 "PackageLocale": "en-US",
9060 "PackageName": "Test Package",
9061 "Publisher": "Example",
9062 "License": "MIT",
9063 "ShortDescription": "Structured output",
9064 "ManifestType": "defaultLocale",
9065 "ManifestVersion": "1.10.0"
9066 }),
9067 serde_json::json!({
9068 "PackageIdentifier": "Test.Package",
9069 "PackageVersion": "1.2.3",
9070 "ManifestType": "installer",
9071 "ManifestVersion": "1.10.0",
9072 "Installers": [
9073 {
9074 "Architecture": "x64",
9075 "InstallerType": "msix",
9076 "InstallerUrl": "https://example.test/Test.Package.msix",
9077 "InstallerSha256": "ABC123",
9078 "Commands": ["testpkg"],
9079 "InstallerSwitches": { "Silent": "/quiet" },
9080 "Dependencies": {
9081 "PackageDependencies": [
9082 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
9083 ]
9084 }
9085 }
9086 ]
9087 }),
9088 ]),
9089 };
9090
9091 let document = result.structured_document();
9092
9093 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9094 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
9095 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
9096 assert_eq!(
9097 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
9098 Some("Microsoft.VCRedist.2015+.x64")
9099 );
9100 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
9101 assert_eq!(
9102 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
9103 Some("/quiet")
9104 );
9105 }
9106
9107 #[test]
9108 fn manifest_parses_require_explicit_upgrade_at_top_level() {
9109 let yaml = r#"
9114PackageIdentifier: Test.Package
9115PackageVersion: 1.2.3
9116DefaultLocale: en-US
9117ManifestType: singleton
9118ManifestVersion: 1.10.0
9119PackageLocale: en-US
9120PackageName: Test Package
9121Publisher: Example
9122License: MIT
9123ShortDescription: explicit-upgrade fixture
9124RequireExplicitUpgrade: true
9125Installers:
9126 - Architecture: x64
9127 InstallerType: exe
9128 InstallerUrl: https://example.test/Test.Package.exe
9129 InstallerSha256: ABC123
9130"#;
9131 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9132 assert!(manifest.require_explicit_upgrade);
9133 }
9134
9135 #[test]
9136 fn manifest_parses_require_explicit_upgrade_on_installer() {
9137 let yaml = r#"
9141PackageIdentifier: Test.Package
9142PackageVersion: 1.2.3
9143DefaultLocale: en-US
9144ManifestType: singleton
9145ManifestVersion: 1.10.0
9146PackageLocale: en-US
9147PackageName: Test Package
9148Publisher: Example
9149License: MIT
9150ShortDescription: explicit-upgrade fixture
9151Installers:
9152 - Architecture: x64
9153 InstallerType: exe
9154 InstallerUrl: https://example.test/Test.Package.x64.exe
9155 InstallerSha256: ABC123
9156 - Architecture: arm64
9157 InstallerType: exe
9158 InstallerUrl: https://example.test/Test.Package.arm64.exe
9159 InstallerSha256: DEF456
9160 RequireExplicitUpgrade: true
9161"#;
9162 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9163 assert!(manifest.require_explicit_upgrade);
9164 assert!(!manifest.installers[0].require_explicit_upgrade);
9165 assert!(manifest.installers[1].require_explicit_upgrade);
9166 }
9167
9168 #[test]
9169 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
9170 let yaml = r#"
9171PackageIdentifier: Test.Package
9172PackageVersion: 1.2.3
9173DefaultLocale: en-US
9174ManifestType: singleton
9175ManifestVersion: 1.10.0
9176PackageLocale: en-US
9177PackageName: Test Package
9178Publisher: Example
9179License: MIT
9180ShortDescription: baseline fixture
9181Installers:
9182 - Architecture: x64
9183 InstallerType: exe
9184 InstallerUrl: https://example.test/Test.Package.exe
9185 InstallerSha256: ABC123
9186"#;
9187 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9188 assert!(!manifest.require_explicit_upgrade);
9189 }
9190
9191 #[test]
9192 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
9193 let mut pkg = InstalledPackage {
9197 name: "Edge".to_owned(),
9198 local_id: r"ARP\Machine\X64\Edge".to_owned(),
9199 installed_version: "100.0".to_owned(),
9200 publisher: None,
9201 scope: Some("Machine".to_owned()),
9202 installer_category: Some("exe".to_owned()),
9203 install_location: None,
9204 package_family_names: Vec::new(),
9205 product_codes: Vec::new(),
9206 upgrade_codes: Vec::new(),
9207 correlated: Some(SearchMatch {
9208 source_name: "winget".to_owned(),
9209 source_kind: SourceKind::PreIndexed,
9210 id: "Microsoft.Edge".to_owned(),
9211 name: "Microsoft Edge".to_owned(),
9212 moniker: None,
9213 version: Some("110.0".to_owned()),
9214 channel: None,
9215 match_criteria: None,
9216 }),
9217 installed_version_canonical: false,
9218 correlated_requires_explicit_upgrade: true,
9219 correlated_lacks_compatible_installer: false,
9220 };
9221 let bulk_query = ListQuery {
9222 upgrade_only: true,
9223 ..ListQuery::default()
9224 };
9225 assert!(
9226 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
9227 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
9228 );
9229
9230 let filtered_query = ListQuery {
9233 upgrade_only: true,
9234 id: Some("Microsoft.Edge".to_owned()),
9235 ..ListQuery::default()
9236 };
9237 assert!(
9238 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
9239 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
9240 );
9241
9242 pkg.correlated_requires_explicit_upgrade = false;
9244 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9245 }
9246
9247 #[test]
9248 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
9249 let mut pkg = InstalledPackage {
9250 name: "Foo".to_owned(),
9251 local_id: r"ARP\Machine\X64\Foo".to_owned(),
9252 installed_version: "1.0".to_owned(),
9253 publisher: None,
9254 scope: Some("Machine".to_owned()),
9255 installer_category: Some("exe".to_owned()),
9256 install_location: None,
9257 package_family_names: Vec::new(),
9258 product_codes: Vec::new(),
9259 upgrade_codes: Vec::new(),
9260 correlated: Some(SearchMatch {
9261 source_name: "winget".to_owned(),
9262 source_kind: SourceKind::PreIndexed,
9263 id: "Test.Foo".to_owned(),
9264 name: "Foo".to_owned(),
9265 moniker: None,
9266 version: Some("2.0".to_owned()),
9267 channel: None,
9268 match_criteria: None,
9269 }),
9270 installed_version_canonical: false,
9271 correlated_requires_explicit_upgrade: false,
9272 correlated_lacks_compatible_installer: true,
9273 };
9274 let bulk_query = ListQuery {
9275 upgrade_only: true,
9276 ..ListQuery::default()
9277 };
9278 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9279
9280 let filtered_query = ListQuery {
9282 upgrade_only: true,
9283 id: Some("Test.Foo".to_owned()),
9284 ..ListQuery::default()
9285 };
9286 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
9287
9288 pkg.correlated_lacks_compatible_installer = false;
9290 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9291 }
9292
9293 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
9294 let installers = arches
9295 .iter()
9296 .map(|a| Installer {
9297 architecture: Some((*a).to_owned()),
9298 installer_type: None,
9299 url: None,
9300 sha256: None,
9301 product_code: None,
9302 locale: None,
9303 scope: None,
9304 release_date: None,
9305 package_family_name: None,
9306 upgrade_code: None,
9307 platforms: Vec::new(),
9308 minimum_os_version: None,
9309 switches: InstallerSwitches::default(),
9310 commands: Vec::new(),
9311 package_dependencies: Vec::new(),
9312 require_explicit_upgrade: false,
9313 nested_installer_type: None,
9314 nested_installer_files: Vec::new(),
9315 })
9316 .collect();
9317 Manifest {
9318 id: "Test".to_owned(),
9319 name: "Test".to_owned(),
9320 version: "1.0".to_owned(),
9321 channel: String::new(),
9322 publisher: None,
9323 description: None,
9324 moniker: None,
9325 package_url: None,
9326 publisher_url: None,
9327 publisher_support_url: None,
9328 license: None,
9329 license_url: None,
9330 privacy_url: None,
9331 author: None,
9332 copyright: None,
9333 copyright_url: None,
9334 release_notes: None,
9335 release_notes_url: None,
9336 tags: Vec::new(),
9337 agreements: Vec::new(),
9338 package_dependencies: Vec::new(),
9339 documentation: Vec::new(),
9340 installers,
9341 require_explicit_upgrade: false,
9342 }
9343 }
9344
9345 #[test]
9346 fn manifest_compatible_installer_neutral_always_matches() {
9347 assert!(manifest_has_compatible_installer(
9349 &synthetic_manifest_with_installer_arches(&["neutral"])
9350 ));
9351 }
9352
9353 #[test]
9354 fn manifest_compatible_installer_empty_installers_pass_through() {
9355 assert!(manifest_has_compatible_installer(
9358 &synthetic_manifest_with_installer_arches(&[])
9359 ));
9360 }
9361
9362 #[test]
9363 fn manifest_compatible_installer_rejects_alien_arch_only() {
9364 assert!(!manifest_has_compatible_installer(
9366 &synthetic_manifest_with_installer_arches(&["ppc"])
9367 ));
9368 }
9369
9370 #[test]
9371 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
9372 assert!(manifest_has_compatible_installer(
9374 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
9375 ));
9376 }
9377
9378 #[test]
9379 fn lookup_unique_normalized_identity_returns_unique_match() {
9380 let connection = Connection::open_in_memory().expect("open in-memory db");
9382 connection
9383 .execute_batch(
9384 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9385 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9386 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
9387 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
9388 )
9389 .expect("seed schema");
9390
9391 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
9392 .expect("query")
9393 .expect("unique match");
9394 assert_eq!(rowid, 100);
9395 }
9396
9397 #[test]
9398 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
9399 let connection = Connection::open_in_memory().expect("open in-memory db");
9404 connection
9405 .execute_batch(
9406 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9407 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9408 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
9409 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
9410 )
9411 .expect("seed schema");
9412
9413 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
9414 assert!(rowid.is_none(), "ambiguous match must not correlate");
9415 }
9416
9417 #[test]
9418 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
9419 let connection = Connection::open_in_memory().expect("open in-memory db");
9423 connection
9424 .execute_batch(
9425 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9426 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9427 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
9428 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
9429 )
9430 .expect("seed schema");
9431
9432 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
9433 assert_eq!(rowid, Some(100));
9434 }
9435
9436 #[test]
9437 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
9438 let connection = Connection::open_in_memory().expect("open in-memory db");
9441 connection
9442 .execute_batch(
9443 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
9444 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
9445 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
9446 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
9447 )
9448 .expect("seed schema");
9449
9450 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
9451 assert!(
9452 rowid.is_none(),
9453 "name matches package 100 but publisher matches 200 — no intersection"
9454 );
9455 }
9456
9457 #[test]
9458 fn parse_yaml_manifest_bundle_returns_singleton_document() {
9459 let yaml = r#"
9460PackageIdentifier: Test.Package
9461PackageVersion: 1.2.3
9462DefaultLocale: en-US
9463ManifestType: version
9464ManifestVersion: 1.10.0
9465---
9466PackageIdentifier: Test.Package
9467PackageVersion: 1.2.3
9468PackageLocale: en-US
9469PackageName: Test Package
9470Publisher: Example
9471License: MIT
9472ShortDescription: Structured output
9473ManifestType: defaultLocale
9474ManifestVersion: 1.10.0
9475---
9476PackageIdentifier: Test.Package
9477PackageVersion: 1.2.3
9478ManifestType: installer
9479ManifestVersion: 1.10.0
9480Installers:
9481 - Architecture: x64
9482 InstallerType: exe
9483 InstallerUrl: https://example.test/Test.Package.exe
9484 InstallerSha256: ABC123
9485"#;
9486
9487 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
9488
9489 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
9490 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
9491 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
9492 }
9493
9494 #[test]
9495 fn collapse_structured_documents_returns_plural_show_documents() {
9496 let documents = collapse_structured_documents(&[
9497 JsonValue::Array(vec![
9498 serde_json::json!({
9499 "PackageIdentifier": "Test.Package.One",
9500 "PackageVersion": "1.0.0",
9501 "DefaultLocale": "en-US",
9502 "ManifestType": "version",
9503 "ManifestVersion": "1.10.0"
9504 }),
9505 serde_json::json!({
9506 "PackageIdentifier": "Test.Package.One",
9507 "PackageVersion": "1.0.0",
9508 "PackageLocale": "en-US",
9509 "PackageName": "Test Package One",
9510 "ManifestType": "defaultLocale",
9511 "ManifestVersion": "1.10.0"
9512 }),
9513 serde_json::json!({
9514 "PackageIdentifier": "Test.Package.One",
9515 "PackageVersion": "1.0.0",
9516 "ManifestType": "installer",
9517 "ManifestVersion": "1.10.0",
9518 "Installers": [
9519 {
9520 "Architecture": "x64",
9521 "InstallerType": "exe",
9522 "InstallerUrl": "https://example.test/one.exe",
9523 "InstallerSha256": "ABC123"
9524 }
9525 ]
9526 }),
9527 ]),
9528 serde_json::json!({
9529 "PackageIdentifier": "Test.Package.Two",
9530 "PackageVersion": "2.0.0",
9531 "PackageLocale": "en-US",
9532 "PackageName": "Test Package Two",
9533 "ManifestType": "singleton",
9534 "ManifestVersion": "1.12.0"
9535 }),
9536 ]);
9537
9538 assert_eq!(documents.len(), 2);
9539 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
9540 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
9541 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
9542 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
9543 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
9544 }
9545
9546 #[test]
9547 fn unsupported_action_result_marks_noop_and_warning() {
9548 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
9549
9550 assert!(result.success);
9551 assert!(result.no_op);
9552 assert_eq!(result.exit_code, 0);
9553 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
9554 }
9555
9556 #[test]
9557 fn create_install_no_op_result_honors_no_upgrade() {
9558 let mut request = InstallRequest::new(PackageQuery {
9559 id: Some("Contoso.App".to_owned()),
9560 ..PackageQuery::default()
9561 });
9562 request.no_upgrade = true;
9563
9564 let manifest = Manifest {
9565 id: "Contoso.App".to_owned(),
9566 name: "Contoso App".to_owned(),
9567 version: "2.0.0".to_owned(),
9568 channel: String::new(),
9569 publisher: None,
9570 description: None,
9571 moniker: None,
9572 package_url: None,
9573 publisher_url: None,
9574 publisher_support_url: None,
9575 license: None,
9576 license_url: None,
9577 privacy_url: None,
9578 author: None,
9579 copyright: None,
9580 copyright_url: None,
9581 release_notes: None,
9582 release_notes_url: None,
9583 tags: Vec::new(),
9584 agreements: Vec::new(),
9585 package_dependencies: Vec::new(),
9586 documentation: Vec::new(),
9587 installers: Vec::new(),
9588 require_explicit_upgrade: false,
9589 };
9590 let existing = ListMatch {
9591 name: "Contoso App".to_owned(),
9592 id: "Contoso.App".to_owned(),
9593 local_id: "Contoso.App".to_owned(),
9594 installed_version: "1.0.0".to_owned(),
9595 available_version: Some("2.0.0".to_owned()),
9596 source_name: Some("winget".to_owned()),
9597 publisher: None,
9598 scope: None,
9599 installer_category: None,
9600 install_location: None,
9601 package_family_names: Vec::new(),
9602 product_codes: Vec::new(),
9603 upgrade_codes: Vec::new(),
9604 };
9605
9606 let result =
9607 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
9608
9609 assert!(result.success);
9610 assert!(result.no_op);
9611 assert_eq!(result.version, "1.0.0");
9612 assert_eq!(
9613 result.warnings,
9614 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
9615 );
9616 }
9617
9618 #[test]
9619 fn create_install_no_op_result_skips_reinstall_when_current() {
9620 let request = InstallRequest::new(PackageQuery {
9621 id: Some("Contoso.App".to_owned()),
9622 ..PackageQuery::default()
9623 });
9624
9625 let manifest = Manifest {
9626 id: "Contoso.App".to_owned(),
9627 name: "Contoso App".to_owned(),
9628 version: "2.0.0".to_owned(),
9629 channel: String::new(),
9630 publisher: None,
9631 description: None,
9632 moniker: None,
9633 package_url: None,
9634 publisher_url: None,
9635 publisher_support_url: None,
9636 license: None,
9637 license_url: None,
9638 privacy_url: None,
9639 author: None,
9640 copyright: None,
9641 copyright_url: None,
9642 release_notes: None,
9643 release_notes_url: None,
9644 tags: Vec::new(),
9645 agreements: Vec::new(),
9646 package_dependencies: Vec::new(),
9647 documentation: Vec::new(),
9648 installers: Vec::new(),
9649 require_explicit_upgrade: false,
9650 };
9651 let existing = ListMatch {
9652 name: "Contoso App".to_owned(),
9653 id: "Contoso.App".to_owned(),
9654 local_id: "Contoso.App".to_owned(),
9655 installed_version: "2.0.0".to_owned(),
9656 available_version: Some("2.0.0".to_owned()),
9657 source_name: Some("winget".to_owned()),
9658 publisher: None,
9659 scope: None,
9660 installer_category: None,
9661 install_location: None,
9662 package_family_names: Vec::new(),
9663 product_codes: Vec::new(),
9664 upgrade_codes: Vec::new(),
9665 };
9666
9667 let result =
9668 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
9669
9670 assert!(result.success);
9671 assert!(result.no_op);
9672 assert_eq!(result.version, "2.0.0");
9673 assert_eq!(
9674 result.warnings,
9675 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
9676 );
9677 }
9678
9679 #[test]
9680 fn collapse_structured_document_projects_merged_manifest() {
9681 let document = collapse_structured_document(&serde_json::json!({
9682 "PackageIdentifier": "Test.Package",
9683 "PackageVersion": "1.2.3",
9684 "PackageLocale": "en-US",
9685 "PackageName": "Test Package",
9686 "Publisher": "Example",
9687 "InstallerType": "exe",
9688 "Installers": [
9689 {
9690 "Architecture": "x64",
9691 "InstallerUrl": "https://example.test/Test.Package.exe",
9692 "InstallerSha256": "ABC123"
9693 }
9694 ],
9695 "ManifestType": "merged",
9696 "ManifestVersion": "1.10.0"
9697 }));
9698
9699 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9700 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
9701 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
9702 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
9703 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
9704 }
9705
9706 #[test]
9707 fn parses_fixture_manifest() {
9708 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
9709 .join("tests")
9710 .join("fixtures")
9711 .join("ManifestV1_28-Singleton.yaml");
9712 let bytes = fs::read(root).expect("fixture bytes");
9713 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
9714 assert_eq!(manifest.id, "microsoft.msixsdk");
9715 assert_eq!(manifest.version, "1.7.32");
9716 assert_eq!(manifest.name, "MSIX SDK");
9717 assert!(!manifest.installers.is_empty());
9718 }
9719
9720 #[test]
9721 fn parses_installer_switches_from_yaml_manifest() {
9722 let yaml = r#"
9723PackageIdentifier: Test.Package
9724PackageVersion: 1.2.3
9725PackageName: Test Package
9726InstallerSwitches:
9727 SilentWithProgress: /SILENT
9728Installers:
9729 - Architecture: x64
9730 InstallerType: inno
9731 InstallerUrl: https://example.test/Test.Package.exe
9732 InstallerSha256: ABC123
9733 InstallerSwitches:
9734 Silent: /VERYSILENT
9735 Interactive: /HELP
9736"#;
9737
9738 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
9739 let installer = &manifest.installers[0];
9740
9741 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
9742 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
9743 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
9744 }
9745
9746 #[test]
9747 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
9748 let yaml = r#"
9749PackageIdentifier: Test.Package
9750PackageVersion: 1.2.3
9751PackageName: Test Package
9752Platform:
9753 - Windows.Desktop
9754MinimumOSVersion: 10.0.19041.0
9755Installers:
9756 - Architecture: x64
9757 InstallerType: exe
9758 InstallerUrl: https://example.test/Test.Package.exe
9759 InstallerSha256: ABC123
9760 - Architecture: x64
9761 InstallerType: msix
9762 Platform:
9763 - Windows.Universal
9764 MinimumOSVersion: 10.0.22621.0
9765 InstallerUrl: https://example.test/Test.Package.msix
9766 InstallerSha256: DEF456
9767"#;
9768
9769 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
9770
9771 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
9772 assert_eq!(
9773 manifest.installers[0].minimum_os_version.as_deref(),
9774 Some("10.0.19041.0")
9775 );
9776 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
9777 assert_eq!(
9778 manifest.installers[1].minimum_os_version.as_deref(),
9779 Some("10.0.22621.0")
9780 );
9781 }
9782
9783 #[test]
9784 fn installer_switch_arguments_prefer_manifest_switches() {
9785 let installer = Installer {
9786 architecture: None,
9787 installer_type: Some("inno".to_owned()),
9788 url: None,
9789 sha256: None,
9790 product_code: None,
9791 locale: None,
9792 scope: None,
9793 release_date: None,
9794 package_family_name: None,
9795 upgrade_code: None,
9796 platforms: Vec::new(),
9797 minimum_os_version: None,
9798 switches: InstallerSwitches {
9799 silent: Some("/mysilent".to_owned()),
9800 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
9801 interactive: Some("/myinteractive".to_owned()),
9802 ..InstallerSwitches::default()
9803 },
9804 commands: Vec::new(),
9805 package_dependencies: Vec::new(),
9806 require_explicit_upgrade: false,
9807 nested_installer_type: None,
9808 nested_installer_files: Vec::new(),
9809 };
9810 let manifest = Manifest {
9811 id: "Test.Package".to_owned(),
9812 name: "Test Package".to_owned(),
9813 version: "1.0.0".to_owned(),
9814 channel: String::new(),
9815 publisher: None,
9816 description: None,
9817 moniker: None,
9818 package_url: None,
9819 publisher_url: None,
9820 publisher_support_url: None,
9821 license: None,
9822 license_url: None,
9823 privacy_url: None,
9824 author: None,
9825 copyright: None,
9826 copyright_url: None,
9827 release_notes: None,
9828 release_notes_url: None,
9829 tags: Vec::new(),
9830 agreements: Vec::new(),
9831 package_dependencies: Vec::new(),
9832 documentation: Vec::new(),
9833 installers: Vec::new(),
9834 require_explicit_upgrade: false,
9835 };
9836
9837 let mut silent_request = InstallRequest::new(PackageQuery::default());
9838 silent_request.mode = InstallerMode::Silent;
9839 let mut progress_request = InstallRequest::new(PackageQuery::default());
9840 progress_request.mode = InstallerMode::SilentWithProgress;
9841 let mut interactive_request = InstallRequest::new(PackageQuery::default());
9842 interactive_request.mode = InstallerMode::Interactive;
9843 assert_eq!(
9844 installer_command_arguments(
9845 "inno",
9846 &silent_request,
9847 &manifest,
9848 Path::new("installer.exe"),
9849 &installer
9850 ),
9851 vec!["/mysilent".to_owned()]
9852 );
9853 assert_eq!(
9854 installer_command_arguments(
9855 "inno",
9856 &progress_request,
9857 &manifest,
9858 Path::new("installer.exe"),
9859 &installer
9860 ),
9861 vec!["/mysilentwithprogress".to_owned()]
9862 );
9863 assert_eq!(
9864 installer_command_arguments(
9865 "inno",
9866 &interactive_request,
9867 &manifest,
9868 Path::new("installer.exe"),
9869 &installer
9870 ),
9871 vec!["/myinteractive".to_owned()]
9872 );
9873 }
9874
9875 #[test]
9876 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
9877 let installer = Installer {
9878 architecture: None,
9879 installer_type: Some("inno".to_owned()),
9880 url: None,
9881 sha256: None,
9882 product_code: None,
9883 locale: None,
9884 scope: None,
9885 release_date: None,
9886 package_family_name: None,
9887 upgrade_code: None,
9888 platforms: Vec::new(),
9889 minimum_os_version: None,
9890 switches: InstallerSwitches::default(),
9891 commands: Vec::new(),
9892 package_dependencies: Vec::new(),
9893 require_explicit_upgrade: false,
9894 nested_installer_type: None,
9895 nested_installer_files: Vec::new(),
9896 };
9897 let manifest = Manifest {
9898 id: "Test.Package".to_owned(),
9899 name: "Test Package".to_owned(),
9900 version: "1.0.0".to_owned(),
9901 channel: String::new(),
9902 publisher: None,
9903 description: None,
9904 moniker: None,
9905 package_url: None,
9906 publisher_url: None,
9907 publisher_support_url: None,
9908 license: None,
9909 license_url: None,
9910 privacy_url: None,
9911 author: None,
9912 copyright: None,
9913 copyright_url: None,
9914 release_notes: None,
9915 release_notes_url: None,
9916 tags: Vec::new(),
9917 agreements: Vec::new(),
9918 package_dependencies: Vec::new(),
9919 documentation: Vec::new(),
9920 installers: Vec::new(),
9921 require_explicit_upgrade: false,
9922 };
9923 let mut progress_request = InstallRequest::new(PackageQuery::default());
9924 progress_request.mode = InstallerMode::SilentWithProgress;
9925 let mut silent_request = InstallRequest::new(PackageQuery::default());
9926 silent_request.mode = InstallerMode::Silent;
9927
9928 assert_eq!(
9929 installer_command_arguments(
9930 "inno",
9931 &progress_request,
9932 &manifest,
9933 Path::new("installer.exe"),
9934 &installer
9935 ),
9936 vec![
9937 "/SP-".to_owned(),
9938 "/SILENT".to_owned(),
9939 "/SUPPRESSMSGBOXES".to_owned(),
9940 "/NORESTART".to_owned()
9941 ]
9942 );
9943 assert_eq!(
9944 installer_command_arguments(
9945 "inno",
9946 &silent_request,
9947 &manifest,
9948 Path::new("installer.exe"),
9949 &installer
9950 ),
9951 vec![
9952 "/SP-".to_owned(),
9953 "/VERYSILENT".to_owned(),
9954 "/SUPPRESSMSGBOXES".to_owned(),
9955 "/NORESTART".to_owned()
9956 ]
9957 );
9958 }
9959
9960 #[test]
9961 fn installer_command_arguments_append_manifest_and_cli_switches() {
9962 let installer = Installer {
9963 architecture: None,
9964 installer_type: Some("msi".to_owned()),
9965 url: None,
9966 sha256: None,
9967 product_code: None,
9968 locale: None,
9969 scope: None,
9970 release_date: None,
9971 package_family_name: None,
9972 upgrade_code: None,
9973 platforms: Vec::new(),
9974 minimum_os_version: None,
9975 switches: InstallerSwitches {
9976 custom: Some("ADDLOCAL=Core".to_owned()),
9977 log: Some("/log \"<LOGPATH>\"".to_owned()),
9978 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
9979 ..InstallerSwitches::default()
9980 },
9981 commands: Vec::new(),
9982 package_dependencies: Vec::new(),
9983 require_explicit_upgrade: false,
9984 nested_installer_type: None,
9985 nested_installer_files: Vec::new(),
9986 };
9987 let manifest = Manifest {
9988 id: "ShareX.ShareX".to_owned(),
9989 name: "ShareX".to_owned(),
9990 version: "19.0.2".to_owned(),
9991 channel: String::new(),
9992 publisher: None,
9993 description: None,
9994 moniker: None,
9995 package_url: None,
9996 publisher_url: None,
9997 publisher_support_url: None,
9998 license: None,
9999 license_url: None,
10000 privacy_url: None,
10001 author: None,
10002 copyright: None,
10003 copyright_url: None,
10004 release_notes: None,
10005 release_notes_url: None,
10006 tags: Vec::new(),
10007 agreements: Vec::new(),
10008 package_dependencies: Vec::new(),
10009 documentation: Vec::new(),
10010 installers: Vec::new(),
10011 require_explicit_upgrade: false,
10012 };
10013 let mut request = InstallRequest::new(PackageQuery::default());
10014 request.mode = InstallerMode::Silent;
10015 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
10016 request.custom = Some("REBOOT=ReallySuppress".to_owned());
10017 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
10018
10019 assert_eq!(
10020 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
10021 vec![
10022 "/i".to_owned(),
10023 r"C:\temp\ShareX.msi".to_owned(),
10024 "/quiet".to_owned(),
10025 "/norestart".to_owned(),
10026 "/log".to_owned(),
10027 r"C:\temp\winget.log".to_owned(),
10028 "ADDLOCAL=Core".to_owned(),
10029 "REBOOT=ReallySuppress".to_owned(),
10030 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
10031 ]
10032 );
10033 }
10034
10035 #[test]
10036 fn decompresses_mszyml_payload() {
10037 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
10038 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10039 encoder.write_all(payload.as_bytes()).expect("write payload");
10040 let compressed = encoder.finish().expect("finish payload");
10041 let mut mszip = b"CK".to_vec();
10042 mszip.extend_from_slice(&compressed);
10043
10044 let decompressed = decompress_mszyml(&mszip).expect("decompress");
10045 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10046 assert_eq!(parsed.versions[0].version, "1.2.3");
10047 }
10048
10049 #[test]
10050 fn decompresses_mszyml_payload_with_prefix() {
10051 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
10052 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10053 encoder.write_all(payload.as_bytes()).expect("write payload");
10054 let compressed = encoder.finish().expect("finish payload");
10055 let mut prefixed = vec![0u8; 28];
10056 prefixed.extend_from_slice(b"CK");
10057 prefixed.extend_from_slice(&compressed);
10058
10059 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
10060 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10061 assert_eq!(parsed.versions[0].version, "1.2.3");
10062 }
10063
10064 #[test]
10065 fn compares_versions_naturally() {
10066 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
10067 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
10068 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
10069 }
10070
10071 #[test]
10072 fn installer_hash_verification_fails_on_mismatch_without_override() {
10073 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
10074 assert!(result.is_err());
10075 assert!(
10076 result
10077 .expect_err("hash mismatch")
10078 .to_string()
10079 .contains("Installer hash mismatch")
10080 );
10081 }
10082
10083 #[test]
10084 fn installer_hash_verification_allows_mismatch_with_override() {
10085 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
10086 verify_installer_hash(None, b"pinget", false).expect("missing hash");
10087 }
10088
10089 #[test]
10090 fn search_query_many_includes_tag_and_command_conditions() {
10091 let query = PackageQuery {
10092 query: Some("terminal".to_owned()),
10093 ..PackageQuery::default()
10094 };
10095
10096 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10097
10098 assert!(where_clause.contains("tags2"));
10099 assert!(where_clause.contains("commands2"));
10100 assert_eq!(params.len(), 5);
10101 }
10102
10103 #[test]
10104 fn show_query_single_omits_tag_and_command_conditions() {
10105 let query = PackageQuery {
10106 query: Some("terminal".to_owned()),
10107 ..PackageQuery::default()
10108 };
10109
10110 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
10111
10112 assert!(!where_clause.contains("tags2"));
10113 assert!(!where_clause.contains("commands2"));
10114 assert_eq!(params.len(), 3);
10115 }
10116
10117 #[test]
10118 fn explicit_tag_filter_uses_exact_match() {
10119 let query = PackageQuery {
10120 tag: Some("terminal".to_owned()),
10121 ..PackageQuery::default()
10122 };
10123
10124 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10125
10126 assert!(where_clause.contains("tags2"));
10127 assert_eq!(params, vec!["terminal".to_owned()]);
10128 }
10129
10130 #[test]
10131 fn rest_tag_filter_uses_exact_match() {
10132 let query = PackageQuery {
10133 tag: Some("terminal".to_owned()),
10134 ..PackageQuery::default()
10135 };
10136 let info = RestInformation {
10137 required_package_match_fields: Vec::new(),
10138 unsupported_package_match_fields: Vec::new(),
10139 required_query_parameters: Vec::new(),
10140 ..RestInformation::default()
10141 };
10142
10143 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
10144 let filters = body["Filters"].as_array().expect("filters");
10145
10146 assert_eq!(filters.len(), 1);
10147 assert_eq!(filters[0]["PackageMatchField"], "Tag");
10148 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
10149 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
10150 }
10151
10152 #[test]
10153 fn selects_installer_using_requested_filters() {
10154 let installers = vec![
10155 Installer {
10156 architecture: Some("x86".to_owned()),
10157 installer_type: Some("zip".to_owned()),
10158 url: None,
10159 sha256: None,
10160 product_code: None,
10161 locale: Some("en-US".to_owned()),
10162 scope: Some("user".to_owned()),
10163 release_date: None,
10164 package_family_name: None,
10165 upgrade_code: None,
10166 platforms: Vec::new(),
10167 minimum_os_version: None,
10168 switches: InstallerSwitches::default(),
10169 commands: Vec::new(),
10170 package_dependencies: Vec::new(),
10171 require_explicit_upgrade: false,
10172 nested_installer_type: None,
10173 nested_installer_files: Vec::new(),
10174 },
10175 Installer {
10176 architecture: Some("x64".to_owned()),
10177 installer_type: Some("msix".to_owned()),
10178 url: None,
10179 sha256: None,
10180 product_code: None,
10181 locale: Some("en-US".to_owned()),
10182 scope: Some("user".to_owned()),
10183 release_date: None,
10184 package_family_name: None,
10185 upgrade_code: None,
10186 platforms: Vec::new(),
10187 minimum_os_version: None,
10188 switches: InstallerSwitches::default(),
10189 commands: vec!["demo".to_owned()],
10190 package_dependencies: Vec::new(),
10191 require_explicit_upgrade: false,
10192 nested_installer_type: None,
10193 nested_installer_files: Vec::new(),
10194 },
10195 ];
10196 let query = PackageQuery {
10197 installer_type: Some("msix".to_owned()),
10198 installer_architecture: Some("x64".to_owned()),
10199 install_scope: Some("user".to_owned()),
10200 locale: Some("en-US".to_owned()),
10201 ..PackageQuery::default()
10202 };
10203
10204 let selected = select_installer(&installers, &query).expect("selected installer");
10205 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
10206 assert_eq!(selected.architecture.as_deref(), Some("x64"));
10207 }
10208
10209 #[test]
10210 fn selects_first_installer_when_rank_ties() {
10211 let installers = vec![
10212 Installer {
10213 architecture: Some("x64".to_owned()),
10214 installer_type: Some("exe".to_owned()),
10215 url: None,
10216 sha256: None,
10217 product_code: None,
10218 locale: Some("en-US".to_owned()),
10219 scope: Some("machine".to_owned()),
10220 release_date: None,
10221 package_family_name: None,
10222 upgrade_code: None,
10223 platforms: Vec::new(),
10224 minimum_os_version: None,
10225 switches: InstallerSwitches::default(),
10226 commands: Vec::new(),
10227 package_dependencies: Vec::new(),
10228 require_explicit_upgrade: false,
10229 nested_installer_type: None,
10230 nested_installer_files: Vec::new(),
10231 },
10232 Installer {
10233 architecture: Some("x64".to_owned()),
10234 installer_type: Some("exe".to_owned()),
10235 url: None,
10236 sha256: None,
10237 product_code: None,
10238 locale: Some("en-US".to_owned()),
10239 scope: Some("user".to_owned()),
10240 release_date: None,
10241 package_family_name: None,
10242 upgrade_code: None,
10243 platforms: Vec::new(),
10244 minimum_os_version: None,
10245 switches: InstallerSwitches::default(),
10246 commands: Vec::new(),
10247 package_dependencies: Vec::new(),
10248 require_explicit_upgrade: false,
10249 nested_installer_type: None,
10250 nested_installer_files: Vec::new(),
10251 },
10252 ];
10253
10254 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
10255 assert_eq!(selected.scope.as_deref(), Some("machine"));
10256 }
10257
10258 #[test]
10259 fn selects_installer_using_requested_platform() {
10260 let installers = vec![
10261 Installer {
10262 architecture: Some("x64".to_owned()),
10263 installer_type: Some("exe".to_owned()),
10264 url: None,
10265 sha256: None,
10266 product_code: None,
10267 locale: Some("en-US".to_owned()),
10268 scope: Some("user".to_owned()),
10269 release_date: None,
10270 package_family_name: None,
10271 upgrade_code: None,
10272 platforms: vec!["Windows.Universal".to_owned()],
10273 minimum_os_version: None,
10274 switches: InstallerSwitches::default(),
10275 commands: Vec::new(),
10276 package_dependencies: Vec::new(),
10277 require_explicit_upgrade: false,
10278 nested_installer_type: None,
10279 nested_installer_files: Vec::new(),
10280 },
10281 Installer {
10282 architecture: Some("x64".to_owned()),
10283 installer_type: Some("exe".to_owned()),
10284 url: None,
10285 sha256: None,
10286 product_code: None,
10287 locale: Some("en-US".to_owned()),
10288 scope: Some("user".to_owned()),
10289 release_date: None,
10290 package_family_name: None,
10291 upgrade_code: None,
10292 platforms: vec!["Windows.Desktop".to_owned()],
10293 minimum_os_version: None,
10294 switches: InstallerSwitches::default(),
10295 commands: Vec::new(),
10296 package_dependencies: Vec::new(),
10297 require_explicit_upgrade: false,
10298 nested_installer_type: None,
10299 nested_installer_files: Vec::new(),
10300 },
10301 ];
10302 let query = PackageQuery {
10303 installer_type: Some("exe".to_owned()),
10304 platform: Some("Windows.Desktop".to_owned()),
10305 ..PackageQuery::default()
10306 };
10307
10308 let selected = select_installer(&installers, &query).expect("selected installer");
10309 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
10310 }
10311
10312 #[test]
10313 fn selects_installer_using_requested_os_version() {
10314 let installers = vec![
10315 Installer {
10316 architecture: Some("x64".to_owned()),
10317 installer_type: Some("exe".to_owned()),
10318 url: None,
10319 sha256: None,
10320 product_code: None,
10321 locale: Some("en-US".to_owned()),
10322 scope: Some("user".to_owned()),
10323 release_date: None,
10324 package_family_name: None,
10325 upgrade_code: None,
10326 platforms: Vec::new(),
10327 minimum_os_version: Some("10.0.22621.0".to_owned()),
10328 switches: InstallerSwitches::default(),
10329 commands: Vec::new(),
10330 package_dependencies: Vec::new(),
10331 require_explicit_upgrade: false,
10332 nested_installer_type: None,
10333 nested_installer_files: Vec::new(),
10334 },
10335 Installer {
10336 architecture: Some("x64".to_owned()),
10337 installer_type: Some("exe".to_owned()),
10338 url: None,
10339 sha256: None,
10340 product_code: None,
10341 locale: Some("en-US".to_owned()),
10342 scope: Some("user".to_owned()),
10343 release_date: None,
10344 package_family_name: None,
10345 upgrade_code: None,
10346 platforms: Vec::new(),
10347 minimum_os_version: Some("10.0.19041.0".to_owned()),
10348 switches: InstallerSwitches::default(),
10349 commands: Vec::new(),
10350 package_dependencies: Vec::new(),
10351 require_explicit_upgrade: false,
10352 nested_installer_type: None,
10353 nested_installer_files: Vec::new(),
10354 },
10355 ];
10356 let query = PackageQuery {
10357 installer_type: Some("exe".to_owned()),
10358 os_version: Some("10.0.19045.0".to_owned()),
10359 ..PackageQuery::default()
10360 };
10361
10362 let selected = select_installer(&installers, &query).expect("selected installer");
10363 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
10364 }
10365
10366 #[test]
10367 fn derives_correlation_name_candidates() {
10368 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
10369 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
10370 assert!(candidates.contains(&"PowerToys".to_owned()));
10371 }
10372
10373 #[test]
10374 fn correlates_installed_package_to_available_match() {
10375 let installed = InstalledPackage {
10376 name: "PowerToys (Preview) x64".to_owned(),
10377 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
10378 installed_version: "0.98.1".to_owned(),
10379 publisher: None,
10380 scope: Some("Machine".to_owned()),
10381 installer_category: Some("exe".to_owned()),
10382 install_location: None,
10383 package_family_names: Vec::new(),
10384 product_codes: Vec::new(),
10385 upgrade_codes: Vec::new(),
10386 correlated: None,
10387 installed_version_canonical: false,
10388 correlated_requires_explicit_upgrade: false,
10389 correlated_lacks_compatible_installer: false,
10390 };
10391 let candidates = vec![SearchMatch {
10392 source_name: "winget".to_owned(),
10393 source_kind: SourceKind::PreIndexed,
10394 id: "Microsoft.PowerToys".to_owned(),
10395 name: "PowerToys".to_owned(),
10396 moniker: None,
10397 version: Some("0.98.1".to_owned()),
10398 channel: None,
10399 match_criteria: None,
10400 }];
10401
10402 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
10403 assert_eq!(correlated.id, "Microsoft.PowerToys");
10404 }
10405
10406 #[test]
10407 fn list_query_uses_available_lookup_for_tag_filters() {
10408 let query = ListQuery {
10409 tag: Some("terminal".to_owned()),
10410 ..ListQuery::default()
10411 };
10412
10413 assert!(list_query_needs_available_lookup(&query));
10414 }
10415
10416 #[test]
10417 fn list_tag_filter_requires_correlated_match() {
10418 let package = InstalledPackage {
10419 name: "PowerToys (Preview) x64".to_owned(),
10420 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
10421 installed_version: "0.98.1".to_owned(),
10422 publisher: None,
10423 scope: Some("Machine".to_owned()),
10424 installer_category: Some("exe".to_owned()),
10425 install_location: None,
10426 package_family_names: Vec::new(),
10427 product_codes: Vec::new(),
10428 upgrade_codes: Vec::new(),
10429 correlated: None,
10430 installed_version_canonical: false,
10431 correlated_requires_explicit_upgrade: false,
10432 correlated_lacks_compatible_installer: false,
10433 };
10434 let query = ListQuery {
10435 tag: Some("powertoys".to_owned()),
10436 ..ListQuery::default()
10437 };
10438
10439 assert!(!list_package_matches(&package, &query));
10440 }
10441
10442 #[test]
10443 fn list_lookup_ignores_display_count() {
10444 let query = ListQuery {
10445 query: Some("git".to_owned()),
10446 count: Some(5),
10447 ..ListQuery::default()
10448 };
10449
10450 let package_query = package_query_from_list_query(&query);
10451 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
10452 }
10453
10454 #[test]
10455 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
10456 let installed = InstalledPackage {
10461 name: "Notepad++ (ARM 64-bit)".to_owned(),
10462 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
10463 installed_version: "8.8.9".to_owned(),
10464 publisher: None,
10465 scope: Some("Machine".to_owned()),
10466 installer_category: Some("exe".to_owned()),
10467 install_location: None,
10468 package_family_names: Vec::new(),
10469 product_codes: Vec::new(),
10470 upgrade_codes: Vec::new(),
10471 correlated: None,
10472 installed_version_canonical: false,
10473 correlated_requires_explicit_upgrade: false,
10474 correlated_lacks_compatible_installer: false,
10475 };
10476 let candidates = vec![
10477 SearchMatch {
10478 source_name: "winget".to_owned(),
10479 source_kind: SourceKind::PreIndexed,
10480 id: "ndd.Notepad--".to_owned(),
10481 name: "Notepad--".to_owned(),
10482 moniker: None,
10483 version: Some("1.0.0".to_owned()),
10484 channel: None,
10485 match_criteria: None,
10486 },
10487 SearchMatch {
10488 source_name: "winget".to_owned(),
10489 source_kind: SourceKind::PreIndexed,
10490 id: "Notepad++.Notepad++".to_owned(),
10491 name: "Notepad++".to_owned(),
10492 moniker: None,
10493 version: Some("8.9.5".to_owned()),
10494 channel: None,
10495 match_criteria: None,
10496 },
10497 ];
10498
10499 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
10500 assert_eq!(correlated.id, "Notepad++.Notepad++");
10501 }
10502
10503 #[test]
10504 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
10505 let installed = InstalledPackage {
10510 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
10511 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
10512 installed_version: "40.10.18029".to_owned(),
10513 publisher: None,
10514 scope: Some("Machine".to_owned()),
10515 installer_category: Some("msi".to_owned()),
10516 install_location: None,
10517 package_family_names: Vec::new(),
10518 product_codes: Vec::new(),
10519 upgrade_codes: Vec::new(),
10520 correlated: None,
10521 installed_version_canonical: false,
10522 correlated_requires_explicit_upgrade: false,
10523 correlated_lacks_compatible_installer: false,
10524 };
10525 let candidates = vec![
10526 SearchMatch {
10527 source_name: "winget".to_owned(),
10528 source_kind: SourceKind::PreIndexed,
10529 id: "ZeroBrane.Studio".to_owned(),
10530 name: "Studio".to_owned(),
10531 moniker: None,
10532 version: Some("1.0".to_owned()),
10533 channel: None,
10534 match_criteria: None,
10535 },
10536 SearchMatch {
10537 source_name: "winget".to_owned(),
10538 source_kind: SourceKind::PreIndexed,
10539 id: "BrickLink.Studio".to_owned(),
10540 name: "Studio".to_owned(),
10541 moniker: None,
10542 version: Some("1.0".to_owned()),
10543 channel: None,
10544 match_criteria: None,
10545 },
10546 SearchMatch {
10547 source_name: "winget".to_owned(),
10548 source_kind: SourceKind::PreIndexed,
10549 id: "Microsoft.DotNet.SDK.10".to_owned(),
10550 name: "Microsoft .NET SDK 10.0".to_owned(),
10551 moniker: None,
10552 version: Some("10.0.204".to_owned()),
10553 channel: None,
10554 match_criteria: None,
10555 },
10556 ];
10557
10558 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
10559 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
10560 }
10561
10562 #[test]
10563 fn msix_packages_do_not_correlate_via_name() {
10564 let installed = InstalledPackage {
10572 name: "Microsoft Teams".to_owned(),
10573 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
10574 installed_version: "25290.205.4069.4894".to_owned(),
10575 publisher: None,
10576 scope: Some("User".to_owned()),
10577 installer_category: Some("msix".to_owned()),
10578 install_location: None,
10579 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
10580 product_codes: Vec::new(),
10581 upgrade_codes: Vec::new(),
10582 correlated: None,
10583 installed_version_canonical: false,
10584 correlated_requires_explicit_upgrade: false,
10585 correlated_lacks_compatible_installer: false,
10586 };
10587 let candidates = vec![SearchMatch {
10588 source_name: "winget".to_owned(),
10589 source_kind: SourceKind::PreIndexed,
10590 id: "Microsoft.Teams".to_owned(),
10591 name: "Microsoft Teams".to_owned(),
10592 moniker: None,
10593 version: Some("26106.1906.4665.7308".to_owned()),
10594 channel: None,
10595 match_criteria: None,
10596 }];
10597
10598 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
10599 }
10600
10601 #[test]
10602 fn name_fallback_refuses_ambiguous_winners() {
10603 let installed = InstalledPackage {
10611 name: "Git".to_owned(),
10612 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
10613 installed_version: "2.53.0".to_owned(),
10614 publisher: Some("The Git Development Community".to_owned()),
10615 scope: Some("Machine".to_owned()),
10616 installer_category: Some("exe".to_owned()),
10617 install_location: None,
10618 package_family_names: Vec::new(),
10619 product_codes: Vec::new(),
10620 upgrade_codes: Vec::new(),
10621 correlated: None,
10622 installed_version_canonical: false,
10623 correlated_requires_explicit_upgrade: false,
10624 correlated_lacks_compatible_installer: false,
10625 };
10626 let candidates = vec![
10627 SearchMatch {
10628 source_name: "winget".to_owned(),
10629 source_kind: SourceKind::PreIndexed,
10630 id: "Git.Git".to_owned(),
10631 name: "Git".to_owned(),
10632 moniker: None,
10633 version: Some("2.54.0".to_owned()),
10634 channel: None,
10635 match_criteria: None,
10636 },
10637 SearchMatch {
10638 source_name: "winget".to_owned(),
10639 source_kind: SourceKind::PreIndexed,
10640 id: "Microsoft.Git".to_owned(),
10641 name: "Git".to_owned(),
10642 moniker: None,
10643 version: Some("2.53.0.0.7".to_owned()),
10644 channel: None,
10645 match_criteria: None,
10646 },
10647 ];
10648
10649 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
10650 }
10651
10652 #[test]
10653 fn msix_with_resource_string_name_does_not_correlate() {
10654 let installed = InstalledPackage {
10658 name: "ms-resource:appDisplayName".to_owned(),
10659 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
10660 installed_version: "1.28.239.0".to_owned(),
10661 publisher: None,
10662 scope: Some("User".to_owned()),
10663 installer_category: Some("msix".to_owned()),
10664 install_location: None,
10665 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
10666 product_codes: Vec::new(),
10667 upgrade_codes: Vec::new(),
10668 correlated: None,
10669 installed_version_canonical: false,
10670 correlated_requires_explicit_upgrade: false,
10671 correlated_lacks_compatible_installer: false,
10672 };
10673 let candidates = vec![SearchMatch {
10674 source_name: "winget".to_owned(),
10675 source_kind: SourceKind::PreIndexed,
10676 id: "Microsoft.AppInstaller".to_owned(),
10677 name: "App Installer".to_owned(),
10678 moniker: None,
10679 version: Some("1.28.240.0".to_owned()),
10680 channel: None,
10681 match_criteria: None,
10682 }];
10683
10684 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
10685 }
10686
10687 #[test]
10688 fn strict_list_correlation_avoids_short_substring_matches() {
10689 let installed = InstalledPackage {
10690 name: "GitHub".to_owned(),
10691 local_id: r"ARP\User\X64\GitHub".to_owned(),
10692 installed_version: "1.0.0".to_owned(),
10693 publisher: None,
10694 scope: Some("User".to_owned()),
10695 installer_category: Some("exe".to_owned()),
10696 install_location: None,
10697 package_family_names: Vec::new(),
10698 product_codes: Vec::new(),
10699 upgrade_codes: Vec::new(),
10700 correlated: None,
10701 installed_version_canonical: false,
10702 correlated_requires_explicit_upgrade: false,
10703 correlated_lacks_compatible_installer: false,
10704 };
10705 let candidates = vec![SearchMatch {
10706 source_name: "winget".to_owned(),
10707 source_kind: SourceKind::PreIndexed,
10708 id: "Git.Git.PreRelease".to_owned(),
10709 name: "Git".to_owned(),
10710 moniker: None,
10711 version: Some("2.54.0".to_owned()),
10712 channel: None,
10713 match_criteria: None,
10714 }];
10715
10716 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
10717 }
10718
10719 #[test]
10720 fn detects_available_upgrade_from_correlated_package() {
10721 let package = InstalledPackage {
10722 name: "AzCopy v10".to_owned(),
10723 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
10724 installed_version: "10.32.2".to_owned(),
10725 publisher: None,
10726 scope: Some("Machine".to_owned()),
10727 installer_category: Some("exe".to_owned()),
10728 install_location: None,
10729 package_family_names: Vec::new(),
10730 product_codes: Vec::new(),
10731 upgrade_codes: Vec::new(),
10732 correlated: Some(SearchMatch {
10733 source_name: "winget".to_owned(),
10734 source_kind: SourceKind::PreIndexed,
10735 id: "Microsoft.Azure.AZCopy.10".to_owned(),
10736 name: "AzCopy v10".to_owned(),
10737 moniker: None,
10738 version: Some("10.32.3".to_owned()),
10739 channel: None,
10740 match_criteria: None,
10741 }),
10742 installed_version_canonical: false,
10743 correlated_requires_explicit_upgrade: false,
10744 correlated_lacks_compatible_installer: false,
10745 };
10746
10747 assert!(installed_package_has_upgrade(&package));
10748 }
10749
10750 #[test]
10751 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
10752 let package = InstalledPackage {
10753 name: "Example Tool".to_owned(),
10754 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
10755 installed_version: "Unknown".to_owned(),
10756 publisher: None,
10757 scope: Some("User".to_owned()),
10758 installer_category: Some("exe".to_owned()),
10759 install_location: None,
10760 package_family_names: Vec::new(),
10761 product_codes: Vec::new(),
10762 upgrade_codes: Vec::new(),
10763 correlated: Some(SearchMatch {
10764 source_name: "winget".to_owned(),
10765 source_kind: SourceKind::PreIndexed,
10766 id: "Contoso.ExampleTool".to_owned(),
10767 name: "Example Tool".to_owned(),
10768 moniker: None,
10769 version: Some("2.0.0".to_owned()),
10770 channel: None,
10771 match_criteria: None,
10772 }),
10773 installed_version_canonical: false,
10774 correlated_requires_explicit_upgrade: false,
10775 correlated_lacks_compatible_installer: false,
10776 };
10777 let query = ListQuery {
10778 upgrade_only: true,
10779 include_unknown: true,
10780 ..Default::default()
10781 };
10782
10783 assert!(installed_package_matches_upgrade_filter(&package, &query));
10784 assert_eq!(
10785 list_match_from_installed(package).available_version.as_deref(),
10786 Some("2.0.0")
10787 );
10788 }
10789
10790 #[test]
10791 fn create_repair_list_query_includes_installed_selectors() {
10792 let request = RepairRequest {
10793 query: PackageQuery {
10794 query: Some("powertoys".to_owned()),
10795 id: Some("Microsoft.PowerToys".to_owned()),
10796 name: Some("PowerToys".to_owned()),
10797 moniker: Some("powertoys".to_owned()),
10798 source: Some("winget".to_owned()),
10799 exact: true,
10800 version: Some("0.98.1".to_owned()),
10801 install_scope: Some("Machine".to_owned()),
10802 ..PackageQuery::default()
10803 },
10804 manifest_path: None,
10805 product_code: Some("{GUID}".to_owned()),
10806 mode: InstallerMode::Silent,
10807 log_path: Some(PathBuf::from("repair.log")),
10808 accept_package_agreements: true,
10809 force: true,
10810 ignore_security_hash: true,
10811 };
10812
10813 let query = Repository::create_repair_list_query(&request);
10814 assert_eq!(query.query.as_deref(), Some("powertoys"));
10815 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
10816 assert_eq!(query.name.as_deref(), Some("PowerToys"));
10817 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
10818 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
10819 assert_eq!(query.version.as_deref(), Some("0.98.1"));
10820 assert_eq!(query.source.as_deref(), Some("winget"));
10821 assert!(query.exact);
10822 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
10823 assert_eq!(query.count, Some(100));
10824 }
10825
10826 #[test]
10827 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
10828 let request = RepairRequest {
10829 query: PackageQuery {
10830 name: Some("PowerToys".to_owned()),
10831 source: Some("winget".to_owned()),
10832 locale: Some("en-US".to_owned()),
10833 installer_architecture: Some("x64".to_owned()),
10834 install_scope: Some("Machine".to_owned()),
10835 ..PackageQuery::default()
10836 },
10837 manifest_path: None,
10838 product_code: None,
10839 mode: InstallerMode::SilentWithProgress,
10840 log_path: Some(PathBuf::from("repair.log")),
10841 accept_package_agreements: true,
10842 force: false,
10843 ignore_security_hash: true,
10844 };
10845 let installed = ListMatch {
10846 name: "PowerToys".to_owned(),
10847 id: "Microsoft.PowerToys".to_owned(),
10848 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
10849 installed_version: "0.98.1".to_owned(),
10850 available_version: Some("0.98.2".to_owned()),
10851 source_name: Some("winget".to_owned()),
10852 publisher: None,
10853 scope: Some("Machine".to_owned()),
10854 installer_category: Some("exe".to_owned()),
10855 install_location: None,
10856 package_family_names: Vec::new(),
10857 product_codes: Vec::new(),
10858 upgrade_codes: Vec::new(),
10859 };
10860
10861 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
10862 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
10863 assert!(install_request.query.query.is_none());
10864 assert!(install_request.query.name.is_none());
10865 assert!(install_request.query.moniker.is_none());
10866 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
10867 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
10868 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
10869 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
10870 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
10871 assert!(install_request.query.exact);
10872 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
10873 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
10874 assert!(install_request.accept_package_agreements);
10875 assert!(install_request.force);
10876 assert!(install_request.ignore_security_hash);
10877 }
10878
10879 #[test]
10880 fn source_scoped_pin_operations_round_trip() {
10881 let app_root = temp_app_root("pins");
10882 let result = (|| -> Result<()> {
10883 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
10884 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
10885 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
10886 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
10887
10888 assert_eq!(repository.list_pins(None)?.len(), 3);
10889 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
10890 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
10891
10892 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
10893 assert_eq!(repository.list_pins(None)?.len(), 2);
10894 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
10895
10896 repository.reset_pins(Some("test"))?;
10897 let remaining = repository.list_pins(None)?;
10898 assert_eq!(remaining.len(), 1);
10899 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
10900
10901 repository.reset_pins(None)?;
10902 assert!(repository.list_pins(None)?.is_empty());
10903 Ok(())
10904 })();
10905
10906 let _ = fs::remove_dir_all(&app_root);
10907 result.expect("pin round trip");
10908 }
10909
10910 #[test]
10911 fn pin_operations_work_with_packaged_pin_schema() {
10912 let app_root = temp_app_root("pins_packaged_schema");
10913 let result = (|| -> Result<()> {
10914 let db_path = pins_db_path(&app_root);
10915 if let Some(parent) = db_path.parent() {
10916 fs::create_dir_all(parent)?;
10917 }
10918
10919 let conn = Connection::open(&db_path)?;
10920 conn.execute_batch(
10921 "CREATE TABLE pin (
10922 package_id TEXT NOT NULL,
10923 source_id TEXT NOT NULL,
10924 type INTEGER NOT NULL,
10925 version TEXT NOT NULL,
10926 PRIMARY KEY (package_id, source_id)
10927 )",
10928 )?;
10929 drop(conn);
10930
10931 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
10932 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
10933
10934 let pins = repository.list_pins(None)?;
10935 assert_eq!(pins.len(), 1);
10936 assert_eq!(pins[0].package_id, "Contoso.Tool");
10937 assert_eq!(pins[0].version, "1.2.*");
10938 assert_eq!(pins[0].source_id, "winget");
10939 assert_eq!(pins[0].pin_type, PinType::Gating);
10940
10941 let conn = Connection::open(&db_path)?;
10942 let stored_pin = conn.query_row(
10943 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
10944 rusqlite::params!["Contoso.Tool", "winget"],
10945 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
10946 )?;
10947 assert_eq!(stored_pin.0, 3);
10948 assert_eq!(stored_pin.1, "1.2.*");
10949
10950 Ok(())
10951 })();
10952
10953 let _ = fs::remove_dir_all(&app_root);
10954 result.expect("packaged pin schema round trip");
10955 }
10956
10957 #[test]
10958 fn version_pin_patterns_match_exact_and_prefix_values() {
10959 assert!(version_matches_pin_pattern("1.2.3", "*"));
10960 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
10961 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
10962 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
10963 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
10964 }
10965
10966 #[test]
10967 fn source_specific_pins_override_source_agnostic_pins() {
10968 let item = ListMatch {
10969 name: "Contoso Tool".to_owned(),
10970 id: "Contoso.Tool".to_owned(),
10971 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
10972 installed_version: "1.0.0".to_owned(),
10973 available_version: Some("2.0.0".to_owned()),
10974 source_name: Some("winget".to_owned()),
10975 publisher: None,
10976 scope: None,
10977 installer_category: None,
10978 install_location: None,
10979 package_family_names: Vec::new(),
10980 product_codes: Vec::new(),
10981 upgrade_codes: Vec::new(),
10982 };
10983 let pins = vec![
10984 PinRecord {
10985 package_id: "Contoso.Tool".to_owned(),
10986 version: "*".to_owned(),
10987 source_id: String::new(),
10988 pin_type: PinType::Blocking,
10989 },
10990 PinRecord {
10991 package_id: "Contoso.Tool".to_owned(),
10992 version: "2.0.*".to_owned(),
10993 source_id: "winget".to_owned(),
10994 pin_type: PinType::Pinning,
10995 },
10996 ];
10997
10998 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
10999 }
11000
11001 #[test]
11002 fn blocking_pin_blocks_upgrade() {
11003 let item = ListMatch {
11004 name: "Contoso Tool".to_owned(),
11005 id: "Contoso.Tool".to_owned(),
11006 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11007 installed_version: "1.0.0".to_owned(),
11008 available_version: Some("2.0.0".to_owned()),
11009 source_name: Some("winget".to_owned()),
11010 publisher: None,
11011 scope: None,
11012 installer_category: None,
11013 install_location: None,
11014 package_family_names: Vec::new(),
11015 product_codes: Vec::new(),
11016 upgrade_codes: Vec::new(),
11017 };
11018 let pins = vec![PinRecord {
11019 package_id: "Contoso.Tool".to_owned(),
11020 version: "*".to_owned(),
11021 source_id: "winget".to_owned(),
11022 pin_type: PinType::Blocking,
11023 }];
11024
11025 assert!(is_upgrade_blocked_by_pin(&item, &pins));
11026 }
11027
11028 #[cfg(windows)]
11029 #[test]
11030 fn parses_msix_package_full_name_into_version_and_family() {
11031 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
11032 .expect("package metadata");
11033
11034 assert_eq!(parsed.version, "0.98.1.0");
11035 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
11036 }
11037
11038 #[cfg(windows)]
11039 #[test]
11040 fn recognizes_windows_system_paths() {
11041 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
11042 assert!(!is_windows_system_path(
11043 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
11044 ));
11045 }
11046
11047 #[test]
11048 fn search_ranking_prefers_exact_name_over_tag_match() {
11049 let query = PackageQuery {
11050 query: Some("PowerToys".to_owned()),
11051 ..Default::default()
11052 };
11053 let exact_name = SearchMatch {
11054 source_name: "winget".to_owned(),
11055 source_kind: SourceKind::PreIndexed,
11056 id: "Microsoft.PowerToys".to_owned(),
11057 name: "PowerToys".to_owned(),
11058 moniker: Some("powertoys".to_owned()),
11059 version: Some("0.98.1".to_owned()),
11060 channel: None,
11061 match_criteria: Some("Moniker: powertoys".to_owned()),
11062 };
11063 let tag_match = SearchMatch {
11064 source_name: "winget".to_owned(),
11065 source_kind: SourceKind::PreIndexed,
11066 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
11067 name: "QR Codes for Command Palette".to_owned(),
11068 moniker: None,
11069 version: Some("0.4.0.0".to_owned()),
11070 channel: None,
11071 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
11072 };
11073
11074 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
11075 }
11076
11077 #[test]
11078 fn select_best_text_match_prefers_exact_tag_value() {
11079 let values = vec![
11080 "microsoft-powertoys".to_owned(),
11081 "powertoys-run".to_owned(),
11082 "powertoys".to_owned(),
11083 ];
11084
11085 assert_eq!(
11086 select_best_text_match(values, "PowerToys", false).as_deref(),
11087 Some("powertoys")
11088 );
11089 }
11090
11091 #[test]
11092 fn search_match_criteria_is_blank_for_name_match() {
11093 let query = PackageQuery {
11094 query: Some("PowerToys".to_owned()),
11095 ..Default::default()
11096 };
11097
11098 let result = infer_match_criteria(
11099 "Microsoft.PowerToys",
11100 "PowerToys",
11101 Some("powertoys"),
11102 &query,
11103 SearchSemantics::Many,
11104 |_| Ok(Some("powertoys".to_owned())),
11105 |_| Ok(None),
11106 )
11107 .expect("match criteria");
11108
11109 assert_eq!(result, None);
11110 }
11111
11112 #[test]
11113 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
11114 let query = PackageQuery {
11115 query: Some("PowerToys".to_owned()),
11116 ..Default::default()
11117 };
11118 let unknown_version = SearchMatch {
11119 source_name: "msstore".to_owned(),
11120 source_kind: SourceKind::Rest,
11121 id: "XP89DCGQ3K6VLD".to_owned(),
11122 name: "Microsoft PowerToys".to_owned(),
11123 moniker: None,
11124 version: Some("Unknown".to_owned()),
11125 channel: None,
11126 match_criteria: None,
11127 };
11128 let tag_match = SearchMatch {
11129 source_name: "winget".to_owned(),
11130 source_kind: SourceKind::PreIndexed,
11131 id: "Riri.QRCodeforCmdPal".to_owned(),
11132 name: "QR Code for CmdPal".to_owned(),
11133 moniker: None,
11134 version: Some("0.0.1.0".to_owned()),
11135 channel: None,
11136 match_criteria: Some("Tag: powertoys".to_owned()),
11137 };
11138
11139 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
11140 }
11141
11142 #[test]
11143 fn search_source_fetch_results_ignore_small_display_count() {
11144 let query = PackageQuery {
11145 count: Some(5),
11146 ..Default::default()
11147 };
11148
11149 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
11150 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
11151 }
11152
11153 #[test]
11154 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
11155 let query = PackageQuery {
11156 query: Some("PowerToys".to_owned()),
11157 ..Default::default()
11158 };
11159 let prefix_name = SearchMatch {
11160 source_name: "winget".to_owned(),
11161 source_kind: SourceKind::PreIndexed,
11162 id: "advaith.CurrencyConverterPowerToys".to_owned(),
11163 name: "PowerToys-Run-Currency-Converter".to_owned(),
11164 moniker: None,
11165 version: Some("1.5.4".to_owned()),
11166 channel: None,
11167 match_criteria: None,
11168 };
11169 let exact_tag = SearchMatch {
11170 source_name: "winget".to_owned(),
11171 source_kind: SourceKind::PreIndexed,
11172 id: "Riri.QRCodeforCmdPal".to_owned(),
11173 name: "QR Code for CmdPal".to_owned(),
11174 moniker: None,
11175 version: Some("0.0.1.0".to_owned()),
11176 channel: None,
11177 match_criteria: Some("Tag: powertoys".to_owned()),
11178 };
11179
11180 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
11181 }
11182
11183 #[test]
11184 fn list_sort_prefers_main_package_then_sparse_app() {
11185 let main = InstalledPackage {
11186 name: "PowerToys (Preview) x64".to_owned(),
11187 local_id: r"ARP\User\X64\PowerToys".to_owned(),
11188 installed_version: "0.98.1".to_owned(),
11189 publisher: None,
11190 scope: Some("User".to_owned()),
11191 installer_category: Some("exe".to_owned()),
11192 install_location: None,
11193 package_family_names: Vec::new(),
11194 product_codes: Vec::new(),
11195 upgrade_codes: Vec::new(),
11196 correlated: None,
11197 installed_version_canonical: false,
11198 correlated_requires_explicit_upgrade: false,
11199 correlated_lacks_compatible_installer: false,
11200 };
11201 let sparse = InstalledPackage {
11202 name: "PowerToys.SparseApp".to_owned(),
11203 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11204 installed_version: "0.98.1.0".to_owned(),
11205 publisher: None,
11206 scope: Some("User".to_owned()),
11207 installer_category: Some("msix".to_owned()),
11208 install_location: None,
11209 package_family_names: Vec::new(),
11210 product_codes: Vec::new(),
11211 upgrade_codes: Vec::new(),
11212 correlated: None,
11213 installed_version_canonical: false,
11214 correlated_requires_explicit_upgrade: false,
11215 correlated_lacks_compatible_installer: false,
11216 };
11217 let extension = InstalledPackage {
11218 name: "PowerToys FileLocksmith Context Menu".to_owned(),
11219 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11220 installed_version: "0.98.1.0".to_owned(),
11221 publisher: None,
11222 scope: Some("User".to_owned()),
11223 installer_category: Some("msix".to_owned()),
11224 install_location: None,
11225 package_family_names: Vec::new(),
11226 product_codes: Vec::new(),
11227 upgrade_codes: Vec::new(),
11228 correlated: None,
11229 installed_version_canonical: false,
11230 correlated_requires_explicit_upgrade: false,
11231 correlated_lacks_compatible_installer: false,
11232 };
11233
11234 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
11235 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
11236 }
11237
11238 #[test]
11239 #[cfg(windows)]
11240 fn arp_subkey_name_extracts_registry_subkey() {
11241 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
11242 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
11243 }
11244
11245 #[test]
11246 #[cfg(windows)]
11247 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
11248 let installed = ListMatch {
11249 name: "ShareX".to_owned(),
11250 id: "ShareX.ShareX".to_owned(),
11251 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
11252 installed_version: "19.0.2".to_owned(),
11253 available_version: None,
11254 source_name: Some("winget".to_owned()),
11255 publisher: Some("ShareX Team".to_owned()),
11256 scope: Some("Machine".to_owned()),
11257 installer_category: Some("exe".to_owned()),
11258 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
11259 package_family_names: Vec::new(),
11260 product_codes: Vec::new(),
11261 upgrade_codes: Vec::new(),
11262 };
11263
11264 assert!(registry_entry_matches_installed_package(
11265 "ShareX", "ShareX", None, &installed
11266 ));
11267 assert!(!registry_entry_matches_installed_package(
11268 "ShareX.ShareX",
11269 "ShareX.ShareX",
11270 None,
11271 &installed
11272 ));
11273 }
11274
11275 #[test]
11276 #[cfg(windows)]
11277 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
11278 assert_eq!(
11279 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
11280 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
11281 );
11282 assert_eq!(
11283 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
11284 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
11285 );
11286 assert_eq!(
11287 r#""C:\Program Files\ShareX\unins000.exe""#,
11288 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
11289 );
11290 assert_eq!(
11291 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
11292 build_uninstall_command(
11293 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
11294 true,
11295 false,
11296 )
11297 );
11298 }
11299
11300 #[test]
11301 #[cfg(windows)]
11302 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
11303 let installed = ListMatch {
11304 name: "lazygit".to_owned(),
11305 id: "JesseDuffield.lazygit".to_owned(),
11306 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
11307 installed_version: "0.61.1".to_owned(),
11308 available_version: None,
11309 source_name: Some("winget".to_owned()),
11310 publisher: Some("Jesse Duffield".to_owned()),
11311 scope: Some("User".to_owned()),
11312 installer_category: Some("exe".to_owned()),
11313 install_location: Some(
11314 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
11315 ),
11316 package_family_names: Vec::new(),
11317 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
11318 upgrade_codes: Vec::new(),
11319 };
11320 let mut request = UninstallRequest::new(PackageQuery {
11321 id: Some("JesseDuffield.lazygit".to_owned()),
11322 install_scope: Some("user".to_owned()),
11323 ..PackageQuery::default()
11324 });
11325 request.mode = InstallerMode::Silent;
11326
11327 assert_eq!(
11328 Some(vec![
11329 "uninstall".to_owned(),
11330 "--id".to_owned(),
11331 "JesseDuffield.lazygit".to_owned(),
11332 "--exact".to_owned(),
11333 "--disable-interactivity".to_owned(),
11334 "--source".to_owned(),
11335 "winget".to_owned(),
11336 "--scope".to_owned(),
11337 "user".to_owned(),
11338 "--silent".to_owned(),
11339 ]),
11340 build_winget_uninstall_arguments(&installed, &request)
11341 );
11342
11343 assert_eq!(
11344 Some(vec![
11345 "uninstall".to_owned(),
11346 "--id".to_owned(),
11347 "JesseDuffield.lazygit".to_owned(),
11348 "--exact".to_owned(),
11349 "--disable-interactivity".to_owned(),
11350 "--source".to_owned(),
11351 "winget".to_owned(),
11352 "--silent".to_owned(),
11353 ]),
11354 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
11355 );
11356 }
11357
11358 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
11359 PackageVersionDataEntry {
11360 version: version.to_owned(),
11361 manifest_relative_path: format!("manifests/x/{version}"),
11362 manifest_hash: "00".repeat(32),
11363 arp_min_version: arp_min.map(str::to_owned),
11364 arp_max_version: arp_max.map(str::to_owned),
11365 }
11366 }
11367
11368 #[test]
11369 fn map_arp_version_returns_catalog_version_inside_range() {
11370 let entries = vec![
11375 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
11376 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
11377 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
11378 ];
11379
11380 assert_eq!(
11381 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
11382 Some("10.0.108")
11383 );
11384 }
11385
11386 #[test]
11387 fn map_arp_version_returns_none_when_no_range_matches() {
11388 let entries = vec![
11393 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
11394 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
11395 ];
11396
11397 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
11398 }
11399
11400 #[test]
11401 fn map_arp_version_skips_entries_missing_arp_bounds() {
11402 let entries = vec![
11406 version_entry("9.0.300", None, None),
11407 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
11408 ];
11409
11410 assert_eq!(
11411 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
11412 Some("9.0.117")
11413 );
11414 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
11415 }
11416
11417 #[test]
11418 fn map_arp_version_handles_inclusive_range_endpoints() {
11419 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
11422
11423 assert_eq!(
11424 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
11425 Some("3.14.5")
11426 );
11427 assert_eq!(
11428 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
11429 Some("3.14.5")
11430 );
11431 assert_eq!(
11432 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
11433 Some("3.14.5")
11434 );
11435 }
11436
11437 #[test]
11438 fn map_arp_version_ignores_unknown_marker() {
11439 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
11443
11444 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
11445 assert!(map_arp_version_to_catalog(&entries, "").is_none());
11446 }
11447
11448 #[test]
11449 fn latest_arp_anchored_skips_internal_versions() {
11450 let entries = vec![
11456 version_entry("8000.836.2153.0", None, None),
11457 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
11458 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
11459 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
11460 ];
11461
11462 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
11463 }
11464
11465 #[test]
11466 fn latest_arp_anchored_returns_none_without_bounds() {
11467 let entries = vec![
11471 version_entry("1.28.240.0", None, None),
11472 version_entry("1.27.470.0", None, None),
11473 ];
11474
11475 assert!(latest_arp_anchored_version(&entries).is_none());
11476 }
11477
11478 #[test]
11479 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
11480 let package = InstalledPackage {
11481 name: "AzCopy v10".to_owned(),
11482 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11483 installed_version: "10.32.2".to_owned(),
11484 publisher: Some("Microsoft".to_owned()),
11485 scope: Some("Machine".to_owned()),
11486 installer_category: Some("exe".to_owned()),
11487 install_location: None,
11488 package_family_names: Vec::new(),
11489 product_codes: Vec::new(),
11490 upgrade_codes: Vec::new(),
11491 correlated: Some(SearchMatch {
11492 source_name: "winget".to_owned(),
11493 source_kind: SourceKind::PreIndexed,
11494 id: "Microsoft.Azure.AZCopy.10".to_owned(),
11495 name: "AzCopy v10".to_owned(),
11496 moniker: None,
11497 version: Some("10.32.3".to_owned()),
11498 channel: None,
11499 match_criteria: None,
11500 }),
11501 installed_version_canonical: false,
11502 correlated_requires_explicit_upgrade: false,
11503 correlated_lacks_compatible_installer: false,
11504 };
11505
11506 let item = list_match_from_installed(package);
11507
11508 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
11509 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
11510 assert_eq!(item.source_name.as_deref(), Some("winget"));
11511 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
11512 }
11513
11514 #[test]
11515 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
11516 let package = InstalledPackage {
11517 name: "Atlassian CLI".to_owned(),
11518 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
11519 installed_version: "1.3.18-stable".to_owned(),
11520 publisher: Some("Atlassian".to_owned()),
11521 scope: Some("User".to_owned()),
11522 installer_category: Some("exe".to_owned()),
11523 install_location: None,
11524 package_family_names: Vec::new(),
11525 product_codes: Vec::new(),
11526 upgrade_codes: Vec::new(),
11527 correlated: Some(SearchMatch {
11528 source_name: "winget".to_owned(),
11529 source_kind: SourceKind::PreIndexed,
11530 id: "Atlassian.AtlassianCLI".to_owned(),
11531 name: "Atlassian CLI".to_owned(),
11532 moniker: None,
11533 version: Some("1.3.18-stable".to_owned()),
11534 channel: None,
11535 match_criteria: Some("ProductCode".to_owned()),
11536 }),
11537 installed_version_canonical: false,
11538 correlated_requires_explicit_upgrade: false,
11539 correlated_lacks_compatible_installer: false,
11540 };
11541
11542 let item = list_match_from_installed(package);
11543
11544 assert_eq!(item.id, "Atlassian.AtlassianCLI");
11545 assert_eq!(
11546 item.local_id,
11547 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
11548 );
11549 assert_ne!(item.id, item.local_id);
11550 assert_eq!(item.source_name.as_deref(), Some("winget"));
11551 }
11552
11553 #[test]
11554 fn list_response_serialization_keeps_pascal_case_fields() {
11555 let response = ListResponse {
11556 matches: vec![ListMatch {
11557 name: "PowerToys".to_owned(),
11558 id: "Microsoft.PowerToys".to_owned(),
11559 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11560 installed_version: "0.98.1".to_owned(),
11561 available_version: Some("0.99.0".to_owned()),
11562 source_name: Some("winget".to_owned()),
11563 publisher: Some("Microsoft".to_owned()),
11564 scope: Some("Machine".to_owned()),
11565 installer_category: Some("msi".to_owned()),
11566 install_location: None,
11567 package_family_names: Vec::new(),
11568 product_codes: Vec::new(),
11569 upgrade_codes: Vec::new(),
11570 }],
11571 warnings: Vec::new(),
11572 truncated: false,
11573 };
11574
11575 let value = serde_json::to_value(&response).expect("serialize list response");
11576 let match_value = &value["Matches"][0];
11577
11578 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
11579 assert_eq!(match_value["InstalledVersion"], "0.98.1");
11580 assert_eq!(match_value["AvailableVersion"], "0.99.0");
11581 assert_eq!(match_value["SourceName"], "winget");
11582 assert!(match_value.get("local_id").is_none());
11583 assert!(match_value.get("installed_version").is_none());
11584 assert!(match_value.get("available_version").is_none());
11585 assert!(match_value.get("source_name").is_none());
11586 }
11587
11588 #[test]
11589 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
11590 let response = SearchResponse {
11591 matches: vec![SearchMatch {
11592 source_name: "msstore".to_owned(),
11593 source_kind: SourceKind::Rest,
11594 id: "9WZDNCRFJBMP".to_owned(),
11595 name: "Microsoft To Do".to_owned(),
11596 moniker: None,
11597 version: Some("2.123.456.0".to_owned()),
11598 channel: None,
11599 match_criteria: None,
11600 }],
11601 warnings: Vec::new(),
11602 truncated: false,
11603 };
11604
11605 let value = serde_json::to_value(&response).expect("serialize search response");
11606 let match_value = &value["Matches"][0];
11607
11608 assert_eq!(match_value["Name"], "Microsoft To Do");
11609 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
11610 assert_eq!(match_value["Version"], "2.123.456.0");
11611 assert_eq!(match_value["SourceName"], "msstore");
11612 assert!(match_value.get("source_name").is_none());
11613 }
11614
11615 #[test]
11616 fn versions_result_serialization_keeps_pascal_case_fields() {
11617 let result = VersionsResult {
11618 package: SearchMatch {
11619 source_name: "winget".to_owned(),
11620 source_kind: SourceKind::PreIndexed,
11621 id: "Microsoft.PowerToys".to_owned(),
11622 name: "PowerToys".to_owned(),
11623 moniker: None,
11624 version: Some("0.99.0".to_owned()),
11625 channel: None,
11626 match_criteria: None,
11627 },
11628 versions: vec![
11629 VersionKey {
11630 version: "0.99.0".to_owned(),
11631 channel: "stable".to_owned(),
11632 },
11633 VersionKey {
11634 version: "0.100.0-preview".to_owned(),
11635 channel: "preview".to_owned(),
11636 },
11637 ],
11638 warnings: Vec::new(),
11639 };
11640
11641 let value = serde_json::to_value(&result).expect("serialize versions result");
11642
11643 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
11644 assert_eq!(value["Versions"][0]["Channel"], "stable");
11645 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
11646 assert_eq!(value["Versions"][1]["Channel"], "preview");
11647 assert!(value.get("versions").is_none());
11648 }
11649
11650 #[test]
11651 fn version_data_parses_arp_bounds_from_winget_payload() {
11652 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";
11656
11657 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
11658 assert_eq!(document.versions.len(), 2);
11659 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
11660 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
11661 assert!(document.versions[1].arp_min_version.is_none());
11662 assert!(document.versions[1].arp_max_version.is_none());
11663 }
11664
11665 #[test]
11666 fn msix_resource_string_name_resolves_to_catalog_name() {
11667 let mut package = InstalledPackage {
11672 name: "ms-resource:appDisplayName".to_owned(),
11673 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11674 installed_version: "1.28.239.0".to_owned(),
11675 publisher: None,
11676 scope: Some("User".to_owned()),
11677 installer_category: Some("msix".to_owned()),
11678 install_location: None,
11679 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11680 product_codes: Vec::new(),
11681 upgrade_codes: Vec::new(),
11682 correlated: Some(SearchMatch {
11683 source_name: "winget".to_owned(),
11684 source_kind: SourceKind::PreIndexed,
11685 id: "Microsoft.AppInstaller".to_owned(),
11686 name: "App Installer".to_owned(),
11687 moniker: None,
11688 version: Some("1.28.240.0".to_owned()),
11689 channel: None,
11690 match_criteria: Some("PackageFamilyName".to_owned()),
11691 }),
11692 installed_version_canonical: false,
11693 correlated_requires_explicit_upgrade: false,
11694 correlated_lacks_compatible_installer: false,
11695 };
11696 apply_msix_resource_string_name_fix(&mut package);
11697 assert_eq!(package.name, "App Installer");
11698 }
11699
11700 #[test]
11701 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
11702 let mut package = InstalledPackage {
11707 name: "ms-resource:appDisplayName".to_owned(),
11708 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
11709 installed_version: "1.0".to_owned(),
11710 publisher: None,
11711 scope: Some("Machine".to_owned()),
11712 installer_category: Some("msi".to_owned()),
11713 install_location: None,
11714 package_family_names: Vec::new(),
11715 product_codes: Vec::new(),
11716 upgrade_codes: Vec::new(),
11717 correlated: Some(SearchMatch {
11718 source_name: "winget".to_owned(),
11719 source_kind: SourceKind::PreIndexed,
11720 id: "Some.Package".to_owned(),
11721 name: "Should Not Apply".to_owned(),
11722 moniker: None,
11723 version: Some("1.0".to_owned()),
11724 channel: None,
11725 match_criteria: None,
11726 }),
11727 installed_version_canonical: false,
11728 correlated_requires_explicit_upgrade: false,
11729 correlated_lacks_compatible_installer: false,
11730 };
11731 apply_msix_resource_string_name_fix(&mut package);
11732 assert_eq!(package.name, "ms-resource:appDisplayName");
11733 }
11734
11735 #[test]
11736 fn msix_resource_string_fix_skips_resolved_names() {
11737 let mut package = InstalledPackage {
11742 name: "Microsoft Teams".to_owned(),
11743 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11744 installed_version: "25290.205.4069.4894".to_owned(),
11745 publisher: None,
11746 scope: Some("User".to_owned()),
11747 installer_category: Some("msix".to_owned()),
11748 install_location: None,
11749 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11750 product_codes: Vec::new(),
11751 upgrade_codes: Vec::new(),
11752 correlated: Some(SearchMatch {
11753 source_name: "winget".to_owned(),
11754 source_kind: SourceKind::PreIndexed,
11755 id: "Microsoft.Teams".to_owned(),
11756 name: "Microsoft Teams Catalog Name".to_owned(),
11757 moniker: None,
11758 version: Some("26106.1906.4665.7308".to_owned()),
11759 channel: None,
11760 match_criteria: Some("PackageFamilyName".to_owned()),
11761 }),
11762 installed_version_canonical: false,
11763 correlated_requires_explicit_upgrade: false,
11764 correlated_lacks_compatible_installer: false,
11765 };
11766 apply_msix_resource_string_name_fix(&mut package);
11767 assert_eq!(package.name, "Microsoft Teams");
11768 }
11769
11770 #[cfg(windows)]
11771 #[test]
11772 fn unflip_packed_guid_reverses_msi_installer_packing() {
11773 assert_eq!(
11780 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
11781 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
11782 );
11783 assert_eq!(
11786 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
11787 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
11788 );
11789 assert!(unflip_packed_guid("nothex").is_none());
11791 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
11793 }
11794
11795 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
11796 InstalledPackage {
11797 name: format!("{id} install"),
11798 local_id: format!(r"ARP\Machine\X64\{id}"),
11799 installed_version: installed_version.to_owned(),
11800 publisher: None,
11801 scope: Some("Machine".to_owned()),
11802 installer_category: Some("msi".to_owned()),
11803 install_location: None,
11804 package_family_names: Vec::new(),
11805 product_codes: Vec::new(),
11806 upgrade_codes: Vec::new(),
11807 correlated: Some(SearchMatch {
11808 source_name: "winget".to_owned(),
11809 source_kind: SourceKind::PreIndexed,
11810 id: id.to_owned(),
11811 name: id.to_owned(),
11812 moniker: None,
11813 version: Some("99.0.0".to_owned()),
11814 channel: None,
11815 match_criteria: None,
11816 }),
11817 installed_version_canonical: canonical,
11818 correlated_requires_explicit_upgrade: false,
11819 correlated_lacks_compatible_installer: false,
11820 }
11821 }
11822
11823 #[test]
11824 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
11825 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
11832 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
11833
11834 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
11835 assert_eq!(result.len(), 1);
11836 assert_eq!(result[0].installed_version, "10.0.108");
11837 assert!(result[0].installed_version_canonical);
11838 }
11839
11840 #[test]
11841 fn dedupe_keeps_highest_among_canonical_rows() {
11842 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
11843 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
11844
11845 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
11846 assert_eq!(result.len(), 1);
11847 assert_eq!(result[0].installed_version, "1.7.9");
11848 }
11849
11850 #[test]
11851 fn dedupe_leaves_uncorrelated_rows_alone() {
11852 let uncorrelated = InstalledPackage {
11856 name: "Foo".to_owned(),
11857 local_id: r"ARP\Machine\X64\Foo".to_owned(),
11858 installed_version: "1.0".to_owned(),
11859 publisher: None,
11860 scope: Some("Machine".to_owned()),
11861 installer_category: Some("exe".to_owned()),
11862 install_location: None,
11863 package_family_names: Vec::new(),
11864 product_codes: Vec::new(),
11865 upgrade_codes: Vec::new(),
11866 correlated: None,
11867 installed_version_canonical: false,
11868 correlated_requires_explicit_upgrade: false,
11869 correlated_lacks_compatible_installer: false,
11870 };
11871 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
11872 assert_eq!(result.len(), 1);
11873 }
11874}