1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fmt::{Display, Formatter};
6use std::fs;
7use std::io::{Cursor, Read};
8#[cfg(windows)]
9use std::mem::{MaybeUninit, size_of};
10#[cfg(windows)]
11use std::os::windows::ffi::{OsStrExt, OsStringExt};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, OnceLock, RwLock};
14
15use anyhow::{Context, Result, anyhow, bail};
16use chrono::{DateTime, Duration, Utc};
17use reqwest::blocking::{Client, Response};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
21use sha2::{Digest, Sha256};
22use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
23#[cfg(windows)]
24use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
25#[cfg(windows)]
26use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
27#[cfg(windows)]
28use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
29#[cfg(windows)]
30use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
31#[cfg(windows)]
32use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
33#[cfg(windows)]
34use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
35#[cfg(windows)]
36use winreg::{RegKey, enums::*};
37use zip::ZipArchive;
38
39const DEFAULT_MARKET: &str = "US";
40const DEFAULT_MAX_RESULTS: usize = 50;
41const LIST_LOOKUP_MAX_RESULTS: usize = 500;
42const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
43const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
44const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 5;
45const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
46#[cfg(windows)]
47const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
48#[cfg(windows)]
49const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
50const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
51 "Installed package discovery is not supported on this platform; returning no installed packages.";
52const INSTALL_UNSUPPORTED_WARNING: &str =
53 "Installing packages is not supported on this platform; no changes were made.";
54const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
55const REPAIR_REINSTALL_WARNING: &str =
56 "Pinget repair currently re-runs the package install flow for the selected package.";
57const UNINSTALL_UNSUPPORTED_WARNING: &str =
58 "Uninstalling packages is not supported on this platform; no changes were made.";
59#[cfg(windows)]
60const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
61const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
62 "LocalManifestFiles",
63 "BypassCertificatePinningForMicrosoftStore",
64 "InstallerHashOverride",
65 "LocalArchiveMalwareScanOverride",
66 "ProxyCommandLineOptions",
67];
68const REST_SUPPORTED_CONTRACTS: &[&str] = &[
69 "1.12.0", "1.10.0", "1.9.0", "1.7.0", "1.6.0", "1.5.0", "1.4.0", "1.1.0", "1.0.0",
70];
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
73pub enum SourceKind {
74 #[serde(rename = "PreIndexed", alias = "preIndexed")]
75 PreIndexed,
76 #[serde(rename = "Rest", alias = "rest")]
77 Rest,
78}
79
80impl Display for SourceKind {
81 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82 match self {
83 SourceKind::PreIndexed => f.write_str("preindexed"),
84 SourceKind::Rest => f.write_str("rest"),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SourceRecord {
91 pub name: String,
92 pub kind: SourceKind,
93 pub arg: String,
94 pub identifier: String,
95 #[serde(default = "default_source_trust_level")]
96 pub trust_level: String,
97 #[serde(default)]
98 pub explicit: bool,
99 #[serde(default)]
100 pub priority: i32,
101 #[serde(default)]
102 pub last_update: Option<DateTime<Utc>>,
103 #[serde(default)]
104 pub source_version: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108struct SourceStore {
109 sources: Vec<SourceRecord>,
110}
111
112impl Default for SourceStore {
113 fn default() -> Self {
114 Self {
115 sources: vec![
116 SourceRecord {
117 name: "winget".to_owned(),
118 kind: SourceKind::PreIndexed,
119 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
120 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
121 trust_level: "Trusted".to_owned(),
122 explicit: false,
123 priority: 0,
124 last_update: None,
125 source_version: None,
126 },
127 SourceRecord {
128 name: "msstore".to_owned(),
129 kind: SourceKind::Rest,
130 arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
131 identifier: "StoreEdgeFD".to_owned(),
132 trust_level: "Trusted".to_owned(),
133 explicit: false,
134 priority: 0,
135 last_update: None,
136 source_version: None,
137 },
138 ],
139 }
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct RepositoryOptions {
146 pub app_root: PathBuf,
147 pub user_agent: String,
148 pub pre_indexed_source_auto_update_interval: Option<Duration>,
149}
150
151impl RepositoryOptions {
152 pub fn new(app_root: impl Into<PathBuf>) -> Self {
154 Self {
155 app_root: app_root.into(),
156 user_agent: DEFAULT_USER_AGENT.to_owned(),
157 pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
158 }
159 }
160
161 pub fn for_current_user() -> Result<Self> {
163 Ok(Self::new(default_app_root()?))
164 }
165
166 #[must_use]
168 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
169 self.user_agent = user_agent.into();
170 self
171 }
172
173 #[must_use]
175 pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
176 self.pre_indexed_source_auto_update_interval = interval;
177 self
178 }
179}
180
181#[derive(Debug, Clone, Default)]
182pub struct PackageQuery {
183 pub query: Option<String>,
184 pub id: Option<String>,
185 pub name: Option<String>,
186 pub moniker: Option<String>,
187 pub tag: Option<String>,
188 pub command: Option<String>,
189 pub source: Option<String>,
190 pub count: Option<usize>,
191 pub exact: bool,
192 pub version: Option<String>,
193 pub channel: Option<String>,
194 pub locale: Option<String>,
195 pub installer_type: Option<String>,
196 pub installer_architecture: Option<String>,
197 pub platform: Option<String>,
198 pub os_version: Option<String>,
199 pub install_scope: Option<String>,
200}
201
202#[derive(Debug, Clone, Default)]
203pub struct ListQuery {
204 pub query: Option<String>,
205 pub id: Option<String>,
206 pub name: Option<String>,
207 pub moniker: Option<String>,
208 pub tag: Option<String>,
209 pub command: Option<String>,
210 pub product_code: Option<String>,
211 pub version: Option<String>,
212 pub source: Option<String>,
213 pub count: Option<usize>,
214 pub exact: bool,
215 pub install_scope: Option<String>,
216 pub upgrade_only: bool,
217 pub include_unknown: bool,
218 pub include_pinned: bool,
219}
220
221#[derive(Debug, Clone, serde::Serialize)]
222#[serde(rename_all = "PascalCase")]
223pub struct SearchMatch {
224 pub source_name: String,
225 pub source_kind: SourceKind,
226 pub id: String,
227 pub name: String,
228 pub moniker: Option<String>,
229 pub version: Option<String>,
230 pub channel: Option<String>,
231 pub match_criteria: Option<String>,
232}
233
234#[derive(Debug, Clone, serde::Serialize)]
235#[serde(rename_all = "PascalCase")]
236pub struct SearchResponse {
237 pub matches: Vec<SearchMatch>,
238 pub warnings: Vec<String>,
239 pub truncated: bool,
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
243#[serde(rename_all = "PascalCase")]
244pub struct ListMatch {
245 pub name: String,
246 pub id: String,
247 pub local_id: String,
248 pub installed_version: String,
249 pub available_version: Option<String>,
250 pub source_name: Option<String>,
251 pub publisher: Option<String>,
252 pub scope: Option<String>,
253 pub installer_category: Option<String>,
254 pub install_location: Option<String>,
255 pub package_family_names: Vec<String>,
256 pub product_codes: Vec<String>,
257 pub upgrade_codes: Vec<String>,
258}
259
260#[derive(Debug, Clone, serde::Serialize)]
261#[serde(rename_all = "PascalCase")]
262pub struct ListResponse {
263 pub matches: Vec<ListMatch>,
264 pub warnings: Vec<String>,
265 pub truncated: bool,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
269#[serde(rename_all = "PascalCase")]
270pub struct VersionKey {
271 pub version: String,
272 pub channel: String,
273}
274
275#[derive(Debug, Clone, serde::Serialize)]
276pub struct Manifest {
277 pub id: String,
278 pub name: String,
279 pub version: String,
280 pub channel: String,
281 pub publisher: Option<String>,
282 pub description: Option<String>,
283 pub moniker: Option<String>,
284 pub package_url: Option<String>,
285 pub publisher_url: Option<String>,
286 pub publisher_support_url: Option<String>,
287 pub license: Option<String>,
288 pub license_url: Option<String>,
289 pub privacy_url: Option<String>,
290 pub author: Option<String>,
291 pub copyright: Option<String>,
292 pub copyright_url: Option<String>,
293 pub release_notes: Option<String>,
294 pub release_notes_url: Option<String>,
295 pub tags: Vec<String>,
296 pub agreements: Vec<PackageAgreement>,
297 pub package_dependencies: Vec<String>,
298 pub documentation: Vec<Documentation>,
299 pub installers: Vec<Installer>,
300 pub require_explicit_upgrade: bool,
305}
306
307#[derive(Debug, Clone, serde::Serialize)]
308pub struct Documentation {
309 pub label: Option<String>,
310 pub url: String,
311}
312
313#[derive(Debug, Clone, serde::Serialize)]
314pub struct PackageAgreement {
315 pub label: Option<String>,
316 pub text: Option<String>,
317 pub url: Option<String>,
318}
319
320#[derive(Debug, Clone, Default, serde::Serialize)]
321pub struct Installer {
322 pub architecture: Option<String>,
323 pub installer_type: Option<String>,
324 pub nested_installer_type: Option<String>,
325 pub nested_installer_files: Vec<NestedInstallerFile>,
326 pub url: Option<String>,
327 pub sha256: Option<String>,
328 pub product_code: Option<String>,
329 pub locale: Option<String>,
330 pub scope: Option<String>,
331 pub release_date: Option<String>,
332 pub package_family_name: Option<String>,
333 pub upgrade_code: Option<String>,
334 pub platforms: Vec<String>,
335 pub minimum_os_version: Option<String>,
336 pub switches: InstallerSwitches,
337 pub commands: Vec<String>,
338 pub package_dependencies: Vec<String>,
339 pub require_explicit_upgrade: bool,
340}
341
342#[derive(Debug, Clone, Default, serde::Serialize)]
343pub struct NestedInstallerFile {
344 pub relative_file_path: String,
346 pub portable_command_alias: Option<String>,
350}
351
352#[derive(Debug, Clone, Default, serde::Serialize)]
353pub struct InstallerSwitches {
354 pub silent: Option<String>,
355 pub silent_with_progress: Option<String>,
356 pub interactive: Option<String>,
357 pub custom: Option<String>,
358 pub log: Option<String>,
359 pub install_location: Option<String>,
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum InstallerMode {
364 Interactive,
365 SilentWithProgress,
366 Silent,
367}
368
369#[derive(Debug, Clone)]
370pub struct InstallRequest {
371 pub query: PackageQuery,
372 pub manifest_path: Option<PathBuf>,
373 pub mode: InstallerMode,
374 pub log_path: Option<PathBuf>,
375 pub custom: Option<String>,
376 pub override_args: Option<String>,
377 pub install_location: Option<String>,
378 pub skip_dependencies: bool,
379 pub dependencies_only: bool,
380 pub accept_package_agreements: bool,
381 pub force: bool,
382 pub rename: Option<String>,
383 pub uninstall_previous: bool,
384 pub ignore_security_hash: bool,
385 pub dependency_source: Option<String>,
386 pub no_upgrade: bool,
387}
388
389impl InstallRequest {
390 pub fn new(query: PackageQuery) -> Self {
391 Self {
392 query,
393 manifest_path: None,
394 mode: InstallerMode::SilentWithProgress,
395 log_path: None,
396 custom: None,
397 override_args: None,
398 install_location: None,
399 skip_dependencies: false,
400 dependencies_only: false,
401 accept_package_agreements: false,
402 force: false,
403 rename: None,
404 uninstall_previous: false,
405 ignore_security_hash: false,
406 dependency_source: None,
407 no_upgrade: false,
408 }
409 }
410}
411
412#[derive(Debug, Clone)]
413pub struct UninstallRequest {
414 pub query: PackageQuery,
415 pub manifest_path: Option<PathBuf>,
416 pub product_code: Option<String>,
417 pub mode: InstallerMode,
418 pub all_versions: bool,
419 pub force: bool,
420 pub purge: bool,
421 pub preserve: bool,
422 pub log_path: Option<PathBuf>,
423}
424
425impl UninstallRequest {
426 pub fn new(query: PackageQuery) -> Self {
427 Self {
428 query,
429 manifest_path: None,
430 product_code: None,
431 mode: InstallerMode::SilentWithProgress,
432 all_versions: false,
433 force: false,
434 purge: false,
435 preserve: false,
436 log_path: None,
437 }
438 }
439}
440
441#[derive(Debug, Clone)]
442pub struct RepairRequest {
443 pub query: PackageQuery,
444 pub manifest_path: Option<PathBuf>,
445 pub product_code: Option<String>,
446 pub mode: InstallerMode,
447 pub log_path: Option<PathBuf>,
448 pub accept_package_agreements: bool,
449 pub force: bool,
450 pub ignore_security_hash: bool,
451}
452
453impl RepairRequest {
454 pub fn new(query: PackageQuery) -> Self {
455 Self {
456 query,
457 manifest_path: None,
458 product_code: None,
459 mode: InstallerMode::SilentWithProgress,
460 log_path: None,
461 accept_package_agreements: false,
462 force: false,
463 ignore_security_hash: false,
464 }
465 }
466}
467
468#[derive(Debug, Clone, serde::Serialize)]
469pub struct ShowResult {
470 pub package: SearchMatch,
471 pub manifest: Manifest,
472 pub selected_installer: Option<Installer>,
473 pub cached_files: Vec<PathBuf>,
474 pub warnings: Vec<String>,
475 #[serde(skip_serializing)]
476 manifest_documents: JsonValue,
477}
478
479impl ShowResult {
480 pub fn structured_document(&self) -> JsonValue {
481 collapse_structured_document(&self.manifest_documents)
482 }
483}
484
485#[derive(Debug, Clone, serde::Serialize)]
490pub struct DownloadOutput {
491 pub manifest: Manifest,
492 pub installer_path: PathBuf,
493 pub manifest_path: PathBuf,
494}
495
496struct FetchedInstaller {
500 manifest: Manifest,
501 manifest_documents: JsonValue,
502 installer: Installer,
503 installer_path: PathBuf,
504}
505
506#[derive(Debug, Clone, serde::Serialize)]
507#[serde(rename_all = "PascalCase")]
508pub struct VersionsResult {
509 pub package: SearchMatch,
510 pub versions: Vec<VersionKey>,
511 pub warnings: Vec<String>,
512}
513
514#[derive(Debug, Clone, serde::Serialize)]
515#[serde(rename_all = "PascalCase")]
516pub struct CacheWarmResult {
517 pub package: SearchMatch,
518 pub cached_files: Vec<PathBuf>,
519 pub warnings: Vec<String>,
520}
521
522#[derive(Debug, Clone, serde::Serialize)]
523#[serde(rename_all = "PascalCase")]
524pub struct SourceUpdateResult {
525 pub name: String,
526 pub kind: SourceKind,
527 pub detail: String,
528}
529
530#[derive(Debug, Clone, serde::Serialize)]
531#[serde(rename_all = "PascalCase")]
532pub struct PinRecord {
533 pub package_id: String,
534 pub version: String,
535 pub source_id: String,
536 pub pin_type: PinType,
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
540pub enum PinType {
541 Pinning,
542 Blocking,
543 Gating,
544}
545
546impl Display for PinType {
547 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
548 match self {
549 PinType::Pinning => f.write_str("Pinning"),
550 PinType::Blocking => f.write_str("Blocking"),
551 PinType::Gating => f.write_str("Gating"),
552 }
553 }
554}
555
556#[derive(Debug, Clone, serde::Serialize)]
558pub struct InstallResult {
559 pub package_id: String,
560 pub version: String,
561 pub installer_path: PathBuf,
562 pub installer_type: String,
563 pub exit_code: i32,
564 pub success: bool,
565 pub no_op: bool,
566 pub warnings: Vec<String>,
567}
568
569impl InstallerSwitches {
570 fn with_fallback(&self, fallback: &Self) -> Self {
571 Self {
572 silent: self.silent.clone().or_else(|| fallback.silent.clone()),
573 silent_with_progress: self
574 .silent_with_progress
575 .clone()
576 .or_else(|| fallback.silent_with_progress.clone()),
577 interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
578 custom: self.custom.clone().or_else(|| fallback.custom.clone()),
579 log: self.log.clone().or_else(|| fallback.log.clone()),
580 install_location: self
581 .install_location
582 .clone()
583 .or_else(|| fallback.install_location.clone()),
584 }
585 }
586}
587
588fn installed_package_discovery_supported() -> bool {
589 cfg!(windows)
590}
591
592fn package_actions_supported() -> bool {
593 cfg!(windows)
594}
595
596fn unsupported_action_result(
597 package_id: impl Into<String>,
598 version: impl Into<String>,
599 installer_type: impl Into<String>,
600 warning: &str,
601) -> InstallResult {
602 InstallResult {
603 package_id: package_id.into(),
604 version: version.into(),
605 installer_path: PathBuf::new(),
606 installer_type: installer_type.into(),
607 exit_code: 0,
608 success: true,
609 no_op: true,
610 warnings: vec![warning.to_owned()],
611 }
612}
613
614#[derive(Debug, Clone)]
615struct InstalledPackage {
616 name: String,
617 local_id: String,
618 installed_version: String,
619 publisher: Option<String>,
620 scope: Option<String>,
621 installer_category: Option<String>,
622 install_location: Option<String>,
623 package_family_names: Vec<String>,
624 product_codes: Vec<String>,
625 upgrade_codes: Vec<String>,
626 correlated: Option<SearchMatch>,
627 installed_version_canonical: bool,
634 correlated_requires_explicit_upgrade: bool,
638 correlated_lacks_compatible_installer: bool,
642}
643
644#[derive(Debug, Clone)]
645struct LocatedMatch {
646 display: SearchMatch,
647 source_index: usize,
648 locator: MatchLocator,
649}
650
651#[derive(Debug)]
652struct SearchSourceMatches {
653 matches: Vec<LocatedMatch>,
654 truncated: bool,
655}
656
657#[derive(Debug, Clone)]
658enum MatchLocator {
659 PreIndexedV1 {
660 package_rowid: i64,
661 },
662 PreIndexedV2 {
663 package_rowid: i64,
664 package_hash: String,
665 },
666 Rest {
667 package_id: String,
668 versions: Vec<VersionKey>,
669 },
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
673enum SearchSemantics {
674 Many,
675 Single,
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize)]
679struct RestInfoCache {
680 expires_at: DateTime<Utc>,
681 value: RestInformation,
682}
683
684#[derive(Debug, Clone, Default, Serialize, Deserialize)]
685struct RestInformation {
686 #[serde(rename = "SourceIdentifier")]
687 source_identifier: String,
688 #[serde(rename = "ServerSupportedVersions", default)]
689 server_supported_versions: Vec<String>,
690 #[serde(rename = "RequiredPackageMatchFields", default)]
691 required_package_match_fields: Vec<String>,
692 #[serde(rename = "UnsupportedPackageMatchFields", default)]
693 unsupported_package_match_fields: Vec<String>,
694 #[serde(rename = "RequiredQueryParameters", default)]
695 required_query_parameters: Vec<String>,
696 #[serde(rename = "UnsupportedQueryParameters", default)]
697 unsupported_query_parameters: Vec<String>,
698}
699
700#[derive(Debug, Clone, Deserialize)]
701struct PackageVersionDataDocument {
702 #[serde(rename = "vD", default)]
703 versions: Vec<PackageVersionDataEntry>,
704}
705
706#[derive(Debug, Clone, Deserialize)]
707struct PackageVersionDataEntry {
708 #[serde(rename = "v")]
709 version: String,
710 #[serde(rename = "rP")]
711 manifest_relative_path: String,
712 #[serde(rename = "s256H")]
713 manifest_hash: String,
714 #[serde(rename = "aMiV", default)]
719 arp_min_version: Option<String>,
720 #[serde(rename = "aMaV", default)]
721 arp_max_version: Option<String>,
722}
723
724pub struct Repository {
725 app_root: PathBuf,
726 client: Client,
727 store: SourceStore,
728 use_system_winget_sources: bool,
729 pre_indexed_source_auto_update_interval: Option<Duration>,
730 preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
731}
732
733#[derive(Debug, Clone)]
734struct PreindexedRefreshAttempt {
735 attempted_at: DateTime<Utc>,
736 succeeded: bool,
737}
738
739#[derive(Debug, Clone, Default)]
740struct PreindexedRefreshOutcome {
741 attempted: bool,
742 reused_recent_attempt: bool,
743 error: Option<String>,
744}
745
746struct PreindexedOpenConnection {
747 connection: Connection,
748 refresh: PreindexedRefreshOutcome,
749}
750
751#[derive(Debug)]
752struct MissingPackageVersionError {
753 package_id: String,
754 source_name: String,
755 requested_version: String,
756 requested_channel: Option<String>,
757}
758
759impl Display for MissingPackageVersionError {
760 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
761 let channel = self
762 .requested_channel
763 .as_deref()
764 .filter(|value| !value.is_empty())
765 .map(|value| format!(" with channel '{value}'"))
766 .unwrap_or_default();
767 write!(
768 f,
769 "Version '{}'{} was not found for package '{}' in source '{}'.",
770 self.requested_version, channel, self.package_id, self.source_name
771 )
772 }
773}
774
775impl std::error::Error for MissingPackageVersionError {}
776
777fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
778 static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
779 LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
780}
781
782impl Repository {
783 pub fn open() -> Result<Self> {
785 Self::open_with_options(RepositoryOptions::for_current_user()?)
786 }
787
788 pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
790 ensure_app_dirs(&options.app_root)?;
791 let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
792 let store = load_store(&options.app_root)?;
793 let client = Client::builder()
794 .user_agent(&options.user_agent)
795 .build()
796 .context("failed to build HTTP client")?;
797 Ok(Self {
798 app_root: options.app_root,
799 client,
800 store,
801 use_system_winget_sources,
802 pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
803 preindexed_refresh_attempts: HashMap::new(),
804 })
805 }
806
807 pub fn app_root(&self) -> &Path {
808 &self.app_root
809 }
810
811 pub fn list_sources(&self) -> Vec<SourceRecord> {
812 self.store.sources.clone()
813 }
814
815 pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
816 self.add_source_with_metadata(name, arg, kind, None, false, 0)
817 }
818
819 pub fn add_source_with_metadata(
820 &mut self,
821 name: &str,
822 arg: &str,
823 kind: SourceKind,
824 trust_level: Option<&str>,
825 explicit: bool,
826 priority: i32,
827 ) -> Result<()> {
828 if self.use_system_winget_sources {
829 system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
830 self.refresh_system_winget_sources()?;
831 return Ok(());
832 }
833
834 if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
835 bail!("A source with name '{name}' already exists.");
836 }
837 if self.store.sources.iter().any(|s| s.arg == arg) {
838 bail!("A source with argument '{arg}' already exists.");
839 }
840 let identifier = name.to_owned();
841 self.store.sources.push(SourceRecord {
842 name: name.to_owned(),
843 kind,
844 arg: arg.to_owned(),
845 identifier,
846 trust_level: normalize_source_trust_level(trust_level)?,
847 explicit,
848 priority,
849 last_update: None,
850 source_version: None,
851 });
852 self.save_store()?;
853 Ok(())
854 }
855
856 pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
857 if self.use_system_winget_sources {
858 bail!("Editing system WinGet sources is not supported by winget source commands.");
859 }
860
861 let source = self
862 .store
863 .sources
864 .iter_mut()
865 .find(|source| source.name.eq_ignore_ascii_case(name))
866 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
867
868 if let Some(explicit) = explicit {
869 source.explicit = explicit;
870 }
871
872 if trust_level.is_some() {
873 source.trust_level = normalize_source_trust_level(trust_level)?;
874 }
875
876 self.save_store()?;
877 Ok(())
878 }
879
880 pub fn remove_source(&mut self, name: &str) -> Result<()> {
881 if self.use_system_winget_sources {
882 system_winget_remove_source(name)?;
883 self.refresh_system_winget_sources()?;
884 return Ok(());
885 }
886
887 let idx = self
888 .store
889 .sources
890 .iter()
891 .position(|s| s.name.eq_ignore_ascii_case(name))
892 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
893 let source = self.store.sources.remove(idx);
894 let state_dir = self.source_state_dir(&source);
896 if state_dir.exists() {
897 let _ = fs::remove_dir_all(&state_dir);
898 }
899 self.save_store()?;
900 Ok(())
901 }
902
903 pub fn reset_source(&mut self, name: &str) -> Result<()> {
904 if self.use_system_winget_sources {
905 system_winget_reset_source(name)?;
906 self.refresh_system_winget_sources()?;
907 return Ok(());
908 }
909
910 let idx = self
911 .store
912 .sources
913 .iter()
914 .position(|source| source.name.eq_ignore_ascii_case(name))
915 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
916
917 let source = self.store.sources[idx].clone();
918 let state_dir = self.source_state_dir(&source);
919 if state_dir.exists() {
920 let _ = fs::remove_dir_all(&state_dir);
921 }
922
923 if let Some(default_source) = SourceStore::default()
924 .sources
925 .into_iter()
926 .find(|source| source.name.eq_ignore_ascii_case(name))
927 {
928 self.store.sources[idx] = default_source;
929 } else {
930 self.store.sources[idx].last_update = None;
931 self.store.sources[idx].source_version = None;
932 }
933
934 self.save_store()?;
935 Ok(())
936 }
937
938 pub fn reset_sources(&mut self) -> Result<()> {
939 if self.use_system_winget_sources {
940 system_winget_reset_sources()?;
941 self.refresh_system_winget_sources()?;
942 return Ok(());
943 }
944
945 for source in &self.store.sources {
947 let state_dir = self.source_state_dir(source);
948 if state_dir.exists() {
949 let _ = fs::remove_dir_all(&state_dir);
950 }
951 }
952 self.store = SourceStore::default();
953 self.save_store()?;
954 Ok(())
955 }
956
957 pub fn supported_admin_settings() -> &'static [&'static str] {
958 SUPPORTED_ADMIN_SETTINGS
959 }
960
961 pub fn get_user_settings(&self) -> Result<JsonValue> {
962 Ok(JsonValue::Object(load_json_object(&user_settings_path(
963 &self.app_root,
964 ))?))
965 }
966
967 pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
968 let update = match user_settings {
969 JsonValue::Object(object) => object.clone(),
970 _ => bail!("user settings must be a JSON object"),
971 };
972
973 let effective = if merge {
974 merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
975 } else {
976 update
977 };
978 save_json_object(user_settings_path(&self.app_root), &effective)?;
979 Ok(JsonValue::Object(effective))
980 }
981
982 pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
983 let current = self.get_user_settings()?;
984 Ok(if ignore_not_set {
985 json_contains(¤t, expected)
986 } else {
987 current == *expected
988 })
989 }
990
991 pub fn get_admin_settings(&self) -> Result<JsonValue> {
992 let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
993 for name in SUPPORTED_ADMIN_SETTINGS {
994 settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
995 }
996
997 Ok(JsonValue::Object(settings))
998 }
999
1000 pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1001 let normalized = normalize_admin_setting_name(name)?;
1002 let mut settings = match self.get_admin_settings()? {
1003 JsonValue::Object(settings) => settings,
1004 _ => JsonMap::new(),
1005 };
1006 settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1007 save_json_object(admin_settings_path(&self.app_root), &settings)
1008 }
1009
1010 pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1011 if !reset_all && name.is_none() {
1012 bail!("resetting admin settings requires a setting name or reset-all");
1013 }
1014
1015 let mut settings = match self.get_admin_settings()? {
1016 JsonValue::Object(settings) => settings,
1017 _ => JsonMap::new(),
1018 };
1019
1020 if reset_all {
1021 for setting_name in SUPPORTED_ADMIN_SETTINGS {
1022 settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1023 }
1024 } else {
1025 settings.insert(
1026 normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1027 JsonValue::Bool(false),
1028 );
1029 }
1030
1031 save_json_object(admin_settings_path(&self.app_root), &settings)
1032 }
1033
1034 pub fn ensure_settings_files(&self) -> Result<()> {
1035 let user_settings = user_settings_path(&self.app_root);
1036 if !user_settings.exists() {
1037 save_json_object(user_settings, &JsonMap::new())?;
1038 }
1039
1040 let admin_settings = admin_settings_path(&self.app_root);
1041 if !admin_settings.exists() {
1042 let mut values = JsonMap::new();
1043 for name in SUPPORTED_ADMIN_SETTINGS {
1044 values.insert((*name).to_owned(), JsonValue::Bool(false));
1045 }
1046 save_json_object(admin_settings, &values)?;
1047 }
1048
1049 Ok(())
1050 }
1051
1052 pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1053 if self.use_system_winget_sources {
1054 return self.update_system_winget_sources(source_name);
1055 }
1056
1057 let indexes = self.resolve_source_indexes(source_name)?;
1058 let mut results = Vec::new();
1059
1060 for index in indexes {
1061 let detail = match self.store.sources[index].kind {
1062 SourceKind::PreIndexed => self.update_preindexed(index)?,
1063 SourceKind::Rest => self.update_rest(index)?,
1064 };
1065
1066 results.push(SourceUpdateResult {
1067 name: self.store.sources[index].name.clone(),
1068 kind: self.store.sources[index].kind,
1069 detail,
1070 });
1071 }
1072
1073 self.save_store()?;
1074 Ok(results)
1075 }
1076
1077 pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1078 let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
1079 Ok(SearchResponse {
1080 matches: matches.into_iter().map(|item| item.display).collect(),
1081 warnings,
1082 truncated,
1083 })
1084 }
1085
1086 pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1087 let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
1088 let mut structured_documents = Vec::with_capacity(matches.len());
1089 for located in matches {
1090 let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1091 structured_documents.push(manifest_documents);
1092 }
1093
1094 Ok(collapse_structured_documents(&structured_documents))
1095 }
1096
1097 pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1098 if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1099 bail!("--include-unknown and --include-pinned require --upgrade-available");
1100 }
1101 if query.source.is_some()
1102 && query.query.is_none()
1103 && query.id.is_none()
1104 && query.name.is_none()
1105 && query.moniker.is_none()
1106 && query.tag.is_none()
1107 && query.command.is_none()
1108 {
1109 bail!("list --source currently requires a query or explicit filter");
1110 }
1111
1112 let has_filter = list_query_needs_available_lookup(query);
1113 let needs_available = true;
1118
1119 let mut warnings = Vec::new();
1120 if !installed_package_discovery_supported() {
1121 warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1122 }
1123 let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1124
1125 if needs_available {
1126 warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1132
1133 warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1142 }
1143
1144 if needs_available && has_filter {
1145 let available_query = package_query_from_list_query(query);
1148 let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
1149 warnings.extend(source_warnings);
1150 let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1151 for package in &mut installed {
1152 if package.correlated.is_some() {
1153 continue;
1154 }
1155 package.correlated =
1156 correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1157 }
1158 } else if needs_available {
1159 warnings.extend(self.correlate_all_installed(&mut installed)?);
1161 }
1162
1163 if needs_available {
1164 warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1175 }
1176
1177 if query.upgrade_only {
1184 installed = dedupe_correlated_for_upgrade(installed);
1185 }
1186
1187 let mut matches = installed
1188 .into_iter()
1189 .filter(|package| {
1190 list_package_matches(package, query)
1191 && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1192 })
1193 .collect::<Vec<_>>();
1194
1195 matches.sort_by(|left, right| {
1196 list_sort_weight(left)
1197 .cmp(&list_sort_weight(right))
1198 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1199 .then_with(|| left.local_id.cmp(&right.local_id))
1200 });
1201
1202 let pins = if query.upgrade_only {
1203 self.list_pins(query.source.as_deref())?
1204 } else {
1205 Vec::new()
1206 };
1207 let mut list_matches = matches
1208 .into_iter()
1209 .map(list_match_from_installed)
1210 .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1211 .collect::<Vec<_>>();
1212 let truncated = if let Some(limit) = query.count {
1213 let was_truncated = list_matches.len() > limit;
1214 list_matches.truncate(limit);
1215 was_truncated
1216 } else {
1217 false
1218 };
1219
1220 Ok(ListResponse {
1221 matches: list_matches,
1222 warnings,
1223 truncated,
1224 })
1225 }
1226
1227 fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1231 if installed.iter().all(|p| p.correlated.is_some()) {
1232 return Ok(Vec::new());
1233 }
1234 let all_query = PackageQuery {
1235 query: None,
1236 id: None,
1237 name: None,
1238 moniker: None,
1239 tag: None,
1240 command: None,
1241 source: None,
1242 count: Some(100_000), exact: false,
1244 version: None,
1245 channel: None,
1246 locale: None,
1247 installer_type: None,
1248 installer_architecture: None,
1249 platform: None,
1250 os_version: None,
1251 install_scope: None,
1252 };
1253 let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
1254 let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
1255
1256 for package in installed.iter_mut() {
1257 if package.correlated.is_some() {
1258 continue;
1259 }
1260 package.correlated = correlate_installed_package(package, &candidates, true);
1261 }
1262
1263 Ok(warnings)
1264 }
1265
1266 fn correlate_installed_via_index(
1275 &mut self,
1276 installed: &mut [InstalledPackage],
1277 requested_source: Option<&str>,
1278 ) -> Result<Vec<String>> {
1279 let source_indices: Vec<usize> = self
1280 .store
1281 .sources
1282 .iter()
1283 .enumerate()
1284 .filter(|(_, source)| {
1285 source.kind == SourceKind::PreIndexed
1286 && match requested_source {
1287 Some(name) => source.name.eq_ignore_ascii_case(name),
1288 None => true,
1289 }
1290 })
1291 .map(|(index, _)| index)
1292 .collect();
1293
1294 for source_index in source_indices {
1295 let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1296 let connection = match self.open_preindexed_connection(source_index) {
1297 Ok(c) => c,
1298 Err(_) => continue,
1299 };
1300 if !v2_identity_tables_present(&connection) {
1301 continue;
1302 }
1303 let mut acc = Vec::new();
1304 for (idx, package) in installed.iter().enumerate() {
1305 if package.correlated.is_some() {
1306 continue;
1307 }
1308 let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1309 continue;
1310 };
1311 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1312 continue;
1313 };
1314 acc.push((idx, meta, by));
1315 }
1316 acc
1317 };
1318
1319 let source = self.source_clone(source_index);
1320 for (idx, meta, by) in resolved {
1321 let (canonical_installed, anchored_latest) =
1322 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1323 Ok((entries, _)) => (
1324 map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1325 latest_arp_anchored_version(&entries),
1326 ),
1327 Err(_) => (None, None),
1328 };
1329 let available_version = match (&canonical_installed, anchored_latest) {
1336 (Some(_), Some(anchored)) => anchored,
1337 _ => meta.latest_version,
1338 };
1339 let installed_pkg = &mut installed[idx];
1340 installed_pkg.correlated = Some(SearchMatch {
1341 source_name: source.name.clone(),
1342 source_kind: source.kind,
1343 id: meta.id,
1344 name: meta.name,
1345 moniker: meta.moniker,
1346 version: Some(available_version),
1347 channel: None,
1348 match_criteria: Some(by.to_owned()),
1349 });
1350 if let Some(version) = canonical_installed {
1351 installed_pkg.installed_version = version;
1352 installed_pkg.installed_version_canonical = true;
1353 }
1354 }
1355 }
1356
1357 Ok(Vec::new())
1358 }
1359
1360 fn correlate_installed_by_normalized_identity(
1369 &mut self,
1370 installed: &mut [InstalledPackage],
1371 requested_source: Option<&str>,
1372 ) -> Result<Vec<String>> {
1373 let source_indices: Vec<usize> = self
1374 .store
1375 .sources
1376 .iter()
1377 .enumerate()
1378 .filter(|(_, source)| {
1379 source.kind == SourceKind::PreIndexed
1380 && match requested_source {
1381 Some(name) => source.name.eq_ignore_ascii_case(name),
1382 None => true,
1383 }
1384 })
1385 .map(|(index, _)| index)
1386 .collect();
1387
1388 for source_index in source_indices {
1389 let resolved: Vec<(usize, V2PackageMetadata)> = {
1390 let connection = match self.open_preindexed_connection(source_index) {
1391 Ok(c) => c,
1392 Err(_) => continue,
1393 };
1394 if !v2_normalized_identity_tables_present(&connection) {
1395 continue;
1396 }
1397 let mut acc = Vec::new();
1398 for (idx, pkg) in installed.iter().enumerate() {
1399 if pkg.correlated.is_some() {
1400 continue;
1401 }
1402 if pkg.local_id.starts_with("MSIX\\") {
1407 continue;
1408 }
1409 let Some(publisher) = pkg.publisher.as_deref() else {
1410 continue;
1411 };
1412 let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1413 if normalized_name.is_empty() {
1414 continue;
1415 }
1416 let normalized_publisher = name_normalization::normalize_publisher(publisher);
1417 if normalized_publisher.is_empty() {
1418 continue;
1419 }
1420 let Some(rowid) =
1421 lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1422 else {
1423 continue;
1424 };
1425 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1426 continue;
1427 };
1428 acc.push((idx, meta));
1429 }
1430 acc
1431 };
1432
1433 let source = self.source_clone(source_index);
1434 for (idx, meta) in resolved {
1435 let (canonical_installed, anchored_latest) =
1436 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1437 Ok((entries, _)) => (
1438 map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
1439 latest_arp_anchored_version(&entries),
1440 ),
1441 Err(_) => (None, None),
1442 };
1443 let available_version = match (&canonical_installed, anchored_latest) {
1444 (Some(_), Some(anchored)) => anchored,
1445 _ => meta.latest_version,
1446 };
1447 let installed_pkg = &mut installed[idx];
1448 installed_pkg.correlated = Some(SearchMatch {
1449 source_name: source.name.clone(),
1450 source_kind: source.kind,
1451 id: meta.id,
1452 name: meta.name,
1453 moniker: meta.moniker,
1454 version: Some(available_version),
1455 channel: None,
1456 match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1457 });
1458 if let Some(version) = canonical_installed {
1459 installed_pkg.installed_version = version;
1460 installed_pkg.installed_version_canonical = true;
1461 }
1462 }
1463 }
1464
1465 Ok(Vec::new())
1466 }
1467
1468 fn enrich_correlated_via_index(
1480 &mut self,
1481 installed: &mut [InstalledPackage],
1482 requested_source: Option<&str>,
1483 ) -> Result<Vec<String>> {
1484 for package in installed.iter_mut() {
1486 apply_msix_resource_string_name_fix(package);
1487 }
1488
1489 let source_indices: Vec<usize> = self
1492 .store
1493 .sources
1494 .iter()
1495 .enumerate()
1496 .filter(|(_, source)| {
1497 source.kind == SourceKind::PreIndexed
1498 && match requested_source {
1499 Some(name) => source.name.eq_ignore_ascii_case(name),
1500 None => true,
1501 }
1502 })
1503 .map(|(index, _)| index)
1504 .collect();
1505
1506 for source_index in source_indices {
1507 let needs: Vec<(usize, V2PackageMetadata)> = {
1508 let connection = match self.open_preindexed_connection(source_index) {
1509 Ok(c) => c,
1510 Err(_) => continue,
1511 };
1512 if !v2_identity_tables_present(&connection) {
1513 continue;
1514 }
1515 let mut acc = Vec::new();
1516 for (idx, pkg) in installed.iter().enumerate() {
1517 let Some(candidate) = pkg.correlated.as_ref() else {
1518 continue;
1519 };
1520 if !candidate
1524 .source_name
1525 .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1526 {
1527 continue;
1528 }
1529 let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1530 continue;
1531 };
1532 acc.push((idx, meta));
1533 }
1534 acc
1535 };
1536
1537 let source = self.source_clone(source_index);
1538 for (idx, meta) in needs {
1539 let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1544 Ok((entries, _)) => entries,
1545 Err(_) => continue,
1546 };
1547
1548 if !installed[idx].installed_version_canonical {
1549 let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
1550 let anchored_latest = latest_arp_anchored_version(&entries);
1551 if let Some(canonical) = canonical_installed {
1552 installed[idx].installed_version = canonical;
1553 installed[idx].installed_version_canonical = true;
1554 if let (Some(correlated), Some(anchored)) =
1555 (installed[idx].correlated.as_mut(), anchored_latest)
1556 {
1557 correlated.version = Some(anchored);
1558 }
1559 }
1560 }
1561
1562 if let Some(latest) = entries.first() {
1568 let bytes_result = self.get_cached_source_file(
1569 "V2_M",
1570 &source,
1571 &latest.manifest_relative_path,
1572 Some(latest.manifest_hash.as_str()),
1573 );
1574 if let Ok(bytes) = bytes_result
1575 && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1576 {
1577 installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1578 installed[idx].correlated_lacks_compatible_installer =
1579 !manifest_has_compatible_installer(&manifest);
1580 }
1581 }
1582 }
1583 }
1584
1585 Ok(Vec::new())
1586 }
1587
1588 pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1589 let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1590 let versions = self.versions_for_match(&located, query)?;
1591 Ok(VersionsResult {
1592 package: located.display,
1593 versions,
1594 warnings,
1595 })
1596 }
1597
1598 pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1599 let (located, warnings) = self.find_single_match(query)?;
1600 let versions = self.versions_for_match(&located, query)?;
1601 Ok(VersionsResult {
1602 package: located.display,
1603 versions,
1604 warnings,
1605 })
1606 }
1607
1608 pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
1609 let (located, warnings) = self.find_single_match(query)?;
1610 let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
1611 let selected_installer = select_installer(&manifest.installers, query);
1612
1613 Ok(ShowResult {
1614 package: located.display,
1615 manifest,
1616 selected_installer,
1617 cached_files,
1618 warnings,
1619 manifest_documents,
1620 })
1621 }
1622
1623 pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
1624 let (located, warnings) = self.find_single_match(query)?;
1625 let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
1626
1627 Ok(CacheWarmResult {
1628 package: located.display,
1629 cached_files,
1630 warnings,
1631 })
1632 }
1633
1634 pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
1637 let db_path = pins_db_path(&self.app_root);
1638 if !db_path.exists() {
1639 return Ok(Vec::new());
1640 }
1641 let conn = Self::open_sqlite_connection(db_path)?;
1642 let pin_type_column = match resolve_pin_type_column(&conn)? {
1643 Some(column) => column,
1644 None => return Ok(Vec::new()),
1645 };
1646 let sql = if source_id.is_some() {
1647 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
1648 } else {
1649 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
1650 };
1651 let params = if let Some(source_id) = source_id {
1652 vec![SqlValue::Text(source_id.to_owned())]
1653 } else {
1654 Vec::new()
1655 };
1656 query_rows(&conn, &sql, params, |row| {
1657 let pin_type_int = row_i64(row, 3)?;
1658 Ok(PinRecord {
1659 package_id: row_string(row, 0)?,
1660 version: row_string(row, 1)?,
1661 source_id: row_string(row, 2)?,
1662 pin_type: decode_pin_type(pin_type_int),
1663 })
1664 })
1665 }
1666
1667 pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
1668 let db_path = pins_db_path(&self.app_root);
1669 let conn = Self::open_sqlite_connection(db_path)?;
1670 execute_batch_sql(
1671 &conn,
1672 "CREATE TABLE IF NOT EXISTS pin (
1673 package_id TEXT NOT NULL,
1674 version TEXT NOT NULL DEFAULT '*',
1675 source_id TEXT NOT NULL DEFAULT '',
1676 type INTEGER NOT NULL DEFAULT 0,
1677 PRIMARY KEY (package_id, source_id)
1678 )",
1679 )?;
1680 let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
1681 let type_int: i64 = match pin_type {
1682 PinType::Pinning => 2,
1683 PinType::Blocking => 4,
1684 PinType::Gating => 3,
1685 };
1686 execute_sql(
1687 &conn,
1688 &format!(
1689 "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
1690 ),
1691 vec![
1692 SqlValue::Text(package_id.to_owned()),
1693 SqlValue::Text(version.to_owned()),
1694 SqlValue::Text(source_id.to_owned()),
1695 SqlValue::Integer(type_int),
1696 ],
1697 )?;
1698 conn.cacheflush().context("failed to flush pin database")?;
1699 Ok(())
1700 }
1701
1702 pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
1703 let db_path = pins_db_path(&self.app_root);
1704 if !db_path.exists() {
1705 return Ok(false);
1706 }
1707 let conn = Self::open_sqlite_connection(db_path)?;
1708 let count = if let Some(source_id) = source_id {
1709 execute_sql(
1710 &conn,
1711 "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
1712 vec![
1713 SqlValue::Text(package_id.to_owned()),
1714 SqlValue::Text(source_id.to_owned()),
1715 ],
1716 )?
1717 } else {
1718 execute_sql(
1719 &conn,
1720 "DELETE FROM pin WHERE package_id = ?1",
1721 vec![SqlValue::Text(package_id.to_owned())],
1722 )?
1723 };
1724 conn.cacheflush().context("failed to flush pin database")?;
1725 Ok(count > 0)
1726 }
1727
1728 pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
1729 let db_path = pins_db_path(&self.app_root);
1730 if !db_path.exists() {
1731 return Ok(());
1732 }
1733
1734 if let Some(source_id) = source_id {
1735 let conn = Self::open_sqlite_connection(db_path)?;
1736 execute_sql(
1737 &conn,
1738 "DELETE FROM pin WHERE source_id = ?1",
1739 vec![SqlValue::Text(source_id.to_owned())],
1740 )?;
1741 conn.cacheflush().context("failed to flush pin database")?;
1742 } else {
1743 fs::remove_file(&db_path)?;
1744 }
1745 Ok(())
1746 }
1747
1748 fn save_store(&self) -> Result<()> {
1749 save_store(&self.app_root, &self.store)
1750 }
1751
1752 fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
1753 source_state_dir(&self.app_root, source)
1754 }
1755
1756 pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
1759 self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
1760 }
1761
1762 pub fn download_installer_for_request(
1763 &mut self,
1764 request: &InstallRequest,
1765 download_dir: &Path,
1766 ) -> Result<DownloadOutput> {
1767 let fetched = self.fetch_installer_for_request(request, download_dir)?;
1768 let manifest_yaml =
1769 build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
1770 .context("failed to render manifest YAML")?;
1771 let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
1772 let manifest_path = download_dir.join(&manifest_filename);
1773 fs::write(&manifest_path, manifest_yaml.as_bytes())
1774 .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
1775
1776 Ok(DownloadOutput {
1777 manifest: fetched.manifest,
1778 installer_path: fetched.installer_path,
1779 manifest_path,
1780 })
1781 }
1782
1783 fn fetch_installer_for_request(
1788 &mut self,
1789 request: &InstallRequest,
1790 download_dir: &Path,
1791 ) -> Result<FetchedInstaller> {
1792 let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
1793 let installer = select_installer(&manifest.installers, &request.query)
1794 .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
1795 let url = installer
1796 .url
1797 .as_deref()
1798 .ok_or_else(|| anyhow!("Installer has no URL"))?;
1799
1800 fs::create_dir_all(download_dir)?;
1801 let filename = request.rename.as_deref().unwrap_or_else(|| {
1802 url.rsplit('/')
1803 .next()
1804 .unwrap_or("installer")
1805 .split('?')
1806 .next()
1807 .unwrap_or("installer")
1808 });
1809 let dest = download_dir.join(filename);
1810
1811 let response = self.client.get(url).send().context("failed to download installer")?;
1812 if !response.status().is_success() {
1813 bail!("Download failed: HTTP {}", response.status());
1814 }
1815 let bytes = response.bytes()?;
1816 fs::write(&dest, &bytes)?;
1817
1818 if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
1819 let _ = fs::remove_file(&dest);
1820 return Err(error);
1821 }
1822
1823 Ok(FetchedInstaller {
1824 manifest,
1825 manifest_documents,
1826 installer,
1827 installer_path: dest,
1828 })
1829 }
1830
1831 pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
1832 let mut request = InstallRequest::new(query.clone());
1833 request.mode = if silent {
1834 InstallerMode::Silent
1835 } else {
1836 InstallerMode::SilentWithProgress
1837 };
1838 self.install_request(&request)
1839 }
1840
1841 pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
1842 let mut request = InstallRequest::new(query.clone());
1843 request.mode = mode;
1844 self.install_request(&request)
1845 }
1846
1847 pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
1848 let manifest = self.resolve_manifest_for_install(request)?;
1849 if !package_actions_supported() {
1850 let installer_type = select_installer(&manifest.installers, &request.query)
1851 .and_then(|installer| installer.installer_type)
1852 .unwrap_or_else(|| "install".to_owned())
1853 .to_lowercase();
1854 return Ok(unsupported_action_result(
1855 manifest.id.clone(),
1856 manifest.version.clone(),
1857 installer_type,
1858 INSTALL_UNSUPPORTED_WARNING,
1859 ));
1860 }
1861
1862 let existing_match = self.find_installed_package_for_install(request, &manifest)?;
1863 if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
1864 return Ok(no_op_result);
1865 }
1866
1867 Self::ensure_package_agreements_accepted(&manifest, request)?;
1868 self.install_dependencies(&manifest, request, &mut HashSet::new())?;
1869
1870 if request.dependencies_only {
1871 return Ok(InstallResult {
1872 package_id: manifest.id.clone(),
1873 version: manifest.version.clone(),
1874 installer_path: PathBuf::new(),
1875 installer_type: "dependencies".to_owned(),
1876 exit_code: 0,
1877 success: true,
1878 no_op: false,
1879 warnings: Vec::new(),
1880 });
1881 }
1882
1883 let installer = select_installer(&manifest.installers, &request.query)
1884 .ok_or_else(|| anyhow!("No applicable installer found"))?;
1885
1886 if request.uninstall_previous {
1887 let mut uninstall_request = UninstallRequest::new(PackageQuery {
1888 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1889 query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
1890 source: request.query.source.clone(),
1891 exact: true,
1892 version: request.query.version.clone(),
1893 ..PackageQuery::default()
1894 });
1895 uninstall_request.product_code = installer.product_code.clone();
1896 uninstall_request.mode = InstallerMode::Silent;
1897 uninstall_request.all_versions = true;
1898 uninstall_request.force = true;
1899 let _ = self.uninstall_request(&uninstall_request);
1900 }
1901
1902 let temp_dir = std::env::temp_dir().join("pinget-install");
1903 let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
1904
1905 let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
1906
1907 let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
1908
1909 Ok(InstallResult {
1910 package_id: manifest.id.clone(),
1911 version: manifest.version.clone(),
1912 installer_path,
1913 installer_type,
1914 exit_code,
1915 success: exit_code == 0,
1916 no_op: false,
1917 warnings: Vec::new(),
1918 })
1919 }
1920
1921 fn find_installed_package_for_install(
1922 &mut self,
1923 request: &InstallRequest,
1924 manifest: &Manifest,
1925 ) -> Result<Option<ListMatch>> {
1926 let installed_matches = self.list(&ListQuery {
1927 query: request.query.query.clone(),
1928 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
1929 name: request.query.name.clone(),
1930 moniker: request.query.moniker.clone(),
1931 tag: None,
1932 command: None,
1933 product_code: None,
1934 version: None,
1935 source: request.query.source.clone(),
1936 count: Some(100),
1937 exact: request.query.exact || !manifest.id.trim().is_empty(),
1938 install_scope: request.query.install_scope.clone(),
1939 upgrade_only: false,
1940 include_unknown: false,
1941 include_pinned: false,
1942 })?;
1943
1944 Ok(installed_matches.matches.into_iter().find(|candidate| {
1945 candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
1946 }))
1947 }
1948
1949 fn create_install_no_op_result(
1950 request: &InstallRequest,
1951 manifest: &Manifest,
1952 existing_match: Option<&ListMatch>,
1953 ) -> Option<InstallResult> {
1954 let existing_match = existing_match?;
1955
1956 if request.no_upgrade {
1957 return Some(InstallResult {
1958 package_id: manifest.id.clone(),
1959 version: existing_match.installed_version.clone(),
1960 installer_path: PathBuf::new(),
1961 installer_type: "install".to_owned(),
1962 exit_code: 0,
1963 success: true,
1964 no_op: true,
1965 warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
1966 });
1967 }
1968
1969 if !request.force
1970 && !existing_match.installed_version.trim().is_empty()
1971 && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
1972 {
1973 return Some(InstallResult {
1974 package_id: manifest.id.clone(),
1975 version: existing_match.installed_version.clone(),
1976 installer_path: PathBuf::new(),
1977 installer_type: "install".to_owned(),
1978 exit_code: 0,
1979 success: true,
1980 no_op: true,
1981 warnings: vec![
1982 "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
1983 ],
1984 });
1985 }
1986
1987 None
1988 }
1989
1990 pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
1991 if request.manifest_path.is_none() && !package_actions_supported() {
1992 let package_id = request
1993 .query
1994 .id
1995 .clone()
1996 .or_else(|| request.query.name.clone())
1997 .or_else(|| request.query.moniker.clone())
1998 .or_else(|| request.query.query.clone())
1999 .or_else(|| request.product_code.clone())
2000 .unwrap_or_else(|| "repair".to_owned());
2001 return Ok(unsupported_action_result(
2002 package_id,
2003 request.query.version.clone().unwrap_or_default(),
2004 "repair".to_owned(),
2005 REPAIR_UNSUPPORTED_WARNING,
2006 ));
2007 }
2008
2009 let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2010 let installed_match = if request.manifest_path.is_some() {
2011 None
2012 } else {
2013 let installed = self.list(&Self::create_repair_list_query(request))?;
2014 warnings.extend(installed.warnings);
2015 if installed.matches.is_empty() {
2016 bail!("No installed package matched the supplied repair query.");
2017 }
2018 if installed.matches.len() > 1 {
2019 bail!("Multiple installed packages matched the supplied repair query.");
2020 }
2021 installed.matches.into_iter().next()
2022 };
2023
2024 let mut result =
2025 self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2026 warnings.extend(result.warnings);
2027 result.warnings = warnings;
2028 Ok(result)
2029 }
2030
2031 pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2032 let mut request = UninstallRequest::new(query.clone());
2033 request.mode = if silent {
2034 InstallerMode::Silent
2035 } else {
2036 InstallerMode::SilentWithProgress
2037 };
2038 self.uninstall_request(&request)
2039 }
2040
2041 pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2042 if !package_actions_supported() {
2043 let (package_id, version) = if let Some(path) = &request.manifest_path {
2044 let manifest = Self::load_manifest_from_path(path)?;
2045 (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2046 } else {
2047 (
2048 request
2049 .query
2050 .id
2051 .clone()
2052 .or_else(|| request.query.query.clone())
2053 .or_else(|| request.query.name.clone())
2054 .or_else(|| request.query.moniker.clone())
2055 .or_else(|| request.product_code.clone())
2056 .unwrap_or_else(|| "unknown".to_owned()),
2057 request.query.version.clone().unwrap_or_default(),
2058 )
2059 };
2060
2061 return Ok(unsupported_action_result(
2062 package_id,
2063 version,
2064 "uninstall",
2065 UNINSTALL_UNSUPPORTED_WARNING,
2066 ));
2067 }
2068
2069 let matches = self.resolve_uninstall_matches(request)?;
2070 let mut exit_code = 0;
2071
2072 for installed in &matches {
2073 exit_code = uninstall_package(installed, request)?;
2074 if exit_code != 0 && !request.all_versions {
2075 break;
2076 }
2077 }
2078
2079 let installed = matches
2080 .first()
2081 .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2082
2083 Ok(InstallResult {
2084 package_id: installed.id.clone(),
2085 version: installed.installed_version.clone(),
2086 installer_path: PathBuf::new(),
2087 installer_type: "uninstall".to_owned(),
2088 exit_code,
2089 success: exit_code == 0,
2090 no_op: false,
2091 warnings: Vec::new(),
2092 })
2093 }
2094
2095 fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2096 self.resolve_manifest_and_documents_for_install(request)
2097 .map(|(manifest, _)| manifest)
2098 }
2099
2100 fn resolve_manifest_and_documents_for_install(
2101 &mut self,
2102 request: &InstallRequest,
2103 ) -> Result<(Manifest, JsonValue)> {
2104 if let Some(path) = &request.manifest_path {
2105 return Self::load_manifest_from_path_with_documents(path);
2106 }
2107
2108 let (located, _warnings) = self.find_single_match(&request.query)?;
2109 let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2110 Ok((manifest, documents))
2111 }
2112
2113 fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2114 if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2115 bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2116 }
2117 Ok(())
2118 }
2119
2120 fn install_dependencies(
2121 &mut self,
2122 manifest: &Manifest,
2123 request: &InstallRequest,
2124 visited: &mut HashSet<String>,
2125 ) -> Result<()> {
2126 if request.skip_dependencies {
2127 return Ok(());
2128 }
2129
2130 let dependency_ids = manifest
2131 .package_dependencies
2132 .iter()
2133 .chain(
2134 manifest
2135 .installers
2136 .iter()
2137 .flat_map(|installer| installer.package_dependencies.iter()),
2138 )
2139 .filter(|value| !value.trim().is_empty())
2140 .cloned()
2141 .collect::<BTreeSet<_>>();
2142
2143 for dependency_id in dependency_ids {
2144 if !visited.insert(dependency_id.clone()) {
2145 continue;
2146 }
2147
2148 let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2149 self.install_request(&dependency_request)?;
2150 }
2151
2152 Ok(())
2153 }
2154
2155 fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2156 let mut dependency_request = InstallRequest::new(PackageQuery {
2157 id: Some(dependency_id),
2158 source: request
2159 .dependency_source
2160 .clone()
2161 .or_else(|| request.query.source.clone()),
2162 exact: true,
2163 ..PackageQuery::default()
2164 });
2165 dependency_request.mode = match request.mode {
2166 InstallerMode::Interactive => InstallerMode::Interactive,
2167 InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2168 };
2169 dependency_request.accept_package_agreements = request.accept_package_agreements;
2170 dependency_request.no_upgrade = true;
2171 dependency_request.ignore_security_hash = request.ignore_security_hash;
2172 dependency_request.dependency_source = request.dependency_source.clone();
2173 dependency_request
2174 }
2175
2176 fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2177 ListQuery {
2178 query: request.query.query.clone(),
2179 id: request.query.id.clone(),
2180 name: request.query.name.clone(),
2181 moniker: request.query.moniker.clone(),
2182 product_code: request.product_code.clone(),
2183 version: request.query.version.clone(),
2184 source: request.query.source.clone(),
2185 count: Some(100),
2186 exact: request.query.exact,
2187 install_scope: request.query.install_scope.clone(),
2188 ..ListQuery::default()
2189 }
2190 }
2191
2192 fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2193 let mut query = request.query.clone();
2194 query.query = if installed_match.is_none() {
2195 request.query.query.clone()
2196 } else {
2197 None
2198 };
2199 query.id = installed_match
2200 .map(|item| item.id.clone())
2201 .or_else(|| request.query.id.clone());
2202 query.name = if installed_match.is_none() {
2203 request.query.name.clone()
2204 } else {
2205 None
2206 };
2207 query.moniker = if installed_match.is_none() {
2208 request.query.moniker.clone()
2209 } else {
2210 None
2211 };
2212 query.source = request
2213 .query
2214 .source
2215 .clone()
2216 .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2217 query.exact = true;
2218 query.version = request
2219 .query
2220 .version
2221 .clone()
2222 .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2223
2224 InstallRequest {
2225 query,
2226 manifest_path: request.manifest_path.clone(),
2227 mode: request.mode,
2228 log_path: request.log_path.clone(),
2229 custom: None,
2230 override_args: None,
2231 install_location: None,
2232 skip_dependencies: false,
2233 dependencies_only: false,
2234 accept_package_agreements: request.accept_package_agreements,
2235 force: true,
2236 rename: None,
2237 uninstall_previous: false,
2238 ignore_security_hash: request.ignore_security_hash,
2239 dependency_source: None,
2240 no_upgrade: false,
2241 }
2242 }
2243
2244 fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2245 let mut effective_query = request.query.clone();
2246 let mut effective_product_code = request.product_code.clone();
2247
2248 if let Some(path) = &request.manifest_path {
2249 let manifest = Self::load_manifest_from_path(path)?;
2250 effective_query = PackageQuery {
2251 query: Some(manifest.id.clone()),
2252 id: Some(manifest.id.clone()),
2253 name: Some(manifest.name.clone()),
2254 exact: true,
2255 version: effective_query.version.clone().or(Some(manifest.version)),
2256 ..PackageQuery::default()
2257 };
2258 effective_product_code = effective_product_code.or_else(|| {
2259 manifest
2260 .installers
2261 .iter()
2262 .find_map(|installer| installer.product_code.clone())
2263 });
2264 }
2265
2266 let list_query = ListQuery {
2267 query: effective_query.query.clone(),
2268 id: effective_query.id.clone(),
2269 name: effective_query.name.clone(),
2270 moniker: effective_query.moniker.clone(),
2271 tag: None,
2272 command: None,
2273 product_code: effective_product_code,
2274 version: effective_query.version.clone(),
2275 source: effective_query.source.clone(),
2276 count: if request.all_versions { None } else { Some(100) },
2277 exact: effective_query.exact,
2278 install_scope: effective_query.install_scope,
2279 upgrade_only: false,
2280 include_unknown: false,
2281 include_pinned: false,
2282 };
2283
2284 let list_result = self.list(&list_query)?;
2285 if list_result.matches.is_empty() {
2286 bail!("No installed package found matching the query");
2287 }
2288 if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2289 bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2290 }
2291
2292 Ok(if request.all_versions {
2293 list_result.matches
2294 } else {
2295 vec![list_result.matches[0].clone()]
2296 })
2297 }
2298
2299 fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2300 Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2301 }
2302
2303 fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2304 let resolved = resolve_manifest_path(manifest_path)?;
2305 let bytes =
2306 fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2307 parse_yaml_manifest_bundle(&bytes)
2308 }
2309
2310 fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2311 self.find_single_match_with_semantics(query, SearchSemantics::Single)
2312 }
2313
2314 fn find_single_match_with_semantics(
2315 &mut self,
2316 query: &PackageQuery,
2317 semantics: SearchSemantics,
2318 ) -> Result<(LocatedMatch, Vec<String>)> {
2319 let (matches, warnings, _) = self.search_located(query, semantics)?;
2320
2321 if matches.is_empty() {
2322 bail!("no package matched the supplied query");
2323 }
2324
2325 if matches.len() > 1 {
2326 let choices = matches
2327 .iter()
2328 .take(10)
2329 .map(|item| {
2330 format!(
2331 "{} [{}] ({})",
2332 item.display.name, item.display.id, item.display.source_name
2333 )
2334 })
2335 .collect::<Vec<_>>()
2336 .join(", ");
2337 bail!("multiple packages matched: {choices}");
2338 }
2339
2340 Ok((matches.into_iter().next().expect("one match"), warnings))
2341 }
2342
2343 fn search_located(
2344 &mut self,
2345 query: &PackageQuery,
2346 semantics: SearchSemantics,
2347 ) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
2348 let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2349 let mut matches = Vec::new();
2350 let mut warnings = Vec::new();
2351 let mut truncated = false;
2352
2353 for index in indexes {
2354 match self.search_source(index, query, semantics) {
2355 Ok(mut source_matches) => {
2356 truncated |= source_matches.truncated;
2357 matches.append(&mut source_matches.matches);
2358 }
2359 Err(_error) => warnings.push(format!(
2360 "Failed when searching source; results will not be included: {}",
2361 self.store.sources[index].name
2362 )),
2363 }
2364 }
2365
2366 if semantics == SearchSemantics::Many {
2367 matches.sort_by(|left, right| {
2368 search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2369 });
2370 let limit = max_results(query);
2371 if matches.len() > limit {
2372 truncated = true;
2373 matches.truncate(limit);
2374 }
2375 }
2376
2377 Ok((matches, warnings, truncated))
2378 }
2379
2380 fn search_source(
2381 &mut self,
2382 source_index: usize,
2383 query: &PackageQuery,
2384 semantics: SearchSemantics,
2385 ) -> Result<SearchSourceMatches> {
2386 match self.store.sources[source_index].kind {
2387 SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2388 SourceKind::Rest => self.search_rest(source_index, query, semantics),
2389 }
2390 }
2391
2392 fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2393 match &located.locator {
2394 MatchLocator::PreIndexedV1 { package_rowid } => {
2395 let source = self.source_clone(located.source_index);
2396 let connection = self.open_preindexed_connection(located.source_index)?;
2397 let versions = query_v1_versions(&connection, *package_rowid)?;
2398 let mut keys = versions
2399 .into_iter()
2400 .map(|row| VersionKey {
2401 version: row.version,
2402 channel: row.channel,
2403 })
2404 .collect::<Vec<_>>();
2405 sort_versions_desc(&mut keys);
2406 if keys.is_empty() {
2407 bail!("no versions found for {} in {}", located.display.id, source.name);
2408 }
2409 Ok(keys)
2410 }
2411 MatchLocator::PreIndexedV2 {
2412 package_rowid,
2413 package_hash,
2414 } => {
2415 let source = self.source_clone(located.source_index);
2416 let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2417 let mut keys = entries
2418 .into_iter()
2419 .map(|entry| VersionKey {
2420 version: entry.version,
2421 channel: String::new(),
2422 })
2423 .collect::<Vec<_>>();
2424 sort_versions_desc(&mut keys);
2425 Ok(keys)
2426 }
2427 MatchLocator::Rest { versions, .. } => {
2428 let mut keys = versions.clone();
2429 sort_versions_desc(&mut keys);
2430 Ok(keys)
2431 }
2432 }
2433 }
2434
2435 fn manifest_for_match(
2436 &mut self,
2437 located: &LocatedMatch,
2438 query: &PackageQuery,
2439 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2440 self.manifest_for_match_with_refresh(located, query, true)
2441 }
2442
2443 fn manifest_for_match_with_refresh(
2444 &mut self,
2445 located: &LocatedMatch,
2446 query: &PackageQuery,
2447 allow_explicit_version_refresh: bool,
2448 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2449 match &located.locator {
2450 MatchLocator::PreIndexedV1 { package_rowid } => {
2451 let source = self.source_clone(located.source_index);
2452 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2453 let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2454 let selected = match select_v1_version(
2455 &versions,
2456 query.version.as_deref(),
2457 query.channel.as_deref(),
2458 &located.display.id,
2459 &source.name,
2460 ) {
2461 Ok(selected) => selected,
2462 Err(error)
2463 if allow_explicit_version_refresh
2464 && query.version.is_some()
2465 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2466 {
2467 let open_refresh = opened.refresh.clone();
2468 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2469 return missing_error_after_open_refresh(error, &open_refresh);
2470 }
2471 if self.has_recent_successful_preindexed_refresh_attempt(
2472 &self.preindexed_refresh_lock_key(&source),
2473 ) {
2474 return Err(error);
2475 }
2476 drop(opened);
2477 return self.refresh_preindexed_and_retry_manifest(
2478 located.source_index,
2479 &located.display,
2480 query,
2481 error,
2482 );
2483 }
2484 Err(error) => return Err(error),
2485 };
2486 let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2487 let bytes =
2488 self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2489 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2490 manifest.version = selected.version.clone();
2491 manifest.channel = selected.channel;
2492 Ok((manifest, manifest_documents, vec![bytes.path]))
2493 }
2494 MatchLocator::PreIndexedV2 {
2495 package_rowid,
2496 package_hash,
2497 } => {
2498 let source = self.source_clone(located.source_index);
2499 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2500 let (entries, version_data_file) =
2501 self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2502 let selected = match select_v2_version(
2503 &entries,
2504 query.version.as_deref(),
2505 query.channel.as_deref(),
2506 &located.display.id,
2507 &source.name,
2508 ) {
2509 Ok(selected) => selected,
2510 Err(error)
2511 if allow_explicit_version_refresh
2512 && query.version.is_some()
2513 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2514 {
2515 let open_refresh = opened.refresh.clone();
2516 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2517 return missing_error_after_open_refresh(error, &open_refresh);
2518 }
2519 if self.has_recent_successful_preindexed_refresh_attempt(
2520 &self.preindexed_refresh_lock_key(&source),
2521 ) {
2522 return Err(error);
2523 }
2524 drop(opened);
2525 return self.refresh_preindexed_and_retry_manifest(
2526 located.source_index,
2527 &located.display,
2528 query,
2529 error,
2530 );
2531 }
2532 Err(error) => return Err(error),
2533 };
2534 let manifest_bytes = self.get_cached_source_file(
2535 "V2_M",
2536 &source,
2537 &selected.manifest_relative_path,
2538 Some(selected.manifest_hash.as_str()),
2539 )?;
2540 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
2541 manifest.version = selected.version;
2542 Ok((
2543 manifest,
2544 manifest_documents,
2545 vec![version_data_file, manifest_bytes.path],
2546 ))
2547 }
2548 MatchLocator::Rest { package_id, versions } => {
2549 let source = self.source_clone(located.source_index);
2550 let selected = select_rest_version(
2551 versions,
2552 query.version.as_deref(),
2553 query.channel.as_deref(),
2554 &located.display.id,
2555 &source.name,
2556 )?;
2557 let (bytes, cache_path) =
2558 self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
2559 let (manifest, manifest_documents) =
2560 parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
2561 Ok((manifest, manifest_documents, vec![cache_path]))
2562 }
2563 }
2564 }
2565
2566 fn refresh_preindexed_and_retry_manifest(
2567 &mut self,
2568 source_index: usize,
2569 previous_display: &SearchMatch,
2570 query: &PackageQuery,
2571 missing_error: anyhow::Error,
2572 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2573 self.refresh_preindexed_source(source_index, true)
2574 .with_context(|| format!("{missing_error} The source refresh also failed"))?;
2575 let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
2576 self.manifest_for_match_with_refresh(&refreshed, query, false)
2577 }
2578
2579 fn find_refreshed_preindexed_match(
2580 &mut self,
2581 source_index: usize,
2582 previous_display: &SearchMatch,
2583 query: &PackageQuery,
2584 ) -> Result<LocatedMatch> {
2585 let source_name = self.store.sources[source_index].name.clone();
2586 let mut source_query = query.clone();
2587 source_query.source = Some(source_name.clone());
2588 let matches = self
2589 .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
2590 .matches;
2591 if let Some(found) = matches
2592 .iter()
2593 .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
2594 {
2595 return Ok(found.clone());
2596 }
2597 if matches.len() == 1 {
2598 return Ok(matches.into_iter().next().expect("single match"));
2599 }
2600 bail!(
2601 "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
2602 previous_display.id,
2603 source_name
2604 )
2605 }
2606
2607 fn search_preindexed(
2608 &mut self,
2609 source_index: usize,
2610 query: &PackageQuery,
2611 semantics: SearchSemantics,
2612 ) -> Result<SearchSourceMatches> {
2613 let connection = self.open_preindexed_connection(source_index)?;
2614 let source = self.source_clone(source_index);
2615
2616 match query_v2_matches(&connection, query, semantics) {
2617 Ok((rows, truncated)) => Ok(SearchSourceMatches {
2618 truncated,
2619 matches: rows
2620 .into_iter()
2621 .map(|row| LocatedMatch {
2622 display: SearchMatch {
2623 source_name: source.name.clone(),
2624 source_kind: source.kind,
2625 id: row.id.clone(),
2626 name: row.name.clone(),
2627 moniker: row.moniker.clone(),
2628 version: Some(row.version.clone()),
2629 channel: None,
2630 match_criteria: row.match_criteria.clone(),
2631 },
2632 source_index,
2633 locator: MatchLocator::PreIndexedV2 {
2634 package_rowid: row.package_rowid,
2635 package_hash: row.package_hash,
2636 },
2637 })
2638 .collect(),
2639 }),
2640 Err(error) if can_fallback_to_v1(&error) => {
2641 let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
2642 let grouped = group_v1_rows(rows);
2643
2644 Ok(SearchSourceMatches {
2645 truncated,
2646 matches: grouped
2647 .into_iter()
2648 .map(|row| LocatedMatch {
2649 display: SearchMatch {
2650 source_name: source.name.clone(),
2651 source_kind: source.kind,
2652 id: row.id.clone(),
2653 name: row.name.clone(),
2654 moniker: row.moniker.clone(),
2655 version: Some(row.version.clone()),
2656 channel: Some(row.channel.clone()),
2657 match_criteria: row.match_criteria.clone(),
2658 },
2659 source_index,
2660 locator: MatchLocator::PreIndexedV1 {
2661 package_rowid: row.package_rowid,
2662 },
2663 })
2664 .collect(),
2665 })
2666 }
2667 Err(error) => Err(error),
2668 }
2669 }
2670
2671 fn search_rest(
2672 &mut self,
2673 source_index: usize,
2674 query: &PackageQuery,
2675 semantics: SearchSemantics,
2676 ) -> Result<SearchSourceMatches> {
2677 let source = self.source_clone(source_index);
2678 let info = self.load_rest_information(source_index)?;
2679 let contract = choose_contract(&info.server_supported_versions)
2680 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
2681
2682 let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
2683 let body = build_rest_search_body(query, &info, semantics)?;
2684 let response = self
2685 .client
2686 .post(url)
2687 .header("Version", contract)
2688 .json(&body)
2689 .send()
2690 .context("REST search request failed")?
2691 .error_for_status()
2692 .context("REST search request returned an error")?;
2693 let json = response
2694 .json::<JsonValue>()
2695 .context("failed to parse REST search response")?;
2696 let data = json
2697 .get("Data")
2698 .and_then(JsonValue::as_array)
2699 .cloned()
2700 .unwrap_or_default();
2701 let max_results = source_fetch_results(query, semantics);
2702
2703 let mut results = Vec::new();
2704 for item in data {
2705 let package_id = json_string(&item, "PackageIdentifier")
2706 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
2707 let package_name =
2708 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
2709 let mut versions = parse_rest_versions(&item)?;
2710 sort_versions_desc(&mut versions);
2711 let latest = versions
2712 .first()
2713 .cloned()
2714 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
2715
2716 results.push(LocatedMatch {
2717 display: SearchMatch {
2718 source_name: source.name.clone(),
2719 source_kind: source.kind,
2720 id: package_id.clone(),
2721 name: package_name,
2722 moniker: json_string(&item, "Moniker"),
2723 version: Some(latest.version.clone()),
2724 channel: if latest.channel.is_empty() {
2725 None
2726 } else {
2727 Some(latest.channel.clone())
2728 },
2729 match_criteria: rest_match_criteria(&item, query, semantics),
2730 },
2731 source_index,
2732 locator: MatchLocator::Rest { package_id, versions },
2733 });
2734 }
2735
2736 Ok(SearchSourceMatches {
2737 truncated: results.len() >= max_results,
2738 matches: results,
2739 })
2740 }
2741
2742 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
2743 Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
2744 }
2745
2746 fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
2747 let source = self.source_clone(source_index);
2748 let index_path = preindexed_index_path(&self.app_root, &source);
2749 let mut refresh = PreindexedRefreshOutcome::default();
2750 if !index_path.exists() {
2751 let _ = self.refresh_preindexed_source(source_index, true)?;
2752 refresh.attempted = true;
2753 } else if self.is_preindexed_index_stale(&source, &index_path) {
2754 let lock_key = self.preindexed_refresh_lock_key(&source);
2755 if self.has_recent_preindexed_refresh_attempt(&lock_key) {
2756 refresh.attempted = true;
2757 refresh.reused_recent_attempt = true;
2758 } else {
2759 refresh.attempted = true;
2760 if let Err(error) = self.refresh_preindexed_source(source_index, false) {
2761 refresh.error = Some(format!("{error:#}"));
2762 }
2763 }
2764 }
2765
2766 Ok(PreindexedOpenConnection {
2767 connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
2768 refresh,
2769 })
2770 }
2771
2772 fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
2773 let Some(interval) = self.pre_indexed_source_auto_update_interval else {
2774 return false;
2775 };
2776
2777 let last_update = source.last_update.or_else(|| {
2778 fs::metadata(index_path)
2779 .ok()
2780 .and_then(|metadata| metadata.modified().ok())
2781 .map(DateTime::<Utc>::from)
2782 });
2783
2784 match last_update {
2785 Some(last_update) => Utc::now() - last_update >= interval,
2786 None => true,
2787 }
2788 }
2789
2790 fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
2791 format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
2792 }
2793
2794 fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2795 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2796 Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2797 })
2798 }
2799
2800 fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
2801 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
2802 attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
2803 })
2804 }
2805
2806 fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
2807 self.preindexed_refresh_attempts.insert(
2808 lock_key,
2809 PreindexedRefreshAttempt {
2810 attempted_at: Utc::now(),
2811 succeeded,
2812 },
2813 );
2814 }
2815
2816 fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
2817 {
2818 let locks = preindexed_refresh_locks()
2819 .read()
2820 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2821 if let Some(existing) = locks.get(lock_key) {
2822 return Ok(Arc::clone(existing));
2823 }
2824 }
2825
2826 let mut locks = preindexed_refresh_locks()
2827 .write()
2828 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
2829 Ok(Arc::clone(
2830 locks
2831 .entry(lock_key.to_owned())
2832 .or_insert_with(|| Arc::new(Mutex::new(()))),
2833 ))
2834 }
2835
2836 fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
2837 let source = self.source_clone(source_index);
2838 let index_path = preindexed_index_path(&self.app_root, &source);
2839 let lock_key = self.preindexed_refresh_lock_key(&source);
2840 let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
2841 let _guard = refresh_lock
2842 .lock()
2843 .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
2844
2845 if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
2846 return Ok("source already refreshed".to_owned());
2847 }
2848
2849 let result = self.update_preindexed(source_index);
2850 self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
2851 let detail = result?;
2852 if !self.use_system_winget_sources {
2853 self.save_store()?;
2854 }
2855 Ok(detail)
2856 }
2857
2858 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
2859 let path_text = path.to_string_lossy().into_owned();
2860 let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
2861 .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
2862 database
2863 .connect()
2864 .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
2865 }
2866
2867 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
2868 let source = &mut self.store.sources[source_index];
2869 let state_dir = source_state_dir(&self.app_root, source);
2870 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
2871
2872 let mut last_error = None;
2873 for candidate in PREINDEXED_CANDIDATES {
2874 let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
2875 match self.client.get(&url).send() {
2876 Ok(response) if response.status().is_success() => {
2877 let header_version = response
2878 .headers()
2879 .get("x-ms-meta-sourceversion")
2880 .and_then(|value| value.to_str().ok())
2881 .map(str::to_owned);
2882 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
2883 let payload = bytes.to_vec();
2884 let index_bytes = extract_zip_member(&payload, "Public/index.db")
2885 .context("preindexed package did not contain Public/index.db")?;
2886
2887 fs::write(preindexed_package_path(&self.app_root, source), &payload)
2888 .context("failed to persist source package")?;
2889 let index_path = preindexed_index_path(&self.app_root, source);
2890 let temp_index_path = state_dir.join(format!(
2891 "index.{}.tmp",
2892 Utc::now().timestamp_nanos_opt().unwrap_or_default()
2893 ));
2894 fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
2895 replace_file(&temp_index_path, &index_path).context("failed to persist source index")?;
2896 remove_sqlite_sidecars(&index_path);
2901 source.last_update = Some(Utc::now());
2902 source.source_version = header_version;
2903 return Ok(format!("downloaded {}", candidate));
2904 }
2905 Ok(response) => {
2906 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2907 }
2908 Err(error) => {
2909 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2910 }
2911 }
2912 }
2913
2914 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2915 }
2916
2917 fn update_rest(&mut self, source_index: usize) -> Result<String> {
2918 let info = self.fetch_rest_information(source_index, true)?;
2919 let source = &mut self.store.sources[source_index];
2920 if !info.source_identifier.is_empty() {
2921 source.identifier = info.source_identifier.clone();
2922 }
2923 source.last_update = Some(Utc::now());
2924 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2925 Ok("refreshed information cache".to_owned())
2926 }
2927
2928 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2929 let source = self.source_clone(source_index);
2930 let cache_path = rest_information_cache_path(&self.app_root, &source);
2931
2932 if cache_path.exists() {
2933 let cache = serde_json::from_slice::<RestInfoCache>(
2934 &fs::read(&cache_path).context("failed to read REST information cache")?,
2935 )
2936 .context("failed to parse REST information cache")?;
2937 if cache.expires_at > Utc::now() {
2938 return Ok(cache.value);
2939 }
2940 }
2941
2942 self.fetch_rest_information(source_index, true)
2943 }
2944
2945 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2946 let source = self.source_clone(source_index);
2947 let url = format!("{}/information", source.arg.trim_end_matches('/'));
2948 let response = self
2949 .client
2950 .get(url)
2951 .send()
2952 .context("REST information request failed")?
2953 .error_for_status()
2954 .context("REST information request returned an error")?;
2955
2956 let max_age = cache_control_max_age(&response);
2957 let json = response
2958 .json::<JsonValue>()
2959 .context("failed to parse REST information response")?;
2960 let data = json
2961 .get("Data")
2962 .cloned()
2963 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2964 let info = serde_json::from_value::<RestInformation>(data)
2965 .context("failed to deserialize REST information payload")?;
2966
2967 if persist {
2968 let cache = RestInfoCache {
2969 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2970 value: info.clone(),
2971 };
2972 write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2973 }
2974
2975 Ok(info)
2976 }
2977
2978 fn load_v2_version_data(
2979 &self,
2980 source: &SourceRecord,
2981 package_rowid: i64,
2982 package_hash: &str,
2983 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2984 let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2985 .context("failed to reopen preindexed index for V2 version data")?;
2986 let package_hash = package_hash.to_ascii_lowercase();
2987 let package_id = query_optional_value(
2988 &connection,
2989 "SELECT id FROM packages WHERE rowid = ?1",
2990 vec![SqlValue::Integer(package_rowid)],
2991 |row| row_string(row, 0),
2992 )?
2993 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2994 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2995 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2996 let yaml = decompress_mszyml(&file.bytes)?;
2997 let document =
2998 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2999 Ok((document.versions, file.path))
3000 }
3001
3002 fn get_cached_source_file(
3003 &self,
3004 bucket: &str,
3005 source: &SourceRecord,
3006 relative_path: &str,
3007 expected_hash: Option<&str>,
3008 ) -> Result<CachedBytes> {
3009 let normalized_relative = relative_path.replace('\\', "/");
3010 let cache_path =
3011 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3012
3013 if cache_path.exists() {
3014 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3015 if hash_matches(expected_hash, &cached) {
3016 return Ok(CachedBytes {
3017 path: cache_path,
3018 bytes: cached,
3019 });
3020 }
3021 }
3022
3023 let url = format!(
3024 "{}/{}",
3025 source.arg.trim_end_matches('/'),
3026 normalized_relative.trim_start_matches('/')
3027 );
3028 let response = self
3029 .client
3030 .get(url)
3031 .send()
3032 .context("failed to fetch source file")?
3033 .error_for_status()
3034 .context("source file request returned an error")?;
3035 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3036
3037 if let Some(hash) = expected_hash {
3038 verify_hash(hash, &bytes)?;
3039 }
3040
3041 if let Some(parent) = cache_path.parent() {
3042 fs::create_dir_all(parent).context("failed to create cache directory")?;
3043 }
3044 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3045
3046 Ok(CachedBytes {
3047 path: cache_path,
3048 bytes,
3049 })
3050 }
3051
3052 fn get_or_fetch_rest_manifest(
3053 &mut self,
3054 source: &SourceRecord,
3055 package_id: &str,
3056 version: &str,
3057 channel: &str,
3058 ) -> Result<(Vec<u8>, PathBuf)> {
3059 let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
3060 if cache_path.exists() {
3061 return Ok((
3062 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3063 cache_path,
3064 ));
3065 }
3066
3067 let info = self.load_rest_information_by_name(&source.name)?;
3068 let contract = choose_contract(&info.server_supported_versions)
3069 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3070 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3071
3072 let mut params = vec![("Version", version.to_owned())];
3073 if !channel.is_empty() {
3074 params.push(("Channel", channel.to_owned()));
3075 }
3076 if info
3077 .required_query_parameters
3078 .iter()
3079 .any(|value| value.eq_ignore_ascii_case("Market"))
3080 {
3081 params.push(("Market", default_market()));
3082 }
3083
3084 let response = self
3085 .client
3086 .get(url)
3087 .header("Version", contract)
3088 .query(¶ms)
3089 .send()
3090 .context("REST manifest request failed")?
3091 .error_for_status()
3092 .context("REST manifest request returned an error")?;
3093 let bytes = response
3094 .bytes()
3095 .context("failed to read REST manifest response")?
3096 .to_vec();
3097
3098 if let Some(parent) = cache_path.parent() {
3099 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3100 }
3101 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3102
3103 Ok((bytes, cache_path))
3104 }
3105
3106 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3107 let index = self
3108 .store
3109 .sources
3110 .iter()
3111 .position(|source| source.name.eq_ignore_ascii_case(name))
3112 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3113 self.load_rest_information(index)
3114 }
3115
3116 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3117 if let Some(name) = source_name {
3118 let index = self
3119 .store
3120 .sources
3121 .iter()
3122 .position(|source| source.name.eq_ignore_ascii_case(name))
3123 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3124 return Ok(vec![index]);
3125 }
3126
3127 let mut indexes: Vec<_> = self
3128 .store
3129 .sources
3130 .iter()
3131 .enumerate()
3132 .filter(|(_, source)| !source.explicit)
3133 .collect();
3134 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3135 right_source
3136 .priority
3137 .cmp(&left_source.priority)
3138 .then_with(|| left_index.cmp(right_index))
3139 });
3140 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3141 }
3142
3143 fn source_clone(&self, index: usize) -> SourceRecord {
3144 self.store.sources[index].clone()
3145 }
3146
3147 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3148 if self.use_system_winget_sources {
3149 self.store = load_system_winget_source_store()?;
3150 }
3151 Ok(())
3152 }
3153
3154 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3155 self.refresh_system_winget_sources()?;
3156 let selected_names: Vec<_> = if source_name.is_none() {
3157 self.store.sources.iter().map(|source| source.name.clone()).collect()
3158 } else {
3159 self.resolve_source_indexes(source_name)?
3160 .into_iter()
3161 .map(|index| self.store.sources[index].name.clone())
3162 .collect()
3163 };
3164
3165 let detail = system_winget_update_sources(source_name)?;
3166 self.refresh_system_winget_sources()?;
3167
3168 Ok(selected_names
3169 .into_iter()
3170 .filter_map(|name| {
3171 self.store
3172 .sources
3173 .iter()
3174 .find(|source| source.name.eq_ignore_ascii_case(&name))
3175 .map(|source| SourceUpdateResult {
3176 name: source.name.clone(),
3177 kind: source.kind,
3178 detail: detail.clone(),
3179 })
3180 })
3181 .collect())
3182 }
3183}
3184
3185#[derive(Debug)]
3186struct CachedBytes {
3187 path: PathBuf,
3188 bytes: Vec<u8>,
3189}
3190
3191#[derive(Debug, Clone)]
3192struct V1SearchRow {
3193 manifest_rowid: i64,
3194 package_rowid: i64,
3195 version: String,
3196 channel: String,
3197 id: String,
3198 name: String,
3199 moniker: Option<String>,
3200 match_criteria: Option<String>,
3201}
3202
3203#[derive(Debug, Clone)]
3204struct V1VersionRow {
3205 version: String,
3206 channel: String,
3207 pathpart_id: i64,
3208 manifest_hash: Option<String>,
3209}
3210
3211#[derive(Debug, Clone)]
3212struct V2SearchRow {
3213 package_rowid: i64,
3214 package_hash: String,
3215 id: String,
3216 name: String,
3217 moniker: Option<String>,
3218 version: String,
3219 match_criteria: Option<String>,
3220}
3221
3222fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3223 query.query.is_some()
3224 || query.id.is_some()
3225 || query.name.is_some()
3226 || query.moniker.is_some()
3227 || query.tag.is_some()
3228 || query.command.is_some()
3229 || query.source.is_some()
3230}
3231
3232fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3233 PackageQuery {
3234 query: query.query.clone(),
3235 id: query.id.clone(),
3236 name: query.name.clone(),
3237 moniker: query.moniker.clone(),
3238 tag: query.tag.clone(),
3239 command: query.command.clone(),
3240 source: query.source.clone(),
3241 count: Some(LIST_LOOKUP_MAX_RESULTS),
3242 exact: query.exact,
3243 version: None,
3244 channel: None,
3245 locale: None,
3246 installer_type: None,
3247 installer_architecture: None,
3248 platform: None,
3249 os_version: None,
3250 install_scope: query.install_scope.clone(),
3251 }
3252}
3253
3254fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3255 query.query.is_some() || query.id.is_some() || query.name.is_some()
3256}
3257
3258fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3259 package
3260 .correlated
3261 .as_ref()
3262 .and_then(|candidate| candidate.version.as_deref())
3263 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3264}
3265
3266fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3267 package.installed_version.eq_ignore_ascii_case("Unknown")
3268}
3269
3270fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3271 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3274 return false;
3275 }
3276 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3279 return false;
3280 }
3281 installed_package_has_upgrade(package)
3282 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3283}
3284
3285fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3291 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3292 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3293
3294 for package in packages {
3295 match package.correlated.as_ref() {
3296 Some(candidate) => {
3297 let key = (
3298 candidate.id.to_ascii_lowercase(),
3299 candidate.source_name.to_ascii_lowercase(),
3300 );
3301 let keep_new = match by_id.get(&key) {
3302 Some(existing) => {
3303 match (
3309 package.installed_version_canonical,
3310 existing.installed_version_canonical,
3311 ) {
3312 (true, false) => true,
3313 (false, true) => false,
3314 _ => {
3315 compare_version(&package.installed_version, &existing.installed_version)
3316 == Ordering::Greater
3317 }
3318 }
3319 }
3320 None => true,
3321 };
3322 if keep_new {
3323 by_id.insert(key, package);
3324 }
3325 }
3326 None => uncorrelated.push(package),
3327 }
3328 }
3329
3330 let mut result = uncorrelated;
3331 result.extend(by_id.into_values());
3332 result
3333}
3334
3335fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3336 let mut source_specific = None;
3337 let mut source_agnostic = None;
3338
3339 for pin in pins {
3340 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3341 continue;
3342 }
3343
3344 if pin.source_id.is_empty() {
3345 if source_agnostic.is_none() {
3346 source_agnostic = Some(pin);
3347 }
3348 } else if item
3349 .source_name
3350 .as_deref()
3351 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3352 {
3353 source_specific = Some(pin);
3354 break;
3355 }
3356 }
3357
3358 source_specific.or(source_agnostic)
3359}
3360
3361fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3362 let Some(available_version) = item.available_version.as_deref() else {
3363 return false;
3364 };
3365 let Some(pin) = find_applicable_pin(item, pins) else {
3366 return false;
3367 };
3368
3369 match pin.pin_type {
3370 PinType::Blocking => true,
3371 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3372 }
3373}
3374
3375fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3376 if pattern.trim().is_empty() || pattern == "*" {
3377 return true;
3378 }
3379
3380 if !pattern.contains('*') {
3381 return version.eq_ignore_ascii_case(pattern);
3382 }
3383
3384 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3385 let prefix = &pattern[..pattern.len() - 1];
3386 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3387 }
3388
3389 version.eq_ignore_ascii_case(pattern)
3390}
3391
3392fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3393 let available_version = package.correlated.as_ref().and_then(|candidate| {
3394 candidate.version.as_ref().and_then(|candidate_version| {
3395 if installed_package_has_unknown_version(&package)
3396 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3397 {
3398 Some(candidate_version.clone())
3399 } else {
3400 None
3401 }
3402 })
3403 });
3404 let source_name = package
3405 .correlated
3406 .as_ref()
3407 .map(|candidate| candidate.source_name.clone())
3408 .filter(|value| !value.is_empty());
3409 let id = package
3410 .correlated
3411 .as_ref()
3412 .map(|candidate| candidate.id.clone())
3413 .unwrap_or_else(|| package.local_id.clone());
3414
3415 ListMatch {
3416 name: package.name,
3417 id,
3418 local_id: package.local_id,
3419 installed_version: package.installed_version,
3420 available_version,
3421 source_name,
3422 publisher: package.publisher,
3423 scope: package.scope,
3424 installer_category: package.installer_category,
3425 install_location: package.install_location,
3426 package_family_names: package.package_family_names,
3427 product_codes: package.product_codes,
3428 upgrade_codes: package.upgrade_codes,
3429 }
3430}
3431
3432fn list_sort_weight(package: &InstalledPackage) -> usize {
3433 if package.local_id.starts_with("ARP\\") {
3434 0
3435 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3436 1
3437 } else {
3438 2
3439 }
3440}
3441
3442fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3443 let correlated = package.correlated.as_ref();
3444
3445 if let Some(value) = &query.id {
3446 let local_match = matches_text(&package.local_id, value, query.exact);
3447 let correlated_match = correlated
3448 .map(|candidate| matches_text(&candidate.id, value, query.exact))
3449 .unwrap_or(false);
3450 if !local_match && !correlated_match {
3451 return false;
3452 }
3453 }
3454
3455 if let Some(value) = &query.name
3456 && !matches_text(&package.name, value, query.exact)
3457 {
3458 return false;
3459 }
3460
3461 if let Some(value) = &query.product_code
3462 && !package
3463 .product_codes
3464 .iter()
3465 .any(|code| code.eq_ignore_ascii_case(value))
3466 {
3467 return false;
3468 }
3469
3470 if let Some(value) = &query.version
3471 && !package.installed_version.eq_ignore_ascii_case(value)
3472 {
3473 return false;
3474 }
3475
3476 if let Some(value) = &query.query {
3477 let local_match =
3478 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3479 let correlated_match = correlated
3480 .map(|candidate| {
3481 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3482 })
3483 .unwrap_or(false);
3484 if !local_match && !correlated_match {
3485 return false;
3486 }
3487 }
3488
3489 if let Some(source) = &query.source
3490 && correlated
3491 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3492 .unwrap_or(true)
3493 {
3494 return false;
3495 }
3496
3497 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3498 return false;
3499 }
3500
3501 true
3502}
3503
3504fn correlate_installed_package(
3505 package: &InstalledPackage,
3506 candidates: &[SearchMatch],
3507 allow_loose_name_match: bool,
3508) -> Option<SearchMatch> {
3509 if package.local_id.starts_with("MSIX\\") {
3517 return None;
3518 }
3519
3520 let installed_name = normalize_correlation_name(&package.name);
3521 let installed_name_lower = package.name.to_ascii_lowercase();
3522 let candidate_names = correlation_name_candidates(&package.name);
3523
3524 let mut scored: Vec<(i32, SearchMatch)> = candidates
3525 .iter()
3526 .filter_map(|candidate| {
3527 let candidate_name = normalize_correlation_name(&candidate.name);
3528 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3529 1000
3530 } else if candidate_names.iter().any(|name| {
3531 let normalized = normalize_correlation_name(name);
3532 normalized == candidate_name
3533 }) {
3534 900
3535 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3536 700
3537 } else {
3538 0
3539 };
3540
3541 if base_score == 0 {
3542 return None;
3543 }
3544
3545 let candidate_lower = candidate.name.to_ascii_lowercase();
3558 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3559 100
3560 } else if installed_name_lower.contains(&candidate_lower) {
3561 50
3562 } else {
3563 0
3564 };
3565
3566 Some((base_score + prefix_bonus, candidate.clone()))
3567 })
3568 .collect();
3569
3570 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3571 let mut iter = scored.into_iter();
3572 let (top_score, top_match) = iter.next()?;
3573
3574 if let Some((next_score, _)) = iter.next()
3582 && next_score == top_score
3583 {
3584 return None;
3585 }
3586 Some(top_match)
3587}
3588
3589fn correlation_name_candidates(name: &str) -> Vec<String> {
3590 let mut candidates = Vec::new();
3591 candidates.push(name.trim().to_owned());
3592
3593 let mut trimmed = name.trim().to_owned();
3594 if let Some(index) = trimmed.find(" (") {
3595 trimmed.truncate(index);
3596 }
3597
3598 let mut words = Vec::new();
3599 for token in trimmed.split_whitespace() {
3600 let lower = token
3601 .trim_matches(|ch: char| ch == '(' || ch == ')')
3602 .to_ascii_lowercase();
3603 if !words.is_empty()
3604 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3605 {
3606 break;
3607 }
3608 words.push(token);
3609 }
3610
3611 if !words.is_empty() {
3612 candidates.push(words.join(" "));
3613 }
3614
3615 candidates.sort();
3616 candidates.dedup();
3617 candidates
3618}
3619
3620fn normalize_correlation_name(value: &str) -> String {
3621 value
3622 .chars()
3623 .filter(|ch| ch.is_ascii_alphanumeric())
3624 .flat_map(|ch| ch.to_lowercase())
3625 .collect()
3626}
3627
3628struct V2PackageMetadata {
3629 rowid: i64,
3630 id: String,
3631 name: String,
3632 moniker: Option<String>,
3633 latest_version: String,
3634 package_hash: String,
3635}
3636
3637fn lookup_identity_match_v2(
3643 connection: &Connection,
3644 package: &InstalledPackage,
3645) -> Result<Option<(i64, &'static str)>> {
3646 for pfn in &package.package_family_names {
3647 if let Some(rowid) = query_optional_value(
3648 connection,
3649 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3650 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3651 |row| row_i64(row, 0),
3652 )? {
3653 return Ok(Some((rowid, "PackageFamilyName")));
3654 }
3655 }
3656 for code in &package.upgrade_codes {
3665 if let Some(rowid) = query_optional_value(
3666 connection,
3667 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3668 vec![SqlValue::Text(code.to_ascii_lowercase())],
3669 |row| row_i64(row, 0),
3670 )? {
3671 return Ok(Some((rowid, "UpgradeCode")));
3672 }
3673 }
3674 for code in &package.product_codes {
3675 if let Some(rowid) = query_optional_value(
3676 connection,
3677 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3678 vec![SqlValue::Text(code.to_ascii_lowercase())],
3679 |row| row_i64(row, 0),
3680 )? {
3681 return Ok(Some((rowid, "ProductCode")));
3682 }
3683 }
3684 Ok(None)
3685}
3686
3687fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3691 query_optional_value(
3692 connection,
3693 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3694 vec![SqlValue::Text(package_id.to_owned())],
3695 |row| {
3696 Ok(V2PackageMetadata {
3697 rowid: row_i64(row, 0)?,
3698 id: row_string(row, 1)?,
3699 name: row_string(row, 2)?,
3700 moniker: row_opt_string(row, 3)?,
3701 latest_version: row_string(row, 4)?,
3702 package_hash: row_hex_string(row, 5)?,
3703 })
3704 },
3705 )
3706}
3707
3708fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3716 if !package.local_id.starts_with("MSIX\\") {
3717 return;
3718 }
3719 if !package.name.starts_with("ms-resource:") {
3720 return;
3721 }
3722 let Some(candidate) = package.correlated.as_ref() else {
3723 return;
3724 };
3725 if !candidate.name.is_empty() {
3726 package.name = candidate.name.clone();
3727 }
3728}
3729
3730fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3731 query_optional_value(
3732 connection,
3733 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3734 vec![SqlValue::Integer(package_rowid)],
3735 |row| {
3736 Ok(V2PackageMetadata {
3737 rowid: package_rowid,
3738 id: row_string(row, 0)?,
3739 name: row_string(row, 1)?,
3740 moniker: row_opt_string(row, 2)?,
3741 latest_version: row_string(row, 3)?,
3742 package_hash: row_hex_string(row, 4)?,
3743 })
3744 },
3745 )
3746}
3747
3748fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3749 query_optional_value(
3750 connection,
3751 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3752 Vec::new(),
3753 |row| row_i64(row, 0),
3754 )
3755 .ok()
3756 .flatten()
3757 .is_some()
3758}
3759
3760fn lookup_unique_normalized_identity(
3767 connection: &Connection,
3768 norm_name: &str,
3769 norm_publisher: &str,
3770) -> Result<Option<i64>> {
3771 let rows: Vec<i64> = query_rows(
3772 connection,
3773 "SELECT DISTINCT n.package FROM norm_names2 n \
3774 WHERE n.norm_name = ?1 \
3775 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3776 LIMIT 2",
3777 vec![
3778 SqlValue::Text(norm_name.to_owned()),
3779 SqlValue::Text(norm_publisher.to_owned()),
3780 ],
3781 |row| row_i64(row, 0),
3782 )?;
3783 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3784}
3785
3786fn v2_identity_tables_present(connection: &Connection) -> bool {
3787 query_optional_value(
3788 connection,
3789 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3790 Vec::new(),
3791 |row| row_i64(row, 0),
3792 )
3793 .ok()
3794 .flatten()
3795 .is_some()
3796}
3797
3798fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3805 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3806 return None;
3807 }
3808 for entry in entries {
3809 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3810 continue;
3811 };
3812 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3813 {
3814 return Some(entry.version.clone());
3815 }
3816 }
3817 None
3818}
3819
3820fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3829 entries
3830 .iter()
3831 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3832 .max_by(|a, b| compare_version(&a.version, &b.version))
3833 .map(|entry| entry.version.clone())
3834}
3835
3836#[cfg(windows)]
3837fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3838 let mut packages = Vec::new();
3839 let mut seen = BTreeSet::new();
3840
3841 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3842 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3843
3844 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3852
3853 if machine {
3854 collect_uninstall_view(
3855 &mut packages,
3856 &mut seen,
3857 &upgrade_codes,
3858 RegKey::predef(HKEY_LOCAL_MACHINE),
3859 "Machine",
3860 "X64",
3861 KEY_READ | KEY_WOW64_64KEY,
3862 )?;
3863 collect_uninstall_view(
3864 &mut packages,
3865 &mut seen,
3866 &upgrade_codes,
3867 RegKey::predef(HKEY_LOCAL_MACHINE),
3868 "Machine",
3869 "X86",
3870 KEY_READ | KEY_WOW64_32KEY,
3871 )?;
3872 collect_appmodel_packages(
3873 &mut packages,
3874 &mut seen,
3875 RegKey::predef(HKEY_LOCAL_MACHINE),
3876 "Machine",
3877 KEY_READ | KEY_WOW64_64KEY,
3878 )?;
3879 }
3880
3881 if user {
3882 collect_uninstall_view(
3883 &mut packages,
3884 &mut seen,
3885 &upgrade_codes,
3886 RegKey::predef(HKEY_CURRENT_USER),
3887 "User",
3888 "X64",
3889 KEY_READ,
3890 )?;
3891 collect_appmodel_packages(
3892 &mut packages,
3893 &mut seen,
3894 RegKey::predef(HKEY_CURRENT_USER),
3895 "User",
3896 KEY_READ,
3897 )?;
3898 }
3899
3900 Ok(packages)
3901}
3902
3903#[cfg(windows)]
3909fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
3910 let mut map = HashMap::new();
3911 if include_machine {
3912 collect_msi_upgrade_codes_from(
3913 RegKey::predef(HKEY_LOCAL_MACHINE),
3914 r"SOFTWARE\Classes\Installer\UpgradeCodes",
3915 KEY_READ | KEY_WOW64_64KEY,
3916 &mut map,
3917 );
3918 }
3919 if include_user {
3920 collect_msi_upgrade_codes_from(
3921 RegKey::predef(HKEY_CURRENT_USER),
3922 r"Software\Microsoft\Installer\UpgradeCodes",
3923 KEY_READ,
3924 &mut map,
3925 );
3926 }
3927 map
3928}
3929
3930#[cfg(windows)]
3931fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
3932 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3933 return;
3934 };
3935 for key_name in upgrade_codes.enum_keys().flatten() {
3936 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3937 continue;
3938 };
3939 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3940 continue;
3941 };
3942 for (value_name, _) in subkey.enum_values().flatten() {
3943 if let Some(product_code) = unflip_packed_guid(&value_name) {
3944 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3948 }
3949 }
3950 }
3951}
3952
3953#[cfg(windows)]
3958fn unflip_packed_guid(packed: &str) -> Option<String> {
3959 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3960 return None;
3961 }
3962 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3963 let bytes = packed.as_bytes();
3964 let mut reversed = String::with_capacity(32);
3965 let mut offset = 0;
3966 for size in CHUNKS {
3967 let chunk = &bytes[offset..offset + size];
3968 for &c in chunk.iter().rev() {
3969 reversed.push(c as char);
3970 }
3971 offset += size;
3972 }
3973 let r = reversed.to_ascii_lowercase();
3974 Some(format!(
3975 "{{{}-{}-{}-{}-{}}}",
3976 &r[0..8],
3977 &r[8..12],
3978 &r[12..16],
3979 &r[16..20],
3980 &r[20..32]
3981 ))
3982}
3983
3984#[cfg(not(windows))]
3985fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3986 Ok(Vec::new())
3987}
3988
3989#[cfg(windows)]
3990fn collect_uninstall_view(
3991 packages: &mut Vec<InstalledPackage>,
3992 seen: &mut BTreeSet<String>,
3993 upgrade_code_map: &HashMap<String, String>,
3994 root: RegKey,
3995 scope: &str,
3996 arch: &str,
3997 flags: u32,
3998) -> Result<()> {
3999 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4000
4001 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4002 Ok(key) => key,
4003 Err(_) => return Ok(()),
4004 };
4005
4006 for key_name in uninstall.enum_keys().flatten() {
4007 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4008 Ok(key) => key,
4009 Err(_) => continue,
4010 };
4011 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4012 {
4013 continue;
4014 }
4015
4016 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4017 continue;
4018 };
4019
4020 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4021 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4022 let publisher = read_reg_string(&subkey, "Publisher");
4023 let install_location = read_reg_string(&subkey, "InstallLocation");
4024 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4025 .into_iter()
4026 .collect::<Vec<_>>();
4027 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4028 if product_codes.is_empty() && looks_like_product_code(&key_name) {
4029 product_codes.push(key_name.to_ascii_lowercase());
4030 }
4031 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4032 if upgrade_codes.is_empty() {
4033 for code in &product_codes {
4037 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4038 upgrade_codes.push(uc.clone());
4039 break;
4040 }
4041 }
4042 }
4043 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4044 .as_deref()
4045 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4046 {
4047 Some("portable".to_owned())
4053 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4054 Some("msi".to_owned())
4055 } else if key_name.starts_with("MSIX\\") {
4056 Some("msix".to_owned())
4057 } else {
4058 Some("exe".to_owned())
4059 };
4060
4061 let dedupe_key = format!(
4062 "{}|{}|{}|{}",
4063 local_id,
4064 name.to_ascii_lowercase(),
4065 installed_version.to_ascii_lowercase(),
4066 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4067 );
4068 if !seen.insert(dedupe_key) {
4069 continue;
4070 }
4071
4072 packages.push(InstalledPackage {
4073 name,
4074 local_id,
4075 installed_version,
4076 publisher,
4077 scope: Some(scope.to_owned()),
4078 installer_category,
4079 install_location,
4080 package_family_names,
4081 product_codes,
4082 upgrade_codes,
4083 correlated: None,
4084 installed_version_canonical: false,
4085 correlated_requires_explicit_upgrade: false,
4086 correlated_lacks_compatible_installer: false,
4087 });
4088 }
4089
4090 Ok(())
4091}
4092
4093#[cfg(windows)]
4094fn collect_appmodel_packages(
4095 packages: &mut Vec<InstalledPackage>,
4096 seen: &mut BTreeSet<String>,
4097 root: RegKey,
4098 scope: &str,
4099 flags: u32,
4100) -> Result<()> {
4101 const APPMODEL_PACKAGES_PATH: &str =
4102 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4103
4104 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4105 Ok(key) => key,
4106 Err(_) => return Ok(()),
4107 };
4108
4109 for key_name in appmodel.enum_keys().flatten() {
4110 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4111 Ok(key) => key,
4112 Err(_) => continue,
4113 };
4114
4115 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4116 continue;
4117 };
4118 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4119 if install_location.as_deref().is_some_and(is_windows_system_path) {
4120 continue;
4121 }
4122
4123 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4124 continue;
4125 };
4126
4127 let local_id = format!(r"MSIX\{key_name}");
4128 let dedupe_key = format!(
4129 "{}|{}|{}",
4130 local_id,
4131 name.to_ascii_lowercase(),
4132 metadata.version.to_ascii_lowercase()
4133 );
4134 if !seen.insert(dedupe_key) {
4135 continue;
4136 }
4137
4138 packages.push(InstalledPackage {
4139 name,
4140 local_id,
4141 installed_version: metadata.version,
4142 publisher: None,
4143 scope: Some(scope.to_owned()),
4144 installer_category: Some("msix".to_owned()),
4145 install_location,
4146 package_family_names: vec![metadata.family_name],
4147 product_codes: Vec::new(),
4148 upgrade_codes: Vec::new(),
4149 correlated: None,
4150 installed_version_canonical: false,
4151 correlated_requires_explicit_upgrade: false,
4152 correlated_lacks_compatible_installer: false,
4153 });
4154 }
4155
4156 Ok(())
4157}
4158
4159#[cfg(windows)]
4160struct ParsedMsixPackageFullName {
4161 version: String,
4162 family_name: String,
4163}
4164
4165#[cfg(windows)]
4166fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4167 let segments = value.split('_').collect::<Vec<_>>();
4168 if segments.len() < 5 {
4169 return None;
4170 }
4171
4172 let name = segments[..segments.len() - 4].join("_");
4173 if name.is_empty() {
4174 return None;
4175 }
4176
4177 let version = segments[segments.len() - 4].trim();
4178 let resource_id = segments[segments.len() - 2].trim();
4179 let publisher_id = segments[segments.len() - 1].trim();
4180 if version.is_empty() || publisher_id.is_empty() {
4181 return None;
4182 }
4183
4184 let family_name = if resource_id.is_empty() {
4185 format!("{name}_{publisher_id}")
4186 } else {
4187 format!("{name}_{resource_id}_{publisher_id}")
4188 };
4189
4190 Some(ParsedMsixPackageFullName {
4191 version: version.to_owned(),
4192 family_name,
4193 })
4194}
4195
4196#[cfg(windows)]
4197fn is_windows_system_path(path: &str) -> bool {
4198 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
4199}
4200
4201#[cfg(windows)]
4202fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
4203 key.get_value::<String, _>(value_name)
4204 .ok()
4205 .map(|value| value.trim().to_owned())
4206 .filter(|value| !value.is_empty())
4207}
4208
4209#[cfg(windows)]
4210fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
4211 key.get_value::<u32, _>(value_name).ok()
4212}
4213
4214#[cfg(windows)]
4215fn looks_like_product_code(value: &str) -> bool {
4216 value.starts_with('{') && value.ends_with('}')
4217}
4218
4219fn ensure_app_dirs(app_root: &Path) -> Result<()> {
4220 fs::create_dir_all(app_root).context("failed to create app root directory")?;
4221 if uses_packaged_layout(app_root) {
4222 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
4223 let user_sources_path = packaged_user_sources_path(app_root)?;
4224 if let Some(parent) = user_sources_path.parent() {
4225 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
4226 }
4227 } else {
4228 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
4229 }
4230 Ok(())
4231}
4232
4233fn default_app_root() -> Result<PathBuf> {
4234 if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
4235 return Ok(PathBuf::from(app_root));
4236 }
4237
4238 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
4239
4240 #[cfg(windows)]
4241 {
4242 if has_current_package_identity() {
4243 return Ok(local_app_data
4244 .join("Packages")
4245 .join(PACKAGED_FAMILY_NAME)
4246 .join("LocalState"));
4247 }
4248 Ok(local_app_data.join("Devolutions").join("Pinget"))
4249 }
4250
4251 #[cfg(not(windows))]
4252 {
4253 Ok(local_app_data.join("pinget"))
4254 }
4255}
4256
4257#[cfg(windows)]
4258fn has_current_package_identity() -> bool {
4259 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
4262
4263 let mut length: u32 = 0;
4264 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
4267 rc != APPMODEL_ERROR_NO_PACKAGE
4268}
4269
4270fn store_path(app_root: &Path) -> PathBuf {
4271 app_root.join("sources.json")
4272}
4273
4274fn user_settings_path(app_root: &Path) -> PathBuf {
4275 if uses_packaged_layout(app_root) {
4276 app_root.join("settings.json")
4277 } else {
4278 app_root.join("user-settings.json")
4279 }
4280}
4281
4282fn admin_settings_path(app_root: &Path) -> PathBuf {
4283 app_root.join("admin-settings.json")
4284}
4285
4286fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
4287 if uses_packaged_layout(app_root) {
4288 return app_root
4289 .join(packaged_source_type(source.kind))
4290 .join(sanitize_path_segment(&source.identifier));
4291 }
4292
4293 app_root.join("sources").join(sanitize_path_segment(&source.name))
4294}
4295
4296fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4297 source_state_dir(app_root, source).join("source.msix")
4298}
4299
4300fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4301 source_state_dir(app_root, source).join("index.db")
4302}
4303
4304fn remove_sqlite_sidecars(db_path: &Path) {
4308 for suffix in ["-wal", "-shm"] {
4309 let mut sidecar = db_path.as_os_str().to_owned();
4310 sidecar.push(suffix);
4311 let _ = fs::remove_file(PathBuf::from(sidecar));
4312 }
4313}
4314
4315#[cfg(windows)]
4316const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
4317
4318#[cfg(windows)]
4319fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
4320 let source_wide = source
4321 .as_os_str()
4322 .encode_wide()
4323 .chain(std::iter::once(0))
4324 .collect::<Vec<_>>();
4325 let target_wide = target
4326 .as_os_str()
4327 .encode_wide()
4328 .chain(std::iter::once(0))
4329 .collect::<Vec<_>>();
4330 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
4331 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
4334 if ok == 0 {
4335 return Err(std::io::Error::last_os_error());
4336 }
4337 Ok(())
4338}
4339
4340#[cfg(windows)]
4341fn replace_file(source: &Path, target: &Path) -> Result<()> {
4342 let to_err = |error: std::io::Error| -> anyhow::Error {
4343 anyhow::Error::new(error).context(format!(
4344 "failed to replace {} with {}",
4345 target.display(),
4346 source.display()
4347 ))
4348 };
4349
4350 let first = match move_replace_existing(source, target) {
4352 Ok(()) => return Ok(()),
4353 Err(error) => error,
4354 };
4355
4356 if !source.exists() || !target.exists() {
4359 return Err(to_err(first));
4360 }
4361
4362 let mut last = first;
4367 for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
4368 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
4369 match move_replace_existing(source, target) {
4370 Ok(()) => return Ok(()),
4371 Err(error) => last = error,
4372 }
4373 }
4374
4375 let staged = target.with_extension("old.tmp");
4381 if move_replace_existing(target, &staged).is_err() {
4382 return Err(to_err(last));
4383 }
4384 match move_replace_existing(source, target) {
4385 Ok(()) => {
4386 let _ = fs::remove_file(&staged);
4387 Ok(())
4388 }
4389 Err(error) => {
4390 let _ = move_replace_existing(&staged, target);
4391 Err(to_err(error))
4392 }
4393 }
4394}
4395
4396#[cfg(not(windows))]
4397fn replace_file(source: &Path, target: &Path) -> Result<()> {
4398 fs::rename(source, target)
4399 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
4400}
4401
4402fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4403 source_state_dir(app_root, source).join("rest-information.json")
4404}
4405
4406fn rest_manifest_cache_path_with_root(
4407 app_root: &Path,
4408 source: &SourceRecord,
4409 package_id: &str,
4410 version: &str,
4411 channel: &str,
4412) -> PathBuf {
4413 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
4414 let digest = sha256_hex(key.as_bytes());
4415 cache_root(app_root)
4416 .join("REST_M")
4417 .join(sanitize_path_segment(&source.identifier))
4418 .join(format!("{digest}.json"))
4419}
4420
4421fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
4422 cache_root(app_root)
4423 .join(bucket)
4424 .join(sanitize_path_segment(identifier))
4425}
4426
4427fn load_store(app_root: &Path) -> Result<SourceStore> {
4428 if uses_packaged_layout(app_root)
4429 && let Some(store) = load_packaged_store(app_root)?
4430 {
4431 return Ok(store);
4432 }
4433
4434 let path = store_path(app_root);
4435 if !path.exists() {
4436 let store = SourceStore::default();
4437 save_store(app_root, &store)?;
4438 return Ok(store);
4439 }
4440
4441 let bytes = fs::read(path).context("failed to read source store")?;
4442 serde_json::from_slice(&bytes).context("failed to parse source store")
4443}
4444
4445fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4446 if uses_system_winget_source_commands(app_root) {
4447 bail!("System WinGet source settings must be modified through winget.");
4448 }
4449
4450 if uses_packaged_layout(app_root) {
4451 return save_packaged_store(app_root, store);
4452 }
4453
4454 write_json(store_path(app_root), store)
4455}
4456
4457fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
4458 let user_sources_path = packaged_user_sources_path(app_root)?;
4459 let metadata_path = app_root.join("sources_metadata");
4460 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
4461 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
4462 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4463}
4464
4465fn load_packaged_store_from_streams(
4466 user_sources_yaml: Option<&str>,
4467 metadata_yaml: Option<&str>,
4468) -> Result<Option<SourceStore>> {
4469 if is_secure_settings_stub(user_sources_yaml) {
4470 return load_system_winget_source_store().map(Some);
4471 }
4472
4473 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4474}
4475
4476fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4477 let user_sources_path = packaged_user_sources_path(app_root)?;
4478 if let Some(parent) = user_sources_path.parent() {
4479 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4480 }
4481
4482 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4483 .context("failed to write packaged user_sources stream")?;
4484 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4485 .context("failed to write packaged source metadata")?;
4486 Ok(())
4487}
4488
4489fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4490 if !uses_packaged_layout(app_root) {
4491 return false;
4492 }
4493
4494 #[cfg(windows)]
4495 {
4496 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4497 return false;
4498 };
4499
4500 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4501 is_secure_settings_stub(user_sources_yaml.as_deref())
4502 }
4503
4504 #[cfg(not(windows))]
4505 {
4506 false
4507 }
4508}
4509
4510fn is_secure_settings_stub(value: Option<&str>) -> bool {
4511 value
4512 .map(str::trim_start)
4513 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4514}
4515
4516fn load_system_winget_source_store() -> Result<SourceStore> {
4517 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4518 Ok(SourceStore {
4519 sources: parse_system_winget_source_export(&result.stdout)?,
4520 })
4521}
4522
4523fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4524 run_checked_winget_source_command(
4525 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4526 &format!("add WinGet source '{name}'"),
4527 )?;
4528 Ok(())
4529}
4530
4531fn system_winget_remove_source(name: &str) -> Result<()> {
4532 run_checked_winget_source_command(
4533 vec![
4534 "source".to_owned(),
4535 "remove".to_owned(),
4536 "--name".to_owned(),
4537 name.to_owned(),
4538 "--disable-interactivity".to_owned(),
4539 ],
4540 &format!("remove WinGet source '{name}'"),
4541 )?;
4542 Ok(())
4543}
4544
4545fn system_winget_reset_source(name: &str) -> Result<()> {
4546 run_checked_winget_source_command(
4547 vec![
4548 "source".to_owned(),
4549 "reset".to_owned(),
4550 "--name".to_owned(),
4551 name.to_owned(),
4552 "--force".to_owned(),
4553 "--disable-interactivity".to_owned(),
4554 ],
4555 &format!("reset WinGet source '{name}'"),
4556 )?;
4557 Ok(())
4558}
4559
4560fn system_winget_reset_sources() -> Result<()> {
4561 run_checked_winget_source_command(
4562 vec![
4563 "source".to_owned(),
4564 "reset".to_owned(),
4565 "--force".to_owned(),
4566 "--disable-interactivity".to_owned(),
4567 ],
4568 "reset WinGet sources",
4569 )?;
4570 Ok(())
4571}
4572
4573fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4574 let mut args = vec!["source".to_owned(), "update".to_owned()];
4575 if let Some(name) = name {
4576 args.push("--name".to_owned());
4577 args.push(name.to_owned());
4578 }
4579
4580 args.push("--disable-interactivity".to_owned());
4581 let result = run_checked_winget_source_command(
4582 args,
4583 if name.is_some() {
4584 "update WinGet source"
4585 } else {
4586 "update WinGet sources"
4587 },
4588 )?;
4589 let detail = result.stdout.trim();
4590 Ok(if detail.is_empty() {
4591 "Done".to_owned()
4592 } else {
4593 detail.to_owned()
4594 })
4595}
4596
4597fn build_winget_source_export_arguments() -> Vec<String> {
4598 vec![
4599 "source".to_owned(),
4600 "export".to_owned(),
4601 "--disable-interactivity".to_owned(),
4602 ]
4603}
4604
4605fn build_winget_source_add_arguments(
4606 name: &str,
4607 arg: &str,
4608 kind: SourceKind,
4609 trust_level: &str,
4610 explicit: bool,
4611) -> Vec<String> {
4612 let mut args = vec![
4613 "source".to_owned(),
4614 "add".to_owned(),
4615 "--name".to_owned(),
4616 name.to_owned(),
4617 "--arg".to_owned(),
4618 arg.to_owned(),
4619 "--type".to_owned(),
4620 packaged_source_type(kind).to_owned(),
4621 "--disable-interactivity".to_owned(),
4622 ];
4623
4624 if !trust_level.trim().is_empty() {
4625 args.push("--trust-level".to_owned());
4626 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4627 "trusted".to_owned()
4628 } else {
4629 "none".to_owned()
4630 });
4631 }
4632
4633 if explicit {
4634 args.push("--explicit".to_owned());
4635 }
4636
4637 args
4638}
4639
4640#[derive(Debug)]
4641struct WingetSourceCommandResult {
4642 exit_code: i32,
4643 stdout: String,
4644 stderr: String,
4645}
4646
4647type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4648
4649static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4650 RwLock::new(run_winget_source_command_process);
4651
4652fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4653 let result = run_winget_source_command(&args)?;
4654 if result.exit_code == 0 {
4655 return Ok(result);
4656 }
4657
4658 let detail = [result.stderr.trim(), result.stdout.trim()]
4659 .into_iter()
4660 .filter(|value| !value.is_empty())
4661 .collect::<Vec<_>>()
4662 .join("\n");
4663 if detail.is_empty() {
4664 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4665 }
4666
4667 bail!(
4668 "Failed to {action}; winget exited with code {}:\n{}",
4669 result.exit_code,
4670 detail
4671 );
4672}
4673
4674fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4675 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4676 .read()
4677 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4678 runner(args)
4679}
4680
4681fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4682 use std::process::Command;
4683
4684 let output = Command::new("winget")
4685 .args(args)
4686 .output()
4687 .context("failed to run winget source command")?;
4688
4689 Ok(WingetSourceCommandResult {
4690 exit_code: output.status.code().unwrap_or(-1),
4691 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4692 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4693 })
4694}
4695
4696fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4697 let trimmed = output.trim();
4698 if trimmed.is_empty() {
4699 return Ok(Vec::new());
4700 }
4701
4702 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4703 && let Some(sources) = parse_system_winget_source_export_value(&value)?
4704 {
4705 return Ok(order_sources(sources));
4706 }
4707
4708 let mut sources = Vec::new();
4709 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4710 if line.is_empty() {
4711 continue;
4712 }
4713
4714 let value: JsonValue = serde_json::from_str(line)
4715 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4716 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4717 bail!("WinGet source export returned a non-source line: {line}");
4718 };
4719 sources.append(&mut parsed);
4720 }
4721
4722 Ok(order_sources(sources))
4723}
4724
4725fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4726 match value {
4727 JsonValue::Array(items) => items
4728 .iter()
4729 .map(parse_system_winget_source_record)
4730 .collect::<Result<Vec<_>>>()
4731 .map(Some),
4732 JsonValue::Object(object) => {
4733 if let Some(JsonValue::Array(items)) = object.get("Sources") {
4734 items
4735 .iter()
4736 .map(parse_system_winget_source_record)
4737 .collect::<Result<Vec<_>>>()
4738 .map(Some)
4739 } else if object.contains_key("Name") {
4740 Ok(Some(vec![parse_system_winget_source_record(value)?]))
4741 } else {
4742 Ok(None)
4743 }
4744 }
4745 _ => Ok(None),
4746 }
4747}
4748
4749fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4750 let object = value
4751 .as_object()
4752 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4753 let name = required_json_string(object, "Name")?;
4754 let source_type = required_json_string(object, "Type")?;
4755 let arg = required_json_string(object, "Arg")?;
4756 let kind = parse_system_winget_source_kind(&source_type)?;
4757 let identifier = optional_json_string(object, "Identifier")
4758 .or_else(|| optional_json_string(object, "Data"))
4759 .unwrap_or_else(|| name.clone());
4760
4761 Ok(SourceRecord {
4762 name,
4763 kind,
4764 arg,
4765 identifier,
4766 trust_level: parse_system_winget_trust_level(object),
4767 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4768 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4769 last_update: None,
4770 source_version: None,
4771 })
4772}
4773
4774fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4775 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4776}
4777
4778fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4779 match object.get(key)? {
4780 JsonValue::String(value) => Some(value.clone()),
4781 JsonValue::Number(value) => Some(value.to_string()),
4782 _ => None,
4783 }
4784}
4785
4786fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4787 match object.get(key)? {
4788 JsonValue::Bool(value) => Some(*value),
4789 JsonValue::String(value) => value.parse().ok(),
4790 _ => None,
4791 }
4792}
4793
4794fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4795 match object.get(key)? {
4796 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4797 JsonValue::String(value) => value.parse().ok(),
4798 _ => None,
4799 }
4800}
4801
4802fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4803 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4804 Ok(SourceKind::PreIndexed)
4805 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4806 Ok(SourceKind::Rest)
4807 } else {
4808 bail!("Unsupported WinGet source type '{value}'.")
4809 }
4810}
4811
4812fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4813 let Some(value) = object.get("TrustLevel") else {
4814 return "None".to_owned();
4815 };
4816
4817 match value {
4818 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4819 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4820 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4821 _ => "None".to_owned(),
4822 }
4823}
4824
4825fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4826 value
4827 .as_str()
4828 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4829}
4830
4831fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4832 sources.sort_by(|left, right| {
4833 left.explicit
4834 .cmp(&right.explicit)
4835 .then_with(|| right.priority.cmp(&left.priority))
4836 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4837 });
4838 sources
4839}
4840
4841fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4842 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4843 return None;
4844 }
4845
4846 let mut sources = SourceStore::default().sources;
4847 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4848 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4849 continue;
4850 };
4851
4852 if yaml_bool(entry.get("IsTombstone")) {
4853 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4854 continue;
4855 }
4856
4857 let Some(mapped) = map_packaged_source_entry(&entry) else {
4858 continue;
4859 };
4860
4861 if let Some(existing) = sources
4862 .iter_mut()
4863 .find(|source| source.name.eq_ignore_ascii_case(&name))
4864 {
4865 *existing = mapped;
4866 } else {
4867 sources.push(mapped);
4868 }
4869 }
4870
4871 for entry in parse_packaged_yaml_entries(metadata_yaml) {
4872 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4873 continue;
4874 };
4875
4876 let Some(source) = sources
4877 .iter_mut()
4878 .find(|source| source.name.eq_ignore_ascii_case(&name))
4879 else {
4880 continue;
4881 };
4882
4883 if let Some(last_update) = entry
4884 .get("LastUpdate")
4885 .and_then(|value| value.as_deref())
4886 .and_then(parse_packaged_datetime)
4887 {
4888 source.last_update = Some(last_update);
4889 }
4890
4891 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4892 && !source_version.is_empty()
4893 {
4894 source.source_version = Some(source_version);
4895 }
4896 }
4897
4898 Some(SourceStore { sources })
4899}
4900
4901fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4902 let Some(yaml) = yaml else {
4903 return Vec::new();
4904 };
4905
4906 let mut entries = Vec::new();
4907 let mut current: Option<BTreeMap<String, Option<String>>> = None;
4908 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4909 let line = raw_line.trim_end();
4910 let trimmed = line.trim();
4911 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4912 continue;
4913 }
4914
4915 if let Some(remainder) = trimmed.strip_prefix("- ") {
4916 if let Some(entry) = current.take() {
4917 entries.push(entry);
4918 }
4919
4920 let mut entry = BTreeMap::new();
4921 add_packaged_yaml_key_value(&mut entry, remainder);
4922 current = Some(entry);
4923 continue;
4924 }
4925
4926 if let Some(entry) = current.as_mut() {
4927 add_packaged_yaml_key_value(entry, trimmed);
4928 }
4929 }
4930
4931 if let Some(entry) = current {
4932 entries.push(entry);
4933 }
4934
4935 entries
4936}
4937
4938fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4939 let Some((key, value)) = line.split_once(':') else {
4940 return;
4941 };
4942
4943 entry.insert(
4944 key.trim().to_owned(),
4945 Some(unquote_yaml_scalar(value.trim()).to_owned()),
4946 );
4947}
4948
4949fn unquote_yaml_scalar(value: &str) -> &str {
4950 if value.len() >= 2 {
4951 let bytes = value.as_bytes();
4952 let first = bytes[0] as char;
4953 let last = bytes[value.len() - 1] as char;
4954 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4955 return &value[1..value.len() - 1];
4956 }
4957 }
4958
4959 value
4960}
4961
4962fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4963 let name = entry.get("Name").and_then(|value| value.clone())?;
4964 let source_type = entry.get("Type").and_then(|value| value.clone())?;
4965 let arg = entry.get("Arg").and_then(|value| value.clone())?;
4966 let kind = parse_packaged_source_kind(&source_type)?;
4967
4968 Some(SourceRecord {
4969 name: name.clone(),
4970 kind,
4971 arg,
4972 identifier: entry
4973 .get("Identifier")
4974 .and_then(|value| value.clone())
4975 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4976 .unwrap_or(name),
4977 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4978 explicit: yaml_bool(entry.get("Explicit")),
4979 priority: entry
4980 .get("Priority")
4981 .and_then(|value| value.as_deref())
4982 .and_then(|value| value.parse::<i32>().ok())
4983 .unwrap_or_default(),
4984 last_update: None,
4985 source_version: None,
4986 })
4987}
4988
4989fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4990 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4991 Some(SourceKind::PreIndexed)
4992 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4993 Some(SourceKind::Rest)
4994 } else {
4995 None
4996 }
4997}
4998
4999fn parse_packaged_trust_level(value: Option<&str>) -> String {
5000 match value {
5001 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
5002 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
5003 _ => "None".to_owned(),
5004 }
5005}
5006
5007fn yaml_bool(value: Option<&Option<String>>) -> bool {
5008 value
5009 .and_then(|raw| raw.as_deref())
5010 .and_then(|raw| raw.parse::<bool>().ok())
5011 .unwrap_or(false)
5012}
5013
5014fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
5015 if let Ok(unix_seconds) = value.parse::<i64>() {
5016 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
5017 }
5018
5019 DateTime::parse_from_rfc3339(value)
5020 .ok()
5021 .map(|value| value.with_timezone(&Utc))
5022}
5023
5024fn render_packaged_sources_yaml(store: &SourceStore) -> String {
5025 let mut lines = vec!["Sources:".to_owned()];
5026 for source in &store.sources {
5027 lines.push(format!(" - Name: {}", source.name));
5028 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
5029 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
5030 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
5031 lines.push(format!(
5032 " TrustLevel: {}",
5033 if source.trust_level.eq_ignore_ascii_case("Trusted") {
5034 1
5035 } else {
5036 0
5037 }
5038 ));
5039 lines.push(format!(" Explicit: {}", source.explicit));
5040 lines.push(format!(" Priority: {}", source.priority));
5041 lines.push(" IsTombstone: false".to_owned());
5042 }
5043 lines.push(String::new());
5044 lines.join("\n")
5045}
5046
5047fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
5048 let mut lines = vec!["Sources:".to_owned()];
5049 for source in &store.sources {
5050 if source.last_update.is_none() && source.source_version.is_none() {
5051 continue;
5052 }
5053
5054 lines.push(format!(" - Name: {}", source.name));
5055 if let Some(last_update) = source.last_update {
5056 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
5057 }
5058 if let Some(source_version) = &source.source_version {
5059 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
5060 }
5061 }
5062 lines.push(String::new());
5063 lines.join("\n")
5064}
5065
5066fn yaml_scalar(value: &str) -> String {
5067 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
5068 format!("\"{}\"", value.replace('"', "\\\""))
5069 } else {
5070 value.to_owned()
5071 }
5072}
5073
5074fn uses_packaged_layout(app_root: &Path) -> bool {
5075 #[cfg(windows)]
5076 {
5077 try_get_packaged_local_state_root(app_root).is_some()
5078 }
5079
5080 #[cfg(not(windows))]
5081 {
5082 let _ = app_root;
5083 false
5084 }
5085}
5086
5087#[cfg(windows)]
5088fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5089 let local_state = app_root.file_name()?.to_string_lossy();
5090 if !local_state.eq_ignore_ascii_case("LocalState") {
5091 return None;
5092 }
5093
5094 let family = app_root.parent()?.file_name()?.to_string_lossy();
5095 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
5096 return None;
5097 }
5098
5099 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
5100 if !packages.eq_ignore_ascii_case("Packages") {
5101 return None;
5102 }
5103
5104 Some(app_root.to_path_buf())
5105}
5106
5107#[cfg(windows)]
5108fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
5109 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
5110 Ok(packaged_secure_settings_root()?.join("user_sources"))
5111}
5112
5113#[cfg(not(windows))]
5114fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
5115 bail!("packaged WinGet source streams are only available on Windows")
5116}
5117
5118#[cfg(windows)]
5119fn packaged_secure_settings_root() -> Result<PathBuf> {
5120 let program_data = std::env::var_os("PROGRAMDATA")
5121 .map(PathBuf::from)
5122 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
5123 Ok(program_data
5124 .join("Microsoft")
5125 .join("WinGet")
5126 .join(current_user_sid()?)
5127 .join("settings")
5128 .join("pkg")
5129 .join(PACKAGED_NAME))
5130}
5131
5132#[cfg(windows)]
5133#[allow(clippy::multiple_unsafe_ops_per_block)]
5134fn current_user_sid() -> Result<String> {
5135 fn close_token(token: HANDLE) {
5136 if !token.is_null() {
5137 unsafe {
5139 CloseHandle(token);
5140 }
5141 }
5142 }
5143
5144 let mut token: HANDLE = std::ptr::null_mut();
5145 let current_process = unsafe { GetCurrentProcess() };
5147 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
5149 bail!("failed to open current process token")
5150 }
5151
5152 let mut length = 0;
5153 unsafe {
5155 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
5156 }
5157
5158 let element_size = size_of::<TOKEN_USER>();
5159 let element_count = (length as usize).div_ceil(element_size);
5160 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
5161 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
5163 close_token(token);
5164 bail!("failed to get current user token information")
5165 }
5166
5167 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
5169 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
5170 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
5172 close_token(token);
5173 bail!("failed to convert current user SID")
5174 }
5175
5176 let mut len = 0usize;
5177 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
5179 len += 1;
5180 }
5181
5182 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
5184 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
5185 unsafe {
5187 LocalFree(sid_ptr.cast());
5188 }
5189 close_token(token);
5190 Ok(sid)
5191}
5192
5193fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
5194 app_root.join("Microsoft").join("Windows Package Manager")
5195}
5196
5197fn cache_root(app_root: &Path) -> PathBuf {
5198 if uses_packaged_layout(app_root) {
5199 packaged_file_cache_root(app_root)
5200 } else {
5201 default_cache_root_fallback()
5202 }
5203}
5204
5205fn default_cache_root_fallback() -> PathBuf {
5206 std::env::temp_dir().join("cache")
5207}
5208
5209fn packaged_source_type(kind: SourceKind) -> &'static str {
5210 match kind {
5211 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
5212 SourceKind::Rest => "Microsoft.Rest",
5213 }
5214}
5215
5216fn sanitize_path_segment(value: &str) -> String {
5217 value
5218 .chars()
5219 .map(|ch| match ch {
5220 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
5221 _ => ch,
5222 })
5223 .collect()
5224}
5225
5226fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
5227 if !path.exists() {
5228 return Ok(JsonMap::new());
5229 }
5230
5231 let bytes = fs::read(path).context("failed to read JSON file")?;
5232 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
5233 Ok(match value {
5234 JsonValue::Object(object) => object,
5235 _ => JsonMap::new(),
5236 })
5237}
5238
5239fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
5240 write_json(path, value)
5241}
5242
5243fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
5244 if let Some(parent) = path.parent() {
5245 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
5246 }
5247
5248 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
5249 fs::write(path, bytes).context("failed to write JSON file")
5250}
5251
5252fn merge_json_objects(
5253 current: &JsonMap<String, JsonValue>,
5254 update: &JsonMap<String, JsonValue>,
5255) -> JsonMap<String, JsonValue> {
5256 let mut merged = current.clone();
5257 for (key, value) in update {
5258 match (merged.get(key), value) {
5259 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
5260 merged.insert(
5261 key.clone(),
5262 JsonValue::Object(merge_json_objects(current_object, update_object)),
5263 );
5264 }
5265 _ => {
5266 merged.insert(key.clone(), value.clone());
5267 }
5268 }
5269 }
5270
5271 merged
5272}
5273
5274fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
5275 match expected {
5276 JsonValue::Object(expected_object) => match current {
5277 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
5278 current_object
5279 .get(key)
5280 .is_some_and(|current_child| json_contains(current_child, expected_child))
5281 }),
5282 _ => false,
5283 },
5284 JsonValue::Array(expected_array) => match current {
5285 JsonValue::Array(current_array) => {
5286 current_array.len() == expected_array.len()
5287 && current_array
5288 .iter()
5289 .zip(expected_array.iter())
5290 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
5291 }
5292 _ => false,
5293 },
5294 _ => current == expected,
5295 }
5296}
5297
5298fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
5299 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
5300 "none" | "default" => Ok("None".to_owned()),
5301 "trusted" => Ok("Trusted".to_owned()),
5302 other => bail!("unsupported source trust level: {other}"),
5303 }
5304}
5305
5306fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
5307 SUPPORTED_ADMIN_SETTINGS
5308 .iter()
5309 .copied()
5310 .find(|setting| setting.eq_ignore_ascii_case(name))
5311 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
5312}
5313
5314fn default_source_trust_level() -> String {
5315 "None".to_owned()
5316}
5317
5318fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
5319where
5320 F: FnMut(&SqlRow) -> Result<T>,
5321{
5322 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5323 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5324 let mut result = Vec::new();
5325
5326 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
5327 result.push(map(&row)?);
5328 }
5329
5330 Ok(result)
5331}
5332
5333fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
5334where
5335 F: FnOnce(&SqlRow) -> Result<T>,
5336{
5337 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5338 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5339
5340 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
5341 Some(row) => Ok(Some(map(&row)?)),
5342 None => Ok(None),
5343 }
5344}
5345
5346fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
5347 pollster::block_on(connection.execute(sql, params_from_iter(params)))
5348 .with_context(|| format!("failed to execute SQL statement: {sql}"))
5349}
5350
5351fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
5352 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
5353}
5354
5355fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
5356 row.get_value(index)
5357 .with_context(|| format!("failed to read SQL column {index}"))
5358}
5359
5360fn row_string(row: &SqlRow, index: usize) -> Result<String> {
5361 match row_value(row, index)? {
5362 SqlValue::Text(value) => Ok(value),
5363 SqlValue::Integer(value) => Ok(value.to_string()),
5364 SqlValue::Real(value) => Ok(value.to_string()),
5365 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
5366 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5367 }
5368}
5369
5370fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5371 match row_value(row, index)? {
5372 SqlValue::Null => Ok(None),
5373 SqlValue::Text(value) => Ok(Some(value)),
5374 SqlValue::Integer(value) => Ok(Some(value.to_string())),
5375 SqlValue::Real(value) => Ok(Some(value.to_string())),
5376 SqlValue::Blob(value) => Ok(Some(
5377 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
5378 )),
5379 }
5380}
5381
5382fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
5383 match row_value(row, index)? {
5384 SqlValue::Integer(value) => Ok(value),
5385 SqlValue::Text(value) => value
5386 .parse::<i64>()
5387 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
5388 SqlValue::Real(value) => f64_to_i64_checked(value, index),
5389 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5390 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
5391 }
5392}
5393
5394fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
5395 match row_value(row, index)? {
5396 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
5397 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
5398 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5399 SqlValue::Integer(value) => Ok(format!("{value:x}")),
5400 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
5401 }
5402}
5403
5404fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5405 match row_value(row, index)? {
5406 SqlValue::Null => Ok(None),
5407 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
5408 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
5409 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
5410 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
5411 }
5412}
5413
5414fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
5415 if !value.is_finite() {
5416 bail!("SQL column {index} contained a non-finite floating-point value");
5417 }
5418
5419 if value.fract() != 0.0 {
5420 bail!("SQL column {index} contained a non-integer floating-point value");
5421 }
5422
5423 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
5424 bail!("SQL column {index} contained a floating-point value outside the i64 range");
5425 }
5426
5427 format!("{value:.0}")
5428 .parse::<i64>()
5429 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
5430}
5431
5432fn bytes_to_hex(bytes: &[u8]) -> String {
5433 let mut result = String::with_capacity(bytes.len() * 2);
5434 for byte in bytes {
5435 result.push_str(&format!("{byte:02x}"));
5436 }
5437 result
5438}
5439
5440fn query_v2_matches(
5441 connection: &Connection,
5442 query: &PackageQuery,
5443 semantics: SearchSemantics,
5444) -> Result<(Vec<V2SearchRow>, bool)> {
5445 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
5446 let limit = source_fetch_results(query, semantics);
5447 let sql = format!(
5448 "SELECT rowid, id, name, moniker, latest_version, hash \
5449 FROM packages WHERE {where_clause} LIMIT {}",
5450 limit + 1
5451 );
5452 let mut rows = query_rows(
5453 connection,
5454 &sql,
5455 params.into_iter().map(SqlValue::Text).collect(),
5456 |row| {
5457 Ok(V2SearchRow {
5458 package_rowid: row_i64(row, 0)?,
5459 id: row_string(row, 1)?,
5460 name: row_string(row, 2)?,
5461 moniker: row_opt_string(row, 3)?,
5462 version: row_string(row, 4)?,
5463 package_hash: row_hex_string(row, 5)?,
5464 match_criteria: None,
5465 })
5466 },
5467 )?;
5468 for row in &mut rows {
5469 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5470 }
5471 let truncated = rows.len() > limit;
5472 rows.truncate(limit);
5473 Ok((rows, truncated))
5474}
5475
5476fn query_v1_matches(
5477 connection: &Connection,
5478 query: &PackageQuery,
5479 semantics: SearchSemantics,
5480) -> Result<(Vec<V1SearchRow>, bool)> {
5481 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5482 let limit = source_fetch_results(query, semantics);
5483 let sql = format!(
5484 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5485 FROM manifest \
5486 JOIN ids ON manifest.id = ids.rowid \
5487 JOIN names ON manifest.name = names.rowid \
5488 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5489 JOIN versions ON manifest.version = versions.rowid \
5490 JOIN channels ON manifest.channel = channels.rowid \
5491 WHERE {where_clause} LIMIT {}",
5492 limit + 1
5493 );
5494 let mut rows = query_rows(
5495 connection,
5496 &sql,
5497 params.into_iter().map(SqlValue::Text).collect(),
5498 |row| {
5499 Ok(V1SearchRow {
5500 manifest_rowid: row_i64(row, 0)?,
5501 package_rowid: row_i64(row, 1)?,
5502 version: row_string(row, 2)?,
5503 channel: row_string(row, 3)?,
5504 id: row_string(row, 4)?,
5505 name: row_string(row, 5)?,
5506 moniker: row_opt_string(row, 6)?,
5507 match_criteria: None,
5508 })
5509 },
5510 )?;
5511 for row in &mut rows {
5512 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5513 }
5514 let truncated = rows.len() > limit;
5515 rows.truncate(limit);
5516 Ok((rows, truncated))
5517}
5518
5519fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5520 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5521
5522 for row in rows {
5523 match grouped.get(&row.package_rowid) {
5524 Some(existing)
5525 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5526 != Ordering::Less => {}
5527 _ => {
5528 grouped.insert(row.package_rowid, row);
5529 }
5530 }
5531 }
5532
5533 grouped.into_values().collect()
5534}
5535
5536fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5537 let has_hash = table_has_column(connection, "manifest", "hash")?;
5538 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5539 let sql = format!(
5540 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5541 FROM manifest \
5542 JOIN versions ON manifest.version = versions.rowid \
5543 JOIN channels ON manifest.channel = channels.rowid \
5544 WHERE manifest.id = ?1"
5545 );
5546 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5547 Ok(V1VersionRow {
5548 version: row_string(row, 0)?,
5549 channel: row_string(row, 1)?,
5550 pathpart_id: row_i64(row, 2)?,
5551 manifest_hash: row_opt_hex_string(row, 3)?,
5552 })
5553 })
5554}
5555
5556fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5557 let mut parts = Vec::new();
5558 let mut current = Some(pathpart_id);
5559
5560 while let Some(id) = current {
5561 let (parent, pathpart) = query_optional_value(
5562 connection,
5563 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5564 vec![SqlValue::Integer(id)],
5565 |row| {
5566 Ok((
5567 row_opt_string(row, 0)?
5568 .and_then(|value| value.parse::<i64>().ok())
5569 .filter(|value| *value != 0),
5570 row_string(row, 1)?,
5571 ))
5572 },
5573 )?
5574 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5575 parts.push(pathpart);
5576 current = parent;
5577 }
5578
5579 parts.reverse();
5580 Ok(parts.join("/"))
5581}
5582
5583fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5584 format!(
5585 "packages/{}/{}/versionData.mszyml",
5586 package_id,
5587 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5588 )
5589}
5590
5591fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5592 let message = format!("{error:#}").to_ascii_lowercase();
5593 message.contains("no such table") && message.contains("packages")
5594}
5595
5596fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5597 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5598 row_string(row, 1)
5599 })? {
5600 if value.eq_ignore_ascii_case(column) {
5601 return Ok(true);
5602 }
5603 }
5604
5605 Ok(false)
5606}
5607
5608fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5609 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5610 let id_column = if v2 { "id" } else { "ids.id" };
5611 let name_column = if v2 { "name" } else { "names.name" };
5612 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5613 let exact_match = query.exact || semantics == SearchSemantics::Single;
5614 let mut params = Vec::new();
5615
5616 if let Some(value) = &query.id {
5617 return (
5618 single_field_condition(id_column, value, exact_match, &mut params),
5619 params,
5620 );
5621 }
5622 if let Some(value) = &query.name {
5623 return (
5624 single_field_condition(name_column, value, exact_match, &mut params),
5625 params,
5626 );
5627 }
5628 if let Some(value) = &query.moniker {
5629 return (
5630 single_field_condition(moniker_column, value, exact_match, &mut params),
5631 params,
5632 );
5633 }
5634 if let Some(value) = &query.tag {
5635 return (
5636 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5637 params,
5638 );
5639 }
5640 if let Some(value) = &query.command {
5641 return (
5642 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5643 params,
5644 );
5645 }
5646 if let Some(value) = &query.query {
5647 if exact_match {
5648 let conditions = [
5649 single_field_condition(id_column, value, true, &mut params),
5650 single_field_condition(name_column, value, true, &mut params),
5651 single_field_condition(moniker_column, value, true, &mut params),
5652 ];
5653 return (format!("({})", conditions.join(" OR ")), params);
5654 }
5655
5656 let mut conditions = vec![
5657 single_field_condition(id_column, value, false, &mut params),
5658 single_field_condition(name_column, value, false, &mut params),
5659 single_field_condition(moniker_column, value, false, &mut params),
5660 ];
5661 if semantics == SearchSemantics::Many {
5662 conditions.push(mapped_field_condition(
5663 v2,
5664 "tag",
5665 value,
5666 rowid_column,
5667 false,
5668 &mut params,
5669 ));
5670 conditions.push(mapped_field_condition(
5671 v2,
5672 "command",
5673 value,
5674 rowid_column,
5675 false,
5676 &mut params,
5677 ));
5678 }
5679 return (format!("({})", conditions.join(" OR ")), params);
5680 }
5681
5682 ("1 = 1".to_owned(), Vec::new())
5683}
5684
5685fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5686 params.push(match_parameter(value, exact));
5687 format!("{column} LIKE ?{}", params.len())
5688}
5689
5690fn mapped_field_condition(
5691 v2: bool,
5692 value_name: &str,
5693 value: &str,
5694 rowid_column: &str,
5695 exact: bool,
5696 params: &mut Vec<String>,
5697) -> String {
5698 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5699 (
5700 format!("{value_name}s2"),
5701 format!("{value_name}s2_map"),
5702 value_name.to_owned(),
5703 "package".to_owned(),
5704 )
5705 } else {
5706 (
5707 format!("{value_name}s"),
5708 format!("{value_name}s_map"),
5709 value_name.to_owned(),
5710 "manifest".to_owned(),
5711 )
5712 };
5713 params.push(match_parameter(value, exact));
5714 let parameter = params.len();
5715 format!(
5722 "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
5723 FROM {map_table_name} JOIN {table_name} ON \
5724 {map_table_name}.{value_name} = {table_name}.rowid \
5725 WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
5726 )
5727}
5728
5729fn match_parameter(value: &str, exact: bool) -> String {
5730 if exact { value.to_owned() } else { format!("%{value}%") }
5731}
5732
5733fn build_rest_search_body(
5734 query: &PackageQuery,
5735 info: &RestInformation,
5736 semantics: SearchSemantics,
5737) -> Result<JsonValue> {
5738 let mut root = serde_json::Map::new();
5739 root.insert(
5740 "MaximumResults".to_owned(),
5741 JsonValue::from(source_fetch_results(query, semantics) as u64),
5742 );
5743 let exact_match = query.exact || semantics == SearchSemantics::Single;
5744
5745 if let Some(value) = &query.query {
5746 if semantics == SearchSemantics::Single {
5747 let mut filters = vec![
5748 rest_filter("PackageIdentifier", value, true),
5749 rest_filter("PackageName", value, true),
5750 rest_filter("Moniker", value, true),
5751 ];
5752 append_required_rest_filters(&mut filters, info);
5753 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5754 return Ok(JsonValue::Object(root));
5755 }
5756
5757 root.insert(
5758 "Query".to_owned(),
5759 serde_json::json!({
5760 "KeyWord": value,
5761 "MatchType": if exact_match { "Exact" } else { "Substring" },
5762 }),
5763 );
5764 }
5765
5766 let mut filters = Vec::new();
5767 if let Some(value) = &query.id {
5768 filters.push(rest_filter("PackageIdentifier", value, exact_match));
5769 }
5770 if let Some(value) = &query.name {
5771 filters.push(rest_filter("PackageName", value, exact_match));
5772 }
5773 if let Some(value) = &query.moniker {
5774 filters.push(rest_filter("Moniker", value, exact_match));
5775 }
5776 if let Some(value) = &query.tag {
5777 filters.push(rest_filter("Tag", value, true));
5778 }
5779 if let Some(value) = &query.command {
5780 filters.push(rest_filter("Command", value, true));
5781 }
5782 append_required_rest_filters(&mut filters, info);
5783
5784 if !filters.is_empty() {
5785 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5786 }
5787
5788 Ok(JsonValue::Object(root))
5789}
5790
5791fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5792 if info
5793 .required_package_match_fields
5794 .iter()
5795 .any(|field| field.eq_ignore_ascii_case("Market"))
5796 {
5797 filters.push(rest_filter("Market", &default_market(), true));
5798 }
5799}
5800
5801fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5802 serde_json::json!({
5803 "PackageMatchField": field,
5804 "RequestMatch": {
5805 "KeyWord": value,
5806 "MatchType": if exact { "Exact" } else { "Substring" },
5807 }
5808 })
5809}
5810
5811fn max_results(query: &PackageQuery) -> usize {
5812 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5813}
5814
5815fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5816 match semantics {
5817 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5818 SearchSemantics::Single => max_results(query),
5819 }
5820}
5821
5822fn infer_preindexed_match_criteria_v2(
5823 connection: &Connection,
5824 row: &V2SearchRow,
5825 query: &PackageQuery,
5826 semantics: SearchSemantics,
5827) -> Result<Option<String>> {
5828 infer_match_criteria(
5829 &row.id,
5830 &row.name,
5831 row.moniker.as_deref(),
5832 query,
5833 semantics,
5834 |value| {
5835 find_mapped_value_v2(
5836 connection,
5837 "tags2",
5838 "tags2_map",
5839 "tag",
5840 row.package_rowid,
5841 value,
5842 query.exact,
5843 )
5844 },
5845 |value| {
5846 find_mapped_value_v2(
5847 connection,
5848 "commands2",
5849 "commands2_map",
5850 "command",
5851 row.package_rowid,
5852 value,
5853 query.exact,
5854 )
5855 },
5856 )
5857}
5858
5859fn infer_preindexed_match_criteria_v1(
5860 connection: &Connection,
5861 row: &V1SearchRow,
5862 query: &PackageQuery,
5863 semantics: SearchSemantics,
5864) -> Result<Option<String>> {
5865 infer_match_criteria(
5866 &row.id,
5867 &row.name,
5868 row.moniker.as_deref(),
5869 query,
5870 semantics,
5871 |value| {
5872 find_mapped_value_v1(
5873 connection,
5874 "tags",
5875 "tags_map",
5876 "tag",
5877 row.manifest_rowid,
5878 value,
5879 query.exact,
5880 )
5881 },
5882 |value| {
5883 find_mapped_value_v1(
5884 connection,
5885 "commands",
5886 "commands_map",
5887 "command",
5888 row.manifest_rowid,
5889 value,
5890 query.exact,
5891 )
5892 },
5893 )
5894}
5895
5896fn infer_match_criteria<FTag, FCommand>(
5897 id: &str,
5898 name: &str,
5899 moniker: Option<&str>,
5900 query: &PackageQuery,
5901 semantics: SearchSemantics,
5902 find_tag: FTag,
5903 find_command: FCommand,
5904) -> Result<Option<String>>
5905where
5906 FTag: FnOnce(&str) -> Result<Option<String>>,
5907 FCommand: FnOnce(&str) -> Result<Option<String>>,
5908{
5909 if let Some(value) = &query.tag {
5910 return Ok(Some(format_match_criteria("Tag", value)));
5911 }
5912 if let Some(value) = &query.command {
5913 return Ok(Some(format_match_criteria("Command", value)));
5914 }
5915 if let Some(value) = &query.moniker {
5916 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5917 }
5918 if semantics == SearchSemantics::Single {
5919 return Ok(None);
5920 }
5921 if let Some(value) = &query.query {
5922 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5923 return Ok(None);
5924 }
5925 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5926 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5927 }
5928 if let Some(tag) = find_tag(value)? {
5929 return Ok(Some(format_match_criteria("Tag", &tag)));
5930 }
5931 if let Some(command) = find_command(value)? {
5932 return Ok(Some(format_match_criteria("Command", &command)));
5933 }
5934 }
5935 Ok(None)
5936}
5937
5938fn find_mapped_value_v2(
5939 connection: &Connection,
5940 table_name: &str,
5941 map_table_name: &str,
5942 value_name: &str,
5943 package_rowid: i64,
5944 query: &str,
5945 exact: bool,
5946) -> Result<Option<String>> {
5947 let sql = format!(
5948 "SELECT {table_name}.{value_name} FROM {map_table_name} \
5949 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5950 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5951 );
5952 let values = query_rows(
5953 connection,
5954 &sql,
5955 vec![
5956 SqlValue::Integer(package_rowid),
5957 SqlValue::Text(match_parameter(query, exact)),
5958 ],
5959 |row| row_string(row, 0),
5960 )?;
5961 Ok(select_best_text_match(values, query, exact))
5962}
5963
5964fn find_mapped_value_v1(
5965 connection: &Connection,
5966 table_name: &str,
5967 map_table_name: &str,
5968 value_name: &str,
5969 manifest_rowid: i64,
5970 query: &str,
5971 exact: bool,
5972) -> Result<Option<String>> {
5973 let sql = format!(
5974 "SELECT {table_name}.{value_name} FROM {map_table_name} \
5975 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5976 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5977 );
5978 let values = query_rows(
5979 connection,
5980 &sql,
5981 vec![
5982 SqlValue::Integer(manifest_rowid),
5983 SqlValue::Text(match_parameter(query, exact)),
5984 ],
5985 |row| row_string(row, 0),
5986 )?;
5987 Ok(select_best_text_match(values, query, exact))
5988}
5989
5990fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5991 if let Some(value) = &query.tag {
5992 return Some(format_match_criteria("Tag", value));
5993 }
5994 if let Some(value) = &query.command {
5995 return Some(format_match_criteria("Command", value));
5996 }
5997 if let Some(value) = &query.moniker {
5998 return Some(format_match_criteria("Moniker", value));
5999 }
6000 if semantics == SearchSemantics::Single {
6001 return None;
6002 }
6003 let value = query.query.as_deref()?;
6004 if json_string(item, "PackageIdentifier")
6005 .as_deref()
6006 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6007 || json_string(item, "PackageName")
6008 .as_deref()
6009 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
6010 {
6011 return None;
6012 }
6013 json_string(item, "Moniker")
6014 .filter(|candidate| matches_text(candidate, value, query.exact))
6015 .map(|candidate| format_match_criteria("Moniker", &candidate))
6016}
6017
6018fn format_match_criteria(field: &str, value: &str) -> String {
6019 format!("{field}: {value}")
6020}
6021
6022fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
6023 if exact {
6024 candidate.eq_ignore_ascii_case(query)
6025 } else {
6026 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
6027 }
6028}
6029
6030fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
6031 let mut score = 0;
6032
6033 if let Some(value) = &query.query {
6034 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
6035 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
6036 if let Some(moniker) = candidate.moniker.as_deref() {
6037 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
6038 }
6039 }
6040
6041 if let Some(value) = &query.id {
6042 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
6043 }
6044 if let Some(value) = &query.name {
6045 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
6046 }
6047 if let Some(value) = &query.moniker
6048 && let Some(moniker) = candidate.moniker.as_deref()
6049 {
6050 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
6051 }
6052 if let Some(value) = &query.tag
6053 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6054 {
6055 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
6056 }
6057 if let Some(value) = &query.command
6058 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
6059 {
6060 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
6061 }
6062
6063 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
6064 score += 5;
6065 }
6066 if search_match_has_unknown_version(candidate) {
6067 score = score.saturating_sub(10);
6068 }
6069
6070 score
6071}
6072
6073fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
6074 candidate
6075 .version
6076 .as_deref()
6077 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
6078}
6079
6080fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6089 let mut document = match documents {
6090 JsonValue::Object(map) => map.clone(),
6091 _ => serde_json::Map::new(),
6092 };
6093
6094 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
6095 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
6096 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
6097 if !manifest.channel.is_empty() {
6098 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
6099 } else {
6100 document.remove("Channel");
6101 }
6102
6103 let mut single_installer = serde_json::Map::new();
6106 if let Some(arch) = installer.architecture.as_ref() {
6107 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
6108 }
6109 if let Some(installer_type) = installer.installer_type.as_ref() {
6110 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
6111 }
6112 if let Some(url) = installer.url.as_ref() {
6113 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
6114 }
6115 if let Some(sha) = installer.sha256.as_ref() {
6116 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
6117 }
6118 if let Some(scope) = installer.scope.as_ref() {
6119 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
6120 }
6121 if let Some(locale) = installer.locale.as_ref() {
6122 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
6123 }
6124 if let Some(product_code) = installer.product_code.as_ref() {
6125 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
6126 }
6127 if let Some(release_date) = installer.release_date.as_ref() {
6128 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
6129 }
6130
6131 document.insert(
6132 "Installers".to_owned(),
6133 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
6134 );
6135 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
6136
6137 let value = JsonValue::Object(document);
6138 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
6139}
6140
6141fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
6146 let mut filename = derive_download_filename_stem(manifest, installer);
6147 filename.push_str(".yaml");
6148 filename
6149}
6150
6151fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
6152 let mut stem = sanitize_filename_component(&manifest.name);
6153 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
6154 stem.push('_');
6155 stem.push_str(&sanitize_filename_component(&manifest.version));
6156 }
6157 if let Some(scope) = installer.scope.as_deref() {
6158 stem.push('_');
6159 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
6160 }
6161 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
6162 stem.push('_');
6163 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
6164
6165 let installer_type = installer.installer_type.as_deref().unwrap_or("");
6166 if !installer_type.is_empty() {
6167 stem.push('_');
6168 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
6169 }
6170 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
6171 stem.push('_');
6172 stem.push_str(&sanitize_filename_component(locale));
6173 }
6174
6175 stem
6176}
6177
6178fn capitalize_first(value: &str) -> String {
6179 let mut chars = value.chars();
6180 match chars.next() {
6181 Some(first) => {
6182 let mut out = first.to_ascii_uppercase().to_string();
6183 out.push_str(&chars.as_str().to_ascii_lowercase());
6184 out
6185 }
6186 None => String::new(),
6187 }
6188}
6189
6190fn format_architecture(value: &str) -> String {
6191 match value.to_ascii_lowercase().as_str() {
6192 "x64" => "X64".to_owned(),
6193 "x86" => "X86".to_owned(),
6194 "arm" => "Arm".to_owned(),
6195 "arm64" => "Arm64".to_owned(),
6196 "neutral" => "Neutral".to_owned(),
6197 other => capitalize_first(other),
6198 }
6199}
6200
6201fn sanitize_filename_component(value: &str) -> String {
6202 value
6203 .chars()
6204 .map(|ch| match ch {
6205 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
6206 ch if ch.is_control() => '_',
6207 ch => ch,
6208 })
6209 .collect()
6210}
6211
6212fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
6213 if manifest_path.is_file() {
6214 return Ok(manifest_path.to_path_buf());
6215 }
6216
6217 if !manifest_path.is_dir() {
6218 bail!("Manifest path not found: {}", manifest_path.display());
6219 }
6220
6221 let candidate = fs::read_dir(manifest_path)?
6222 .filter_map(|entry| entry.ok())
6223 .map(|entry| entry.path())
6224 .filter(|path| {
6225 path.extension()
6226 .and_then(|value| value.to_str())
6227 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
6228 })
6229 .min();
6230
6231 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
6232}
6233
6234fn score_text_match(
6235 candidate: &str,
6236 query: &str,
6237 exact: bool,
6238 exact_score: usize,
6239 prefix_score: usize,
6240 substring_score: usize,
6241) -> usize {
6242 if candidate.eq_ignore_ascii_case(query) {
6243 return exact_score;
6244 }
6245 if exact {
6246 return 0;
6247 }
6248
6249 let candidate_lower = candidate.to_ascii_lowercase();
6250 let query_lower = query.to_ascii_lowercase();
6251 if candidate_lower.starts_with(&query_lower) {
6252 prefix_score
6253 } else if candidate_lower.contains(&query_lower) {
6254 substring_score
6255 } else {
6256 0
6257 }
6258}
6259
6260fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
6261where
6262 I: IntoIterator<Item = String>,
6263{
6264 values
6265 .into_iter()
6266 .filter_map(|candidate| {
6267 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
6268 (score > 0).then_some((score, candidate))
6269 })
6270 .max_by(|(left_score, left_value), (right_score, right_value)| {
6271 left_score
6272 .cmp(right_score)
6273 .then_with(|| right_value.len().cmp(&left_value.len()))
6274 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
6275 })
6276 .map(|(_, candidate)| candidate)
6277}
6278
6279fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
6280 let (field, value) = criteria.split_once(": ")?;
6281 Some((field, value))
6282}
6283
6284fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
6285 let versions = item
6286 .get("Versions")
6287 .and_then(JsonValue::as_array)
6288 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
6289 let mut result = Vec::new();
6290 for version in versions {
6291 let value = json_string(version, "PackageVersion")
6292 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
6293 let channel = json_string(version, "Channel").unwrap_or_default();
6294 result.push(VersionKey {
6295 version: value,
6296 channel,
6297 });
6298 }
6299 Ok(result)
6300}
6301
6302fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
6303 let mut merged = YamlMapping::new();
6304 let mut documents = Vec::new();
6305 for document in serde_yaml::Deserializer::from_slice(bytes) {
6306 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
6307 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
6308 if let Some(mapping) = value.as_mapping() {
6309 merge_yaml_mapping(&mut merged, mapping);
6310 }
6311 }
6312
6313 let id = yaml_string_from_root(&merged, "PackageIdentifier")
6314 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
6315 let version =
6316 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
6317 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
6318
6319 let installers = parse_yaml_installers(&merged);
6320 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
6321 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6322
6323 Ok((
6324 Manifest {
6325 id,
6326 name,
6327 version,
6328 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
6329 publisher: yaml_localized_string(&merged, "Publisher"),
6330 description: yaml_localized_string(&merged, "Description")
6331 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
6332 moniker: yaml_string_from_root(&merged, "Moniker"),
6333 package_url: yaml_localized_string(&merged, "PackageUrl"),
6334 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
6335 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
6336 license: yaml_localized_string(&merged, "License"),
6337 license_url: yaml_localized_string(&merged, "LicenseUrl"),
6338 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
6339 author: yaml_localized_string(&merged, "Author"),
6340 copyright: yaml_localized_string(&merged, "Copyright"),
6341 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
6342 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
6343 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
6344 tags: yaml_string_list(&merged, "Tags"),
6345 agreements: yaml_agreement_list(&merged),
6346 package_dependencies: yaml_package_dependencies(&merged),
6347 documentation: yaml_documentation_list(&merged),
6348 installers,
6349 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6350 },
6351 collapse_structured_document(&JsonValue::Array(documents)),
6352 ))
6353}
6354
6355fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
6356 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
6357}
6358
6359fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
6360 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
6361 let data = root
6362 .get("Data")
6363 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
6364 let versions = data
6365 .get("Versions")
6366 .and_then(JsonValue::as_array)
6367 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
6368 let selected = versions
6369 .iter()
6370 .find(|item| {
6371 json_string(item, "PackageVersion").as_deref() == Some(version)
6372 && json_string(item, "Channel").unwrap_or_default() == channel
6373 })
6374 .or_else(|| versions.first())
6375 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
6376
6377 let default_locale = selected
6378 .get("DefaultLocale")
6379 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
6380 let name = json_string(default_locale, "PackageName")
6381 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
6382 let installer_switch_defaults = json_installer_switches(selected);
6383 let top_platforms = json_string_list(selected, "Platform");
6384 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
6385 let top_installer_type = json_string(selected, "InstallerType");
6386 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
6387 let top_nested_installer_files = json_nested_installer_files(selected);
6388
6389 let installers = selected
6390 .get("Installers")
6391 .and_then(JsonValue::as_array)
6392 .map(|items| {
6393 items
6394 .iter()
6395 .map(|item| Installer {
6396 platforms: {
6397 let platforms = json_string_list(item, "Platform");
6398 if platforms.is_empty() {
6399 top_platforms.clone()
6400 } else {
6401 platforms
6402 }
6403 },
6404 minimum_os_version: json_string(item, "MinimumOSVersion")
6405 .or_else(|| top_minimum_os_version.clone()),
6406 architecture: json_string(item, "Architecture"),
6407 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
6408 nested_installer_type: json_string(item, "NestedInstallerType")
6409 .or_else(|| top_nested_installer_type.clone()),
6410 nested_installer_files: {
6411 let files = json_nested_installer_files(item);
6412 if files.is_empty() {
6413 top_nested_installer_files.clone()
6414 } else {
6415 files
6416 }
6417 },
6418 url: json_string(item, "InstallerUrl"),
6419 sha256: json_string(item, "InstallerSha256"),
6420 product_code: json_string(item, "ProductCode"),
6421 locale: json_string(item, "InstallerLocale"),
6422 scope: json_string(item, "Scope"),
6423 release_date: json_string(item, "ReleaseDate"),
6424 package_family_name: json_string(item, "PackageFamilyName"),
6425 upgrade_code: json_string(item, "UpgradeCode"),
6426 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
6427 commands: json_string_list(item, "Commands"),
6428 package_dependencies: json_package_dependencies(item),
6429 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
6430 })
6431 .collect::<Vec<_>>()
6432 })
6433 .unwrap_or_default();
6434 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
6435 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6436
6437 let manifest = Manifest {
6438 id: package_id.to_owned(),
6439 name,
6440 version: version.to_owned(),
6441 channel: channel.to_owned(),
6442 publisher: json_string(default_locale, "Publisher"),
6443 description: json_string(default_locale, "Description")
6444 .or_else(|| json_string(default_locale, "ShortDescription")),
6445 moniker: json_string(default_locale, "Moniker"),
6446 package_url: json_string(default_locale, "PackageUrl"),
6447 publisher_url: json_string(default_locale, "PublisherUrl"),
6448 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
6449 license: json_string(default_locale, "License"),
6450 license_url: json_string(default_locale, "LicenseUrl"),
6451 privacy_url: json_string(default_locale, "PrivacyUrl"),
6452 author: json_string(default_locale, "Author"),
6453 copyright: json_string(default_locale, "Copyright"),
6454 copyright_url: json_string(default_locale, "CopyrightUrl"),
6455 release_notes: json_string(default_locale, "ReleaseNotes"),
6456 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
6457 tags: json_string_list(default_locale, "Tags"),
6458 agreements: json_agreement_list(default_locale),
6459 package_dependencies: json_package_dependencies(selected),
6460 documentation: json_documentation_list(default_locale),
6461 installers,
6462 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6463 };
6464
6465 Ok((
6466 manifest,
6467 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
6468 ))
6469}
6470
6471fn collapse_structured_document(document: &JsonValue) -> JsonValue {
6472 match document {
6473 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
6474 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
6475 }
6476 JsonValue::Array(documents) => merge_manifest_documents(documents),
6477 JsonValue::Object(_) if is_merged_manifest_document(document) => {
6478 merge_manifest_documents(&split_merged_manifest_document(document))
6479 }
6480 _ => document.clone(),
6481 }
6482}
6483
6484fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
6485 documents.iter().map(collapse_structured_document).collect()
6486}
6487
6488fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
6489 let version = documents.iter().find(|document| {
6490 document
6491 .get("ManifestType")
6492 .and_then(JsonValue::as_str)
6493 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
6494 });
6495 let default_locale = documents.iter().find(|document| {
6496 document
6497 .get("ManifestType")
6498 .and_then(JsonValue::as_str)
6499 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
6500 });
6501 let installer = documents.iter().find(|document| {
6502 document
6503 .get("ManifestType")
6504 .and_then(JsonValue::as_str)
6505 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
6506 });
6507
6508 let mut singleton = JsonMap::new();
6509 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
6510 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
6511 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
6512
6513 if !singleton.contains_key("PackageLocale")
6514 && let Some(package_locale) = default_locale
6515 .and_then(|document| json_string(document, "PackageLocale"))
6516 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
6517 {
6518 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6519 }
6520
6521 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
6522 singleton.insert(
6523 "ManifestVersion".to_owned(),
6524 JsonValue::String(
6525 installer
6526 .and_then(|document| json_string(document, "ManifestVersion"))
6527 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
6528 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
6529 .unwrap_or_else(|| "1.10.0".to_owned()),
6530 ),
6531 );
6532 singleton.remove("DefaultLocale");
6533
6534 JsonValue::Object(singleton)
6535}
6536
6537fn is_merged_manifest_document(document: &JsonValue) -> bool {
6538 document
6539 .get("ManifestType")
6540 .and_then(JsonValue::as_str)
6541 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
6542}
6543
6544fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
6545 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
6546 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
6547 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6548 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
6549
6550 let version_document = serde_json::json!({
6551 "PackageIdentifier": package_identifier,
6552 "PackageVersion": package_version,
6553 "DefaultLocale": package_locale,
6554 "ManifestType": "version",
6555 "ManifestVersion": manifest_version,
6556 });
6557
6558 let mut default_locale_document = project_manifest_document(
6559 merged,
6560 &[
6561 "PackageIdentifier",
6562 "PackageVersion",
6563 "PackageLocale",
6564 "Publisher",
6565 "PublisherUrl",
6566 "PublisherSupportUrl",
6567 "PrivacyUrl",
6568 "Author",
6569 "PackageName",
6570 "PackageUrl",
6571 "License",
6572 "LicenseUrl",
6573 "Copyright",
6574 "CopyrightUrl",
6575 "ShortDescription",
6576 "Description",
6577 "Moniker",
6578 "Tags",
6579 "Agreements",
6580 "ReleaseNotes",
6581 "ReleaseNotesUrl",
6582 "PurchaseUrl",
6583 "InstallationNotes",
6584 "Documentations",
6585 "Icons",
6586 ],
6587 );
6588 default_locale_document.insert(
6589 "PackageIdentifier".to_owned(),
6590 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6591 );
6592 default_locale_document.insert(
6593 "PackageVersion".to_owned(),
6594 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6595 );
6596 default_locale_document.insert(
6597 "PackageLocale".to_owned(),
6598 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6599 );
6600 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6601 default_locale_document.insert(
6602 "ManifestVersion".to_owned(),
6603 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6604 );
6605
6606 let mut installer_document = project_manifest_document(
6607 merged,
6608 &[
6609 "PackageIdentifier",
6610 "PackageVersion",
6611 "Channel",
6612 "InstallerLocale",
6613 "Platform",
6614 "MinimumOSVersion",
6615 "InstallerType",
6616 "NestedInstallerType",
6617 "NestedInstallerFiles",
6618 "Scope",
6619 "InstallModes",
6620 "InstallerSwitches",
6621 "InstallerSuccessCodes",
6622 "ExpectedReturnCodes",
6623 "UpgradeBehavior",
6624 "Commands",
6625 "Protocols",
6626 "FileExtensions",
6627 "Dependencies",
6628 "PackageFamilyName",
6629 "ProductCode",
6630 "Capabilities",
6631 "RestrictedCapabilities",
6632 "Markets",
6633 "InstallerAbortsTerminal",
6634 "ReleaseDate",
6635 "InstallLocationRequired",
6636 "RequireExplicitUpgrade",
6637 "DisplayInstallWarnings",
6638 "UnsupportedOSArchitectures",
6639 "UnsupportedArguments",
6640 "AppsAndFeaturesEntries",
6641 "ElevationRequirement",
6642 "InstallationMetadata",
6643 "DownloadCommandProhibited",
6644 "RepairBehavior",
6645 "ArchiveBinariesDependOnPath",
6646 "Authentication",
6647 "Installers",
6648 ],
6649 );
6650 installer_document.insert(
6651 "PackageIdentifier".to_owned(),
6652 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6653 );
6654 installer_document.insert(
6655 "PackageVersion".to_owned(),
6656 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6657 );
6658 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6659 installer_document.insert(
6660 "ManifestVersion".to_owned(),
6661 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6662 );
6663
6664 vec![
6665 version_document,
6666 JsonValue::Object(default_locale_document),
6667 JsonValue::Object(installer_document),
6668 ]
6669}
6670
6671fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6672 let mut result = JsonMap::new();
6673 if let Some(source) = source.as_object() {
6674 for key in keys {
6675 if let Some(value) = source.get(*key) {
6676 result.insert((*key).to_owned(), value.clone());
6677 }
6678 }
6679 }
6680
6681 result
6682}
6683
6684fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6685 if let Some(source) = source.and_then(JsonValue::as_object) {
6686 for key in keys {
6687 if let Some(value) = source.get(*key) {
6688 target.insert((*key).to_owned(), value.clone());
6689 }
6690 }
6691 }
6692}
6693
6694fn copy_all_manifest_keys_except(
6695 target: &mut JsonMap<String, JsonValue>,
6696 source: Option<&JsonValue>,
6697 excluded_keys: &[&str],
6698) {
6699 if let Some(source) = source.and_then(JsonValue::as_object) {
6700 for (key, value) in source {
6701 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6702 target.insert(key.clone(), value.clone());
6703 }
6704 }
6705 }
6706}
6707
6708fn build_rest_manifest_documents(
6709 data: &JsonValue,
6710 installer_source: &JsonValue,
6711 default_locale: &JsonValue,
6712 package_id: &str,
6713 version: &str,
6714 channel: &str,
6715) -> JsonValue {
6716 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6717 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6718 let manifest_version = json_string(installer_source, "ManifestVersion")
6719 .or_else(|| json_string(default_locale, "ManifestVersion"))
6720 .or_else(|| json_string(data, "ManifestVersion"))
6721 .unwrap_or_else(|| "1.10.0".to_owned());
6722
6723 let mut version_document = JsonMap::new();
6724 version_document.insert(
6725 "PackageIdentifier".to_owned(),
6726 JsonValue::String(package_identifier.clone()),
6727 );
6728 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6729 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6730 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6731 version_document.insert(
6732 "ManifestVersion".to_owned(),
6733 JsonValue::String(manifest_version.clone()),
6734 );
6735
6736 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6737 default_locale_document.insert(
6738 "PackageIdentifier".to_owned(),
6739 JsonValue::String(package_identifier.clone()),
6740 );
6741 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6742 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6743 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6744 default_locale_document.insert(
6745 "ManifestVersion".to_owned(),
6746 JsonValue::String(manifest_version.clone()),
6747 );
6748
6749 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6750 installer_document.remove("DefaultLocale");
6751 installer_document.remove("Locales");
6752 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6753 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6754 if !channel.is_empty() {
6755 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6756 }
6757 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6758 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6759
6760 let documents = vec![
6761 JsonValue::Object(version_document),
6762 JsonValue::Object(default_locale_document),
6763 JsonValue::Object(installer_document),
6764 ];
6765
6766 merge_manifest_documents(&documents)
6767}
6768
6769fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6770 let base = installer_defaults(root);
6771 let base_switches = yaml_installer_switches(root);
6772 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6773 let installers = items
6774 .iter()
6775 .filter_map(YamlValue::as_mapping)
6776 .map(|item| {
6777 let mut merged = base.clone();
6778 merge_yaml_mapping(&mut merged, item);
6779 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6780 installer_from_yaml(&merged, switches)
6781 })
6782 .collect::<Vec<_>>();
6783 if !installers.is_empty() {
6784 return installers;
6785 }
6786 }
6787
6788 if base.is_empty() {
6789 Vec::new()
6790 } else {
6791 vec![installer_from_yaml(&base, base_switches)]
6792 }
6793}
6794
6795fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6796 let keys = [
6797 "Architecture",
6798 "InstallerType",
6799 "NestedInstallerType",
6800 "NestedInstallerFiles",
6801 "InstallerUrl",
6802 "InstallerSha256",
6803 "ProductCode",
6804 "InstallerLocale",
6805 "Platform",
6806 "MinimumOSVersion",
6807 "Scope",
6808 "ReleaseDate",
6809 "PackageFamilyName",
6810 "UpgradeCode",
6811 "Commands",
6812 ];
6813 let mut defaults = YamlMapping::new();
6814 for key in keys {
6815 if let Some(value) = root.get(YamlValue::from(key)) {
6816 defaults.insert(YamlValue::from(key), value.clone());
6817 }
6818 }
6819 defaults
6820}
6821
6822fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6823 Installer {
6824 architecture: yaml_string(root, "Architecture"),
6825 installer_type: yaml_string(root, "InstallerType"),
6826 nested_installer_type: yaml_string(root, "NestedInstallerType"),
6827 nested_installer_files: yaml_nested_installer_files(root),
6828 url: yaml_string(root, "InstallerUrl"),
6829 sha256: yaml_string(root, "InstallerSha256"),
6830 product_code: yaml_string(root, "ProductCode"),
6831 locale: yaml_string(root, "InstallerLocale"),
6832 scope: yaml_string(root, "Scope"),
6833 release_date: yaml_string(root, "ReleaseDate"),
6834 package_family_name: yaml_string(root, "PackageFamilyName"),
6835 upgrade_code: yaml_string(root, "UpgradeCode"),
6836 platforms: yaml_string_list(root, "Platform"),
6837 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6838 switches,
6839 commands: yaml_string_list(root, "Commands"),
6840 package_dependencies: yaml_package_dependencies(root),
6841 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6842 }
6843}
6844
6845fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6848 let Some(value) = root.get(YamlValue::from(key)) else {
6849 return false;
6850 };
6851 match value {
6852 YamlValue::Bool(b) => *b,
6853 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6854 _ => false,
6855 }
6856}
6857
6858fn json_bool(value: &JsonValue, key: &str) -> bool {
6859 match value.get(key) {
6860 Some(JsonValue::Bool(b)) => *b,
6861 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6862 _ => false,
6863 }
6864}
6865
6866fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6867 let mapping = root
6868 .get(YamlValue::from("InstallerSwitches"))
6869 .or_else(|| root.get(YamlValue::from("Switches")))
6870 .and_then(YamlValue::as_mapping);
6871 yaml_installer_switches_from_mapping(mapping)
6872}
6873
6874fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6875 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6876
6877 InstallerSwitches {
6878 silent: string("Silent"),
6879 silent_with_progress: string("SilentWithProgress"),
6880 interactive: string("Interactive"),
6881 custom: string("Custom"),
6882 log: string("Log"),
6883 install_location: string("InstallLocation"),
6884 }
6885}
6886
6887fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6888 yaml_string(root, key).or_else(|| {
6889 root.get(YamlValue::from("DefaultLocale"))
6890 .and_then(YamlValue::as_mapping)
6891 .and_then(|mapping| yaml_string(mapping, key))
6892 })
6893}
6894
6895fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6896 yaml_string(root, key)
6897}
6898
6899fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6900 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6901}
6902
6903fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6904 match value {
6905 YamlValue::Null => None,
6906 YamlValue::Bool(value) => Some(value.to_string()),
6907 YamlValue::Number(value) => Some(value.to_string()),
6908 YamlValue::String(value) => Some(value.clone()),
6909 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6910 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6911 }
6912}
6913
6914fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6915 root.get(YamlValue::from(key))
6916 .and_then(YamlValue::as_sequence)
6917 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6918 .unwrap_or_default()
6919}
6920
6921fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6922 root.get(YamlValue::from("Documentations"))
6923 .and_then(YamlValue::as_sequence)
6924 .map(|items| {
6925 items
6926 .iter()
6927 .filter_map(YamlValue::as_mapping)
6928 .filter_map(|item| {
6929 let url = yaml_string(item, "DocumentUrl")?;
6930 Some(Documentation {
6931 label: yaml_string(item, "DocumentLabel"),
6932 url,
6933 })
6934 })
6935 .collect()
6936 })
6937 .unwrap_or_default()
6938}
6939
6940fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6941 root.get(YamlValue::from("Agreements"))
6942 .and_then(YamlValue::as_sequence)
6943 .map(|items| {
6944 items
6945 .iter()
6946 .filter_map(YamlValue::as_mapping)
6947 .map(|item| PackageAgreement {
6948 label: yaml_string(item, "AgreementLabel"),
6949 text: yaml_string(item, "Agreement"),
6950 url: yaml_string(item, "AgreementUrl"),
6951 })
6952 .collect()
6953 })
6954 .unwrap_or_default()
6955}
6956
6957fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6958 root.get(YamlValue::from("Dependencies"))
6959 .and_then(YamlValue::as_mapping)
6960 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6961 .and_then(YamlValue::as_sequence)
6962 .map(|items| {
6963 items
6964 .iter()
6965 .filter_map(YamlValue::as_mapping)
6966 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6967 .collect()
6968 })
6969 .unwrap_or_default()
6970}
6971
6972fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
6973 root.get(YamlValue::from("NestedInstallerFiles"))
6974 .and_then(YamlValue::as_sequence)
6975 .map(|items| {
6976 items
6977 .iter()
6978 .filter_map(YamlValue::as_mapping)
6979 .filter_map(|item| {
6980 let relative = yaml_string(item, "RelativeFilePath")?;
6981 Some(NestedInstallerFile {
6982 relative_file_path: relative,
6983 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
6984 })
6985 })
6986 .collect()
6987 })
6988 .unwrap_or_default()
6989}
6990
6991fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6992 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6993}
6994
6995fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6996 value
6997 .get(key)
6998 .and_then(JsonValue::as_array)
6999 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
7000 .unwrap_or_default()
7001}
7002
7003fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
7004 value
7005 .get("Documentations")
7006 .and_then(JsonValue::as_array)
7007 .map(|items| {
7008 items
7009 .iter()
7010 .filter_map(|item| {
7011 let url = json_string(item, "DocumentUrl")?;
7012 Some(Documentation {
7013 label: json_string(item, "DocumentLabel"),
7014 url,
7015 })
7016 })
7017 .collect()
7018 })
7019 .unwrap_or_default()
7020}
7021
7022fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
7023 value
7024 .get("Agreements")
7025 .and_then(JsonValue::as_array)
7026 .map(|items| {
7027 items
7028 .iter()
7029 .map(|item| PackageAgreement {
7030 label: json_string(item, "AgreementLabel"),
7031 text: json_string(item, "Agreement"),
7032 url: json_string(item, "AgreementUrl"),
7033 })
7034 .collect()
7035 })
7036 .unwrap_or_default()
7037}
7038
7039fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
7040 value
7041 .get("Dependencies")
7042 .and_then(JsonValue::as_object)
7043 .and_then(|deps| deps.get("PackageDependencies"))
7044 .and_then(JsonValue::as_array)
7045 .map(|items| {
7046 items
7047 .iter()
7048 .filter_map(|item| json_string(item, "PackageIdentifier"))
7049 .collect()
7050 })
7051 .unwrap_or_default()
7052}
7053
7054fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
7055 value
7056 .get("NestedInstallerFiles")
7057 .and_then(JsonValue::as_array)
7058 .map(|items| {
7059 items
7060 .iter()
7061 .filter_map(|item| {
7062 let relative = json_string(item, "RelativeFilePath")?;
7063 Some(NestedInstallerFile {
7064 relative_file_path: relative,
7065 portable_command_alias: json_string(item, "PortableCommandAlias"),
7066 })
7067 })
7068 .collect()
7069 })
7070 .unwrap_or_default()
7071}
7072
7073fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
7074 let switches = value
7075 .get("InstallerSwitches")
7076 .or_else(|| value.get("Switches"))
7077 .and_then(JsonValue::as_object);
7078
7079 let string = |key: &str| {
7080 switches
7081 .and_then(|switches| switches.get(key))
7082 .and_then(JsonValue::as_str)
7083 .map(str::to_owned)
7084 };
7085
7086 InstallerSwitches {
7087 silent: string("Silent"),
7088 silent_with_progress: string("SilentWithProgress"),
7089 interactive: string("Interactive"),
7090 custom: string("Custom"),
7091 log: string("Log"),
7092 install_location: string("InstallLocation"),
7093 }
7094}
7095
7096fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
7097 for (key, value) in source {
7098 target.insert(key.clone(), value.clone());
7099 }
7100}
7101
7102fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
7103 let reader = Cursor::new(bytes.to_vec());
7104 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
7105 let mut file = archive
7106 .by_name(member_name)
7107 .with_context(|| format!("zip payload missing {member_name}"))?;
7108 let mut output = Vec::new();
7109 file.read_to_end(&mut output)
7110 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
7111 Ok(output)
7112}
7113
7114fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
7115 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
7116 return Ok(output);
7117 }
7118
7119 if !bytes.starts_with(b"CK")
7120 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
7121 {
7122 return decompress_mszyml_payload(&bytes[offset + 2..]);
7123 }
7124
7125 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
7126}
7127
7128fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
7129 let mut decoder = flate2::read::DeflateDecoder::new(payload);
7130 let mut output = String::new();
7131 decoder.read_to_string(&mut output)?;
7132 Ok(output)
7133}
7134
7135fn cache_control_max_age(response: &Response) -> u64 {
7136 response
7137 .headers()
7138 .get(reqwest::header::CACHE_CONTROL)
7139 .and_then(|value| value.to_str().ok())
7140 .and_then(|header| {
7141 header.split(',').find_map(|part| {
7142 let trimmed = part.trim();
7143 trimmed
7144 .strip_prefix("max-age=")
7145 .and_then(|value| value.parse::<u64>().ok())
7146 })
7147 })
7148 .unwrap_or(60)
7149}
7150
7151fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
7152 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
7153 let (major, minor) = major_minor(candidate);
7154 server_versions.iter().any(|version| {
7155 let (server_major, server_minor) = major_minor(version);
7156 server_major == major && server_minor == minor
7157 })
7158 })
7159}
7160
7161fn major_minor(version: &str) -> (String, String) {
7162 let mut parts = version.split('.');
7163 (
7164 parts.next().unwrap_or_default().to_owned(),
7165 parts.next().unwrap_or_default().to_owned(),
7166 )
7167}
7168
7169fn default_market() -> String {
7170 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
7171}
7172
7173fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
7174 let requested_locale = query.locale.as_deref();
7175 let requested_architecture = query.installer_architecture.as_deref();
7176 let requested_type = query.installer_type.as_deref();
7177 let requested_scope = query.install_scope.as_deref();
7178 let requested_platform = query.platform.as_deref();
7179 let requested_os_version = query.os_version.as_deref();
7180 let system_architecture = current_architecture();
7181 let system_platform = current_platform();
7182 let current_os_version = current_os_version();
7183
7184 installers
7185 .iter()
7186 .enumerate()
7187 .filter(|(_, installer)| {
7188 installer_matches_requested(
7189 installer,
7190 requested_type,
7191 requested_scope,
7192 requested_platform,
7193 requested_os_version,
7194 system_platform,
7195 current_os_version.as_deref(),
7196 )
7197 })
7198 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
7199 .max_by_key(|(index, installer)| {
7200 (
7201 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
7202 std::cmp::Reverse(*index),
7203 )
7204 })
7205 .map(|(_, installer)| installer)
7206 .cloned()
7207}
7208
7209fn installer_matches_requested(
7210 installer: &Installer,
7211 requested_type: Option<&str>,
7212 requested_scope: Option<&str>,
7213 requested_platform: Option<&str>,
7214 requested_os_version: Option<&str>,
7215 system_platform: Option<&str>,
7216 current_os_version: Option<&str>,
7217) -> bool {
7218 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
7219 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
7220 && installer_matches_platform(installer, requested_platform, system_platform)
7221 && installer_matches_os_version(installer, requested_os_version, current_os_version)
7222}
7223
7224fn installer_matches_platform(
7225 installer: &Installer,
7226 requested_platform: Option<&str>,
7227 system_platform: Option<&str>,
7228) -> bool {
7229 if installer.platforms.is_empty() {
7230 return true;
7231 }
7232
7233 let Some(effective_platform) = requested_platform.or(system_platform) else {
7234 return true;
7235 };
7236
7237 installer
7238 .platforms
7239 .iter()
7240 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
7241}
7242
7243fn installer_matches_os_version(
7244 installer: &Installer,
7245 requested_os_version: Option<&str>,
7246 current_os_version: Option<&str>,
7247) -> bool {
7248 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
7249 return true;
7250 };
7251
7252 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
7253 return true;
7254 };
7255
7256 match (
7257 parse_dotted_version(minimum_os_version),
7258 parse_dotted_version(actual_os_version),
7259 ) {
7260 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
7261 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
7262 }
7263}
7264
7265fn installer_matches_architecture(
7266 installer: &Installer,
7267 requested_architecture: Option<&str>,
7268 system_architecture: &str,
7269) -> bool {
7270 let Some(architecture) = installer.architecture.as_deref() else {
7271 return true;
7272 };
7273
7274 let architecture = architecture.to_ascii_lowercase();
7275 if let Some(requested) = requested_architecture {
7276 return architecture.eq_ignore_ascii_case(requested);
7277 }
7278
7279 preferred_architectures(system_architecture)
7280 .iter()
7281 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
7282}
7283
7284fn installer_rank(
7285 installer: &Installer,
7286 requested_locale: Option<&str>,
7287 requested_architecture: Option<&str>,
7288 system_architecture: &str,
7289) -> (i32, i32, i32) {
7290 let architecture_rank = architecture_rank(
7291 installer.architecture.as_deref(),
7292 requested_architecture.unwrap_or(system_architecture),
7293 requested_architecture.is_some(),
7294 system_architecture,
7295 );
7296 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
7297 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
7298 (architecture_rank, locale_rank, command_rank)
7299}
7300
7301fn architecture_rank(
7302 installer_architecture: Option<&str>,
7303 preferred_architecture: &str,
7304 strict: bool,
7305 system_architecture: &str,
7306) -> i32 {
7307 let Some(value) = installer_architecture else {
7308 return 0;
7309 };
7310 if value.eq_ignore_ascii_case(preferred_architecture) {
7311 return 5;
7312 }
7313 if value.eq_ignore_ascii_case("neutral") {
7314 return 4;
7315 }
7316 if strict {
7317 return -1;
7318 }
7319
7320 preferred_architectures(system_architecture)
7321 .iter()
7322 .rev()
7323 .position(|candidate| value.eq_ignore_ascii_case(candidate))
7324 .and_then(|index| i32::try_from(index).ok())
7325 .and_then(|index| index.checked_add(1))
7326 .unwrap_or(-1)
7327}
7328
7329fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
7333 if manifest.installers.is_empty() {
7334 return true;
7335 }
7336 let allowed = preferred_architectures(current_architecture());
7337 manifest.installers.iter().any(|installer| {
7338 let arch = installer.architecture.as_deref().unwrap_or("neutral");
7339 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
7340 })
7341}
7342
7343fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
7344 match system_architecture {
7345 "arm64" => &["arm64", "neutral", "x64", "x86"],
7346 "x64" => &["x64", "neutral", "x86"],
7347 "x86" => &["x86", "neutral"],
7348 _ => &["neutral"],
7349 }
7350}
7351
7352fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
7353 match (installer_locale, requested_locale) {
7354 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
7355 (Some(installer), Some(requested))
7356 if installer
7357 .split('-')
7358 .next()
7359 .zip(requested.split('-').next())
7360 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
7361 {
7362 2
7363 }
7364 (None, Some(_)) => 1,
7365 (Some(_), None) => 1,
7366 (None, None) => 0,
7367 _ => -1,
7368 }
7369}
7370
7371fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
7372 match requested {
7373 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
7374 None => true,
7375 }
7376}
7377
7378#[cfg(windows)]
7379fn current_architecture() -> &'static str {
7380 use std::sync::OnceLock;
7381 static CELL: OnceLock<&'static str> = OnceLock::new();
7382 CELL.get_or_init(|| {
7383 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
7388 let mut process_machine: u16 = 0;
7389 let mut native_machine: u16 = 0;
7390 let handle = unsafe { GetCurrentProcess() };
7393 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
7395 if ok != 0 {
7396 match native_machine {
7397 0xAA64 => return "arm64",
7398 0x8664 => return "x64",
7399 0x014C => return "x86",
7400 _ => {}
7401 }
7402 }
7403 compiled_architecture()
7404 })
7405}
7406
7407#[cfg(not(windows))]
7408fn current_architecture() -> &'static str {
7409 compiled_architecture()
7410}
7411
7412fn compiled_architecture() -> &'static str {
7413 match std::env::consts::ARCH {
7414 "x86_64" => "x64",
7415 "x86" => "x86",
7416 "aarch64" => "arm64",
7417 _ => "neutral",
7418 }
7419}
7420
7421fn current_platform() -> Option<&'static str> {
7422 if cfg!(windows) { Some("Windows.Desktop") } else { None }
7423}
7424
7425#[cfg(windows)]
7426fn current_os_version() -> Option<String> {
7427 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7428 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
7429 let build: String = key.get_value("CurrentBuildNumber").ok()?;
7430 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
7431 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
7432 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
7433 Some(format!("{major}.{minor}.{build}.{ubr}"))
7434}
7435
7436#[cfg(not(windows))]
7437fn current_os_version() -> Option<String> {
7438 None
7439}
7440
7441fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
7442 let mut parsed = Vec::new();
7443 for component in version.split('.') {
7444 if component.is_empty() {
7445 continue;
7446 }
7447 parsed.push(component.parse().ok()?);
7448 }
7449
7450 if parsed.is_empty() { None } else { Some(parsed) }
7451}
7452
7453fn select_v1_version(
7454 versions: &[V1VersionRow],
7455 requested_version: Option<&str>,
7456 requested_channel: Option<&str>,
7457 package_id: &str,
7458 source_name: &str,
7459) -> Result<V1VersionRow> {
7460 let mut ordered = versions.to_vec();
7461 ordered.sort_by(|left, right| {
7462 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7463 });
7464
7465 if let Some(version) = requested_version {
7466 let channel = requested_channel.unwrap_or_default();
7467 return ordered
7468 .iter()
7469 .find(|candidate| {
7470 candidate.version.eq_ignore_ascii_case(version)
7471 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7472 })
7473 .cloned()
7474 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7475 }
7476
7477 if let Some(channel) = requested_channel {
7478 return ordered
7479 .iter()
7480 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7481 .cloned()
7482 .ok_or_else(|| {
7483 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7484 });
7485 }
7486
7487 ordered
7488 .into_iter()
7489 .next()
7490 .ok_or_else(|| anyhow!("package had no versions"))
7491}
7492
7493fn select_v2_version(
7494 versions: &[PackageVersionDataEntry],
7495 requested_version: Option<&str>,
7496 requested_channel: Option<&str>,
7497 package_id: &str,
7498 source_name: &str,
7499) -> Result<PackageVersionDataEntry> {
7500 let mut ordered = versions.to_vec();
7501 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
7502
7503 if let Some(channel) = requested_channel {
7504 bail!(
7505 "Channel '{channel}' cannot be matched for package '{package_id}' in pre-indexed V2 source '{source_name}' because the local source index does not include channel metadata."
7506 );
7507 }
7508
7509 if let Some(version) = requested_version {
7510 return ordered
7511 .iter()
7512 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
7513 .cloned()
7514 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7515 }
7516
7517 ordered
7518 .into_iter()
7519 .next()
7520 .ok_or_else(|| anyhow!("package had no V2 versions"))
7521}
7522
7523fn missing_package_version(
7524 package_id: &str,
7525 source_name: &str,
7526 requested_version: &str,
7527 requested_channel: Option<&str>,
7528) -> anyhow::Error {
7529 anyhow!(MissingPackageVersionError {
7530 package_id: package_id.to_owned(),
7531 source_name: source_name.to_owned(),
7532 requested_version: requested_version.to_owned(),
7533 requested_channel: requested_channel.map(str::to_owned),
7534 })
7535}
7536
7537fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
7538 if let Some(refresh_error) = refresh.error.as_deref() {
7539 let missing_error = error.to_string();
7540 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
7541 }
7542
7543 Err(error)
7544}
7545
7546fn select_rest_version<'a>(
7547 versions: &'a [VersionKey],
7548 requested_version: Option<&str>,
7549 requested_channel: Option<&str>,
7550 package_id: &str,
7551 source_name: &str,
7552) -> Result<&'a VersionKey> {
7553 if let Some(version) = requested_version {
7554 let channel = requested_channel.unwrap_or_default();
7555 return versions
7556 .iter()
7557 .find(|candidate| {
7558 candidate.version.eq_ignore_ascii_case(version)
7559 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7560 })
7561 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7562 }
7563
7564 if let Some(channel) = requested_channel {
7565 return versions
7566 .iter()
7567 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7568 .ok_or_else(|| {
7569 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7570 });
7571 }
7572
7573 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
7574}
7575
7576fn sort_versions_desc(versions: &mut [VersionKey]) {
7577 versions.sort_by(|left, right| {
7578 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7579 });
7580}
7581
7582fn compare_version_and_channel(
7583 left_version: &str,
7584 left_channel: &str,
7585 right_version: &str,
7586 right_channel: &str,
7587) -> Ordering {
7588 compare_version(left_version, right_version).then_with(|| {
7589 left_channel
7590 .to_ascii_lowercase()
7591 .cmp(&right_channel.to_ascii_lowercase())
7592 })
7593}
7594
7595fn compare_version(left: &str, right: &str) -> Ordering {
7596 let left_parts = tokenize_version(left);
7597 let right_parts = tokenize_version(right);
7598 let max_len = left_parts.len().max(right_parts.len());
7599
7600 for index in 0..max_len {
7601 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
7602 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
7603 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
7604
7605 let ordering = if let Some((left_number, right_number)) = numeric {
7606 left_number.cmp(&right_number)
7607 } else {
7608 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
7609 };
7610
7611 if ordering != Ordering::Equal {
7612 return ordering;
7613 }
7614 }
7615
7616 Ordering::Equal
7617}
7618
7619fn tokenize_version(value: &str) -> Vec<String> {
7620 let mut parts = Vec::new();
7621 let mut current = String::new();
7622 let mut current_is_digit = None;
7623
7624 for ch in value.chars() {
7625 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
7626 if !current.is_empty() {
7627 parts.push(std::mem::take(&mut current));
7628 }
7629 current_is_digit = None;
7630 continue;
7631 }
7632
7633 let is_digit = ch.is_ascii_digit();
7634 match current_is_digit {
7635 Some(previous) if previous != is_digit => {
7636 parts.push(std::mem::take(&mut current));
7637 current.push(ch);
7638 current_is_digit = Some(is_digit);
7639 }
7640 Some(_) => current.push(ch),
7641 None => {
7642 current.push(ch);
7643 current_is_digit = Some(is_digit);
7644 }
7645 }
7646 }
7647
7648 if !current.is_empty() {
7649 parts.push(current);
7650 }
7651
7652 parts
7653}
7654
7655fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7656 if !hash_matches(Some(expected_hash), bytes) {
7657 bail!("downloaded file hash did not match expected SHA256");
7658 }
7659 Ok(())
7660}
7661
7662fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7663 let Some(expected_hash) = expected_hash else {
7664 return Ok(());
7665 };
7666 if ignore_security_hash {
7667 return Ok(());
7668 }
7669
7670 if !hash_matches(Some(expected_hash), bytes) {
7671 let actual = sha256_hex(bytes);
7672 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7673 }
7674 Ok(())
7675}
7676
7677fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7678 expected_hash
7679 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7680 .unwrap_or(true)
7681}
7682
7683fn sha256_hex(bytes: &[u8]) -> String {
7684 let digest = Sha256::digest(bytes);
7685 let mut output = String::with_capacity(digest.len() * 2);
7686 for byte in digest {
7687 output.push_str(&format!("{byte:02X}"));
7688 }
7689 output
7690}
7691
7692fn pins_db_path(app_root: &Path) -> PathBuf {
7693 if uses_packaged_layout(app_root) {
7694 app_root.join("pinning.db")
7695 } else {
7696 app_root.join("pins.db")
7697 }
7698}
7699
7700fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7701 let mut has_current_column = false;
7702 let mut has_legacy_column = false;
7703
7704 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
7705 match column_name.as_str() {
7706 "type" => has_current_column = true,
7707 "pin_type" => has_legacy_column = true,
7708 _ => {}
7709 }
7710 }
7711
7712 Ok(if has_current_column {
7713 Some("type")
7714 } else if has_legacy_column {
7715 Some("pin_type")
7716 } else {
7717 None
7718 })
7719}
7720
7721fn decode_pin_type(pin_type_int: i64) -> PinType {
7722 match pin_type_int {
7723 4 => PinType::Blocking,
7724 3 => PinType::Gating,
7725 _ => PinType::Pinning,
7726 }
7727}
7728
7729#[cfg(windows)]
7730fn dispatch_installer(
7731 installer_path: &Path,
7732 installer_type: &str,
7733 request: &InstallRequest,
7734 manifest: &Manifest,
7735 installer: &Installer,
7736) -> Result<i32> {
7737 use std::process::Command;
7738
7739 match installer_type {
7740 "msi" | "wix" => {
7741 let mut cmd = Command::new("msiexec");
7742 cmd.args(installer_command_arguments(
7743 installer_type,
7744 request,
7745 manifest,
7746 installer_path,
7747 installer,
7748 ));
7749 let status = cmd.status().context("failed to run msiexec")?;
7750 Ok(status.code().unwrap_or(-1))
7751 }
7752 "msix" | "appx" => {
7753 let mut cmd = Command::new("powershell");
7754 cmd.arg("-NoProfile")
7755 .arg("-Command")
7756 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7757 let status = cmd.status().context("failed to run Add-AppxPackage")?;
7758 Ok(status.code().unwrap_or(-1))
7759 }
7760 "zip" => {
7761 if installer
7767 .nested_installer_type
7768 .as_deref()
7769 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7770 {
7771 return install_portable(installer_path, request, manifest, installer);
7772 }
7773 let target = dirs::data_local_dir()
7774 .unwrap_or_else(|| PathBuf::from("."))
7775 .join("Programs");
7776 fs::create_dir_all(&target)?;
7777 let file = fs::File::open(installer_path)?;
7778 let mut archive = ZipArchive::new(file)?;
7779 archive.extract(&target)?;
7780 Ok(0)
7781 }
7782 "portable" => install_portable(installer_path, request, manifest, installer),
7783 _ => {
7785 let mut cmd = Command::new(installer_path);
7786 cmd.args(installer_command_arguments(
7787 installer_type,
7788 request,
7789 manifest,
7790 installer_path,
7791 installer,
7792 ));
7793 let status = cmd.status().context("failed to run installer")?;
7794 Ok(status.code().unwrap_or(-1))
7795 }
7796 }
7797}
7798
7799#[cfg(not(windows))]
7800fn dispatch_installer(
7801 _installer_path: &Path,
7802 _installer_type: &str,
7803 _request: &InstallRequest,
7804 _manifest: &Manifest,
7805 _installer: &Installer,
7806) -> Result<i32> {
7807 bail!("Installing packages is only supported on Windows")
7808}
7809
7810#[cfg(any(windows, test))]
7811fn installer_command_arguments(
7812 installer_type: &str,
7813 request: &InstallRequest,
7814 _manifest: &Manifest,
7815 installer_path: &Path,
7816 installer: &Installer,
7817) -> Vec<String> {
7818 if let Some(override_args) = request.override_args.as_deref() {
7819 return split_installer_switches(override_args);
7820 }
7821
7822 let mut args = Vec::new();
7823 match installer_type {
7824 "msi" | "wix" => {
7825 args.push("/i".to_owned());
7826 args.push(installer_path.display().to_string());
7827 }
7828 "msix" | "appx" | "zip" => return Vec::new(),
7829 _ => {}
7830 }
7831
7832 let configured = match request.mode {
7833 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7834 InstallerMode::SilentWithProgress => installer
7835 .switches
7836 .silent_with_progress
7837 .as_deref()
7838 .or(installer.switches.silent.as_deref()),
7839 InstallerMode::Silent => installer
7840 .switches
7841 .silent
7842 .as_deref()
7843 .or(installer.switches.silent_with_progress.as_deref()),
7844 };
7845
7846 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7847 args.extend(split_installer_switches(value));
7848 } else {
7849 args.extend(default_installer_arguments(installer_type, request.mode));
7850 }
7851
7852 append_switches(
7853 &mut args,
7854 resolve_template_switch(
7855 installer.switches.log.as_deref(),
7856 default_log_switch(installer_type),
7857 request.log_path.as_ref().map(|path| path.display().to_string()),
7858 "<LOGPATH>",
7859 ),
7860 );
7861 append_switches(&mut args, installer.switches.custom.clone());
7862 append_switches(&mut args, request.custom.clone());
7863 append_switches(
7864 &mut args,
7865 resolve_template_switch(
7866 installer.switches.install_location.as_deref(),
7867 default_install_location_switch(installer_type),
7868 request.install_location.clone(),
7869 "<INSTALLPATH>",
7870 ),
7871 );
7872 args
7873}
7874
7875#[cfg(any(windows, test))]
7876fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7877 match mode {
7878 InstallerMode::Interactive => Vec::new(),
7879 InstallerMode::SilentWithProgress => match installer_type {
7880 "inno" => vec![
7881 "/SP-".to_owned(),
7882 "/SILENT".to_owned(),
7883 "/SUPPRESSMSGBOXES".to_owned(),
7884 "/NORESTART".to_owned(),
7885 ],
7886 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7887 "nullsoft" | "nsis" => vec!["/S".to_owned()],
7888 _ => vec!["/SILENT".to_owned()],
7889 },
7890 InstallerMode::Silent => match installer_type {
7891 "inno" => vec![
7892 "/SP-".to_owned(),
7893 "/VERYSILENT".to_owned(),
7894 "/SUPPRESSMSGBOXES".to_owned(),
7895 "/NORESTART".to_owned(),
7896 ],
7897 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7898 "nullsoft" | "nsis" => vec!["/S".to_owned()],
7899 _ => vec!["/S".to_owned()],
7900 },
7901 }
7902}
7903
7904#[cfg(any(windows, test))]
7905fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7906 match installer_type {
7907 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7908 "inno" => Some("/LOG=\"<LOGPATH>\""),
7909 _ => None,
7910 }
7911}
7912
7913#[cfg(any(windows, test))]
7914fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7915 match installer_type {
7916 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7917 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7918 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7919 _ => None,
7920 }
7921}
7922
7923#[cfg(any(windows, test))]
7924fn resolve_template_switch(
7925 manifest_value: Option<&str>,
7926 fallback: Option<&str>,
7927 replacement: Option<String>,
7928 token: &str,
7929) -> Option<String> {
7930 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7931 let replacement = replacement?;
7932 Some(template.replace(token, &replacement))
7933}
7934
7935#[cfg(any(windows, test))]
7936fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7937 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7938 args.extend(split_installer_switches(&value));
7939 }
7940}
7941
7942#[cfg(any(windows, test))]
7943fn split_installer_switches(value: &str) -> Vec<String> {
7944 let mut args = Vec::new();
7945 let mut current = String::new();
7946 let mut in_quotes = false;
7947
7948 for ch in value.chars() {
7949 match ch {
7950 '"' => in_quotes = !in_quotes,
7951 c if c.is_whitespace() && !in_quotes => {
7952 if !current.is_empty() {
7953 args.push(std::mem::take(&mut current));
7954 }
7955 }
7956 _ => current.push(ch),
7957 }
7958 }
7959
7960 if !current.is_empty() {
7961 args.push(current);
7962 }
7963
7964 args
7965}
7966
7967#[cfg(windows)]
7968fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7969 if request.purge && request.preserve {
7970 bail!("--purge and --preserve cannot be used together.");
7971 }
7972
7973 if installed
7974 .installer_category
7975 .as_deref()
7976 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7977 {
7978 return uninstall_portable(installed, request);
7979 }
7980
7981 if (request.purge || request.preserve) && !request.force {
7982 bail!("--purge and --preserve are currently only supported for portable packages.");
7983 }
7984
7985 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7986 return Ok(exit_code);
7987 }
7988
7989 if let Some(exit_code) = try_uninstall_msix(installed)? {
7990 return Ok(exit_code);
7991 }
7992
7993 bail!(
7994 "No uninstall command found for installed package '{}' ({})",
7995 installed.name,
7996 installed.local_id
7997 );
7998}
7999
8000#[cfg(windows)]
8001fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8002 use std::os::windows::process::CommandExt;
8003 use std::process::Command;
8004
8005 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8006 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8007
8008 let arp_paths = [
8009 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
8010 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
8011 ];
8012
8013 for root in [&hklm, &hkcu] {
8014 for arp_path in &arp_paths {
8015 if let Ok(key) = root.open_subkey(arp_path) {
8016 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
8017 if let Ok(subkey) = key.open_subkey(&subkey_name) {
8018 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
8019 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
8020 if !registry_entry_matches_installed_package(
8021 &subkey_name,
8022 &display_name,
8023 product_code.as_deref(),
8024 installed,
8025 ) {
8026 continue;
8027 }
8028
8029 if let Some(exit_code) =
8030 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
8031 {
8032 return Ok(Some(exit_code));
8033 }
8034
8035 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
8036 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
8037 subkey.get_value("UninstallString").ok()
8038 } else {
8039 quiet_uninstall_cmd
8040 .clone()
8041 .or_else(|| subkey.get_value("UninstallString").ok())
8042 }
8043 .context("No uninstall command found in registry")?;
8044
8045 if is_winget_uninstall_command(&uninstall_cmd)
8046 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
8047 {
8048 return Ok(Some(exit_code));
8049 }
8050
8051 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
8052 let uninstall_line = build_uninstall_command_with_mode(
8053 &uninstall_cmd,
8054 request.mode,
8055 quiet_uninstall_cmd.is_some(),
8056 log_path.as_deref(),
8057 );
8058 let mut cmd = Command::new("cmd");
8067 cmd.raw_arg("/S /C");
8068 cmd.raw_arg(format!("\"{uninstall_line}\""));
8069 let status = cmd.status().context("failed to run uninstaller")?;
8070 return Ok(Some(status.code().unwrap_or(-1)));
8071 }
8072 }
8073 }
8074 }
8075 }
8076
8077 Ok(None)
8078}
8079
8080#[cfg(windows)]
8081fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
8082 use std::process::Command;
8083
8084 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
8085 return Ok(None);
8086 }
8087
8088 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
8089 let mut cmd = Command::new("powershell");
8090 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
8091 package_full_name,
8092 &installed.package_family_names,
8093 ));
8094 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
8095 Ok(Some(status.code().unwrap_or(-1)))
8096}
8097
8098#[cfg(windows)]
8099fn arp_subkey_name(local_id: &str) -> Option<&str> {
8100 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
8101}
8102
8103#[cfg(windows)]
8104fn registry_entry_matches_installed_package(
8105 subkey_name: &str,
8106 display_name: &str,
8107 product_code: Option<&str>,
8108 installed: &ListMatch,
8109) -> bool {
8110 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
8111 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
8112 {
8113 return true;
8114 }
8115
8116 if installed.product_codes.iter().any(|code| {
8117 code.eq_ignore_ascii_case(subkey_name)
8118 || product_code
8119 .map(|value| code.eq_ignore_ascii_case(value))
8120 .unwrap_or(false)
8121 }) {
8122 return true;
8123 }
8124
8125 display_name.eq_ignore_ascii_case(&installed.name)
8126}
8127
8128#[cfg(all(windows, test))]
8129fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
8130 build_uninstall_command_with_mode(
8131 uninstall_cmd,
8132 if silent {
8133 InstallerMode::Silent
8134 } else {
8135 InstallerMode::Interactive
8136 },
8137 has_quiet_command,
8138 None,
8139 )
8140}
8141
8142#[cfg(windows)]
8143fn build_uninstall_command_with_mode(
8144 uninstall_cmd: &str,
8145 mode: InstallerMode,
8146 has_quiet_command: bool,
8147 log_path: Option<&str>,
8148) -> String {
8149 let uninstall_cmd = log_path
8150 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
8151 .unwrap_or_else(|| uninstall_cmd.to_owned());
8152 if mode == InstallerMode::Interactive || has_quiet_command {
8153 return uninstall_cmd;
8154 }
8155
8156 let lower = uninstall_cmd.to_ascii_lowercase();
8157 if is_winget_uninstall_command_lower(&lower) {
8158 return uninstall_cmd;
8159 }
8160 if lower.contains("/quiet")
8161 || lower.contains("/passive")
8162 || lower.contains("/verysilent")
8163 || lower.contains("/silent")
8164 || lower.contains(" /s")
8165 {
8166 return uninstall_cmd;
8167 }
8168
8169 format!("{uninstall_cmd} /S")
8170}
8171
8172#[cfg(windows)]
8173fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8174 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
8175 return Ok(None);
8176 };
8177
8178 let exit_code = run_winget_uninstall(&args)?;
8179 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
8180 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
8181 return Ok(Some(exit_code));
8182 };
8183
8184 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
8185 }
8186
8187 Ok(Some(exit_code))
8188}
8189
8190#[cfg(windows)]
8191fn run_winget_uninstall(args: &[String]) -> Result<i32> {
8192 use std::process::Command;
8193
8194 let status = Command::new("winget")
8195 .args(args)
8196 .status()
8197 .context("failed to run delegated winget uninstall")?;
8198 Ok(status.code().unwrap_or(-1))
8199}
8200
8201#[cfg(windows)]
8202fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
8203 build_winget_uninstall_arguments_with_scope(installed, request, true)
8204}
8205
8206#[cfg(windows)]
8207fn build_winget_uninstall_arguments_with_scope(
8208 installed: &ListMatch,
8209 request: &UninstallRequest,
8210 include_scope: bool,
8211) -> Option<Vec<String>> {
8212 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
8213 return None;
8214 }
8215
8216 let mut args = vec![
8217 "uninstall".to_owned(),
8218 "--id".to_owned(),
8219 installed.id.clone(),
8220 "--exact".to_owned(),
8221 "--disable-interactivity".to_owned(),
8222 ];
8223
8224 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
8225 args.push("--source".to_owned());
8226 args.push(source_name.to_owned());
8227 }
8228
8229 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
8230 args.push("--scope".to_owned());
8231 args.push(scope.to_owned());
8232 }
8233
8234 if request.mode == InstallerMode::Silent {
8235 args.push("--silent".to_owned());
8236 }
8237
8238 Some(args)
8239}
8240
8241#[cfg(windows)]
8242fn is_winget_uninstall_command(command: &str) -> bool {
8243 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
8244}
8245
8246#[cfg(windows)]
8247fn is_winget_uninstall_command_lower(command: &str) -> bool {
8248 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
8249}
8250
8251#[cfg(windows)]
8252fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
8253 let full_name_literal = package_full_name
8254 .map(|value| format!("'{}'", value.replace('\'', "''")))
8255 .unwrap_or_else(|| "$null".to_owned());
8256 let family_names_literal = if package_family_names.is_empty() {
8257 "@()".to_owned()
8258 } else {
8259 format!(
8260 "@({})",
8261 package_family_names
8262 .iter()
8263 .map(|value| format!("'{}'", value.replace('\'', "''")))
8264 .collect::<Vec<_>>()
8265 .join(", ")
8266 )
8267 };
8268
8269 format!(
8270 "$fullName = {full_name_literal}; \
8271 $familyNames = {family_names_literal}; \
8272 $targets = Get-AppxPackage | Where-Object {{ \
8273 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
8274 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
8275 if (-not $targets) {{ exit 1 }}; \
8276 $targets | Remove-AppxPackage"
8277 )
8278}
8279
8280#[cfg(windows)]
8281fn try_run_msi_uninstall(
8282 installed: &ListMatch,
8283 subkey_name: &str,
8284 product_code: Option<&str>,
8285 request: &UninstallRequest,
8286) -> Result<Option<i32>> {
8287 use std::process::Command;
8288
8289 let uninstall_code = installed
8290 .product_codes
8291 .iter()
8292 .map(String::as_str)
8293 .chain(product_code)
8294 .chain(Some(subkey_name))
8295 .find(|value| is_product_code_like(value));
8296
8297 let Some(uninstall_code) = uninstall_code else {
8298 return Ok(None);
8299 };
8300
8301 let mut cmd = Command::new("msiexec");
8302 cmd.arg("/x").arg(uninstall_code);
8303 match request.mode {
8304 InstallerMode::Interactive => {}
8305 InstallerMode::SilentWithProgress => {
8306 cmd.arg("/passive").arg("/norestart");
8307 }
8308 InstallerMode::Silent => {
8309 cmd.arg("/quiet").arg("/norestart");
8310 }
8311 }
8312 if let Some(log_path) = &request.log_path {
8313 cmd.arg("/log").arg(log_path);
8314 }
8315
8316 let status = cmd.status().context("failed to run msiexec uninstall")?;
8317 Ok(Some(status.code().unwrap_or(-1)))
8318}
8319
8320#[cfg(windows)]
8321fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8322 let known_symlink = read_portable_symlink_full_path(installed);
8328
8329 let Some(location) = installed.install_location.as_deref() else {
8330 if request.force {
8331 try_remove_portable_symlinks(installed, &known_symlink, None);
8332 try_remove_portable_registry_entry(installed);
8333 return Ok(0);
8334 }
8335 bail!(
8336 "Portable package '{}' does not expose an install location.",
8337 installed.name
8338 );
8339 };
8340
8341 if request.preserve {
8342 return Ok(0);
8343 }
8344
8345 let path = Path::new(location);
8346 let removed = if path.is_dir() {
8347 fs::remove_dir_all(path)?;
8348 true
8349 } else if path.is_file() {
8350 fs::remove_file(path)?;
8351 true
8352 } else {
8353 false
8354 };
8355
8356 if removed || request.force {
8357 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
8360 try_remove_portable_registry_entry(installed);
8361 return Ok(0);
8362 }
8363
8364 bail!("Portable package location not found: {location}")
8365}
8366
8367#[cfg(windows)]
8373fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
8374 const ARP_PREFIX: &str = r"ARP\";
8375 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
8376 let mut parts = rest.splitn(3, '\\');
8377 let scope = parts.next().unwrap_or("");
8378 let _arch = parts.next();
8379 let subkey_name = parts.next().unwrap_or("");
8380 if subkey_name.is_empty() {
8381 return None;
8382 }
8383 let hive = if scope.eq_ignore_ascii_case("User") {
8384 RegKey::predef(HKEY_CURRENT_USER)
8385 } else {
8386 RegKey::predef(HKEY_LOCAL_MACHINE)
8387 };
8388 let subkey = hive
8389 .open_subkey(format!(
8390 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
8391 ))
8392 .ok()?;
8393 return read_reg_string(&subkey, "PortableSymlinkFullPath");
8394 }
8395 None
8396}
8397
8398#[cfg(windows)]
8404fn try_remove_portable_symlinks(
8405 _installed: &ListMatch,
8406 known_symlink: &Option<String>,
8407 install_location: Option<&Path>,
8408) {
8409 if let Some(path) = known_symlink.as_deref() {
8410 let _ = fs::remove_file(path);
8411 }
8412
8413 let Some(install_dir) = install_location else { return };
8418
8419 let Some(local) = dirs::data_local_dir() else { return };
8420 let links_root = local.join("Microsoft").join("WinGet").join("Links");
8421 let Ok(entries) = fs::read_dir(&links_root) else { return };
8422 for entry in entries.flatten() {
8423 let path = entry.path();
8424 let Ok(target) = fs::read_link(&path) else { continue };
8425 if path_starts_with_case_insensitive(&target, install_dir) {
8426 let _ = fs::remove_file(&path);
8427 }
8428 }
8429}
8430
8431#[cfg(windows)]
8438fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
8439 let lower_components = |p: &Path| -> Vec<String> {
8440 p.components()
8441 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
8442 .collect()
8443 };
8444 let parent_components = lower_components(parent);
8445 let child_components = lower_components(child);
8446 if parent_components.is_empty() || parent_components.len() > child_components.len() {
8447 return false;
8448 }
8449 parent_components
8450 .iter()
8451 .zip(child_components.iter())
8452 .all(|(a, b)| a == b)
8453}
8454
8455#[cfg(windows)]
8462fn try_remove_portable_registry_entry(installed: &ListMatch) {
8463 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
8466 let mut parts = rest.splitn(3, '\\');
8468 let scope = parts.next().unwrap_or("");
8469 let _arch = parts.next();
8470 let subkey_name = parts.next().unwrap_or("");
8471 if subkey_name.is_empty() {
8472 return;
8473 }
8474 let is_hkcu = scope.eq_ignore_ascii_case("User");
8475 vec![(
8476 is_hkcu,
8477 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
8478 )]
8479 } else {
8480 let mut found = Vec::new();
8482 for (is_hkcu, hive) in [
8483 (true, RegKey::predef(HKEY_CURRENT_USER)),
8484 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
8485 ] {
8486 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
8487 continue;
8488 };
8489 for name in uninstall.enum_keys().flatten() {
8490 let Ok(subkey) = uninstall.open_subkey(&name) else {
8491 continue;
8492 };
8493 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
8494 found.push((
8495 is_hkcu,
8496 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
8497 ));
8498 }
8499 }
8500 }
8501 found
8502 };
8503
8504 for (is_hkcu, path) in candidates {
8505 let hive = if is_hkcu {
8506 RegKey::predef(HKEY_CURRENT_USER)
8507 } else {
8508 RegKey::predef(HKEY_LOCAL_MACHINE)
8509 };
8510 let _ = hive.delete_subkey_all(&path);
8511 }
8512}
8513
8514#[cfg(windows)]
8515fn is_product_code_like(value: &str) -> bool {
8516 value.starts_with('{') && value.ends_with('}')
8517}
8518
8519#[cfg(not(windows))]
8520fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
8521 bail!("Uninstalling packages is only supported on Windows")
8522}
8523
8524#[cfg(windows)]
8546fn install_portable(
8547 installer_path: &Path,
8548 request: &InstallRequest,
8549 manifest: &Manifest,
8550 installer: &Installer,
8551) -> Result<i32> {
8552 let source_identifier = portable_source_identifier(&request.query);
8553 let existing_entry = read_existing_portable_entry(&manifest.id);
8554 let subkey_name = existing_entry
8557 .as_ref()
8558 .map(|entry| entry.subkey_name.clone())
8559 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
8560
8561 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
8562
8563 let prev_dir_created = existing_entry
8568 .as_ref()
8569 .and_then(|entry| entry.install_directory_created)
8570 == Some(1);
8571 let dir_existed = target_dir.is_dir();
8572 if prev_dir_created && dir_existed {
8573 clean_directory_contents(&target_dir).with_context(|| {
8574 format!(
8575 "failed to clean existing portable install directory: {}",
8576 target_dir.display()
8577 )
8578 })?;
8579 }
8580
8581 fs::create_dir_all(&target_dir)
8582 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
8583 let install_directory_created = !dir_existed || prev_dir_created;
8587
8588 let installer_type = installer.installer_type.as_deref().unwrap_or("");
8589 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
8590 let file = fs::File::open(installer_path)
8591 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
8592 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
8593 archive
8594 .extract(&target_dir)
8595 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
8596 installer
8600 .nested_installer_files
8601 .first()
8602 .map(|file| target_dir.join(&file.relative_file_path))
8603 .unwrap_or_else(|| target_dir.clone())
8604 } else {
8605 let basename = installer_path
8609 .file_name()
8610 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
8611 let copied = target_dir.join(basename);
8612 fs::copy(installer_path, &copied)
8613 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
8614 copied
8615 };
8616
8617 let alias = determine_portable_alias(installer);
8622 let symlink_full_path = match alias.as_deref() {
8623 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
8624 _ => None,
8625 };
8626
8627 let added_to_path = if symlink_full_path.is_some() {
8631 try_add_links_dir_to_user_path()
8632 } else {
8633 false
8634 };
8635
8636 write_portable_arp_entry(&PortableArpEntry {
8637 subkey_name: &subkey_name,
8638 install_location: &target_dir,
8639 portable_target_full_path: &portable_target_full_path,
8640 portable_symlink_full_path: symlink_full_path.as_deref(),
8641 install_directory_created,
8642 added_to_path,
8643 source_identifier: &source_identifier,
8644 manifest,
8645 })?;
8646
8647 Ok(0)
8648}
8649
8650#[cfg(windows)]
8651struct PortableArpEntry<'a> {
8652 subkey_name: &'a str,
8653 install_location: &'a Path,
8654 portable_target_full_path: &'a Path,
8655 portable_symlink_full_path: Option<&'a Path>,
8656 install_directory_created: bool,
8657 added_to_path: bool,
8658 source_identifier: &'a str,
8659 manifest: &'a Manifest,
8660}
8661
8662#[cfg(windows)]
8668fn determine_portable_alias(installer: &Installer) -> Option<String> {
8669 installer
8670 .nested_installer_files
8671 .iter()
8672 .find_map(|file| file.portable_command_alias.clone())
8673 .or_else(|| installer.commands.first().cloned())
8674}
8675
8676#[cfg(windows)]
8677fn winget_links_dir() -> PathBuf {
8678 dirs::data_local_dir()
8679 .unwrap_or_else(|| PathBuf::from("."))
8680 .join("Microsoft")
8681 .join("WinGet")
8682 .join("Links")
8683}
8684
8685#[cfg(windows)]
8690fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8691 use std::os::windows::fs::symlink_file;
8692 let links_dir = winget_links_dir();
8693 if fs::create_dir_all(&links_dir).is_err() {
8694 return None;
8695 }
8696 let link_path = links_dir.join(format!("{alias}.exe"));
8697
8698 if link_path.symlink_metadata().is_ok() {
8701 let _ = fs::remove_file(&link_path);
8702 }
8703
8704 symlink_file(target, &link_path).ok().map(|()| link_path)
8709}
8710
8711#[cfg(windows)]
8717fn try_add_links_dir_to_user_path() -> bool {
8718 let links_dir = winget_links_dir();
8719 let links_str = links_dir.to_string_lossy().to_string();
8720 let normalized_links = links_str.to_ascii_lowercase();
8721
8722 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8723 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8724 return false;
8725 };
8726 let existing: String = env_key.get_value("Path").unwrap_or_default();
8727 let already_present = existing
8728 .split(';')
8729 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8730 if already_present {
8731 return false;
8732 }
8733
8734 let new_path = if existing.is_empty() {
8735 links_str
8736 } else if existing.ends_with(';') {
8737 format!("{existing}{links_str}")
8738 } else {
8739 format!("{existing};{links_str}")
8740 };
8741
8742 if env_key.set_value("Path", &new_path).is_err() {
8743 return false;
8744 }
8745
8746 broadcast_environment_change();
8747 true
8748}
8749
8750#[cfg(windows)]
8751fn broadcast_environment_change() {
8752 use std::ffi::OsStr;
8753 use std::os::windows::ffi::OsStrExt;
8754
8755 use windows_sys::Win32::UI::WindowsAndMessaging::{
8756 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8757 };
8758
8759 let param: Vec<u16> = OsStr::new("Environment")
8760 .encode_wide()
8761 .chain(std::iter::once(0))
8762 .collect();
8763 unsafe {
8767 let mut result: usize = 0;
8768 SendMessageTimeoutW(
8769 HWND_BROADCAST,
8770 WM_SETTINGCHANGE,
8771 0usize,
8772 param.as_ptr() as isize,
8773 SMTO_ABORTIFHUNG,
8774 5000,
8775 &mut result,
8776 );
8777 }
8778}
8779
8780#[cfg(windows)]
8785fn portable_source_identifier(query: &PackageQuery) -> String {
8786 match query.source.as_deref() {
8787 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8788 Some(name) if !name.is_empty() => name.to_owned(),
8789 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8790 }
8791}
8792
8793#[cfg(windows)]
8794fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8795 format!("{package_id}_{source_identifier}")
8796}
8797
8798#[cfg(windows)]
8799struct ExistingPortableEntry {
8800 install_location: Option<PathBuf>,
8801 install_directory_created: Option<u32>,
8802 subkey_name: String,
8803}
8804
8805#[cfg(windows)]
8806fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8807 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8808 let uninstall = hkcu
8809 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8810 .ok()?;
8811 for name in uninstall.enum_keys().flatten() {
8812 let Ok(subkey) = uninstall.open_subkey(&name) else {
8813 continue;
8814 };
8815 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8816 continue;
8817 }
8818 if !read_reg_string(&subkey, "WinGetInstallerType")
8819 .as_deref()
8820 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8821 {
8822 continue;
8823 }
8824 return Some(ExistingPortableEntry {
8825 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
8826 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
8827 subkey_name: name,
8828 });
8829 }
8830 None
8831}
8832
8833#[cfg(windows)]
8834fn resolve_portable_install_location(
8835 request: &InstallRequest,
8836 existing: &Option<ExistingPortableEntry>,
8837 subkey_name: &str,
8838) -> PathBuf {
8839 resolve_portable_install_location_pure(
8840 request.install_location.as_deref(),
8841 existing
8842 .as_ref()
8843 .and_then(|e| e.install_location.as_deref())
8844 .and_then(Path::to_str),
8845 subkey_name,
8846 &default_user_portable_root(),
8847 )
8848}
8849
8850#[cfg(windows)]
8855fn resolve_portable_install_location_pure(
8856 request_location: Option<&str>,
8857 existing_location: Option<&str>,
8858 subkey_name: &str,
8859 default_user_portable_root: &Path,
8860) -> PathBuf {
8861 if let Some(custom) = request_location
8862 && !custom.is_empty()
8863 {
8864 return PathBuf::from(custom);
8865 }
8866 if let Some(loc) = existing_location
8867 && !loc.is_empty()
8868 {
8869 return PathBuf::from(loc);
8870 }
8871 default_user_portable_root.join(subkey_name)
8872}
8873
8874#[cfg(windows)]
8875fn default_user_portable_root() -> PathBuf {
8876 dirs::data_local_dir()
8877 .unwrap_or_else(|| PathBuf::from("."))
8878 .join("Microsoft")
8879 .join("WinGet")
8880 .join("Packages")
8881}
8882
8883#[cfg(windows)]
8884fn clean_directory_contents(dir: &Path) -> Result<()> {
8885 if !dir.is_dir() {
8886 return Ok(());
8887 }
8888 for entry in fs::read_dir(dir)? {
8889 let entry = entry?;
8890 let path = entry.path();
8891 if path.is_dir() {
8892 fs::remove_dir_all(&path)?;
8893 } else {
8894 fs::remove_file(&path)?;
8895 }
8896 }
8897 Ok(())
8898}
8899
8900#[cfg(windows)]
8904fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
8905 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8906 let uninstall_path = format!(
8907 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
8908 entry.subkey_name
8909 );
8910 let (subkey, _) = hkcu
8911 .create_subkey(&uninstall_path)
8912 .context("failed to create portable ARP registry subkey")?;
8913
8914 let manifest = entry.manifest;
8915 let install_location_str = entry.install_location.to_string_lossy().to_string();
8916 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
8917 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
8918 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
8919 subkey.set_value("InstallLocation", &install_location_str)?;
8920 subkey.set_value(
8921 "PortableTargetFullPath",
8922 &entry.portable_target_full_path.to_string_lossy().to_string(),
8923 )?;
8924 if let Some(symlink_path) = entry.portable_symlink_full_path {
8925 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
8926 }
8927 subkey.set_value(
8928 "InstallDirectoryAddedToPath",
8929 &(if entry.added_to_path { 1u32 } else { 0u32 }),
8930 )?;
8931 subkey.set_value(
8932 "InstallDirectoryCreated",
8933 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
8934 )?;
8935 subkey.set_value(
8936 "DisplayName",
8937 &if manifest.name.is_empty() {
8938 manifest.id.clone()
8939 } else {
8940 manifest.name.clone()
8941 },
8942 )?;
8943 subkey.set_value("DisplayVersion", &manifest.version)?;
8944 if let Some(publisher) = manifest.publisher.as_ref() {
8945 subkey.set_value("Publisher", publisher)?;
8946 }
8947 subkey.set_value(
8948 "UninstallString",
8949 &format!("winget uninstall --product-code {}", entry.subkey_name),
8950 )?;
8951 let today = Utc::now().format("%Y%m%d").to_string();
8952 subkey.set_value("InstallDate", &today)?;
8953 if let Some(url) = manifest.package_url.as_ref() {
8954 subkey.set_value("URLInfoAbout", url)?;
8955 }
8956
8957 Ok(())
8958}
8959
8960#[cfg(test)]
8961mod tests {
8962 use std::io::{Read as _, Write};
8963 use std::net::{SocketAddr, TcpListener, TcpStream};
8964 use std::path::{Path, PathBuf};
8965 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
8966 use std::thread::{self, JoinHandle};
8967
8968 use flate2::Compression;
8969 use flate2::write::DeflateEncoder;
8970 use zip::write::SimpleFileOptions;
8971
8972 use super::*;
8973
8974 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
8975 Manifest {
8976 id: id.to_owned(),
8977 name: name.to_owned(),
8978 version: version.to_owned(),
8979 channel: String::new(),
8980 publisher: Some("Acme".to_owned()),
8981 description: Some("desc".to_owned()),
8982 moniker: None,
8983 package_url: None,
8984 publisher_url: None,
8985 publisher_support_url: None,
8986 license: None,
8987 license_url: None,
8988 privacy_url: None,
8989 author: None,
8990 copyright: None,
8991 copyright_url: None,
8992 release_notes: None,
8993 release_notes_url: None,
8994 tags: Vec::new(),
8995 agreements: Vec::new(),
8996 package_dependencies: Vec::new(),
8997 documentation: Vec::new(),
8998 installers: Vec::new(),
8999 require_explicit_upgrade: false,
9000 }
9001 }
9002
9003 #[test]
9004 fn derive_download_filename_matches_winget_pattern() {
9005 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
9006 let installer = Installer {
9007 architecture: Some("arm64".to_owned()),
9008 installer_type: Some("exe".to_owned()),
9009 scope: Some("user".to_owned()),
9010 locale: Some("en-US".to_owned()),
9011 ..Installer::default()
9012 };
9013 assert_eq!(
9014 derive_download_manifest_filename(&manifest, &installer),
9015 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
9016 );
9017 }
9018
9019 #[test]
9020 fn derive_download_filename_omits_optional_components() {
9021 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
9022 let installer = Installer {
9023 architecture: Some("x64".to_owned()),
9024 installer_type: Some("exe".to_owned()),
9025 ..Installer::default()
9026 };
9027 assert_eq!(
9029 derive_download_manifest_filename(&manifest, &installer),
9030 "7-Zip_26.01_X64_exe.yaml"
9031 );
9032 }
9033
9034 #[test]
9035 fn derive_download_filename_skips_unknown_version() {
9036 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
9037 let installer = Installer {
9038 architecture: Some("neutral".to_owned()),
9039 installer_type: Some("msi".to_owned()),
9040 ..Installer::default()
9041 };
9042 assert_eq!(
9043 derive_download_manifest_filename(&manifest, &installer),
9044 "Test_Neutral_msi.yaml"
9045 );
9046 }
9047
9048 #[test]
9049 fn derive_download_filename_sanitizes_illegal_chars() {
9050 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
9051 let installer = Installer {
9052 architecture: Some("x64".to_owned()),
9053 installer_type: Some("exe".to_owned()),
9054 ..Installer::default()
9055 };
9056 let filename = derive_download_manifest_filename(&manifest, &installer);
9058 assert!(!filename.contains('/') && !filename.contains(':'));
9059 assert!(filename.starts_with("Some_Bad_Name_1.0"));
9060 }
9061
9062 #[test]
9063 fn build_download_manifest_yaml_overrides_identity_fields() {
9064 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9065 let documents = serde_json::json!({
9068 "PackageIdentifier": "stale-id",
9069 "PackageVersion": "",
9070 "PackageName": "Old Name",
9071 "Channel": "preview",
9072 "Installers": [
9073 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
9074 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
9075 ],
9076 });
9077 let installer = Installer {
9078 architecture: Some("x64".to_owned()),
9079 installer_type: Some("exe".to_owned()),
9080 url: Some("https://example.com/a".to_owned()),
9081 ..Installer::default()
9082 };
9083 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9084 assert!(yaml.contains("PackageIdentifier: App.Id"));
9085 assert!(yaml.contains("PackageVersion: 2.0.0"));
9086 assert!(yaml.contains("PackageName: App Name"));
9087 assert!(yaml.contains("ManifestType: merged"));
9088 assert!(!yaml.contains("Channel: preview"));
9091 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
9093 assert!(yaml.contains("https://example.com/a"));
9094 assert!(!yaml.contains("https://example.com/b"));
9095 }
9096
9097 #[test]
9098 fn build_download_manifest_yaml_preserves_typed_channel() {
9099 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9100 manifest.channel = "beta".to_owned();
9101 let documents = serde_json::json!({
9102 "PackageIdentifier": "App.Id",
9103 "PackageVersion": "2.0.0",
9104 "Installers": [],
9105 });
9106 let installer = Installer::default();
9107 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9108 assert!(yaml.contains("Channel: beta"));
9109 }
9110
9111 fn temp_app_root(label: &str) -> PathBuf {
9112 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
9113 std::env::temp_dir().join(format!(
9114 "pinget-rs-tests-{label}-{}-{}",
9115 std::process::id(),
9116 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
9117 ))
9118 }
9119
9120 #[cfg(windows)]
9121 #[test]
9122 fn path_prefix_check_rejects_string_prefix_collisions() {
9123 assert!(!path_starts_with_case_insensitive(
9127 Path::new(r"C:\Foobar\bin.exe"),
9128 Path::new(r"C:\Foo"),
9129 ));
9130 assert!(!path_starts_with_case_insensitive(
9131 Path::new(r"C:\Foo\bin.exe.bak"),
9132 Path::new(r"C:\Foo\bin.exe"),
9133 ));
9134 }
9135
9136 #[cfg(windows)]
9137 #[test]
9138 fn path_prefix_check_accepts_real_children_and_self() {
9139 assert!(path_starts_with_case_insensitive(
9141 Path::new(r"C:\Foo\sub\bin.exe"),
9142 Path::new(r"C:\Foo"),
9143 ));
9144 assert!(path_starts_with_case_insensitive(
9146 Path::new(r"C:\Foo\bin.exe"),
9147 Path::new(r"C:\Foo\bin.exe"),
9148 ));
9149 }
9150
9151 #[cfg(windows)]
9152 #[test]
9153 fn path_prefix_check_is_case_insensitive() {
9154 assert!(path_starts_with_case_insensitive(
9157 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
9158 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
9159 ));
9160 }
9161
9162 #[cfg(windows)]
9163 #[test]
9164 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
9165 let query = PackageQuery {
9166 source: Some("winget".to_owned()),
9167 ..PackageQuery::default()
9168 };
9169 assert_eq!(
9170 portable_source_identifier(&query),
9171 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9172 );
9173 let query = PackageQuery {
9175 source: Some("WINGET".to_owned()),
9176 ..PackageQuery::default()
9177 };
9178 assert_eq!(
9179 portable_source_identifier(&query),
9180 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9181 );
9182 }
9183
9184 #[cfg(windows)]
9185 #[test]
9186 fn portable_source_identifier_passes_through_custom_sources() {
9187 let query = PackageQuery {
9188 source: Some("internal-feed".to_owned()),
9189 ..PackageQuery::default()
9190 };
9191 assert_eq!(portable_source_identifier(&query), "internal-feed");
9192 }
9193
9194 #[cfg(windows)]
9195 #[test]
9196 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
9197 let query = PackageQuery {
9198 source: None,
9199 ..PackageQuery::default()
9200 };
9201 assert_eq!(
9202 portable_source_identifier(&query),
9203 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9204 );
9205 let query = PackageQuery {
9206 source: Some(String::new()),
9207 ..PackageQuery::default()
9208 };
9209 assert_eq!(
9210 portable_source_identifier(&query),
9211 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9212 );
9213 }
9214
9215 #[cfg(windows)]
9216 #[test]
9217 fn portable_subkey_name_matches_winget_format() {
9218 assert_eq!(
9219 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
9220 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
9221 );
9222 }
9223
9224 #[cfg(windows)]
9225 #[test]
9226 fn determine_portable_alias_prefers_nested_installer_alias() {
9227 let installer = Installer {
9228 nested_installer_files: vec![NestedInstallerFile {
9229 relative_file_path: "ripgrep/rg.exe".to_owned(),
9230 portable_command_alias: Some("rg".to_owned()),
9231 }],
9232 commands: vec!["other-name".to_owned()],
9233 ..Installer::default()
9234 };
9235 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
9236 }
9237
9238 #[cfg(windows)]
9239 #[test]
9240 fn determine_portable_alias_falls_back_to_commands() {
9241 let installer = Installer {
9242 commands: vec!["nuget".to_owned()],
9244 ..Installer::default()
9245 };
9246 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
9247
9248 let installer = Installer {
9250 nested_installer_files: vec![NestedInstallerFile {
9251 relative_file_path: "subdir/bin.exe".to_owned(),
9252 portable_command_alias: None,
9253 }],
9254 commands: vec!["bin".to_owned()],
9255 ..Installer::default()
9256 };
9257 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
9258 }
9259
9260 #[cfg(windows)]
9261 #[test]
9262 fn determine_portable_alias_returns_none_without_alias_or_commands() {
9263 let installer = Installer::default();
9264 assert!(determine_portable_alias(&installer).is_none());
9265 }
9266
9267 #[cfg(windows)]
9268 #[test]
9269 fn resolve_portable_install_location_prefers_request_override() {
9270 let result = resolve_portable_install_location_pure(
9271 Some(r"D:\Tools\rg"),
9272 Some(r"C:\Software\rg-test"),
9273 "Sub_Source",
9274 Path::new(r"C:\default"),
9275 );
9276 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
9277 }
9278
9279 #[cfg(windows)]
9280 #[test]
9281 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
9282 let result = resolve_portable_install_location_pure(
9286 None,
9287 Some(r"C:\Software\rg-test"),
9288 "Sub_Source",
9289 Path::new(r"C:\default"),
9290 );
9291 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
9292 }
9293
9294 #[cfg(windows)]
9295 #[test]
9296 fn resolve_portable_install_location_falls_back_to_default_root() {
9297 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
9298 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9299 }
9300
9301 #[cfg(windows)]
9302 #[test]
9303 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
9304 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
9307 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9308 }
9309
9310 #[cfg(windows)]
9311 #[test]
9312 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
9313 let root = std::env::temp_dir().join(format!(
9314 "pinget-clean-test-{}",
9315 Utc::now().timestamp_nanos_opt().unwrap_or_default()
9316 ));
9317 fs::create_dir_all(&root).expect("create temp dir");
9318 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
9319 fs::create_dir_all(root.join("nested")).expect("create nested dir");
9320 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
9321
9322 clean_directory_contents(&root).expect("clean root contents");
9323
9324 assert!(root.is_dir(), "root dir itself must survive");
9325 assert_eq!(
9326 fs::read_dir(&root).expect("read root").count(),
9327 0,
9328 "root must be empty after clean"
9329 );
9330 let _ = fs::remove_dir_all(&root);
9331 }
9332
9333 #[cfg(windows)]
9334 #[test]
9335 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
9336 let bogus = std::env::temp_dir().join(format!(
9337 "pinget-clean-test-bogus-{}",
9338 Utc::now().timestamp_nanos_opt().unwrap_or_default()
9339 ));
9340 assert!(!bogus.exists());
9341 clean_directory_contents(&bogus).expect("clean nonexistent dir");
9342 assert!(!bogus.exists());
9344 }
9345
9346 #[cfg(windows)]
9347 #[test]
9348 fn path_prefix_check_handles_mixed_separators() {
9349 assert!(path_starts_with_case_insensitive(
9352 Path::new(r"C:\Foo\sub/bin.exe"),
9353 Path::new(r"C:\Foo"),
9354 ));
9355 }
9356
9357 #[test]
9358 fn repository_options_capture_custom_host_settings() {
9359 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
9360 .with_user_agent("pinget-rs-tests/1.0")
9361 .with_pre_indexed_source_auto_update_interval(None);
9362
9363 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
9364 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
9365 assert!(options.pre_indexed_source_auto_update_interval.is_none());
9366 }
9367
9368 #[test]
9369 fn dependency_install_requests_are_silent_and_not_forced() {
9370 let mut parent = InstallRequest::new(PackageQuery {
9371 source: Some("winget".to_owned()),
9372 ..PackageQuery::default()
9373 });
9374 parent.mode = InstallerMode::SilentWithProgress;
9375 parent.force = true;
9376 parent.accept_package_agreements = true;
9377 parent.ignore_security_hash = true;
9378 parent.dependency_source = Some("dependencies".to_owned());
9379
9380 let dependency =
9381 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
9382
9383 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
9384 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
9385 assert_eq!(dependency.mode, InstallerMode::Silent);
9386 assert!(!dependency.force);
9387 assert!(dependency.no_upgrade);
9388 assert!(dependency.accept_package_agreements);
9389 assert!(dependency.ignore_security_hash);
9390 }
9391
9392 #[test]
9393 fn storage_paths_use_configured_app_root() {
9394 let app_root = PathBuf::from(r"C:\temp\pinget-test");
9395 let source = SourceRecord {
9396 name: "winget/test".to_owned(),
9397 kind: SourceKind::PreIndexed,
9398 arg: "https://example.com/cache".to_owned(),
9399 identifier: "Test.Source".to_owned(),
9400 trust_level: "None".to_owned(),
9401 explicit: false,
9402 priority: 0,
9403 last_update: None,
9404 source_version: None,
9405 };
9406
9407 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
9408 assert_eq!(
9409 source_state_dir(&app_root, &source),
9410 app_root.join("sources").join("winget_test")
9411 );
9412 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
9413 }
9414
9415 #[cfg(windows)]
9416 #[test]
9417 fn packaged_layout_paths_resolve_from_packaged_app_root() {
9418 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
9419 let app_root = local_app_data
9420 .join("Packages")
9421 .join(PACKAGED_FAMILY_NAME)
9422 .join("LocalState");
9423 assert!(uses_packaged_layout(&app_root));
9424 assert!(
9425 user_settings_path(&app_root)
9426 .display()
9427 .to_string()
9428 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
9429 );
9430 assert!(
9431 packaged_file_cache_root(&app_root)
9432 .display()
9433 .to_string()
9434 .ends_with(&format!(
9435 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
9436 PACKAGED_FAMILY_NAME
9437 ))
9438 );
9439 }
9440
9441 #[cfg(windows)]
9442 #[test]
9443 fn default_app_root_outside_package_avoids_packaged_layout() {
9444 let prior = std::env::var_os("PINGET_APPROOT");
9448 unsafe { std::env::remove_var("PINGET_APPROOT") };
9450
9451 let result = default_app_root();
9452
9453 if let Some(prior) = prior {
9454 unsafe { std::env::set_var("PINGET_APPROOT", prior) };
9456 }
9457
9458 let app_root = result.expect("default app root");
9459 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
9460 assert!(
9461 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
9462 "unexpected fallback app root: {app_root:?}"
9463 );
9464 }
9465
9466 #[test]
9467 fn packaged_source_stream_overlays_defaults_and_metadata() {
9468 let store = parse_packaged_source_store(
9469 Some(
9470 "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",
9471 ),
9472 Some(
9473 "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n",
9474 ),
9475 )
9476 .expect("packaged store");
9477
9478 assert!(!store.sources.iter().any(|source| source.name == "winget"));
9479 let corp = store
9480 .sources
9481 .iter()
9482 .find(|source| source.name == "corp")
9483 .expect("corp source");
9484 assert_eq!(corp.kind, SourceKind::Rest);
9485 assert_eq!(corp.identifier, "Contoso.Rest");
9486 assert_eq!(corp.trust_level, "Trusted");
9487 assert!(corp.explicit);
9488 assert_eq!(corp.priority, 7);
9489 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
9490 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
9491 }
9492
9493 #[test]
9494 fn system_winget_export_parses_json_lines() {
9495 let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9496{"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"}
9497"#;
9498
9499 let sources = parse_system_winget_source_export(output).expect("source export");
9500
9501 assert_eq!(
9502 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
9503 vec!["contoso", "contoso-cache"]
9504 );
9505 assert_eq!(sources[0].kind, SourceKind::Rest);
9506 assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
9507 assert_eq!(sources[0].identifier, "api.contoso.test");
9508 assert_eq!(sources[0].trust_level, "Trusted");
9509 assert!(!sources[0].explicit);
9510 assert_eq!(sources[1].kind, SourceKind::PreIndexed);
9511 assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
9512 assert!(sources[1].explicit);
9513 }
9514
9515 #[test]
9516 fn system_winget_export_parses_wrapped_sources() {
9517 let output = r#"{
9518 "Sources": [
9519 {
9520 "Name": "corp",
9521 "Type": "Microsoft.Rest",
9522 "Arg": "https://packages.contoso.test/api",
9523 "Identifier": "Contoso.Rest",
9524 "TrustLevel": "Trusted",
9525 "Explicit": true,
9526 "Priority": 9
9527 }
9528 ]
9529}"#;
9530
9531 let sources = parse_system_winget_source_export(output).expect("source export");
9532 let source = sources.first().expect("source");
9533 assert_eq!(sources.len(), 1);
9534 assert_eq!(source.name, "corp");
9535 assert_eq!(source.kind, SourceKind::Rest);
9536 assert_eq!(source.identifier, "Contoso.Rest");
9537 assert_eq!(source.trust_level, "Trusted");
9538 assert!(source.explicit);
9539 assert_eq!(source.priority, 9);
9540 }
9541
9542 #[test]
9543 fn system_winget_source_store_detects_secure_settings_stub() {
9544 assert!(is_secure_settings_stub(Some(
9545 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
9546 )));
9547 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
9548 }
9549
9550 #[test]
9551 fn system_winget_source_store_builds_add_arguments() {
9552 let args = build_winget_source_add_arguments(
9553 "corp",
9554 "https://packages.contoso.test/api",
9555 SourceKind::Rest,
9556 "Trusted",
9557 true,
9558 );
9559
9560 assert_eq!(
9561 args,
9562 vec![
9563 "source",
9564 "add",
9565 "--name",
9566 "corp",
9567 "--arg",
9568 "https://packages.contoso.test/api",
9569 "--type",
9570 "Microsoft.Rest",
9571 "--disable-interactivity",
9572 "--trust-level",
9573 "trusted",
9574 "--explicit",
9575 ]
9576 );
9577 }
9578
9579 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
9580 Ok(WingetSourceCommandResult {
9581 exit_code: 0,
9582 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9583"#
9584 .to_owned(),
9585 stderr: String::new(),
9586 })
9587 }
9588
9589 #[test]
9590 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
9591 let original_runner = {
9592 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9593 let original = *runner;
9594 *runner = fake_system_winget_source_export;
9595 original
9596 };
9597
9598 let result = load_packaged_store_from_streams(
9599 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
9600 None,
9601 );
9602
9603 {
9604 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9605 *runner = original_runner;
9606 }
9607
9608 let store = result.expect("packaged store").expect("packaged store");
9609 let source = store.sources.first().expect("source");
9610 assert_eq!(store.sources.len(), 1);
9611 assert_eq!(source.name, "contoso");
9612 assert_eq!(source.kind, SourceKind::Rest);
9613 }
9614
9615 #[test]
9616 fn source_metadata_and_named_reset_round_trip() {
9617 let app_root = temp_app_root("source-reset");
9618 let result = (|| -> Result<()> {
9619 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9620 repository.add_source_with_metadata(
9621 "test",
9622 "https://example.com/test",
9623 SourceKind::Rest,
9624 Some("trusted"),
9625 true,
9626 4,
9627 )?;
9628
9629 repository.edit_source("test", Some(false), None)?;
9630 let edited = repository
9631 .list_sources()
9632 .into_iter()
9633 .find(|source| source.name == "test")
9634 .expect("source");
9635 assert_eq!(edited.trust_level, "Trusted");
9636 assert!(!edited.explicit);
9637 assert_eq!(edited.priority, 4);
9638
9639 repository.reset_source("test")?;
9640 let reset = repository
9641 .list_sources()
9642 .into_iter()
9643 .find(|source| source.name == "test")
9644 .expect("source");
9645 assert_eq!(reset.trust_level, "Trusted");
9646 assert!(!reset.explicit);
9647 assert_eq!(reset.priority, 4);
9648 assert!(reset.last_update.is_none());
9649 assert!(reset.source_version.is_none());
9650 Ok(())
9651 })();
9652
9653 let _ = fs::remove_dir_all(&app_root);
9654 result.expect("source metadata");
9655 }
9656
9657 #[test]
9658 fn explicit_sources_are_skipped_by_default_search_resolution() {
9659 let app_root = temp_app_root("source-resolution");
9660 let result = (|| -> Result<()> {
9661 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9662 repository.add_source_with_metadata(
9663 "explicit-test",
9664 "https://example.com/explicit",
9665 SourceKind::Rest,
9666 Some("trusted"),
9667 true,
9668 10,
9669 )?;
9670
9671 let indexes = repository.resolve_source_indexes(None)?;
9672 let names: Vec<_> = indexes
9673 .into_iter()
9674 .map(|index| repository.list_sources()[index].name.clone())
9675 .collect();
9676 assert!(!names.iter().any(|name| name == "explicit-test"));
9677 Ok(())
9678 })();
9679
9680 let _ = fs::remove_dir_all(&app_root);
9681 result.expect("source resolution");
9682 }
9683
9684 #[test]
9685 fn user_and_admin_settings_round_trip() {
9686 let app_root = temp_app_root("settings");
9687 let result = (|| -> Result<()> {
9688 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9689 repository.set_user_settings(
9690 &serde_json::json!({
9691 "visual": {
9692 "progressBar": "retro"
9693 }
9694 }),
9695 false,
9696 )?;
9697 repository.set_user_settings(
9698 &serde_json::json!({
9699 "experimentalFeatures": {
9700 "directMSI": true
9701 }
9702 }),
9703 true,
9704 )?;
9705
9706 let user_settings = repository.get_user_settings()?;
9707 assert_eq!(
9708 user_settings["visual"]["progressBar"],
9709 serde_json::Value::String("retro".to_owned())
9710 );
9711 assert_eq!(
9712 user_settings["experimentalFeatures"]["directMSI"],
9713 serde_json::Value::Bool(true)
9714 );
9715 assert!(repository.test_user_settings(
9716 &serde_json::json!({
9717 "experimentalFeatures": {
9718 "directMSI": true
9719 }
9720 }),
9721 true
9722 )?);
9723
9724 repository.set_admin_setting("LocalManifestFiles", true)?;
9725 repository.set_admin_setting("InstallerHashOverride", true)?;
9726 let admin_settings = repository.get_admin_settings()?;
9727 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
9728 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
9729
9730 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
9731 let reset_one = repository.get_admin_settings()?;
9732 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
9733
9734 repository.reset_admin_setting(None, true)?;
9735 let reset_all = repository.get_admin_settings()?;
9736 for name in Repository::supported_admin_settings() {
9737 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
9738 }
9739 Ok(())
9740 })();
9741
9742 let _ = fs::remove_dir_all(&app_root);
9743 result.expect("settings");
9744 }
9745
9746 #[test]
9747 fn show_result_structured_document_is_manifest_oriented() {
9748 let result = ShowResult {
9749 package: SearchMatch {
9750 source_name: "winget".to_owned(),
9751 source_kind: SourceKind::PreIndexed,
9752 id: "Test.Package".to_owned(),
9753 name: "Test Package".to_owned(),
9754 moniker: Some("testpkg".to_owned()),
9755 version: Some("1.2.3".to_owned()),
9756 channel: Some("stable".to_owned()),
9757 match_criteria: Some("Id".to_owned()),
9758 },
9759 manifest: Manifest {
9760 id: "Test.Package".to_owned(),
9761 name: "Test Package".to_owned(),
9762 version: "1.2.3".to_owned(),
9763 channel: "stable".to_owned(),
9764 publisher: Some("Contoso".to_owned()),
9765 description: Some("Structured output".to_owned()),
9766 moniker: Some("testpkg".to_owned()),
9767 package_url: None,
9768 publisher_url: None,
9769 publisher_support_url: None,
9770 license: None,
9771 license_url: None,
9772 privacy_url: None,
9773 author: None,
9774 copyright: None,
9775 copyright_url: None,
9776 release_notes: None,
9777 release_notes_url: None,
9778 tags: vec!["utility".to_owned()],
9779 agreements: Vec::new(),
9780 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
9781 documentation: vec![Documentation {
9782 label: Some("Docs".to_owned()),
9783 url: "https://example.test/docs".to_owned(),
9784 }],
9785 installers: vec![Installer {
9786 architecture: Some("x64".to_owned()),
9787 installer_type: Some("msix".to_owned()),
9788 url: Some("https://example.test/Test.Package.msix".to_owned()),
9789 sha256: Some("ABC123".to_owned()),
9790 product_code: None,
9791 locale: Some("en-US".to_owned()),
9792 scope: Some("machine".to_owned()),
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("/quiet".to_owned()),
9800 ..InstallerSwitches::default()
9801 },
9802 commands: vec!["testpkg".to_owned()],
9803 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9804 require_explicit_upgrade: false,
9805 nested_installer_type: None,
9806 nested_installer_files: Vec::new(),
9807 }],
9808 require_explicit_upgrade: false,
9809 },
9810 selected_installer: Some(Installer {
9811 architecture: Some("x64".to_owned()),
9812 installer_type: Some("msix".to_owned()),
9813 url: Some("https://example.test/Test.Package.msix".to_owned()),
9814 sha256: Some("ABC123".to_owned()),
9815 product_code: None,
9816 locale: Some("en-US".to_owned()),
9817 scope: Some("machine".to_owned()),
9818 release_date: None,
9819 package_family_name: None,
9820 upgrade_code: None,
9821 platforms: Vec::new(),
9822 minimum_os_version: None,
9823 switches: InstallerSwitches {
9824 silent: Some("/quiet".to_owned()),
9825 ..InstallerSwitches::default()
9826 },
9827 commands: vec!["testpkg".to_owned()],
9828 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9829 require_explicit_upgrade: false,
9830 nested_installer_type: None,
9831 nested_installer_files: Vec::new(),
9832 }),
9833 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
9834 warnings: vec!["cache warmed".to_owned()],
9835 manifest_documents: JsonValue::Array(vec![
9836 serde_json::json!({
9837 "PackageIdentifier": "Test.Package",
9838 "PackageVersion": "1.2.3",
9839 "DefaultLocale": "en-US",
9840 "ManifestType": "version",
9841 "ManifestVersion": "1.10.0"
9842 }),
9843 serde_json::json!({
9844 "PackageIdentifier": "Test.Package",
9845 "PackageVersion": "1.2.3",
9846 "PackageLocale": "en-US",
9847 "PackageName": "Test Package",
9848 "Publisher": "Example",
9849 "License": "MIT",
9850 "ShortDescription": "Structured output",
9851 "ManifestType": "defaultLocale",
9852 "ManifestVersion": "1.10.0"
9853 }),
9854 serde_json::json!({
9855 "PackageIdentifier": "Test.Package",
9856 "PackageVersion": "1.2.3",
9857 "ManifestType": "installer",
9858 "ManifestVersion": "1.10.0",
9859 "Installers": [
9860 {
9861 "Architecture": "x64",
9862 "InstallerType": "msix",
9863 "InstallerUrl": "https://example.test/Test.Package.msix",
9864 "InstallerSha256": "ABC123",
9865 "Commands": ["testpkg"],
9866 "InstallerSwitches": { "Silent": "/quiet" },
9867 "Dependencies": {
9868 "PackageDependencies": [
9869 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
9870 ]
9871 }
9872 }
9873 ]
9874 }),
9875 ]),
9876 };
9877
9878 let document = result.structured_document();
9879
9880 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9881 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
9882 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
9883 assert_eq!(
9884 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
9885 Some("Microsoft.VCRedist.2015+.x64")
9886 );
9887 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
9888 assert_eq!(
9889 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
9890 Some("/quiet")
9891 );
9892 }
9893
9894 #[test]
9895 fn manifest_parses_require_explicit_upgrade_at_top_level() {
9896 let yaml = r#"
9901PackageIdentifier: Test.Package
9902PackageVersion: 1.2.3
9903DefaultLocale: en-US
9904ManifestType: singleton
9905ManifestVersion: 1.10.0
9906PackageLocale: en-US
9907PackageName: Test Package
9908Publisher: Example
9909License: MIT
9910ShortDescription: explicit-upgrade fixture
9911RequireExplicitUpgrade: true
9912Installers:
9913 - Architecture: x64
9914 InstallerType: exe
9915 InstallerUrl: https://example.test/Test.Package.exe
9916 InstallerSha256: ABC123
9917"#;
9918 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9919 assert!(manifest.require_explicit_upgrade);
9920 }
9921
9922 #[test]
9923 fn manifest_parses_require_explicit_upgrade_on_installer() {
9924 let yaml = r#"
9928PackageIdentifier: Test.Package
9929PackageVersion: 1.2.3
9930DefaultLocale: en-US
9931ManifestType: singleton
9932ManifestVersion: 1.10.0
9933PackageLocale: en-US
9934PackageName: Test Package
9935Publisher: Example
9936License: MIT
9937ShortDescription: explicit-upgrade fixture
9938Installers:
9939 - Architecture: x64
9940 InstallerType: exe
9941 InstallerUrl: https://example.test/Test.Package.x64.exe
9942 InstallerSha256: ABC123
9943 - Architecture: arm64
9944 InstallerType: exe
9945 InstallerUrl: https://example.test/Test.Package.arm64.exe
9946 InstallerSha256: DEF456
9947 RequireExplicitUpgrade: true
9948"#;
9949 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9950 assert!(manifest.require_explicit_upgrade);
9951 assert!(!manifest.installers[0].require_explicit_upgrade);
9952 assert!(manifest.installers[1].require_explicit_upgrade);
9953 }
9954
9955 #[test]
9956 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
9957 let yaml = r#"
9958PackageIdentifier: Test.Package
9959PackageVersion: 1.2.3
9960DefaultLocale: en-US
9961ManifestType: singleton
9962ManifestVersion: 1.10.0
9963PackageLocale: en-US
9964PackageName: Test Package
9965Publisher: Example
9966License: MIT
9967ShortDescription: baseline fixture
9968Installers:
9969 - Architecture: x64
9970 InstallerType: exe
9971 InstallerUrl: https://example.test/Test.Package.exe
9972 InstallerSha256: ABC123
9973"#;
9974 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9975 assert!(!manifest.require_explicit_upgrade);
9976 }
9977
9978 #[test]
9979 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
9980 let mut pkg = InstalledPackage {
9984 name: "Edge".to_owned(),
9985 local_id: r"ARP\Machine\X64\Edge".to_owned(),
9986 installed_version: "100.0".to_owned(),
9987 publisher: None,
9988 scope: Some("Machine".to_owned()),
9989 installer_category: Some("exe".to_owned()),
9990 install_location: None,
9991 package_family_names: Vec::new(),
9992 product_codes: Vec::new(),
9993 upgrade_codes: Vec::new(),
9994 correlated: Some(SearchMatch {
9995 source_name: "winget".to_owned(),
9996 source_kind: SourceKind::PreIndexed,
9997 id: "Microsoft.Edge".to_owned(),
9998 name: "Microsoft Edge".to_owned(),
9999 moniker: None,
10000 version: Some("110.0".to_owned()),
10001 channel: None,
10002 match_criteria: None,
10003 }),
10004 installed_version_canonical: false,
10005 correlated_requires_explicit_upgrade: true,
10006 correlated_lacks_compatible_installer: false,
10007 };
10008 let bulk_query = ListQuery {
10009 upgrade_only: true,
10010 ..ListQuery::default()
10011 };
10012 assert!(
10013 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
10014 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
10015 );
10016
10017 let filtered_query = ListQuery {
10020 upgrade_only: true,
10021 id: Some("Microsoft.Edge".to_owned()),
10022 ..ListQuery::default()
10023 };
10024 assert!(
10025 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
10026 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
10027 );
10028
10029 pkg.correlated_requires_explicit_upgrade = false;
10031 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10032 }
10033
10034 #[test]
10035 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
10036 let mut pkg = InstalledPackage {
10037 name: "Foo".to_owned(),
10038 local_id: r"ARP\Machine\X64\Foo".to_owned(),
10039 installed_version: "1.0".to_owned(),
10040 publisher: None,
10041 scope: Some("Machine".to_owned()),
10042 installer_category: Some("exe".to_owned()),
10043 install_location: None,
10044 package_family_names: Vec::new(),
10045 product_codes: Vec::new(),
10046 upgrade_codes: Vec::new(),
10047 correlated: Some(SearchMatch {
10048 source_name: "winget".to_owned(),
10049 source_kind: SourceKind::PreIndexed,
10050 id: "Test.Foo".to_owned(),
10051 name: "Foo".to_owned(),
10052 moniker: None,
10053 version: Some("2.0".to_owned()),
10054 channel: None,
10055 match_criteria: None,
10056 }),
10057 installed_version_canonical: false,
10058 correlated_requires_explicit_upgrade: false,
10059 correlated_lacks_compatible_installer: true,
10060 };
10061 let bulk_query = ListQuery {
10062 upgrade_only: true,
10063 ..ListQuery::default()
10064 };
10065 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10066
10067 let filtered_query = ListQuery {
10069 upgrade_only: true,
10070 id: Some("Test.Foo".to_owned()),
10071 ..ListQuery::default()
10072 };
10073 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
10074
10075 pkg.correlated_lacks_compatible_installer = false;
10077 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
10078 }
10079
10080 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
10081 let installers = arches
10082 .iter()
10083 .map(|a| Installer {
10084 architecture: Some((*a).to_owned()),
10085 installer_type: None,
10086 url: None,
10087 sha256: None,
10088 product_code: None,
10089 locale: None,
10090 scope: None,
10091 release_date: None,
10092 package_family_name: None,
10093 upgrade_code: None,
10094 platforms: Vec::new(),
10095 minimum_os_version: None,
10096 switches: InstallerSwitches::default(),
10097 commands: Vec::new(),
10098 package_dependencies: Vec::new(),
10099 require_explicit_upgrade: false,
10100 nested_installer_type: None,
10101 nested_installer_files: Vec::new(),
10102 })
10103 .collect();
10104 Manifest {
10105 id: "Test".to_owned(),
10106 name: "Test".to_owned(),
10107 version: "1.0".to_owned(),
10108 channel: String::new(),
10109 publisher: None,
10110 description: None,
10111 moniker: None,
10112 package_url: None,
10113 publisher_url: None,
10114 publisher_support_url: None,
10115 license: None,
10116 license_url: None,
10117 privacy_url: None,
10118 author: None,
10119 copyright: None,
10120 copyright_url: None,
10121 release_notes: None,
10122 release_notes_url: None,
10123 tags: Vec::new(),
10124 agreements: Vec::new(),
10125 package_dependencies: Vec::new(),
10126 documentation: Vec::new(),
10127 installers,
10128 require_explicit_upgrade: false,
10129 }
10130 }
10131
10132 #[test]
10133 fn manifest_compatible_installer_neutral_always_matches() {
10134 assert!(manifest_has_compatible_installer(
10136 &synthetic_manifest_with_installer_arches(&["neutral"])
10137 ));
10138 }
10139
10140 #[test]
10141 fn manifest_compatible_installer_empty_installers_pass_through() {
10142 assert!(manifest_has_compatible_installer(
10145 &synthetic_manifest_with_installer_arches(&[])
10146 ));
10147 }
10148
10149 #[test]
10150 fn manifest_compatible_installer_rejects_alien_arch_only() {
10151 assert!(!manifest_has_compatible_installer(
10153 &synthetic_manifest_with_installer_arches(&["ppc"])
10154 ));
10155 }
10156
10157 #[test]
10158 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
10159 assert!(manifest_has_compatible_installer(
10161 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
10162 ));
10163 }
10164
10165 #[test]
10166 fn lookup_unique_normalized_identity_returns_unique_match() {
10167 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10169 execute_batch_sql(
10170 &connection,
10171 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10172 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10173 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
10174 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
10175 )
10176 .expect("seed schema");
10177
10178 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
10179 .expect("query")
10180 .expect("unique match");
10181 assert_eq!(rowid, 100);
10182 }
10183
10184 #[test]
10185 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
10186 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10191 execute_batch_sql(
10192 &connection,
10193 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10194 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10195 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10196 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
10197 )
10198 .expect("seed schema");
10199
10200 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10201 assert!(rowid.is_none(), "ambiguous match must not correlate");
10202 }
10203
10204 #[test]
10205 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
10206 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10210 execute_batch_sql(
10211 &connection,
10212 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10213 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10214 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10215 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
10216 )
10217 .expect("seed schema");
10218
10219 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10220 assert_eq!(rowid, Some(100));
10221 }
10222
10223 #[test]
10224 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
10225 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10228 execute_batch_sql(
10229 &connection,
10230 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10231 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10232 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
10233 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
10234 )
10235 .expect("seed schema");
10236
10237 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
10238 assert!(
10239 rowid.is_none(),
10240 "name matches package 100 but publisher matches 200 — no intersection"
10241 );
10242 }
10243
10244 #[test]
10245 fn parse_yaml_manifest_bundle_returns_singleton_document() {
10246 let yaml = r#"
10247PackageIdentifier: Test.Package
10248PackageVersion: 1.2.3
10249DefaultLocale: en-US
10250ManifestType: version
10251ManifestVersion: 1.10.0
10252---
10253PackageIdentifier: Test.Package
10254PackageVersion: 1.2.3
10255PackageLocale: en-US
10256PackageName: Test Package
10257Publisher: Example
10258License: MIT
10259ShortDescription: Structured output
10260ManifestType: defaultLocale
10261ManifestVersion: 1.10.0
10262---
10263PackageIdentifier: Test.Package
10264PackageVersion: 1.2.3
10265ManifestType: installer
10266ManifestVersion: 1.10.0
10267Installers:
10268 - Architecture: x64
10269 InstallerType: exe
10270 InstallerUrl: https://example.test/Test.Package.exe
10271 InstallerSha256: ABC123
10272"#;
10273
10274 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
10275
10276 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
10277 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
10278 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
10279 }
10280
10281 #[test]
10282 fn collapse_structured_documents_returns_plural_show_documents() {
10283 let documents = collapse_structured_documents(&[
10284 JsonValue::Array(vec![
10285 serde_json::json!({
10286 "PackageIdentifier": "Test.Package.One",
10287 "PackageVersion": "1.0.0",
10288 "DefaultLocale": "en-US",
10289 "ManifestType": "version",
10290 "ManifestVersion": "1.10.0"
10291 }),
10292 serde_json::json!({
10293 "PackageIdentifier": "Test.Package.One",
10294 "PackageVersion": "1.0.0",
10295 "PackageLocale": "en-US",
10296 "PackageName": "Test Package One",
10297 "ManifestType": "defaultLocale",
10298 "ManifestVersion": "1.10.0"
10299 }),
10300 serde_json::json!({
10301 "PackageIdentifier": "Test.Package.One",
10302 "PackageVersion": "1.0.0",
10303 "ManifestType": "installer",
10304 "ManifestVersion": "1.10.0",
10305 "Installers": [
10306 {
10307 "Architecture": "x64",
10308 "InstallerType": "exe",
10309 "InstallerUrl": "https://example.test/one.exe",
10310 "InstallerSha256": "ABC123"
10311 }
10312 ]
10313 }),
10314 ]),
10315 serde_json::json!({
10316 "PackageIdentifier": "Test.Package.Two",
10317 "PackageVersion": "2.0.0",
10318 "PackageLocale": "en-US",
10319 "PackageName": "Test Package Two",
10320 "ManifestType": "singleton",
10321 "ManifestVersion": "1.12.0"
10322 }),
10323 ]);
10324
10325 assert_eq!(documents.len(), 2);
10326 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
10327 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
10328 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
10329 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
10330 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
10331 }
10332
10333 #[test]
10334 fn unsupported_action_result_marks_noop_and_warning() {
10335 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
10336
10337 assert!(result.success);
10338 assert!(result.no_op);
10339 assert_eq!(result.exit_code, 0);
10340 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
10341 }
10342
10343 #[test]
10344 fn create_install_no_op_result_honors_no_upgrade() {
10345 let mut request = InstallRequest::new(PackageQuery {
10346 id: Some("Contoso.App".to_owned()),
10347 ..PackageQuery::default()
10348 });
10349 request.no_upgrade = true;
10350
10351 let manifest = Manifest {
10352 id: "Contoso.App".to_owned(),
10353 name: "Contoso App".to_owned(),
10354 version: "2.0.0".to_owned(),
10355 channel: String::new(),
10356 publisher: None,
10357 description: None,
10358 moniker: None,
10359 package_url: None,
10360 publisher_url: None,
10361 publisher_support_url: None,
10362 license: None,
10363 license_url: None,
10364 privacy_url: None,
10365 author: None,
10366 copyright: None,
10367 copyright_url: None,
10368 release_notes: None,
10369 release_notes_url: None,
10370 tags: Vec::new(),
10371 agreements: Vec::new(),
10372 package_dependencies: Vec::new(),
10373 documentation: Vec::new(),
10374 installers: Vec::new(),
10375 require_explicit_upgrade: false,
10376 };
10377 let existing = ListMatch {
10378 name: "Contoso App".to_owned(),
10379 id: "Contoso.App".to_owned(),
10380 local_id: "Contoso.App".to_owned(),
10381 installed_version: "1.0.0".to_owned(),
10382 available_version: Some("2.0.0".to_owned()),
10383 source_name: Some("winget".to_owned()),
10384 publisher: None,
10385 scope: None,
10386 installer_category: None,
10387 install_location: None,
10388 package_family_names: Vec::new(),
10389 product_codes: Vec::new(),
10390 upgrade_codes: Vec::new(),
10391 };
10392
10393 let result =
10394 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10395
10396 assert!(result.success);
10397 assert!(result.no_op);
10398 assert_eq!(result.version, "1.0.0");
10399 assert_eq!(
10400 result.warnings,
10401 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
10402 );
10403 }
10404
10405 #[test]
10406 fn create_install_no_op_result_skips_reinstall_when_current() {
10407 let request = InstallRequest::new(PackageQuery {
10408 id: Some("Contoso.App".to_owned()),
10409 ..PackageQuery::default()
10410 });
10411
10412 let manifest = Manifest {
10413 id: "Contoso.App".to_owned(),
10414 name: "Contoso App".to_owned(),
10415 version: "2.0.0".to_owned(),
10416 channel: String::new(),
10417 publisher: None,
10418 description: None,
10419 moniker: None,
10420 package_url: None,
10421 publisher_url: None,
10422 publisher_support_url: None,
10423 license: None,
10424 license_url: None,
10425 privacy_url: None,
10426 author: None,
10427 copyright: None,
10428 copyright_url: None,
10429 release_notes: None,
10430 release_notes_url: None,
10431 tags: Vec::new(),
10432 agreements: Vec::new(),
10433 package_dependencies: Vec::new(),
10434 documentation: Vec::new(),
10435 installers: Vec::new(),
10436 require_explicit_upgrade: false,
10437 };
10438 let existing = ListMatch {
10439 name: "Contoso App".to_owned(),
10440 id: "Contoso.App".to_owned(),
10441 local_id: "Contoso.App".to_owned(),
10442 installed_version: "2.0.0".to_owned(),
10443 available_version: Some("2.0.0".to_owned()),
10444 source_name: Some("winget".to_owned()),
10445 publisher: None,
10446 scope: None,
10447 installer_category: None,
10448 install_location: None,
10449 package_family_names: Vec::new(),
10450 product_codes: Vec::new(),
10451 upgrade_codes: Vec::new(),
10452 };
10453
10454 let result =
10455 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10456
10457 assert!(result.success);
10458 assert!(result.no_op);
10459 assert_eq!(result.version, "2.0.0");
10460 assert_eq!(
10461 result.warnings,
10462 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
10463 );
10464 }
10465
10466 #[test]
10467 fn collapse_structured_document_projects_merged_manifest() {
10468 let document = collapse_structured_document(&serde_json::json!({
10469 "PackageIdentifier": "Test.Package",
10470 "PackageVersion": "1.2.3",
10471 "PackageLocale": "en-US",
10472 "PackageName": "Test Package",
10473 "Publisher": "Example",
10474 "InstallerType": "exe",
10475 "Installers": [
10476 {
10477 "Architecture": "x64",
10478 "InstallerUrl": "https://example.test/Test.Package.exe",
10479 "InstallerSha256": "ABC123"
10480 }
10481 ],
10482 "ManifestType": "merged",
10483 "ManifestVersion": "1.10.0"
10484 }));
10485
10486 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10487 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
10488 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
10489 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
10490 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
10491 }
10492
10493 #[test]
10494 fn parses_fixture_manifest() {
10495 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
10496 .join("tests")
10497 .join("fixtures")
10498 .join("ManifestV1_28-Singleton.yaml");
10499 let bytes = fs::read(root).expect("fixture bytes");
10500 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
10501 assert_eq!(manifest.id, "microsoft.msixsdk");
10502 assert_eq!(manifest.version, "1.7.32");
10503 assert_eq!(manifest.name, "MSIX SDK");
10504 assert!(!manifest.installers.is_empty());
10505 }
10506
10507 #[test]
10508 fn parses_installer_switches_from_yaml_manifest() {
10509 let yaml = r#"
10510PackageIdentifier: Test.Package
10511PackageVersion: 1.2.3
10512PackageName: Test Package
10513InstallerSwitches:
10514 SilentWithProgress: /SILENT
10515Installers:
10516 - Architecture: x64
10517 InstallerType: inno
10518 InstallerUrl: https://example.test/Test.Package.exe
10519 InstallerSha256: ABC123
10520 InstallerSwitches:
10521 Silent: /VERYSILENT
10522 Interactive: /HELP
10523"#;
10524
10525 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10526 let installer = &manifest.installers[0];
10527
10528 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
10529 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
10530 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
10531 }
10532
10533 #[test]
10534 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
10535 let yaml = r#"
10536PackageIdentifier: Test.Package
10537PackageVersion: 1.2.3
10538PackageName: Test Package
10539Platform:
10540 - Windows.Desktop
10541MinimumOSVersion: 10.0.19041.0
10542Installers:
10543 - Architecture: x64
10544 InstallerType: exe
10545 InstallerUrl: https://example.test/Test.Package.exe
10546 InstallerSha256: ABC123
10547 - Architecture: x64
10548 InstallerType: msix
10549 Platform:
10550 - Windows.Universal
10551 MinimumOSVersion: 10.0.22621.0
10552 InstallerUrl: https://example.test/Test.Package.msix
10553 InstallerSha256: DEF456
10554"#;
10555
10556 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10557
10558 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
10559 assert_eq!(
10560 manifest.installers[0].minimum_os_version.as_deref(),
10561 Some("10.0.19041.0")
10562 );
10563 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
10564 assert_eq!(
10565 manifest.installers[1].minimum_os_version.as_deref(),
10566 Some("10.0.22621.0")
10567 );
10568 }
10569
10570 #[test]
10571 fn installer_switch_arguments_prefer_manifest_switches() {
10572 let installer = Installer {
10573 architecture: None,
10574 installer_type: Some("inno".to_owned()),
10575 url: None,
10576 sha256: None,
10577 product_code: None,
10578 locale: None,
10579 scope: None,
10580 release_date: None,
10581 package_family_name: None,
10582 upgrade_code: None,
10583 platforms: Vec::new(),
10584 minimum_os_version: None,
10585 switches: InstallerSwitches {
10586 silent: Some("/mysilent".to_owned()),
10587 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
10588 interactive: Some("/myinteractive".to_owned()),
10589 ..InstallerSwitches::default()
10590 },
10591 commands: Vec::new(),
10592 package_dependencies: Vec::new(),
10593 require_explicit_upgrade: false,
10594 nested_installer_type: None,
10595 nested_installer_files: Vec::new(),
10596 };
10597 let manifest = Manifest {
10598 id: "Test.Package".to_owned(),
10599 name: "Test Package".to_owned(),
10600 version: "1.0.0".to_owned(),
10601 channel: String::new(),
10602 publisher: None,
10603 description: None,
10604 moniker: None,
10605 package_url: None,
10606 publisher_url: None,
10607 publisher_support_url: None,
10608 license: None,
10609 license_url: None,
10610 privacy_url: None,
10611 author: None,
10612 copyright: None,
10613 copyright_url: None,
10614 release_notes: None,
10615 release_notes_url: None,
10616 tags: Vec::new(),
10617 agreements: Vec::new(),
10618 package_dependencies: Vec::new(),
10619 documentation: Vec::new(),
10620 installers: Vec::new(),
10621 require_explicit_upgrade: false,
10622 };
10623
10624 let mut silent_request = InstallRequest::new(PackageQuery::default());
10625 silent_request.mode = InstallerMode::Silent;
10626 let mut progress_request = InstallRequest::new(PackageQuery::default());
10627 progress_request.mode = InstallerMode::SilentWithProgress;
10628 let mut interactive_request = InstallRequest::new(PackageQuery::default());
10629 interactive_request.mode = InstallerMode::Interactive;
10630 assert_eq!(
10631 installer_command_arguments(
10632 "inno",
10633 &silent_request,
10634 &manifest,
10635 Path::new("installer.exe"),
10636 &installer
10637 ),
10638 vec!["/mysilent".to_owned()]
10639 );
10640 assert_eq!(
10641 installer_command_arguments(
10642 "inno",
10643 &progress_request,
10644 &manifest,
10645 Path::new("installer.exe"),
10646 &installer
10647 ),
10648 vec!["/mysilentwithprogress".to_owned()]
10649 );
10650 assert_eq!(
10651 installer_command_arguments(
10652 "inno",
10653 &interactive_request,
10654 &manifest,
10655 Path::new("installer.exe"),
10656 &installer
10657 ),
10658 vec!["/myinteractive".to_owned()]
10659 );
10660 }
10661
10662 #[test]
10663 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
10664 let installer = Installer {
10665 architecture: None,
10666 installer_type: Some("inno".to_owned()),
10667 url: None,
10668 sha256: None,
10669 product_code: None,
10670 locale: None,
10671 scope: None,
10672 release_date: None,
10673 package_family_name: None,
10674 upgrade_code: None,
10675 platforms: Vec::new(),
10676 minimum_os_version: None,
10677 switches: InstallerSwitches::default(),
10678 commands: Vec::new(),
10679 package_dependencies: Vec::new(),
10680 require_explicit_upgrade: false,
10681 nested_installer_type: None,
10682 nested_installer_files: Vec::new(),
10683 };
10684 let manifest = Manifest {
10685 id: "Test.Package".to_owned(),
10686 name: "Test Package".to_owned(),
10687 version: "1.0.0".to_owned(),
10688 channel: String::new(),
10689 publisher: None,
10690 description: None,
10691 moniker: None,
10692 package_url: None,
10693 publisher_url: None,
10694 publisher_support_url: None,
10695 license: None,
10696 license_url: None,
10697 privacy_url: None,
10698 author: None,
10699 copyright: None,
10700 copyright_url: None,
10701 release_notes: None,
10702 release_notes_url: None,
10703 tags: Vec::new(),
10704 agreements: Vec::new(),
10705 package_dependencies: Vec::new(),
10706 documentation: Vec::new(),
10707 installers: Vec::new(),
10708 require_explicit_upgrade: false,
10709 };
10710 let mut progress_request = InstallRequest::new(PackageQuery::default());
10711 progress_request.mode = InstallerMode::SilentWithProgress;
10712 let mut silent_request = InstallRequest::new(PackageQuery::default());
10713 silent_request.mode = InstallerMode::Silent;
10714
10715 assert_eq!(
10716 installer_command_arguments(
10717 "inno",
10718 &progress_request,
10719 &manifest,
10720 Path::new("installer.exe"),
10721 &installer
10722 ),
10723 vec![
10724 "/SP-".to_owned(),
10725 "/SILENT".to_owned(),
10726 "/SUPPRESSMSGBOXES".to_owned(),
10727 "/NORESTART".to_owned()
10728 ]
10729 );
10730 assert_eq!(
10731 installer_command_arguments(
10732 "inno",
10733 &silent_request,
10734 &manifest,
10735 Path::new("installer.exe"),
10736 &installer
10737 ),
10738 vec![
10739 "/SP-".to_owned(),
10740 "/VERYSILENT".to_owned(),
10741 "/SUPPRESSMSGBOXES".to_owned(),
10742 "/NORESTART".to_owned()
10743 ]
10744 );
10745 }
10746
10747 #[test]
10748 fn installer_command_arguments_append_manifest_and_cli_switches() {
10749 let installer = Installer {
10750 architecture: None,
10751 installer_type: Some("msi".to_owned()),
10752 url: None,
10753 sha256: None,
10754 product_code: None,
10755 locale: None,
10756 scope: None,
10757 release_date: None,
10758 package_family_name: None,
10759 upgrade_code: None,
10760 platforms: Vec::new(),
10761 minimum_os_version: None,
10762 switches: InstallerSwitches {
10763 custom: Some("ADDLOCAL=Core".to_owned()),
10764 log: Some("/log \"<LOGPATH>\"".to_owned()),
10765 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
10766 ..InstallerSwitches::default()
10767 },
10768 commands: Vec::new(),
10769 package_dependencies: Vec::new(),
10770 require_explicit_upgrade: false,
10771 nested_installer_type: None,
10772 nested_installer_files: Vec::new(),
10773 };
10774 let manifest = Manifest {
10775 id: "ShareX.ShareX".to_owned(),
10776 name: "ShareX".to_owned(),
10777 version: "19.0.2".to_owned(),
10778 channel: String::new(),
10779 publisher: None,
10780 description: None,
10781 moniker: None,
10782 package_url: None,
10783 publisher_url: None,
10784 publisher_support_url: None,
10785 license: None,
10786 license_url: None,
10787 privacy_url: None,
10788 author: None,
10789 copyright: None,
10790 copyright_url: None,
10791 release_notes: None,
10792 release_notes_url: None,
10793 tags: Vec::new(),
10794 agreements: Vec::new(),
10795 package_dependencies: Vec::new(),
10796 documentation: Vec::new(),
10797 installers: Vec::new(),
10798 require_explicit_upgrade: false,
10799 };
10800 let mut request = InstallRequest::new(PackageQuery::default());
10801 request.mode = InstallerMode::Silent;
10802 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
10803 request.custom = Some("REBOOT=ReallySuppress".to_owned());
10804 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
10805
10806 assert_eq!(
10807 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
10808 vec![
10809 "/i".to_owned(),
10810 r"C:\temp\ShareX.msi".to_owned(),
10811 "/quiet".to_owned(),
10812 "/norestart".to_owned(),
10813 "/log".to_owned(),
10814 r"C:\temp\winget.log".to_owned(),
10815 "ADDLOCAL=Core".to_owned(),
10816 "REBOOT=ReallySuppress".to_owned(),
10817 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
10818 ]
10819 );
10820 }
10821
10822 #[test]
10823 fn decompresses_mszyml_payload() {
10824 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
10825 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10826 encoder.write_all(payload.as_bytes()).expect("write payload");
10827 let compressed = encoder.finish().expect("finish payload");
10828 let mut mszip = b"CK".to_vec();
10829 mszip.extend_from_slice(&compressed);
10830
10831 let decompressed = decompress_mszyml(&mszip).expect("decompress");
10832 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10833 assert_eq!(parsed.versions[0].version, "1.2.3");
10834 }
10835
10836 #[test]
10837 fn decompresses_mszyml_payload_with_prefix() {
10838 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
10839 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10840 encoder.write_all(payload.as_bytes()).expect("write payload");
10841 let compressed = encoder.finish().expect("finish payload");
10842 let mut prefixed = vec![0u8; 28];
10843 prefixed.extend_from_slice(b"CK");
10844 prefixed.extend_from_slice(&compressed);
10845
10846 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
10847 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10848 assert_eq!(parsed.versions[0].version, "1.2.3");
10849 }
10850
10851 #[test]
10852 fn compares_versions_naturally() {
10853 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
10854 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
10855 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
10856 }
10857
10858 #[test]
10859 fn installer_hash_verification_fails_on_mismatch_without_override() {
10860 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
10861 assert!(result.is_err());
10862 assert!(
10863 result
10864 .expect_err("hash mismatch")
10865 .to_string()
10866 .contains("Installer hash mismatch")
10867 );
10868 }
10869
10870 #[test]
10871 fn installer_hash_verification_allows_mismatch_with_override() {
10872 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
10873 verify_installer_hash(None, b"pinget", false).expect("missing hash");
10874 }
10875
10876 #[test]
10877 fn search_query_many_includes_tag_and_command_conditions() {
10878 let query = PackageQuery {
10879 query: Some("terminal".to_owned()),
10880 ..PackageQuery::default()
10881 };
10882
10883 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10884
10885 assert!(where_clause.contains("tags2"));
10886 assert!(where_clause.contains("commands2"));
10887 assert_eq!(params.len(), 5);
10888 }
10889
10890 #[test]
10891 fn tag_and_command_conditions_are_non_correlated() {
10892 let query = PackageQuery {
10899 query: Some("terminal".to_owned()),
10900 ..PackageQuery::default()
10901 };
10902
10903 let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10904
10905 assert!(where_clause.contains("packages.rowid IN (SELECT"));
10906 assert!(!where_clause.contains("EXISTS"));
10907 assert!(!where_clause.contains("= packages.rowid"));
10908 }
10909
10910 #[test]
10911 fn show_query_single_omits_tag_and_command_conditions() {
10912 let query = PackageQuery {
10913 query: Some("terminal".to_owned()),
10914 ..PackageQuery::default()
10915 };
10916
10917 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
10918
10919 assert!(!where_clause.contains("tags2"));
10920 assert!(!where_clause.contains("commands2"));
10921 assert_eq!(params.len(), 3);
10922 }
10923
10924 #[test]
10925 fn explicit_tag_filter_uses_exact_match() {
10926 let query = PackageQuery {
10927 tag: Some("terminal".to_owned()),
10928 ..PackageQuery::default()
10929 };
10930
10931 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10932
10933 assert!(where_clause.contains("tags2"));
10934 assert_eq!(params, vec!["terminal".to_owned()]);
10935 }
10936
10937 #[test]
10938 fn rest_tag_filter_uses_exact_match() {
10939 let query = PackageQuery {
10940 tag: Some("terminal".to_owned()),
10941 ..PackageQuery::default()
10942 };
10943 let info = RestInformation {
10944 required_package_match_fields: Vec::new(),
10945 unsupported_package_match_fields: Vec::new(),
10946 required_query_parameters: Vec::new(),
10947 ..RestInformation::default()
10948 };
10949
10950 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
10951 let filters = body["Filters"].as_array().expect("filters");
10952
10953 assert_eq!(filters.len(), 1);
10954 assert_eq!(filters[0]["PackageMatchField"], "Tag");
10955 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
10956 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
10957 }
10958
10959 #[test]
10960 fn selects_installer_using_requested_filters() {
10961 let installers = vec![
10962 Installer {
10963 architecture: Some("x86".to_owned()),
10964 installer_type: Some("zip".to_owned()),
10965 url: None,
10966 sha256: None,
10967 product_code: None,
10968 locale: Some("en-US".to_owned()),
10969 scope: Some("user".to_owned()),
10970 release_date: None,
10971 package_family_name: None,
10972 upgrade_code: None,
10973 platforms: Vec::new(),
10974 minimum_os_version: None,
10975 switches: InstallerSwitches::default(),
10976 commands: Vec::new(),
10977 package_dependencies: Vec::new(),
10978 require_explicit_upgrade: false,
10979 nested_installer_type: None,
10980 nested_installer_files: Vec::new(),
10981 },
10982 Installer {
10983 architecture: Some("x64".to_owned()),
10984 installer_type: Some("msix".to_owned()),
10985 url: None,
10986 sha256: None,
10987 product_code: None,
10988 locale: Some("en-US".to_owned()),
10989 scope: Some("user".to_owned()),
10990 release_date: None,
10991 package_family_name: None,
10992 upgrade_code: None,
10993 platforms: Vec::new(),
10994 minimum_os_version: None,
10995 switches: InstallerSwitches::default(),
10996 commands: vec!["demo".to_owned()],
10997 package_dependencies: Vec::new(),
10998 require_explicit_upgrade: false,
10999 nested_installer_type: None,
11000 nested_installer_files: Vec::new(),
11001 },
11002 ];
11003 let query = PackageQuery {
11004 installer_type: Some("msix".to_owned()),
11005 installer_architecture: Some("x64".to_owned()),
11006 install_scope: Some("user".to_owned()),
11007 locale: Some("en-US".to_owned()),
11008 ..PackageQuery::default()
11009 };
11010
11011 let selected = select_installer(&installers, &query).expect("selected installer");
11012 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
11013 assert_eq!(selected.architecture.as_deref(), Some("x64"));
11014 }
11015
11016 #[test]
11017 fn selects_first_installer_when_rank_ties() {
11018 let installers = vec![
11019 Installer {
11020 architecture: Some("x64".to_owned()),
11021 installer_type: Some("exe".to_owned()),
11022 url: None,
11023 sha256: None,
11024 product_code: None,
11025 locale: Some("en-US".to_owned()),
11026 scope: Some("machine".to_owned()),
11027 release_date: None,
11028 package_family_name: None,
11029 upgrade_code: None,
11030 platforms: Vec::new(),
11031 minimum_os_version: None,
11032 switches: InstallerSwitches::default(),
11033 commands: Vec::new(),
11034 package_dependencies: Vec::new(),
11035 require_explicit_upgrade: false,
11036 nested_installer_type: None,
11037 nested_installer_files: Vec::new(),
11038 },
11039 Installer {
11040 architecture: Some("x64".to_owned()),
11041 installer_type: Some("exe".to_owned()),
11042 url: None,
11043 sha256: None,
11044 product_code: None,
11045 locale: Some("en-US".to_owned()),
11046 scope: Some("user".to_owned()),
11047 release_date: None,
11048 package_family_name: None,
11049 upgrade_code: None,
11050 platforms: Vec::new(),
11051 minimum_os_version: None,
11052 switches: InstallerSwitches::default(),
11053 commands: Vec::new(),
11054 package_dependencies: Vec::new(),
11055 require_explicit_upgrade: false,
11056 nested_installer_type: None,
11057 nested_installer_files: Vec::new(),
11058 },
11059 ];
11060
11061 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
11062 assert_eq!(selected.scope.as_deref(), Some("machine"));
11063 }
11064
11065 #[test]
11066 fn selects_installer_using_requested_platform() {
11067 let installers = vec![
11068 Installer {
11069 architecture: Some("x64".to_owned()),
11070 installer_type: Some("exe".to_owned()),
11071 url: None,
11072 sha256: None,
11073 product_code: None,
11074 locale: Some("en-US".to_owned()),
11075 scope: Some("user".to_owned()),
11076 release_date: None,
11077 package_family_name: None,
11078 upgrade_code: None,
11079 platforms: vec!["Windows.Universal".to_owned()],
11080 minimum_os_version: None,
11081 switches: InstallerSwitches::default(),
11082 commands: Vec::new(),
11083 package_dependencies: Vec::new(),
11084 require_explicit_upgrade: false,
11085 nested_installer_type: None,
11086 nested_installer_files: Vec::new(),
11087 },
11088 Installer {
11089 architecture: Some("x64".to_owned()),
11090 installer_type: Some("exe".to_owned()),
11091 url: None,
11092 sha256: None,
11093 product_code: None,
11094 locale: Some("en-US".to_owned()),
11095 scope: Some("user".to_owned()),
11096 release_date: None,
11097 package_family_name: None,
11098 upgrade_code: None,
11099 platforms: vec!["Windows.Desktop".to_owned()],
11100 minimum_os_version: None,
11101 switches: InstallerSwitches::default(),
11102 commands: Vec::new(),
11103 package_dependencies: Vec::new(),
11104 require_explicit_upgrade: false,
11105 nested_installer_type: None,
11106 nested_installer_files: Vec::new(),
11107 },
11108 ];
11109 let query = PackageQuery {
11110 installer_type: Some("exe".to_owned()),
11111 platform: Some("Windows.Desktop".to_owned()),
11112 ..PackageQuery::default()
11113 };
11114
11115 let selected = select_installer(&installers, &query).expect("selected installer");
11116 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
11117 }
11118
11119 #[test]
11120 fn selects_installer_using_requested_os_version() {
11121 let installers = vec![
11122 Installer {
11123 architecture: Some("x64".to_owned()),
11124 installer_type: Some("exe".to_owned()),
11125 url: None,
11126 sha256: None,
11127 product_code: None,
11128 locale: Some("en-US".to_owned()),
11129 scope: Some("user".to_owned()),
11130 release_date: None,
11131 package_family_name: None,
11132 upgrade_code: None,
11133 platforms: Vec::new(),
11134 minimum_os_version: Some("10.0.22621.0".to_owned()),
11135 switches: InstallerSwitches::default(),
11136 commands: Vec::new(),
11137 package_dependencies: Vec::new(),
11138 require_explicit_upgrade: false,
11139 nested_installer_type: None,
11140 nested_installer_files: Vec::new(),
11141 },
11142 Installer {
11143 architecture: Some("x64".to_owned()),
11144 installer_type: Some("exe".to_owned()),
11145 url: None,
11146 sha256: None,
11147 product_code: None,
11148 locale: Some("en-US".to_owned()),
11149 scope: Some("user".to_owned()),
11150 release_date: None,
11151 package_family_name: None,
11152 upgrade_code: None,
11153 platforms: Vec::new(),
11154 minimum_os_version: Some("10.0.19041.0".to_owned()),
11155 switches: InstallerSwitches::default(),
11156 commands: Vec::new(),
11157 package_dependencies: Vec::new(),
11158 require_explicit_upgrade: false,
11159 nested_installer_type: None,
11160 nested_installer_files: Vec::new(),
11161 },
11162 ];
11163 let query = PackageQuery {
11164 installer_type: Some("exe".to_owned()),
11165 os_version: Some("10.0.19045.0".to_owned()),
11166 ..PackageQuery::default()
11167 };
11168
11169 let selected = select_installer(&installers, &query).expect("selected installer");
11170 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
11171 }
11172
11173 #[test]
11174 fn derives_correlation_name_candidates() {
11175 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
11176 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
11177 assert!(candidates.contains(&"PowerToys".to_owned()));
11178 }
11179
11180 #[test]
11181 fn correlates_installed_package_to_available_match() {
11182 let installed = InstalledPackage {
11183 name: "PowerToys (Preview) x64".to_owned(),
11184 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11185 installed_version: "0.98.1".to_owned(),
11186 publisher: None,
11187 scope: Some("Machine".to_owned()),
11188 installer_category: Some("exe".to_owned()),
11189 install_location: None,
11190 package_family_names: Vec::new(),
11191 product_codes: Vec::new(),
11192 upgrade_codes: Vec::new(),
11193 correlated: None,
11194 installed_version_canonical: false,
11195 correlated_requires_explicit_upgrade: false,
11196 correlated_lacks_compatible_installer: false,
11197 };
11198 let candidates = vec![SearchMatch {
11199 source_name: "winget".to_owned(),
11200 source_kind: SourceKind::PreIndexed,
11201 id: "Microsoft.PowerToys".to_owned(),
11202 name: "PowerToys".to_owned(),
11203 moniker: None,
11204 version: Some("0.98.1".to_owned()),
11205 channel: None,
11206 match_criteria: None,
11207 }];
11208
11209 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11210 assert_eq!(correlated.id, "Microsoft.PowerToys");
11211 }
11212
11213 #[test]
11214 fn list_query_uses_available_lookup_for_tag_filters() {
11215 let query = ListQuery {
11216 tag: Some("terminal".to_owned()),
11217 ..ListQuery::default()
11218 };
11219
11220 assert!(list_query_needs_available_lookup(&query));
11221 }
11222
11223 #[test]
11224 fn list_tag_filter_requires_correlated_match() {
11225 let package = InstalledPackage {
11226 name: "PowerToys (Preview) x64".to_owned(),
11227 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11228 installed_version: "0.98.1".to_owned(),
11229 publisher: None,
11230 scope: Some("Machine".to_owned()),
11231 installer_category: Some("exe".to_owned()),
11232 install_location: None,
11233 package_family_names: Vec::new(),
11234 product_codes: Vec::new(),
11235 upgrade_codes: Vec::new(),
11236 correlated: None,
11237 installed_version_canonical: false,
11238 correlated_requires_explicit_upgrade: false,
11239 correlated_lacks_compatible_installer: false,
11240 };
11241 let query = ListQuery {
11242 tag: Some("powertoys".to_owned()),
11243 ..ListQuery::default()
11244 };
11245
11246 assert!(!list_package_matches(&package, &query));
11247 }
11248
11249 #[test]
11250 fn list_lookup_ignores_display_count() {
11251 let query = ListQuery {
11252 query: Some("git".to_owned()),
11253 count: Some(5),
11254 ..ListQuery::default()
11255 };
11256
11257 let package_query = package_query_from_list_query(&query);
11258 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
11259 }
11260
11261 #[test]
11262 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
11263 let installed = InstalledPackage {
11268 name: "Notepad++ (ARM 64-bit)".to_owned(),
11269 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
11270 installed_version: "8.8.9".to_owned(),
11271 publisher: None,
11272 scope: Some("Machine".to_owned()),
11273 installer_category: Some("exe".to_owned()),
11274 install_location: None,
11275 package_family_names: Vec::new(),
11276 product_codes: Vec::new(),
11277 upgrade_codes: Vec::new(),
11278 correlated: None,
11279 installed_version_canonical: false,
11280 correlated_requires_explicit_upgrade: false,
11281 correlated_lacks_compatible_installer: false,
11282 };
11283 let candidates = vec![
11284 SearchMatch {
11285 source_name: "winget".to_owned(),
11286 source_kind: SourceKind::PreIndexed,
11287 id: "ndd.Notepad--".to_owned(),
11288 name: "Notepad--".to_owned(),
11289 moniker: None,
11290 version: Some("1.0.0".to_owned()),
11291 channel: None,
11292 match_criteria: None,
11293 },
11294 SearchMatch {
11295 source_name: "winget".to_owned(),
11296 source_kind: SourceKind::PreIndexed,
11297 id: "Notepad++.Notepad++".to_owned(),
11298 name: "Notepad++".to_owned(),
11299 moniker: None,
11300 version: Some("8.9.5".to_owned()),
11301 channel: None,
11302 match_criteria: None,
11303 },
11304 ];
11305
11306 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11307 assert_eq!(correlated.id, "Notepad++.Notepad++");
11308 }
11309
11310 #[test]
11311 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
11312 let installed = InstalledPackage {
11317 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
11318 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
11319 installed_version: "40.10.18029".to_owned(),
11320 publisher: None,
11321 scope: Some("Machine".to_owned()),
11322 installer_category: Some("msi".to_owned()),
11323 install_location: None,
11324 package_family_names: Vec::new(),
11325 product_codes: Vec::new(),
11326 upgrade_codes: Vec::new(),
11327 correlated: None,
11328 installed_version_canonical: false,
11329 correlated_requires_explicit_upgrade: false,
11330 correlated_lacks_compatible_installer: false,
11331 };
11332 let candidates = vec![
11333 SearchMatch {
11334 source_name: "winget".to_owned(),
11335 source_kind: SourceKind::PreIndexed,
11336 id: "ZeroBrane.Studio".to_owned(),
11337 name: "Studio".to_owned(),
11338 moniker: None,
11339 version: Some("1.0".to_owned()),
11340 channel: None,
11341 match_criteria: None,
11342 },
11343 SearchMatch {
11344 source_name: "winget".to_owned(),
11345 source_kind: SourceKind::PreIndexed,
11346 id: "BrickLink.Studio".to_owned(),
11347 name: "Studio".to_owned(),
11348 moniker: None,
11349 version: Some("1.0".to_owned()),
11350 channel: None,
11351 match_criteria: None,
11352 },
11353 SearchMatch {
11354 source_name: "winget".to_owned(),
11355 source_kind: SourceKind::PreIndexed,
11356 id: "Microsoft.DotNet.SDK.10".to_owned(),
11357 name: "Microsoft .NET SDK 10.0".to_owned(),
11358 moniker: None,
11359 version: Some("10.0.204".to_owned()),
11360 channel: None,
11361 match_criteria: None,
11362 },
11363 ];
11364
11365 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11366 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
11367 }
11368
11369 #[test]
11370 fn msix_packages_do_not_correlate_via_name() {
11371 let installed = InstalledPackage {
11379 name: "Microsoft Teams".to_owned(),
11380 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11381 installed_version: "25290.205.4069.4894".to_owned(),
11382 publisher: None,
11383 scope: Some("User".to_owned()),
11384 installer_category: Some("msix".to_owned()),
11385 install_location: None,
11386 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11387 product_codes: Vec::new(),
11388 upgrade_codes: Vec::new(),
11389 correlated: None,
11390 installed_version_canonical: false,
11391 correlated_requires_explicit_upgrade: false,
11392 correlated_lacks_compatible_installer: false,
11393 };
11394 let candidates = vec![SearchMatch {
11395 source_name: "winget".to_owned(),
11396 source_kind: SourceKind::PreIndexed,
11397 id: "Microsoft.Teams".to_owned(),
11398 name: "Microsoft Teams".to_owned(),
11399 moniker: None,
11400 version: Some("26106.1906.4665.7308".to_owned()),
11401 channel: None,
11402 match_criteria: None,
11403 }];
11404
11405 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11406 }
11407
11408 #[test]
11409 fn name_fallback_refuses_ambiguous_winners() {
11410 let installed = InstalledPackage {
11418 name: "Git".to_owned(),
11419 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
11420 installed_version: "2.53.0".to_owned(),
11421 publisher: Some("The Git Development Community".to_owned()),
11422 scope: Some("Machine".to_owned()),
11423 installer_category: Some("exe".to_owned()),
11424 install_location: None,
11425 package_family_names: Vec::new(),
11426 product_codes: Vec::new(),
11427 upgrade_codes: Vec::new(),
11428 correlated: None,
11429 installed_version_canonical: false,
11430 correlated_requires_explicit_upgrade: false,
11431 correlated_lacks_compatible_installer: false,
11432 };
11433 let candidates = vec![
11434 SearchMatch {
11435 source_name: "winget".to_owned(),
11436 source_kind: SourceKind::PreIndexed,
11437 id: "Git.Git".to_owned(),
11438 name: "Git".to_owned(),
11439 moniker: None,
11440 version: Some("2.54.0".to_owned()),
11441 channel: None,
11442 match_criteria: None,
11443 },
11444 SearchMatch {
11445 source_name: "winget".to_owned(),
11446 source_kind: SourceKind::PreIndexed,
11447 id: "Microsoft.Git".to_owned(),
11448 name: "Git".to_owned(),
11449 moniker: None,
11450 version: Some("2.53.0.0.7".to_owned()),
11451 channel: None,
11452 match_criteria: None,
11453 },
11454 ];
11455
11456 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11457 }
11458
11459 #[test]
11460 fn msix_with_resource_string_name_does_not_correlate() {
11461 let installed = InstalledPackage {
11465 name: "ms-resource:appDisplayName".to_owned(),
11466 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11467 installed_version: "1.28.239.0".to_owned(),
11468 publisher: None,
11469 scope: Some("User".to_owned()),
11470 installer_category: Some("msix".to_owned()),
11471 install_location: None,
11472 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11473 product_codes: Vec::new(),
11474 upgrade_codes: Vec::new(),
11475 correlated: None,
11476 installed_version_canonical: false,
11477 correlated_requires_explicit_upgrade: false,
11478 correlated_lacks_compatible_installer: false,
11479 };
11480 let candidates = vec![SearchMatch {
11481 source_name: "winget".to_owned(),
11482 source_kind: SourceKind::PreIndexed,
11483 id: "Microsoft.AppInstaller".to_owned(),
11484 name: "App Installer".to_owned(),
11485 moniker: None,
11486 version: Some("1.28.240.0".to_owned()),
11487 channel: None,
11488 match_criteria: None,
11489 }];
11490
11491 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11492 }
11493
11494 #[test]
11495 fn strict_list_correlation_avoids_short_substring_matches() {
11496 let installed = InstalledPackage {
11497 name: "GitHub".to_owned(),
11498 local_id: r"ARP\User\X64\GitHub".to_owned(),
11499 installed_version: "1.0.0".to_owned(),
11500 publisher: None,
11501 scope: Some("User".to_owned()),
11502 installer_category: Some("exe".to_owned()),
11503 install_location: None,
11504 package_family_names: Vec::new(),
11505 product_codes: Vec::new(),
11506 upgrade_codes: Vec::new(),
11507 correlated: None,
11508 installed_version_canonical: false,
11509 correlated_requires_explicit_upgrade: false,
11510 correlated_lacks_compatible_installer: false,
11511 };
11512 let candidates = vec![SearchMatch {
11513 source_name: "winget".to_owned(),
11514 source_kind: SourceKind::PreIndexed,
11515 id: "Git.Git.PreRelease".to_owned(),
11516 name: "Git".to_owned(),
11517 moniker: None,
11518 version: Some("2.54.0".to_owned()),
11519 channel: None,
11520 match_criteria: None,
11521 }];
11522
11523 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
11524 }
11525
11526 #[test]
11527 fn detects_available_upgrade_from_correlated_package() {
11528 let package = InstalledPackage {
11529 name: "AzCopy v10".to_owned(),
11530 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11531 installed_version: "10.32.2".to_owned(),
11532 publisher: None,
11533 scope: Some("Machine".to_owned()),
11534 installer_category: Some("exe".to_owned()),
11535 install_location: None,
11536 package_family_names: Vec::new(),
11537 product_codes: Vec::new(),
11538 upgrade_codes: Vec::new(),
11539 correlated: Some(SearchMatch {
11540 source_name: "winget".to_owned(),
11541 source_kind: SourceKind::PreIndexed,
11542 id: "Microsoft.Azure.AZCopy.10".to_owned(),
11543 name: "AzCopy v10".to_owned(),
11544 moniker: None,
11545 version: Some("10.32.3".to_owned()),
11546 channel: None,
11547 match_criteria: None,
11548 }),
11549 installed_version_canonical: false,
11550 correlated_requires_explicit_upgrade: false,
11551 correlated_lacks_compatible_installer: false,
11552 };
11553
11554 assert!(installed_package_has_upgrade(&package));
11555 }
11556
11557 #[test]
11558 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
11559 let package = InstalledPackage {
11560 name: "Example Tool".to_owned(),
11561 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
11562 installed_version: "Unknown".to_owned(),
11563 publisher: None,
11564 scope: Some("User".to_owned()),
11565 installer_category: Some("exe".to_owned()),
11566 install_location: None,
11567 package_family_names: Vec::new(),
11568 product_codes: Vec::new(),
11569 upgrade_codes: Vec::new(),
11570 correlated: Some(SearchMatch {
11571 source_name: "winget".to_owned(),
11572 source_kind: SourceKind::PreIndexed,
11573 id: "Contoso.ExampleTool".to_owned(),
11574 name: "Example Tool".to_owned(),
11575 moniker: None,
11576 version: Some("2.0.0".to_owned()),
11577 channel: None,
11578 match_criteria: None,
11579 }),
11580 installed_version_canonical: false,
11581 correlated_requires_explicit_upgrade: false,
11582 correlated_lacks_compatible_installer: false,
11583 };
11584 let query = ListQuery {
11585 upgrade_only: true,
11586 include_unknown: true,
11587 ..Default::default()
11588 };
11589
11590 assert!(installed_package_matches_upgrade_filter(&package, &query));
11591 assert_eq!(
11592 list_match_from_installed(package).available_version.as_deref(),
11593 Some("2.0.0")
11594 );
11595 }
11596
11597 #[test]
11598 fn create_repair_list_query_includes_installed_selectors() {
11599 let request = RepairRequest {
11600 query: PackageQuery {
11601 query: Some("powertoys".to_owned()),
11602 id: Some("Microsoft.PowerToys".to_owned()),
11603 name: Some("PowerToys".to_owned()),
11604 moniker: Some("powertoys".to_owned()),
11605 source: Some("winget".to_owned()),
11606 exact: true,
11607 version: Some("0.98.1".to_owned()),
11608 install_scope: Some("Machine".to_owned()),
11609 ..PackageQuery::default()
11610 },
11611 manifest_path: None,
11612 product_code: Some("{GUID}".to_owned()),
11613 mode: InstallerMode::Silent,
11614 log_path: Some(PathBuf::from("repair.log")),
11615 accept_package_agreements: true,
11616 force: true,
11617 ignore_security_hash: true,
11618 };
11619
11620 let query = Repository::create_repair_list_query(&request);
11621 assert_eq!(query.query.as_deref(), Some("powertoys"));
11622 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
11623 assert_eq!(query.name.as_deref(), Some("PowerToys"));
11624 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
11625 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
11626 assert_eq!(query.version.as_deref(), Some("0.98.1"));
11627 assert_eq!(query.source.as_deref(), Some("winget"));
11628 assert!(query.exact);
11629 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
11630 assert_eq!(query.count, Some(100));
11631 }
11632
11633 #[test]
11634 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
11635 let request = RepairRequest {
11636 query: PackageQuery {
11637 name: Some("PowerToys".to_owned()),
11638 source: Some("winget".to_owned()),
11639 locale: Some("en-US".to_owned()),
11640 installer_architecture: Some("x64".to_owned()),
11641 install_scope: Some("Machine".to_owned()),
11642 ..PackageQuery::default()
11643 },
11644 manifest_path: None,
11645 product_code: None,
11646 mode: InstallerMode::SilentWithProgress,
11647 log_path: Some(PathBuf::from("repair.log")),
11648 accept_package_agreements: true,
11649 force: false,
11650 ignore_security_hash: true,
11651 };
11652 let installed = ListMatch {
11653 name: "PowerToys".to_owned(),
11654 id: "Microsoft.PowerToys".to_owned(),
11655 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
11656 installed_version: "0.98.1".to_owned(),
11657 available_version: Some("0.98.2".to_owned()),
11658 source_name: Some("winget".to_owned()),
11659 publisher: None,
11660 scope: Some("Machine".to_owned()),
11661 installer_category: Some("exe".to_owned()),
11662 install_location: None,
11663 package_family_names: Vec::new(),
11664 product_codes: Vec::new(),
11665 upgrade_codes: Vec::new(),
11666 };
11667
11668 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
11669 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
11670 assert!(install_request.query.query.is_none());
11671 assert!(install_request.query.name.is_none());
11672 assert!(install_request.query.moniker.is_none());
11673 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
11674 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
11675 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
11676 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
11677 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
11678 assert!(install_request.query.exact);
11679 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
11680 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
11681 assert!(install_request.accept_package_agreements);
11682 assert!(install_request.force);
11683 assert!(install_request.ignore_security_hash);
11684 }
11685
11686 #[test]
11687 fn source_scoped_pin_operations_round_trip() {
11688 let app_root = temp_app_root("pins");
11689 let result = (|| -> Result<()> {
11690 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11691 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
11692 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
11693 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
11694
11695 assert_eq!(repository.list_pins(None)?.len(), 3);
11696 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
11697 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
11698
11699 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
11700 assert_eq!(repository.list_pins(None)?.len(), 2);
11701 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
11702
11703 repository.reset_pins(Some("test"))?;
11704 let remaining = repository.list_pins(None)?;
11705 assert_eq!(remaining.len(), 1);
11706 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
11707
11708 repository.reset_pins(None)?;
11709 assert!(repository.list_pins(None)?.is_empty());
11710 Ok(())
11711 })();
11712
11713 let _ = fs::remove_dir_all(&app_root);
11714 result.expect("pin round trip");
11715 }
11716
11717 #[test]
11718 fn pin_operations_work_with_packaged_pin_schema() {
11719 let app_root = temp_app_root("pins_packaged_schema");
11720 let result = (|| -> Result<()> {
11721 let db_path = pins_db_path(&app_root);
11722 if let Some(parent) = db_path.parent() {
11723 fs::create_dir_all(parent)?;
11724 }
11725
11726 let conn = Repository::open_sqlite_connection(db_path.clone())?;
11727 execute_batch_sql(
11728 &conn,
11729 "CREATE TABLE pin (
11730 package_id TEXT NOT NULL,
11731 source_id TEXT NOT NULL,
11732 type INTEGER NOT NULL,
11733 version TEXT NOT NULL,
11734 PRIMARY KEY (package_id, source_id)
11735 )",
11736 )?;
11737 conn.cacheflush().context("failed to flush pin database")?;
11738 drop(conn);
11739
11740 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11741 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
11742
11743 let pins = repository.list_pins(None)?;
11744 assert_eq!(pins.len(), 1);
11745 assert_eq!(pins[0].package_id, "Contoso.Tool");
11746 assert_eq!(pins[0].version, "1.2.*");
11747 assert_eq!(pins[0].source_id, "winget");
11748 assert_eq!(pins[0].pin_type, PinType::Gating);
11749
11750 let conn = Repository::open_sqlite_connection(db_path)?;
11751 let stored_pin = query_optional_value(
11752 &conn,
11753 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
11754 vec![
11755 SqlValue::Text("Contoso.Tool".to_owned()),
11756 SqlValue::Text("winget".to_owned()),
11757 ],
11758 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
11759 )?
11760 .ok_or_else(|| anyhow!("expected stored pin"))?;
11761 assert_eq!(stored_pin.0, 3);
11762 assert_eq!(stored_pin.1, "1.2.*");
11763
11764 Ok(())
11765 })();
11766
11767 let _ = fs::remove_dir_all(&app_root);
11768 result.expect("packaged pin schema round trip");
11769 }
11770
11771 #[test]
11772 fn version_pin_patterns_match_exact_and_prefix_values() {
11773 assert!(version_matches_pin_pattern("1.2.3", "*"));
11774 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
11775 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
11776 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
11777 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
11778 }
11779
11780 #[test]
11781 fn source_specific_pins_override_source_agnostic_pins() {
11782 let item = ListMatch {
11783 name: "Contoso Tool".to_owned(),
11784 id: "Contoso.Tool".to_owned(),
11785 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11786 installed_version: "1.0.0".to_owned(),
11787 available_version: Some("2.0.0".to_owned()),
11788 source_name: Some("winget".to_owned()),
11789 publisher: None,
11790 scope: None,
11791 installer_category: None,
11792 install_location: None,
11793 package_family_names: Vec::new(),
11794 product_codes: Vec::new(),
11795 upgrade_codes: Vec::new(),
11796 };
11797 let pins = vec![
11798 PinRecord {
11799 package_id: "Contoso.Tool".to_owned(),
11800 version: "*".to_owned(),
11801 source_id: String::new(),
11802 pin_type: PinType::Blocking,
11803 },
11804 PinRecord {
11805 package_id: "Contoso.Tool".to_owned(),
11806 version: "2.0.*".to_owned(),
11807 source_id: "winget".to_owned(),
11808 pin_type: PinType::Pinning,
11809 },
11810 ];
11811
11812 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
11813 }
11814
11815 #[test]
11816 fn blocking_pin_blocks_upgrade() {
11817 let item = ListMatch {
11818 name: "Contoso Tool".to_owned(),
11819 id: "Contoso.Tool".to_owned(),
11820 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11821 installed_version: "1.0.0".to_owned(),
11822 available_version: Some("2.0.0".to_owned()),
11823 source_name: Some("winget".to_owned()),
11824 publisher: None,
11825 scope: None,
11826 installer_category: None,
11827 install_location: None,
11828 package_family_names: Vec::new(),
11829 product_codes: Vec::new(),
11830 upgrade_codes: Vec::new(),
11831 };
11832 let pins = vec![PinRecord {
11833 package_id: "Contoso.Tool".to_owned(),
11834 version: "*".to_owned(),
11835 source_id: "winget".to_owned(),
11836 pin_type: PinType::Blocking,
11837 }];
11838
11839 assert!(is_upgrade_blocked_by_pin(&item, &pins));
11840 }
11841
11842 #[cfg(windows)]
11843 #[test]
11844 fn parses_msix_package_full_name_into_version_and_family() {
11845 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
11846 .expect("package metadata");
11847
11848 assert_eq!(parsed.version, "0.98.1.0");
11849 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
11850 }
11851
11852 #[cfg(windows)]
11853 #[test]
11854 fn recognizes_windows_system_paths() {
11855 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
11856 assert!(!is_windows_system_path(
11857 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
11858 ));
11859 }
11860
11861 #[test]
11862 fn search_ranking_prefers_exact_name_over_tag_match() {
11863 let query = PackageQuery {
11864 query: Some("PowerToys".to_owned()),
11865 ..Default::default()
11866 };
11867 let exact_name = SearchMatch {
11868 source_name: "winget".to_owned(),
11869 source_kind: SourceKind::PreIndexed,
11870 id: "Microsoft.PowerToys".to_owned(),
11871 name: "PowerToys".to_owned(),
11872 moniker: Some("powertoys".to_owned()),
11873 version: Some("0.98.1".to_owned()),
11874 channel: None,
11875 match_criteria: Some("Moniker: powertoys".to_owned()),
11876 };
11877 let tag_match = SearchMatch {
11878 source_name: "winget".to_owned(),
11879 source_kind: SourceKind::PreIndexed,
11880 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
11881 name: "QR Codes for Command Palette".to_owned(),
11882 moniker: None,
11883 version: Some("0.4.0.0".to_owned()),
11884 channel: None,
11885 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
11886 };
11887
11888 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
11889 }
11890
11891 #[test]
11892 fn select_best_text_match_prefers_exact_tag_value() {
11893 let values = vec![
11894 "microsoft-powertoys".to_owned(),
11895 "powertoys-run".to_owned(),
11896 "powertoys".to_owned(),
11897 ];
11898
11899 assert_eq!(
11900 select_best_text_match(values, "PowerToys", false).as_deref(),
11901 Some("powertoys")
11902 );
11903 }
11904
11905 #[test]
11906 fn search_match_criteria_is_blank_for_name_match() {
11907 let query = PackageQuery {
11908 query: Some("PowerToys".to_owned()),
11909 ..Default::default()
11910 };
11911
11912 let result = infer_match_criteria(
11913 "Microsoft.PowerToys",
11914 "PowerToys",
11915 Some("powertoys"),
11916 &query,
11917 SearchSemantics::Many,
11918 |_| Ok(Some("powertoys".to_owned())),
11919 |_| Ok(None),
11920 )
11921 .expect("match criteria");
11922
11923 assert_eq!(result, None);
11924 }
11925
11926 #[test]
11927 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
11928 let query = PackageQuery {
11929 query: Some("PowerToys".to_owned()),
11930 ..Default::default()
11931 };
11932 let unknown_version = SearchMatch {
11933 source_name: "msstore".to_owned(),
11934 source_kind: SourceKind::Rest,
11935 id: "XP89DCGQ3K6VLD".to_owned(),
11936 name: "Microsoft PowerToys".to_owned(),
11937 moniker: None,
11938 version: Some("Unknown".to_owned()),
11939 channel: None,
11940 match_criteria: None,
11941 };
11942 let tag_match = SearchMatch {
11943 source_name: "winget".to_owned(),
11944 source_kind: SourceKind::PreIndexed,
11945 id: "Riri.QRCodeforCmdPal".to_owned(),
11946 name: "QR Code for CmdPal".to_owned(),
11947 moniker: None,
11948 version: Some("0.0.1.0".to_owned()),
11949 channel: None,
11950 match_criteria: Some("Tag: powertoys".to_owned()),
11951 };
11952
11953 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
11954 }
11955
11956 #[test]
11957 fn search_source_fetch_results_ignore_small_display_count() {
11958 let query = PackageQuery {
11959 count: Some(5),
11960 ..Default::default()
11961 };
11962
11963 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
11964 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
11965 }
11966
11967 #[test]
11968 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
11969 let query = PackageQuery {
11970 query: Some("PowerToys".to_owned()),
11971 ..Default::default()
11972 };
11973 let prefix_name = SearchMatch {
11974 source_name: "winget".to_owned(),
11975 source_kind: SourceKind::PreIndexed,
11976 id: "advaith.CurrencyConverterPowerToys".to_owned(),
11977 name: "PowerToys-Run-Currency-Converter".to_owned(),
11978 moniker: None,
11979 version: Some("1.5.4".to_owned()),
11980 channel: None,
11981 match_criteria: None,
11982 };
11983 let exact_tag = SearchMatch {
11984 source_name: "winget".to_owned(),
11985 source_kind: SourceKind::PreIndexed,
11986 id: "Riri.QRCodeforCmdPal".to_owned(),
11987 name: "QR Code for CmdPal".to_owned(),
11988 moniker: None,
11989 version: Some("0.0.1.0".to_owned()),
11990 channel: None,
11991 match_criteria: Some("Tag: powertoys".to_owned()),
11992 };
11993
11994 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
11995 }
11996
11997 #[test]
11998 fn list_sort_prefers_main_package_then_sparse_app() {
11999 let main = InstalledPackage {
12000 name: "PowerToys (Preview) x64".to_owned(),
12001 local_id: r"ARP\User\X64\PowerToys".to_owned(),
12002 installed_version: "0.98.1".to_owned(),
12003 publisher: None,
12004 scope: Some("User".to_owned()),
12005 installer_category: Some("exe".to_owned()),
12006 install_location: None,
12007 package_family_names: Vec::new(),
12008 product_codes: Vec::new(),
12009 upgrade_codes: Vec::new(),
12010 correlated: None,
12011 installed_version_canonical: false,
12012 correlated_requires_explicit_upgrade: false,
12013 correlated_lacks_compatible_installer: false,
12014 };
12015 let sparse = InstalledPackage {
12016 name: "PowerToys.SparseApp".to_owned(),
12017 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12018 installed_version: "0.98.1.0".to_owned(),
12019 publisher: None,
12020 scope: Some("User".to_owned()),
12021 installer_category: Some("msix".to_owned()),
12022 install_location: None,
12023 package_family_names: Vec::new(),
12024 product_codes: Vec::new(),
12025 upgrade_codes: Vec::new(),
12026 correlated: None,
12027 installed_version_canonical: false,
12028 correlated_requires_explicit_upgrade: false,
12029 correlated_lacks_compatible_installer: false,
12030 };
12031 let extension = InstalledPackage {
12032 name: "PowerToys FileLocksmith Context Menu".to_owned(),
12033 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
12034 installed_version: "0.98.1.0".to_owned(),
12035 publisher: None,
12036 scope: Some("User".to_owned()),
12037 installer_category: Some("msix".to_owned()),
12038 install_location: None,
12039 package_family_names: Vec::new(),
12040 product_codes: Vec::new(),
12041 upgrade_codes: Vec::new(),
12042 correlated: None,
12043 installed_version_canonical: false,
12044 correlated_requires_explicit_upgrade: false,
12045 correlated_lacks_compatible_installer: false,
12046 };
12047
12048 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
12049 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
12050 }
12051
12052 #[test]
12053 #[cfg(windows)]
12054 fn arp_subkey_name_extracts_registry_subkey() {
12055 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
12056 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
12057 }
12058
12059 #[test]
12060 #[cfg(windows)]
12061 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
12062 let installed = ListMatch {
12063 name: "ShareX".to_owned(),
12064 id: "ShareX.ShareX".to_owned(),
12065 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
12066 installed_version: "19.0.2".to_owned(),
12067 available_version: None,
12068 source_name: Some("winget".to_owned()),
12069 publisher: Some("ShareX Team".to_owned()),
12070 scope: Some("Machine".to_owned()),
12071 installer_category: Some("exe".to_owned()),
12072 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
12073 package_family_names: Vec::new(),
12074 product_codes: Vec::new(),
12075 upgrade_codes: Vec::new(),
12076 };
12077
12078 assert!(registry_entry_matches_installed_package(
12079 "ShareX", "ShareX", None, &installed
12080 ));
12081 assert!(!registry_entry_matches_installed_package(
12082 "ShareX.ShareX",
12083 "ShareX.ShareX",
12084 None,
12085 &installed
12086 ));
12087 }
12088
12089 #[test]
12090 #[cfg(windows)]
12091 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
12092 assert_eq!(
12093 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
12094 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
12095 );
12096 assert_eq!(
12097 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
12098 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
12099 );
12100 assert_eq!(
12101 r#""C:\Program Files\ShareX\unins000.exe""#,
12102 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
12103 );
12104 assert_eq!(
12105 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12106 build_uninstall_command(
12107 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12108 true,
12109 false,
12110 )
12111 );
12112 }
12113
12114 #[test]
12115 #[cfg(windows)]
12116 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
12117 let installed = ListMatch {
12118 name: "lazygit".to_owned(),
12119 id: "JesseDuffield.lazygit".to_owned(),
12120 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
12121 installed_version: "0.61.1".to_owned(),
12122 available_version: None,
12123 source_name: Some("winget".to_owned()),
12124 publisher: Some("Jesse Duffield".to_owned()),
12125 scope: Some("User".to_owned()),
12126 installer_category: Some("exe".to_owned()),
12127 install_location: Some(
12128 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
12129 ),
12130 package_family_names: Vec::new(),
12131 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
12132 upgrade_codes: Vec::new(),
12133 };
12134 let mut request = UninstallRequest::new(PackageQuery {
12135 id: Some("JesseDuffield.lazygit".to_owned()),
12136 install_scope: Some("user".to_owned()),
12137 ..PackageQuery::default()
12138 });
12139 request.mode = InstallerMode::Silent;
12140
12141 assert_eq!(
12142 Some(vec![
12143 "uninstall".to_owned(),
12144 "--id".to_owned(),
12145 "JesseDuffield.lazygit".to_owned(),
12146 "--exact".to_owned(),
12147 "--disable-interactivity".to_owned(),
12148 "--source".to_owned(),
12149 "winget".to_owned(),
12150 "--scope".to_owned(),
12151 "user".to_owned(),
12152 "--silent".to_owned(),
12153 ]),
12154 build_winget_uninstall_arguments(&installed, &request)
12155 );
12156
12157 assert_eq!(
12158 Some(vec![
12159 "uninstall".to_owned(),
12160 "--id".to_owned(),
12161 "JesseDuffield.lazygit".to_owned(),
12162 "--exact".to_owned(),
12163 "--disable-interactivity".to_owned(),
12164 "--source".to_owned(),
12165 "winget".to_owned(),
12166 "--silent".to_owned(),
12167 ]),
12168 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
12169 );
12170 }
12171
12172 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
12173 PackageVersionDataEntry {
12174 version: version.to_owned(),
12175 manifest_relative_path: format!("manifests/x/{version}"),
12176 manifest_hash: "00".repeat(32),
12177 arp_min_version: arp_min.map(str::to_owned),
12178 arp_max_version: arp_max.map(str::to_owned),
12179 }
12180 }
12181
12182 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
12183 V1VersionRow {
12184 version: version.to_owned(),
12185 channel: channel.to_owned(),
12186 pathpart_id: 0,
12187 manifest_hash: None,
12188 }
12189 }
12190
12191 #[derive(Clone)]
12192 struct TestHttpResponse {
12193 status: u16,
12194 body: Vec<u8>,
12195 source_version: Option<String>,
12196 }
12197
12198 struct TestHttpServer {
12199 addr: SocketAddr,
12200 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12201 counts: Arc<Mutex<HashMap<String, usize>>>,
12202 running: Arc<AtomicBool>,
12203 handle: Option<JoinHandle<()>>,
12204 }
12205
12206 impl TestHttpServer {
12207 fn start() -> Self {
12208 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
12209 let addr = listener.local_addr().expect("test HTTP server address");
12210 let routes = Arc::new(Mutex::new(HashMap::new()));
12211 let counts = Arc::new(Mutex::new(HashMap::new()));
12212 let running = Arc::new(AtomicBool::new(true));
12213 let thread_routes = Arc::clone(&routes);
12214 let thread_counts = Arc::clone(&counts);
12215 let thread_running = Arc::clone(&running);
12216 let handle = thread::spawn(move || {
12217 while thread_running.load(AtomicOrdering::SeqCst) {
12218 let Ok((stream, _)) = listener.accept() else {
12219 continue;
12220 };
12221 handle_test_http_request(stream, &thread_routes, &thread_counts);
12222 }
12223 });
12224
12225 Self {
12226 addr,
12227 routes,
12228 counts,
12229 running,
12230 handle: Some(handle),
12231 }
12232 }
12233
12234 fn url(&self) -> String {
12235 format!("http://{}", self.addr)
12236 }
12237
12238 fn set_bytes(&self, path: &str, body: Vec<u8>) {
12239 self.set_response(
12240 path,
12241 TestHttpResponse {
12242 status: 200,
12243 body,
12244 source_version: None,
12245 },
12246 );
12247 }
12248
12249 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
12250 self.set_response(
12251 "/source.msix",
12252 TestHttpResponse {
12253 status: 200,
12254 body,
12255 source_version: Some(source_version.to_owned()),
12256 },
12257 );
12258 }
12259
12260 fn set_status(&self, path: &str, status: u16) {
12261 self.set_response(
12262 path,
12263 TestHttpResponse {
12264 status,
12265 body: Vec::new(),
12266 source_version: None,
12267 },
12268 );
12269 }
12270
12271 fn set_response(&self, path: &str, response: TestHttpResponse) {
12272 self.routes
12273 .lock()
12274 .expect("test HTTP routes")
12275 .insert(path.to_owned(), response);
12276 }
12277
12278 fn request_count(&self, path: &str) -> usize {
12279 self.counts
12280 .lock()
12281 .expect("test HTTP counts")
12282 .get(path)
12283 .copied()
12284 .unwrap_or_default()
12285 }
12286 }
12287
12288 impl Drop for TestHttpServer {
12289 fn drop(&mut self) {
12290 self.running.store(false, AtomicOrdering::SeqCst);
12291 let _ = TcpStream::connect(self.addr);
12292 if let Some(handle) = self.handle.take() {
12293 let _ = handle.join();
12294 }
12295 }
12296 }
12297
12298 fn handle_test_http_request(
12299 mut stream: TcpStream,
12300 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12301 counts: &Arc<Mutex<HashMap<String, usize>>>,
12302 ) {
12303 let mut buffer = [0u8; 2048];
12304 let read = stream.read(&mut buffer).unwrap_or(0);
12305 let request = String::from_utf8_lossy(&buffer[..read]);
12306 let path = request
12307 .lines()
12308 .next()
12309 .and_then(|line| line.split_whitespace().nth(1))
12310 .unwrap_or("/");
12311 *counts
12312 .lock()
12313 .expect("test HTTP counts")
12314 .entry(path.to_owned())
12315 .or_default() += 1;
12316 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
12317 let response = response.unwrap_or(TestHttpResponse {
12318 status: 404,
12319 body: Vec::new(),
12320 source_version: None,
12321 });
12322 let reason = if response.status == 200 { "OK" } else { "Not Found" };
12323 let mut headers = format!(
12324 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
12325 response.status,
12326 reason,
12327 response.body.len()
12328 );
12329 if let Some(source_version) = response.source_version {
12330 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
12331 }
12332 headers.push_str("\r\n");
12333 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
12334 stream.write_all(&response.body).expect("write test HTTP body");
12335 }
12336
12337 fn test_source_record(source_url: String) -> SourceRecord {
12338 SourceRecord {
12339 name: "test-winget".to_owned(),
12340 kind: SourceKind::PreIndexed,
12341 arg: source_url,
12342 identifier: "Test.Source".to_owned(),
12343 trust_level: "Trusted".to_owned(),
12344 explicit: false,
12345 priority: 0,
12346 last_update: Some(Utc::now()),
12347 source_version: None,
12348 }
12349 }
12350
12351 fn open_test_preindexed_repository(
12352 app_root: &Path,
12353 source: &SourceRecord,
12354 interval: Option<Duration>,
12355 ) -> Repository {
12356 fs::create_dir_all(app_root).expect("create app root");
12357 save_store(
12358 app_root,
12359 &SourceStore {
12360 sources: vec![source.clone()],
12361 },
12362 )
12363 .expect("save source store");
12364 Repository::open_with_options(
12365 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
12366 )
12367 .expect("open repository")
12368 }
12369
12370 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
12371 let index_path = preindexed_index_path(app_root, source);
12372 if let Some(parent) = index_path.parent() {
12373 fs::create_dir_all(parent).expect("create source state directory");
12374 }
12375 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
12376 }
12377
12378 #[test]
12379 fn replace_file_failure_keeps_existing_target() {
12380 let root = temp_app_root("replace_file_failure");
12381 fs::create_dir_all(&root).expect("create replace root");
12382 let target = root.join("index.db");
12383 let missing_source = root.join("missing.tmp");
12384 fs::write(&target, b"old-index").expect("write existing target");
12385
12386 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
12387
12388 assert!(format!("{error:#}").contains("failed to replace"));
12389 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
12390 let _ = fs::remove_dir_all(root);
12391 }
12392
12393 #[cfg(windows)]
12394 #[test]
12395 fn replace_file_succeeds_while_target_is_open() {
12396 use std::os::windows::fs::OpenOptionsExt;
12397 const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
12401
12402 let root = temp_app_root("replace_file_open_target");
12403 fs::create_dir_all(&root).expect("create replace root");
12404 let target = root.join("index.db");
12405 let source = root.join("index.new.tmp");
12406 fs::write(&target, b"old-index").expect("write existing target");
12407 fs::write(&source, b"new-index").expect("write replacement");
12408
12409 let held = fs::OpenOptions::new()
12410 .read(true)
12411 .share_mode(SHARE_ALL)
12412 .open(&target)
12413 .expect("open target with share-delete");
12414
12415 replace_file(&source, &target).expect("stage-aside replace should succeed");
12416
12417 assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
12418 drop(held);
12419 let _ = fs::remove_dir_all(root);
12420 }
12421
12422 fn make_source_package(versions: &[&str]) -> Vec<u8> {
12423 let cursor = Cursor::new(Vec::new());
12424 let mut archive = zip::ZipWriter::new(cursor);
12425 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
12426 archive
12427 .start_file("Public/index.db", options)
12428 .expect("start index entry");
12429 archive
12430 .write_all(&make_v1_index_bytes(versions))
12431 .expect("write index entry");
12432 archive.finish().expect("finish source package").into_inner()
12433 }
12434
12435 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
12436 let root = temp_app_root("preindexed_index_bytes");
12437 fs::create_dir_all(&root).expect("create index temp root");
12438 let db_path = root.join("index.db");
12439 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
12440 let mut sql = String::from(
12441 "CREATE TABLE ids (id TEXT);
12442 CREATE TABLE names (name TEXT);
12443 CREATE TABLE monikers (moniker TEXT);
12444 CREATE TABLE versions (version TEXT);
12445 CREATE TABLE channels (channel TEXT);
12446 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
12447 CREATE TABLE manifest (
12448 id INT64,
12449 name INT64,
12450 moniker INT64,
12451 version INT64,
12452 channel INT64,
12453 pathpart INT64
12454 );
12455 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
12456 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
12457 INSERT INTO channels(rowid, channel) VALUES (1, '');
12458 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
12459 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
12460 );
12461 for (index, version) in versions.iter().enumerate() {
12462 let rowid = index + 1;
12463 sql.push_str(&format!(
12464 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
12465 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
12466 ));
12467 }
12468 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
12469 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
12470 .expect("checkpoint preindexed V1 database");
12471 connection.cacheflush().expect("flush preindexed V1 database");
12472 drop(connection);
12473 let bytes = fs::read(&db_path).expect("read index bytes");
12474 let _ = fs::remove_dir_all(root);
12475 bytes
12476 }
12477
12478 fn test_manifest_yaml(version: &str) -> Vec<u8> {
12479 format!(
12480 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
12481 )
12482 .into_bytes()
12483 }
12484
12485 fn show_test_package(version: Option<&str>) -> PackageQuery {
12486 PackageQuery {
12487 id: Some("Test.Package".to_owned()),
12488 source: Some("test-winget".to_owned()),
12489 exact: true,
12490 version: version.map(str::to_owned),
12491 ..PackageQuery::default()
12492 }
12493 }
12494
12495 #[test]
12496 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
12497 let app_root = temp_app_root("preindexed_explicit_refresh");
12498 let server = TestHttpServer::start();
12499 server.set_status("/source2.msix", 404);
12500 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
12501 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12502 let source = test_source_record(server.url());
12503 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12504 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12505
12506 let result = repository
12507 .show(&show_test_package(Some("2.0.0")))
12508 .expect("show after refresh");
12509
12510 assert_eq!(result.manifest.version, "2.0.0");
12511 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
12512 let _ = fs::remove_dir_all(app_root);
12513 }
12514
12515 #[test]
12516 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
12517 let app_root = temp_app_root("preindexed_explicit_still_missing");
12518 let server = TestHttpServer::start();
12519 server.set_status("/source2.msix", 404);
12520 server.set_source_package(make_source_package(&["1.0.0"]), "same");
12521 let source = test_source_record(server.url());
12522 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12523 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12524
12525 let error = repository
12526 .show(&show_test_package(Some("2.0.0")))
12527 .expect_err("missing explicit version must fail");
12528
12529 assert!(format!("{error:#}").contains("2.0.0"));
12530 let _ = fs::remove_dir_all(app_root);
12531 }
12532
12533 #[test]
12534 fn preindexed_stale_index_refreshes_for_latest_requests() {
12535 let app_root = temp_app_root("preindexed_ttl_refresh");
12536 let server = TestHttpServer::start();
12537 server.set_status("/source2.msix", 404);
12538 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12539 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12540 let mut source = test_source_record(server.url());
12541 source.last_update = Some(Utc::now() - Duration::days(2));
12542 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12543 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12544
12545 let result = repository
12546 .show(&show_test_package(None))
12547 .expect("show latest after TTL refresh");
12548
12549 assert_eq!(result.manifest.version, "2.0.0");
12550 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
12551 let _ = fs::remove_dir_all(app_root);
12552 }
12553
12554 #[test]
12555 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
12556 let app_root = temp_app_root("preindexed_ttl_fallback");
12557 let server = TestHttpServer::start();
12558 server.set_status("/source2.msix", 404);
12559 server.set_status("/source.msix", 404);
12560 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12561 let mut source = test_source_record(server.url());
12562 source.last_update = Some(Utc::now() - Duration::days(2));
12563 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12564 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12565
12566 let result = repository
12567 .show(&show_test_package(None))
12568 .expect("show latest from stale cache after refresh failure");
12569
12570 assert_eq!(result.manifest.version, "1.0.0");
12571 let _ = fs::remove_dir_all(app_root);
12572 }
12573
12574 #[test]
12575 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
12576 let app_root = temp_app_root("preindexed_no_double_refresh");
12577 let server = TestHttpServer::start();
12578 server.set_status("/source2.msix", 404);
12579 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12580 let mut source = test_source_record(server.url());
12581 source.last_update = Some(Utc::now() - Duration::days(2));
12582 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12583 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12584
12585 let error = repository
12586 .show(&show_test_package(Some("3.0.0")))
12587 .expect_err("still-missing explicit version must fail after one stale refresh");
12588
12589 assert!(format!("{error:#}").contains("3.0.0"));
12590 assert_eq!(server.request_count("/source.msix"), 1);
12591 let _ = fs::remove_dir_all(app_root);
12592 }
12593
12594 #[test]
12595 fn select_v2_version_without_explicit_filters_returns_latest() {
12596 let versions = vec![
12597 version_entry("1.0.0", None, None),
12598 version_entry("1.2.0", None, None),
12599 version_entry("1.1.0", None, None),
12600 ];
12601
12602 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
12603
12604 assert_eq!(selected.version, "1.2.0");
12605 }
12606
12607 #[test]
12608 fn select_v2_version_with_missing_requested_version_throws() {
12609 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
12610
12611 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
12612 .expect_err("missing requested version");
12613
12614 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12615 assert!(error.to_string().contains("9.9.9"));
12616 }
12617
12618 #[test]
12619 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
12620 let versions = vec![version_entry("1.2.0", None, None)];
12621
12622 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12623 .expect_err("unsupported channel request");
12624
12625 assert!(error.to_string().contains("beta"));
12626 assert!(error.to_string().contains("channel metadata"));
12627 }
12628
12629 #[test]
12630 fn select_v1_version_channel_only_returns_latest_matching_channel() {
12631 let versions = vec![
12632 v1_version("1.2.0", "stable"),
12633 v1_version("1.3.0", "stable"),
12634 v1_version("2.0.0", "preview"),
12635 ];
12636
12637 let selected =
12638 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
12639
12640 assert_eq!(selected.version, "1.3.0");
12641 }
12642
12643 #[test]
12644 fn select_rest_version_channel_mismatch_throws() {
12645 let versions = vec![
12646 VersionKey {
12647 version: "1.2.0".to_owned(),
12648 channel: "stable".to_owned(),
12649 },
12650 VersionKey {
12651 version: "1.1.0".to_owned(),
12652 channel: "beta".to_owned(),
12653 },
12654 ];
12655
12656 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12657 .expect_err("missing version and channel pair");
12658
12659 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12660 }
12661
12662 #[test]
12663 fn map_arp_version_returns_catalog_version_inside_range() {
12664 let entries = vec![
12669 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12670 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12671 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
12672 ];
12673
12674 assert_eq!(
12675 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
12676 Some("10.0.108")
12677 );
12678 }
12679
12680 #[test]
12681 fn map_arp_version_returns_none_when_no_range_matches() {
12682 let entries = vec![
12687 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12688 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12689 ];
12690
12691 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
12692 }
12693
12694 #[test]
12695 fn map_arp_version_skips_entries_missing_arp_bounds() {
12696 let entries = vec![
12700 version_entry("9.0.300", None, None),
12701 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
12702 ];
12703
12704 assert_eq!(
12705 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
12706 Some("9.0.117")
12707 );
12708 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
12709 }
12710
12711 #[test]
12712 fn map_arp_version_handles_inclusive_range_endpoints() {
12713 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
12716
12717 assert_eq!(
12718 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
12719 Some("3.14.5")
12720 );
12721 assert_eq!(
12722 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
12723 Some("3.14.5")
12724 );
12725 assert_eq!(
12726 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
12727 Some("3.14.5")
12728 );
12729 }
12730
12731 #[test]
12732 fn map_arp_version_ignores_unknown_marker() {
12733 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
12737
12738 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
12739 assert!(map_arp_version_to_catalog(&entries, "").is_none());
12740 }
12741
12742 #[test]
12743 fn latest_arp_anchored_skips_internal_versions() {
12744 let entries = vec![
12750 version_entry("8000.836.2153.0", None, None),
12751 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
12752 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
12753 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
12754 ];
12755
12756 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
12757 }
12758
12759 #[test]
12760 fn latest_arp_anchored_returns_none_without_bounds() {
12761 let entries = vec![
12765 version_entry("1.28.240.0", None, None),
12766 version_entry("1.27.470.0", None, None),
12767 ];
12768
12769 assert!(latest_arp_anchored_version(&entries).is_none());
12770 }
12771
12772 #[test]
12773 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
12774 let package = InstalledPackage {
12775 name: "AzCopy v10".to_owned(),
12776 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
12777 installed_version: "10.32.2".to_owned(),
12778 publisher: Some("Microsoft".to_owned()),
12779 scope: Some("Machine".to_owned()),
12780 installer_category: Some("exe".to_owned()),
12781 install_location: None,
12782 package_family_names: Vec::new(),
12783 product_codes: Vec::new(),
12784 upgrade_codes: Vec::new(),
12785 correlated: Some(SearchMatch {
12786 source_name: "winget".to_owned(),
12787 source_kind: SourceKind::PreIndexed,
12788 id: "Microsoft.Azure.AZCopy.10".to_owned(),
12789 name: "AzCopy v10".to_owned(),
12790 moniker: None,
12791 version: Some("10.32.3".to_owned()),
12792 channel: None,
12793 match_criteria: None,
12794 }),
12795 installed_version_canonical: false,
12796 correlated_requires_explicit_upgrade: false,
12797 correlated_lacks_compatible_installer: false,
12798 };
12799
12800 let item = list_match_from_installed(package);
12801
12802 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
12803 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
12804 assert_eq!(item.source_name.as_deref(), Some("winget"));
12805 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
12806 }
12807
12808 #[test]
12809 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
12810 let package = InstalledPackage {
12811 name: "Atlassian CLI".to_owned(),
12812 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
12813 installed_version: "1.3.18-stable".to_owned(),
12814 publisher: Some("Atlassian".to_owned()),
12815 scope: Some("User".to_owned()),
12816 installer_category: Some("exe".to_owned()),
12817 install_location: None,
12818 package_family_names: Vec::new(),
12819 product_codes: Vec::new(),
12820 upgrade_codes: Vec::new(),
12821 correlated: Some(SearchMatch {
12822 source_name: "winget".to_owned(),
12823 source_kind: SourceKind::PreIndexed,
12824 id: "Atlassian.AtlassianCLI".to_owned(),
12825 name: "Atlassian CLI".to_owned(),
12826 moniker: None,
12827 version: Some("1.3.18-stable".to_owned()),
12828 channel: None,
12829 match_criteria: Some("ProductCode".to_owned()),
12830 }),
12831 installed_version_canonical: false,
12832 correlated_requires_explicit_upgrade: false,
12833 correlated_lacks_compatible_installer: false,
12834 };
12835
12836 let item = list_match_from_installed(package);
12837
12838 assert_eq!(item.id, "Atlassian.AtlassianCLI");
12839 assert_eq!(
12840 item.local_id,
12841 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
12842 );
12843 assert_ne!(item.id, item.local_id);
12844 assert_eq!(item.source_name.as_deref(), Some("winget"));
12845 }
12846
12847 #[test]
12848 fn list_response_serialization_keeps_pascal_case_fields() {
12849 let response = ListResponse {
12850 matches: vec![ListMatch {
12851 name: "PowerToys".to_owned(),
12852 id: "Microsoft.PowerToys".to_owned(),
12853 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
12854 installed_version: "0.98.1".to_owned(),
12855 available_version: Some("0.99.0".to_owned()),
12856 source_name: Some("winget".to_owned()),
12857 publisher: Some("Microsoft".to_owned()),
12858 scope: Some("Machine".to_owned()),
12859 installer_category: Some("msi".to_owned()),
12860 install_location: None,
12861 package_family_names: Vec::new(),
12862 product_codes: Vec::new(),
12863 upgrade_codes: Vec::new(),
12864 }],
12865 warnings: Vec::new(),
12866 truncated: false,
12867 };
12868
12869 let value = serde_json::to_value(&response).expect("serialize list response");
12870 let match_value = &value["Matches"][0];
12871
12872 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
12873 assert_eq!(match_value["InstalledVersion"], "0.98.1");
12874 assert_eq!(match_value["AvailableVersion"], "0.99.0");
12875 assert_eq!(match_value["SourceName"], "winget");
12876 assert!(match_value.get("local_id").is_none());
12877 assert!(match_value.get("installed_version").is_none());
12878 assert!(match_value.get("available_version").is_none());
12879 assert!(match_value.get("source_name").is_none());
12880 }
12881
12882 #[test]
12883 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
12884 let response = SearchResponse {
12885 matches: vec![SearchMatch {
12886 source_name: "msstore".to_owned(),
12887 source_kind: SourceKind::Rest,
12888 id: "9WZDNCRFJBMP".to_owned(),
12889 name: "Microsoft To Do".to_owned(),
12890 moniker: None,
12891 version: Some("2.123.456.0".to_owned()),
12892 channel: None,
12893 match_criteria: None,
12894 }],
12895 warnings: Vec::new(),
12896 truncated: false,
12897 };
12898
12899 let value = serde_json::to_value(&response).expect("serialize search response");
12900 let match_value = &value["Matches"][0];
12901
12902 assert_eq!(match_value["Name"], "Microsoft To Do");
12903 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
12904 assert_eq!(match_value["Version"], "2.123.456.0");
12905 assert_eq!(match_value["SourceName"], "msstore");
12906 assert!(match_value.get("source_name").is_none());
12907 }
12908
12909 #[test]
12910 fn versions_result_serialization_keeps_pascal_case_fields() {
12911 let result = VersionsResult {
12912 package: SearchMatch {
12913 source_name: "winget".to_owned(),
12914 source_kind: SourceKind::PreIndexed,
12915 id: "Microsoft.PowerToys".to_owned(),
12916 name: "PowerToys".to_owned(),
12917 moniker: None,
12918 version: Some("0.99.0".to_owned()),
12919 channel: None,
12920 match_criteria: None,
12921 },
12922 versions: vec![
12923 VersionKey {
12924 version: "0.99.0".to_owned(),
12925 channel: "stable".to_owned(),
12926 },
12927 VersionKey {
12928 version: "0.100.0-preview".to_owned(),
12929 channel: "preview".to_owned(),
12930 },
12931 ],
12932 warnings: Vec::new(),
12933 };
12934
12935 let value = serde_json::to_value(&result).expect("serialize versions result");
12936
12937 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
12938 assert_eq!(value["Versions"][0]["Channel"], "stable");
12939 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
12940 assert_eq!(value["Versions"][1]["Channel"], "preview");
12941 assert!(value.get("versions").is_none());
12942 }
12943
12944 #[test]
12945 fn version_data_parses_arp_bounds_from_winget_payload() {
12946 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";
12950
12951 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
12952 assert_eq!(document.versions.len(), 2);
12953 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
12954 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
12955 assert!(document.versions[1].arp_min_version.is_none());
12956 assert!(document.versions[1].arp_max_version.is_none());
12957 }
12958
12959 #[test]
12960 fn msix_resource_string_name_resolves_to_catalog_name() {
12961 let mut package = InstalledPackage {
12966 name: "ms-resource:appDisplayName".to_owned(),
12967 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
12968 installed_version: "1.28.239.0".to_owned(),
12969 publisher: None,
12970 scope: Some("User".to_owned()),
12971 installer_category: Some("msix".to_owned()),
12972 install_location: None,
12973 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
12974 product_codes: Vec::new(),
12975 upgrade_codes: Vec::new(),
12976 correlated: Some(SearchMatch {
12977 source_name: "winget".to_owned(),
12978 source_kind: SourceKind::PreIndexed,
12979 id: "Microsoft.AppInstaller".to_owned(),
12980 name: "App Installer".to_owned(),
12981 moniker: None,
12982 version: Some("1.28.240.0".to_owned()),
12983 channel: None,
12984 match_criteria: Some("PackageFamilyName".to_owned()),
12985 }),
12986 installed_version_canonical: false,
12987 correlated_requires_explicit_upgrade: false,
12988 correlated_lacks_compatible_installer: false,
12989 };
12990 apply_msix_resource_string_name_fix(&mut package);
12991 assert_eq!(package.name, "App Installer");
12992 }
12993
12994 #[test]
12995 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
12996 let mut package = InstalledPackage {
13001 name: "ms-resource:appDisplayName".to_owned(),
13002 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
13003 installed_version: "1.0".to_owned(),
13004 publisher: None,
13005 scope: Some("Machine".to_owned()),
13006 installer_category: Some("msi".to_owned()),
13007 install_location: None,
13008 package_family_names: Vec::new(),
13009 product_codes: Vec::new(),
13010 upgrade_codes: Vec::new(),
13011 correlated: Some(SearchMatch {
13012 source_name: "winget".to_owned(),
13013 source_kind: SourceKind::PreIndexed,
13014 id: "Some.Package".to_owned(),
13015 name: "Should Not Apply".to_owned(),
13016 moniker: None,
13017 version: Some("1.0".to_owned()),
13018 channel: None,
13019 match_criteria: None,
13020 }),
13021 installed_version_canonical: false,
13022 correlated_requires_explicit_upgrade: false,
13023 correlated_lacks_compatible_installer: false,
13024 };
13025 apply_msix_resource_string_name_fix(&mut package);
13026 assert_eq!(package.name, "ms-resource:appDisplayName");
13027 }
13028
13029 #[test]
13030 fn msix_resource_string_fix_skips_resolved_names() {
13031 let mut package = InstalledPackage {
13036 name: "Microsoft Teams".to_owned(),
13037 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
13038 installed_version: "25290.205.4069.4894".to_owned(),
13039 publisher: None,
13040 scope: Some("User".to_owned()),
13041 installer_category: Some("msix".to_owned()),
13042 install_location: None,
13043 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
13044 product_codes: Vec::new(),
13045 upgrade_codes: Vec::new(),
13046 correlated: Some(SearchMatch {
13047 source_name: "winget".to_owned(),
13048 source_kind: SourceKind::PreIndexed,
13049 id: "Microsoft.Teams".to_owned(),
13050 name: "Microsoft Teams Catalog Name".to_owned(),
13051 moniker: None,
13052 version: Some("26106.1906.4665.7308".to_owned()),
13053 channel: None,
13054 match_criteria: Some("PackageFamilyName".to_owned()),
13055 }),
13056 installed_version_canonical: false,
13057 correlated_requires_explicit_upgrade: false,
13058 correlated_lacks_compatible_installer: false,
13059 };
13060 apply_msix_resource_string_name_fix(&mut package);
13061 assert_eq!(package.name, "Microsoft Teams");
13062 }
13063
13064 #[cfg(windows)]
13065 #[test]
13066 fn unflip_packed_guid_reverses_msi_installer_packing() {
13067 assert_eq!(
13074 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
13075 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
13076 );
13077 assert_eq!(
13080 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
13081 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
13082 );
13083 assert!(unflip_packed_guid("nothex").is_none());
13085 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
13087 }
13088
13089 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
13090 InstalledPackage {
13091 name: format!("{id} install"),
13092 local_id: format!(r"ARP\Machine\X64\{id}"),
13093 installed_version: installed_version.to_owned(),
13094 publisher: None,
13095 scope: Some("Machine".to_owned()),
13096 installer_category: Some("msi".to_owned()),
13097 install_location: None,
13098 package_family_names: Vec::new(),
13099 product_codes: Vec::new(),
13100 upgrade_codes: Vec::new(),
13101 correlated: Some(SearchMatch {
13102 source_name: "winget".to_owned(),
13103 source_kind: SourceKind::PreIndexed,
13104 id: id.to_owned(),
13105 name: id.to_owned(),
13106 moniker: None,
13107 version: Some("99.0.0".to_owned()),
13108 channel: None,
13109 match_criteria: None,
13110 }),
13111 installed_version_canonical: canonical,
13112 correlated_requires_explicit_upgrade: false,
13113 correlated_lacks_compatible_installer: false,
13114 }
13115 }
13116
13117 #[test]
13118 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
13119 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
13126 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
13127
13128 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
13129 assert_eq!(result.len(), 1);
13130 assert_eq!(result[0].installed_version, "10.0.108");
13131 assert!(result[0].installed_version_canonical);
13132 }
13133
13134 #[test]
13135 fn dedupe_keeps_highest_among_canonical_rows() {
13136 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
13137 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
13138
13139 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
13140 assert_eq!(result.len(), 1);
13141 assert_eq!(result[0].installed_version, "1.7.9");
13142 }
13143
13144 #[test]
13145 fn dedupe_leaves_uncorrelated_rows_alone() {
13146 let uncorrelated = InstalledPackage {
13150 name: "Foo".to_owned(),
13151 local_id: r"ARP\Machine\X64\Foo".to_owned(),
13152 installed_version: "1.0".to_owned(),
13153 publisher: None,
13154 scope: Some("Machine".to_owned()),
13155 installer_category: Some("exe".to_owned()),
13156 install_location: None,
13157 package_family_names: Vec::new(),
13158 product_codes: Vec::new(),
13159 upgrade_codes: Vec::new(),
13160 correlated: None,
13161 installed_version_canonical: false,
13162 correlated_requires_explicit_upgrade: false,
13163 correlated_lacks_compatible_installer: false,
13164 };
13165 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
13166 assert_eq!(result.len(), 1);
13167 }
13168}