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 source.last_update = Some(Utc::now());
2897 source.source_version = header_version;
2898 return Ok(format!("downloaded {}", candidate));
2899 }
2900 Ok(response) => {
2901 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
2902 }
2903 Err(error) => {
2904 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
2905 }
2906 }
2907 }
2908
2909 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
2910 }
2911
2912 fn update_rest(&mut self, source_index: usize) -> Result<String> {
2913 let info = self.fetch_rest_information(source_index, true)?;
2914 let source = &mut self.store.sources[source_index];
2915 if !info.source_identifier.is_empty() {
2916 source.identifier = info.source_identifier.clone();
2917 }
2918 source.last_update = Some(Utc::now());
2919 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
2920 Ok("refreshed information cache".to_owned())
2921 }
2922
2923 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
2924 let source = self.source_clone(source_index);
2925 let cache_path = rest_information_cache_path(&self.app_root, &source);
2926
2927 if cache_path.exists() {
2928 let cache = serde_json::from_slice::<RestInfoCache>(
2929 &fs::read(&cache_path).context("failed to read REST information cache")?,
2930 )
2931 .context("failed to parse REST information cache")?;
2932 if cache.expires_at > Utc::now() {
2933 return Ok(cache.value);
2934 }
2935 }
2936
2937 self.fetch_rest_information(source_index, true)
2938 }
2939
2940 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
2941 let source = self.source_clone(source_index);
2942 let url = format!("{}/information", source.arg.trim_end_matches('/'));
2943 let response = self
2944 .client
2945 .get(url)
2946 .send()
2947 .context("REST information request failed")?
2948 .error_for_status()
2949 .context("REST information request returned an error")?;
2950
2951 let max_age = cache_control_max_age(&response);
2952 let json = response
2953 .json::<JsonValue>()
2954 .context("failed to parse REST information response")?;
2955 let data = json
2956 .get("Data")
2957 .cloned()
2958 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
2959 let info = serde_json::from_value::<RestInformation>(data)
2960 .context("failed to deserialize REST information payload")?;
2961
2962 if persist {
2963 let cache = RestInfoCache {
2964 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
2965 value: info.clone(),
2966 };
2967 write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
2968 }
2969
2970 Ok(info)
2971 }
2972
2973 fn load_v2_version_data(
2974 &self,
2975 source: &SourceRecord,
2976 package_rowid: i64,
2977 package_hash: &str,
2978 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
2979 let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
2980 .context("failed to reopen preindexed index for V2 version data")?;
2981 let package_hash = package_hash.to_ascii_lowercase();
2982 let package_id = query_optional_value(
2983 &connection,
2984 "SELECT id FROM packages WHERE rowid = ?1",
2985 vec![SqlValue::Integer(package_rowid)],
2986 |row| row_string(row, 0),
2987 )?
2988 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
2989 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
2990 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
2991 let yaml = decompress_mszyml(&file.bytes)?;
2992 let document =
2993 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
2994 Ok((document.versions, file.path))
2995 }
2996
2997 fn get_cached_source_file(
2998 &self,
2999 bucket: &str,
3000 source: &SourceRecord,
3001 relative_path: &str,
3002 expected_hash: Option<&str>,
3003 ) -> Result<CachedBytes> {
3004 let normalized_relative = relative_path.replace('\\', "/");
3005 let cache_path =
3006 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3007
3008 if cache_path.exists() {
3009 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3010 if hash_matches(expected_hash, &cached) {
3011 return Ok(CachedBytes {
3012 path: cache_path,
3013 bytes: cached,
3014 });
3015 }
3016 }
3017
3018 let url = format!(
3019 "{}/{}",
3020 source.arg.trim_end_matches('/'),
3021 normalized_relative.trim_start_matches('/')
3022 );
3023 let response = self
3024 .client
3025 .get(url)
3026 .send()
3027 .context("failed to fetch source file")?
3028 .error_for_status()
3029 .context("source file request returned an error")?;
3030 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3031
3032 if let Some(hash) = expected_hash {
3033 verify_hash(hash, &bytes)?;
3034 }
3035
3036 if let Some(parent) = cache_path.parent() {
3037 fs::create_dir_all(parent).context("failed to create cache directory")?;
3038 }
3039 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3040
3041 Ok(CachedBytes {
3042 path: cache_path,
3043 bytes,
3044 })
3045 }
3046
3047 fn get_or_fetch_rest_manifest(
3048 &mut self,
3049 source: &SourceRecord,
3050 package_id: &str,
3051 version: &str,
3052 channel: &str,
3053 ) -> Result<(Vec<u8>, PathBuf)> {
3054 let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
3055 if cache_path.exists() {
3056 return Ok((
3057 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3058 cache_path,
3059 ));
3060 }
3061
3062 let info = self.load_rest_information_by_name(&source.name)?;
3063 let contract = choose_contract(&info.server_supported_versions)
3064 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3065 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3066
3067 let mut params = vec![("Version", version.to_owned())];
3068 if !channel.is_empty() {
3069 params.push(("Channel", channel.to_owned()));
3070 }
3071 if info
3072 .required_query_parameters
3073 .iter()
3074 .any(|value| value.eq_ignore_ascii_case("Market"))
3075 {
3076 params.push(("Market", default_market()));
3077 }
3078
3079 let response = self
3080 .client
3081 .get(url)
3082 .header("Version", contract)
3083 .query(¶ms)
3084 .send()
3085 .context("REST manifest request failed")?
3086 .error_for_status()
3087 .context("REST manifest request returned an error")?;
3088 let bytes = response
3089 .bytes()
3090 .context("failed to read REST manifest response")?
3091 .to_vec();
3092
3093 if let Some(parent) = cache_path.parent() {
3094 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3095 }
3096 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3097
3098 Ok((bytes, cache_path))
3099 }
3100
3101 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3102 let index = self
3103 .store
3104 .sources
3105 .iter()
3106 .position(|source| source.name.eq_ignore_ascii_case(name))
3107 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3108 self.load_rest_information(index)
3109 }
3110
3111 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3112 if let Some(name) = source_name {
3113 let index = self
3114 .store
3115 .sources
3116 .iter()
3117 .position(|source| source.name.eq_ignore_ascii_case(name))
3118 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3119 return Ok(vec![index]);
3120 }
3121
3122 let mut indexes: Vec<_> = self
3123 .store
3124 .sources
3125 .iter()
3126 .enumerate()
3127 .filter(|(_, source)| !source.explicit)
3128 .collect();
3129 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3130 right_source
3131 .priority
3132 .cmp(&left_source.priority)
3133 .then_with(|| left_index.cmp(right_index))
3134 });
3135 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3136 }
3137
3138 fn source_clone(&self, index: usize) -> SourceRecord {
3139 self.store.sources[index].clone()
3140 }
3141
3142 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3143 if self.use_system_winget_sources {
3144 self.store = load_system_winget_source_store()?;
3145 }
3146 Ok(())
3147 }
3148
3149 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3150 self.refresh_system_winget_sources()?;
3151 let selected_names: Vec<_> = if source_name.is_none() {
3152 self.store.sources.iter().map(|source| source.name.clone()).collect()
3153 } else {
3154 self.resolve_source_indexes(source_name)?
3155 .into_iter()
3156 .map(|index| self.store.sources[index].name.clone())
3157 .collect()
3158 };
3159
3160 let detail = system_winget_update_sources(source_name)?;
3161 self.refresh_system_winget_sources()?;
3162
3163 Ok(selected_names
3164 .into_iter()
3165 .filter_map(|name| {
3166 self.store
3167 .sources
3168 .iter()
3169 .find(|source| source.name.eq_ignore_ascii_case(&name))
3170 .map(|source| SourceUpdateResult {
3171 name: source.name.clone(),
3172 kind: source.kind,
3173 detail: detail.clone(),
3174 })
3175 })
3176 .collect())
3177 }
3178}
3179
3180#[derive(Debug)]
3181struct CachedBytes {
3182 path: PathBuf,
3183 bytes: Vec<u8>,
3184}
3185
3186#[derive(Debug, Clone)]
3187struct V1SearchRow {
3188 manifest_rowid: i64,
3189 package_rowid: i64,
3190 version: String,
3191 channel: String,
3192 id: String,
3193 name: String,
3194 moniker: Option<String>,
3195 match_criteria: Option<String>,
3196}
3197
3198#[derive(Debug, Clone)]
3199struct V1VersionRow {
3200 version: String,
3201 channel: String,
3202 pathpart_id: i64,
3203 manifest_hash: Option<String>,
3204}
3205
3206#[derive(Debug, Clone)]
3207struct V2SearchRow {
3208 package_rowid: i64,
3209 package_hash: String,
3210 id: String,
3211 name: String,
3212 moniker: Option<String>,
3213 version: String,
3214 match_criteria: Option<String>,
3215}
3216
3217fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3218 query.query.is_some()
3219 || query.id.is_some()
3220 || query.name.is_some()
3221 || query.moniker.is_some()
3222 || query.tag.is_some()
3223 || query.command.is_some()
3224 || query.source.is_some()
3225}
3226
3227fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3228 PackageQuery {
3229 query: query.query.clone(),
3230 id: query.id.clone(),
3231 name: query.name.clone(),
3232 moniker: query.moniker.clone(),
3233 tag: query.tag.clone(),
3234 command: query.command.clone(),
3235 source: query.source.clone(),
3236 count: Some(LIST_LOOKUP_MAX_RESULTS),
3237 exact: query.exact,
3238 version: None,
3239 channel: None,
3240 locale: None,
3241 installer_type: None,
3242 installer_architecture: None,
3243 platform: None,
3244 os_version: None,
3245 install_scope: query.install_scope.clone(),
3246 }
3247}
3248
3249fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3250 query.query.is_some() || query.id.is_some() || query.name.is_some()
3251}
3252
3253fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3254 package
3255 .correlated
3256 .as_ref()
3257 .and_then(|candidate| candidate.version.as_deref())
3258 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3259}
3260
3261fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3262 package.installed_version.eq_ignore_ascii_case("Unknown")
3263}
3264
3265fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3266 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3269 return false;
3270 }
3271 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3274 return false;
3275 }
3276 installed_package_has_upgrade(package)
3277 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3278}
3279
3280fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3286 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3287 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3288
3289 for package in packages {
3290 match package.correlated.as_ref() {
3291 Some(candidate) => {
3292 let key = (
3293 candidate.id.to_ascii_lowercase(),
3294 candidate.source_name.to_ascii_lowercase(),
3295 );
3296 let keep_new = match by_id.get(&key) {
3297 Some(existing) => {
3298 match (
3304 package.installed_version_canonical,
3305 existing.installed_version_canonical,
3306 ) {
3307 (true, false) => true,
3308 (false, true) => false,
3309 _ => {
3310 compare_version(&package.installed_version, &existing.installed_version)
3311 == Ordering::Greater
3312 }
3313 }
3314 }
3315 None => true,
3316 };
3317 if keep_new {
3318 by_id.insert(key, package);
3319 }
3320 }
3321 None => uncorrelated.push(package),
3322 }
3323 }
3324
3325 let mut result = uncorrelated;
3326 result.extend(by_id.into_values());
3327 result
3328}
3329
3330fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3331 let mut source_specific = None;
3332 let mut source_agnostic = None;
3333
3334 for pin in pins {
3335 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3336 continue;
3337 }
3338
3339 if pin.source_id.is_empty() {
3340 if source_agnostic.is_none() {
3341 source_agnostic = Some(pin);
3342 }
3343 } else if item
3344 .source_name
3345 .as_deref()
3346 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3347 {
3348 source_specific = Some(pin);
3349 break;
3350 }
3351 }
3352
3353 source_specific.or(source_agnostic)
3354}
3355
3356fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3357 let Some(available_version) = item.available_version.as_deref() else {
3358 return false;
3359 };
3360 let Some(pin) = find_applicable_pin(item, pins) else {
3361 return false;
3362 };
3363
3364 match pin.pin_type {
3365 PinType::Blocking => true,
3366 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3367 }
3368}
3369
3370fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3371 if pattern.trim().is_empty() || pattern == "*" {
3372 return true;
3373 }
3374
3375 if !pattern.contains('*') {
3376 return version.eq_ignore_ascii_case(pattern);
3377 }
3378
3379 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3380 let prefix = &pattern[..pattern.len() - 1];
3381 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3382 }
3383
3384 version.eq_ignore_ascii_case(pattern)
3385}
3386
3387fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
3388 let available_version = package.correlated.as_ref().and_then(|candidate| {
3389 candidate.version.as_ref().and_then(|candidate_version| {
3390 if installed_package_has_unknown_version(&package)
3391 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater
3392 {
3393 Some(candidate_version.clone())
3394 } else {
3395 None
3396 }
3397 })
3398 });
3399 let source_name = package
3400 .correlated
3401 .as_ref()
3402 .map(|candidate| candidate.source_name.clone())
3403 .filter(|value| !value.is_empty());
3404 let id = package
3405 .correlated
3406 .as_ref()
3407 .map(|candidate| candidate.id.clone())
3408 .unwrap_or_else(|| package.local_id.clone());
3409
3410 ListMatch {
3411 name: package.name,
3412 id,
3413 local_id: package.local_id,
3414 installed_version: package.installed_version,
3415 available_version,
3416 source_name,
3417 publisher: package.publisher,
3418 scope: package.scope,
3419 installer_category: package.installer_category,
3420 install_location: package.install_location,
3421 package_family_names: package.package_family_names,
3422 product_codes: package.product_codes,
3423 upgrade_codes: package.upgrade_codes,
3424 }
3425}
3426
3427fn list_sort_weight(package: &InstalledPackage) -> usize {
3428 if package.local_id.starts_with("ARP\\") {
3429 0
3430 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
3431 1
3432 } else {
3433 2
3434 }
3435}
3436
3437fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
3438 let correlated = package.correlated.as_ref();
3439
3440 if let Some(value) = &query.id {
3441 let local_match = matches_text(&package.local_id, value, query.exact);
3442 let correlated_match = correlated
3443 .map(|candidate| matches_text(&candidate.id, value, query.exact))
3444 .unwrap_or(false);
3445 if !local_match && !correlated_match {
3446 return false;
3447 }
3448 }
3449
3450 if let Some(value) = &query.name
3451 && !matches_text(&package.name, value, query.exact)
3452 {
3453 return false;
3454 }
3455
3456 if let Some(value) = &query.product_code
3457 && !package
3458 .product_codes
3459 .iter()
3460 .any(|code| code.eq_ignore_ascii_case(value))
3461 {
3462 return false;
3463 }
3464
3465 if let Some(value) = &query.version
3466 && !package.installed_version.eq_ignore_ascii_case(value)
3467 {
3468 return false;
3469 }
3470
3471 if let Some(value) = &query.query {
3472 let local_match =
3473 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
3474 let correlated_match = correlated
3475 .map(|candidate| {
3476 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
3477 })
3478 .unwrap_or(false);
3479 if !local_match && !correlated_match {
3480 return false;
3481 }
3482 }
3483
3484 if let Some(source) = &query.source
3485 && correlated
3486 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
3487 .unwrap_or(true)
3488 {
3489 return false;
3490 }
3491
3492 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
3493 return false;
3494 }
3495
3496 true
3497}
3498
3499fn correlate_installed_package(
3500 package: &InstalledPackage,
3501 candidates: &[SearchMatch],
3502 allow_loose_name_match: bool,
3503) -> Option<SearchMatch> {
3504 if package.local_id.starts_with("MSIX\\") {
3512 return None;
3513 }
3514
3515 let installed_name = normalize_correlation_name(&package.name);
3516 let installed_name_lower = package.name.to_ascii_lowercase();
3517 let candidate_names = correlation_name_candidates(&package.name);
3518
3519 let mut scored: Vec<(i32, SearchMatch)> = candidates
3520 .iter()
3521 .filter_map(|candidate| {
3522 let candidate_name = normalize_correlation_name(&candidate.name);
3523 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
3524 1000
3525 } else if candidate_names.iter().any(|name| {
3526 let normalized = normalize_correlation_name(name);
3527 normalized == candidate_name
3528 }) {
3529 900
3530 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
3531 700
3532 } else {
3533 0
3534 };
3535
3536 if base_score == 0 {
3537 return None;
3538 }
3539
3540 let candidate_lower = candidate.name.to_ascii_lowercase();
3553 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
3554 100
3555 } else if installed_name_lower.contains(&candidate_lower) {
3556 50
3557 } else {
3558 0
3559 };
3560
3561 Some((base_score + prefix_bonus, candidate.clone()))
3562 })
3563 .collect();
3564
3565 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
3566 let mut iter = scored.into_iter();
3567 let (top_score, top_match) = iter.next()?;
3568
3569 if let Some((next_score, _)) = iter.next()
3577 && next_score == top_score
3578 {
3579 return None;
3580 }
3581 Some(top_match)
3582}
3583
3584fn correlation_name_candidates(name: &str) -> Vec<String> {
3585 let mut candidates = Vec::new();
3586 candidates.push(name.trim().to_owned());
3587
3588 let mut trimmed = name.trim().to_owned();
3589 if let Some(index) = trimmed.find(" (") {
3590 trimmed.truncate(index);
3591 }
3592
3593 let mut words = Vec::new();
3594 for token in trimmed.split_whitespace() {
3595 let lower = token
3596 .trim_matches(|ch: char| ch == '(' || ch == ')')
3597 .to_ascii_lowercase();
3598 if !words.is_empty()
3599 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
3600 {
3601 break;
3602 }
3603 words.push(token);
3604 }
3605
3606 if !words.is_empty() {
3607 candidates.push(words.join(" "));
3608 }
3609
3610 candidates.sort();
3611 candidates.dedup();
3612 candidates
3613}
3614
3615fn normalize_correlation_name(value: &str) -> String {
3616 value
3617 .chars()
3618 .filter(|ch| ch.is_ascii_alphanumeric())
3619 .flat_map(|ch| ch.to_lowercase())
3620 .collect()
3621}
3622
3623struct V2PackageMetadata {
3624 rowid: i64,
3625 id: String,
3626 name: String,
3627 moniker: Option<String>,
3628 latest_version: String,
3629 package_hash: String,
3630}
3631
3632fn lookup_identity_match_v2(
3638 connection: &Connection,
3639 package: &InstalledPackage,
3640) -> Result<Option<(i64, &'static str)>> {
3641 for pfn in &package.package_family_names {
3642 if let Some(rowid) = query_optional_value(
3643 connection,
3644 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
3645 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
3646 |row| row_i64(row, 0),
3647 )? {
3648 return Ok(Some((rowid, "PackageFamilyName")));
3649 }
3650 }
3651 for code in &package.upgrade_codes {
3660 if let Some(rowid) = query_optional_value(
3661 connection,
3662 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
3663 vec![SqlValue::Text(code.to_ascii_lowercase())],
3664 |row| row_i64(row, 0),
3665 )? {
3666 return Ok(Some((rowid, "UpgradeCode")));
3667 }
3668 }
3669 for code in &package.product_codes {
3670 if let Some(rowid) = query_optional_value(
3671 connection,
3672 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
3673 vec![SqlValue::Text(code.to_ascii_lowercase())],
3674 |row| row_i64(row, 0),
3675 )? {
3676 return Ok(Some((rowid, "ProductCode")));
3677 }
3678 }
3679 Ok(None)
3680}
3681
3682fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
3686 query_optional_value(
3687 connection,
3688 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
3689 vec![SqlValue::Text(package_id.to_owned())],
3690 |row| {
3691 Ok(V2PackageMetadata {
3692 rowid: row_i64(row, 0)?,
3693 id: row_string(row, 1)?,
3694 name: row_string(row, 2)?,
3695 moniker: row_opt_string(row, 3)?,
3696 latest_version: row_string(row, 4)?,
3697 package_hash: row_hex_string(row, 5)?,
3698 })
3699 },
3700 )
3701}
3702
3703fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
3711 if !package.local_id.starts_with("MSIX\\") {
3712 return;
3713 }
3714 if !package.name.starts_with("ms-resource:") {
3715 return;
3716 }
3717 let Some(candidate) = package.correlated.as_ref() else {
3718 return;
3719 };
3720 if !candidate.name.is_empty() {
3721 package.name = candidate.name.clone();
3722 }
3723}
3724
3725fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
3726 query_optional_value(
3727 connection,
3728 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
3729 vec![SqlValue::Integer(package_rowid)],
3730 |row| {
3731 Ok(V2PackageMetadata {
3732 rowid: package_rowid,
3733 id: row_string(row, 0)?,
3734 name: row_string(row, 1)?,
3735 moniker: row_opt_string(row, 2)?,
3736 latest_version: row_string(row, 3)?,
3737 package_hash: row_hex_string(row, 4)?,
3738 })
3739 },
3740 )
3741}
3742
3743fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
3744 query_optional_value(
3745 connection,
3746 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
3747 Vec::new(),
3748 |row| row_i64(row, 0),
3749 )
3750 .ok()
3751 .flatten()
3752 .is_some()
3753}
3754
3755fn lookup_unique_normalized_identity(
3762 connection: &Connection,
3763 norm_name: &str,
3764 norm_publisher: &str,
3765) -> Result<Option<i64>> {
3766 let rows: Vec<i64> = query_rows(
3767 connection,
3768 "SELECT DISTINCT n.package FROM norm_names2 n \
3769 WHERE n.norm_name = ?1 \
3770 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
3771 LIMIT 2",
3772 vec![
3773 SqlValue::Text(norm_name.to_owned()),
3774 SqlValue::Text(norm_publisher.to_owned()),
3775 ],
3776 |row| row_i64(row, 0),
3777 )?;
3778 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
3779}
3780
3781fn v2_identity_tables_present(connection: &Connection) -> bool {
3782 query_optional_value(
3783 connection,
3784 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
3785 Vec::new(),
3786 |row| row_i64(row, 0),
3787 )
3788 .ok()
3789 .flatten()
3790 .is_some()
3791}
3792
3793fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
3800 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
3801 return None;
3802 }
3803 for entry in entries {
3804 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
3805 continue;
3806 };
3807 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
3808 {
3809 return Some(entry.version.clone());
3810 }
3811 }
3812 None
3813}
3814
3815fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
3824 entries
3825 .iter()
3826 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
3827 .max_by(|a, b| compare_version(&a.version, &b.version))
3828 .map(|entry| entry.version.clone())
3829}
3830
3831#[cfg(windows)]
3832fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3833 let mut packages = Vec::new();
3834 let mut seen = BTreeSet::new();
3835
3836 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
3837 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
3838
3839 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
3847
3848 if machine {
3849 collect_uninstall_view(
3850 &mut packages,
3851 &mut seen,
3852 &upgrade_codes,
3853 RegKey::predef(HKEY_LOCAL_MACHINE),
3854 "Machine",
3855 "X64",
3856 KEY_READ | KEY_WOW64_64KEY,
3857 )?;
3858 collect_uninstall_view(
3859 &mut packages,
3860 &mut seen,
3861 &upgrade_codes,
3862 RegKey::predef(HKEY_LOCAL_MACHINE),
3863 "Machine",
3864 "X86",
3865 KEY_READ | KEY_WOW64_32KEY,
3866 )?;
3867 collect_appmodel_packages(
3868 &mut packages,
3869 &mut seen,
3870 RegKey::predef(HKEY_LOCAL_MACHINE),
3871 "Machine",
3872 KEY_READ | KEY_WOW64_64KEY,
3873 )?;
3874 }
3875
3876 if user {
3877 collect_uninstall_view(
3878 &mut packages,
3879 &mut seen,
3880 &upgrade_codes,
3881 RegKey::predef(HKEY_CURRENT_USER),
3882 "User",
3883 "X64",
3884 KEY_READ,
3885 )?;
3886 collect_appmodel_packages(
3887 &mut packages,
3888 &mut seen,
3889 RegKey::predef(HKEY_CURRENT_USER),
3890 "User",
3891 KEY_READ,
3892 )?;
3893 }
3894
3895 Ok(packages)
3896}
3897
3898#[cfg(windows)]
3904fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
3905 let mut map = HashMap::new();
3906 if include_machine {
3907 collect_msi_upgrade_codes_from(
3908 RegKey::predef(HKEY_LOCAL_MACHINE),
3909 r"SOFTWARE\Classes\Installer\UpgradeCodes",
3910 KEY_READ | KEY_WOW64_64KEY,
3911 &mut map,
3912 );
3913 }
3914 if include_user {
3915 collect_msi_upgrade_codes_from(
3916 RegKey::predef(HKEY_CURRENT_USER),
3917 r"Software\Microsoft\Installer\UpgradeCodes",
3918 KEY_READ,
3919 &mut map,
3920 );
3921 }
3922 map
3923}
3924
3925#[cfg(windows)]
3926fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
3927 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
3928 return;
3929 };
3930 for key_name in upgrade_codes.enum_keys().flatten() {
3931 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
3932 continue;
3933 };
3934 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
3935 continue;
3936 };
3937 for (value_name, _) in subkey.enum_values().flatten() {
3938 if let Some(product_code) = unflip_packed_guid(&value_name) {
3939 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
3943 }
3944 }
3945 }
3946}
3947
3948#[cfg(windows)]
3953fn unflip_packed_guid(packed: &str) -> Option<String> {
3954 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
3955 return None;
3956 }
3957 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
3958 let bytes = packed.as_bytes();
3959 let mut reversed = String::with_capacity(32);
3960 let mut offset = 0;
3961 for size in CHUNKS {
3962 let chunk = &bytes[offset..offset + size];
3963 for &c in chunk.iter().rev() {
3964 reversed.push(c as char);
3965 }
3966 offset += size;
3967 }
3968 let r = reversed.to_ascii_lowercase();
3969 Some(format!(
3970 "{{{}-{}-{}-{}-{}}}",
3971 &r[0..8],
3972 &r[8..12],
3973 &r[12..16],
3974 &r[16..20],
3975 &r[20..32]
3976 ))
3977}
3978
3979#[cfg(not(windows))]
3980fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
3981 Ok(Vec::new())
3982}
3983
3984#[cfg(windows)]
3985fn collect_uninstall_view(
3986 packages: &mut Vec<InstalledPackage>,
3987 seen: &mut BTreeSet<String>,
3988 upgrade_code_map: &HashMap<String, String>,
3989 root: RegKey,
3990 scope: &str,
3991 arch: &str,
3992 flags: u32,
3993) -> Result<()> {
3994 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
3995
3996 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
3997 Ok(key) => key,
3998 Err(_) => return Ok(()),
3999 };
4000
4001 for key_name in uninstall.enum_keys().flatten() {
4002 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4003 Ok(key) => key,
4004 Err(_) => continue,
4005 };
4006 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4007 {
4008 continue;
4009 }
4010
4011 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4012 continue;
4013 };
4014
4015 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4016 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4017 let publisher = read_reg_string(&subkey, "Publisher");
4018 let install_location = read_reg_string(&subkey, "InstallLocation");
4019 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4020 .into_iter()
4021 .collect::<Vec<_>>();
4022 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4023 if product_codes.is_empty() && looks_like_product_code(&key_name) {
4024 product_codes.push(key_name.to_ascii_lowercase());
4025 }
4026 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4027 if upgrade_codes.is_empty() {
4028 for code in &product_codes {
4032 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4033 upgrade_codes.push(uc.clone());
4034 break;
4035 }
4036 }
4037 }
4038 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4039 .as_deref()
4040 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4041 {
4042 Some("portable".to_owned())
4048 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4049 Some("msi".to_owned())
4050 } else if key_name.starts_with("MSIX\\") {
4051 Some("msix".to_owned())
4052 } else {
4053 Some("exe".to_owned())
4054 };
4055
4056 let dedupe_key = format!(
4057 "{}|{}|{}|{}",
4058 local_id,
4059 name.to_ascii_lowercase(),
4060 installed_version.to_ascii_lowercase(),
4061 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4062 );
4063 if !seen.insert(dedupe_key) {
4064 continue;
4065 }
4066
4067 packages.push(InstalledPackage {
4068 name,
4069 local_id,
4070 installed_version,
4071 publisher,
4072 scope: Some(scope.to_owned()),
4073 installer_category,
4074 install_location,
4075 package_family_names,
4076 product_codes,
4077 upgrade_codes,
4078 correlated: None,
4079 installed_version_canonical: false,
4080 correlated_requires_explicit_upgrade: false,
4081 correlated_lacks_compatible_installer: false,
4082 });
4083 }
4084
4085 Ok(())
4086}
4087
4088#[cfg(windows)]
4089fn collect_appmodel_packages(
4090 packages: &mut Vec<InstalledPackage>,
4091 seen: &mut BTreeSet<String>,
4092 root: RegKey,
4093 scope: &str,
4094 flags: u32,
4095) -> Result<()> {
4096 const APPMODEL_PACKAGES_PATH: &str =
4097 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
4098
4099 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4100 Ok(key) => key,
4101 Err(_) => return Ok(()),
4102 };
4103
4104 for key_name in appmodel.enum_keys().flatten() {
4105 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4106 Ok(key) => key,
4107 Err(_) => continue,
4108 };
4109
4110 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4111 continue;
4112 };
4113 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4114 if install_location.as_deref().is_some_and(is_windows_system_path) {
4115 continue;
4116 }
4117
4118 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4119 continue;
4120 };
4121
4122 let local_id = format!(r"MSIX\{key_name}");
4123 let dedupe_key = format!(
4124 "{}|{}|{}",
4125 local_id,
4126 name.to_ascii_lowercase(),
4127 metadata.version.to_ascii_lowercase()
4128 );
4129 if !seen.insert(dedupe_key) {
4130 continue;
4131 }
4132
4133 packages.push(InstalledPackage {
4134 name,
4135 local_id,
4136 installed_version: metadata.version,
4137 publisher: None,
4138 scope: Some(scope.to_owned()),
4139 installer_category: Some("msix".to_owned()),
4140 install_location,
4141 package_family_names: vec![metadata.family_name],
4142 product_codes: Vec::new(),
4143 upgrade_codes: Vec::new(),
4144 correlated: None,
4145 installed_version_canonical: false,
4146 correlated_requires_explicit_upgrade: false,
4147 correlated_lacks_compatible_installer: false,
4148 });
4149 }
4150
4151 Ok(())
4152}
4153
4154#[cfg(windows)]
4155struct ParsedMsixPackageFullName {
4156 version: String,
4157 family_name: String,
4158}
4159
4160#[cfg(windows)]
4161fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
4162 let segments = value.split('_').collect::<Vec<_>>();
4163 if segments.len() < 5 {
4164 return None;
4165 }
4166
4167 let name = segments[..segments.len() - 4].join("_");
4168 if name.is_empty() {
4169 return None;
4170 }
4171
4172 let version = segments[segments.len() - 4].trim();
4173 let resource_id = segments[segments.len() - 2].trim();
4174 let publisher_id = segments[segments.len() - 1].trim();
4175 if version.is_empty() || publisher_id.is_empty() {
4176 return None;
4177 }
4178
4179 let family_name = if resource_id.is_empty() {
4180 format!("{name}_{publisher_id}")
4181 } else {
4182 format!("{name}_{resource_id}_{publisher_id}")
4183 };
4184
4185 Some(ParsedMsixPackageFullName {
4186 version: version.to_owned(),
4187 family_name,
4188 })
4189}
4190
4191#[cfg(windows)]
4192fn is_windows_system_path(path: &str) -> bool {
4193 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
4194}
4195
4196#[cfg(windows)]
4197fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
4198 key.get_value::<String, _>(value_name)
4199 .ok()
4200 .map(|value| value.trim().to_owned())
4201 .filter(|value| !value.is_empty())
4202}
4203
4204#[cfg(windows)]
4205fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
4206 key.get_value::<u32, _>(value_name).ok()
4207}
4208
4209#[cfg(windows)]
4210fn looks_like_product_code(value: &str) -> bool {
4211 value.starts_with('{') && value.ends_with('}')
4212}
4213
4214fn ensure_app_dirs(app_root: &Path) -> Result<()> {
4215 fs::create_dir_all(app_root).context("failed to create app root directory")?;
4216 if uses_packaged_layout(app_root) {
4217 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
4218 let user_sources_path = packaged_user_sources_path(app_root)?;
4219 if let Some(parent) = user_sources_path.parent() {
4220 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
4221 }
4222 } else {
4223 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
4224 }
4225 Ok(())
4226}
4227
4228fn default_app_root() -> Result<PathBuf> {
4229 if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
4230 return Ok(PathBuf::from(app_root));
4231 }
4232
4233 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
4234
4235 #[cfg(windows)]
4236 {
4237 if has_current_package_identity() {
4238 return Ok(local_app_data
4239 .join("Packages")
4240 .join(PACKAGED_FAMILY_NAME)
4241 .join("LocalState"));
4242 }
4243 Ok(local_app_data.join("Devolutions").join("Pinget"))
4244 }
4245
4246 #[cfg(not(windows))]
4247 {
4248 Ok(local_app_data.join("pinget"))
4249 }
4250}
4251
4252#[cfg(windows)]
4253fn has_current_package_identity() -> bool {
4254 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
4257
4258 let mut length: u32 = 0;
4259 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
4262 rc != APPMODEL_ERROR_NO_PACKAGE
4263}
4264
4265fn store_path(app_root: &Path) -> PathBuf {
4266 app_root.join("sources.json")
4267}
4268
4269fn user_settings_path(app_root: &Path) -> PathBuf {
4270 if uses_packaged_layout(app_root) {
4271 app_root.join("settings.json")
4272 } else {
4273 app_root.join("user-settings.json")
4274 }
4275}
4276
4277fn admin_settings_path(app_root: &Path) -> PathBuf {
4278 app_root.join("admin-settings.json")
4279}
4280
4281fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
4282 if uses_packaged_layout(app_root) {
4283 return app_root
4284 .join(packaged_source_type(source.kind))
4285 .join(sanitize_path_segment(&source.identifier));
4286 }
4287
4288 app_root.join("sources").join(sanitize_path_segment(&source.name))
4289}
4290
4291fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4292 source_state_dir(app_root, source).join("source.msix")
4293}
4294
4295fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4296 source_state_dir(app_root, source).join("index.db")
4297}
4298
4299#[cfg(windows)]
4300fn replace_file(source: &Path, target: &Path) -> Result<()> {
4301 let source_wide = source
4302 .as_os_str()
4303 .encode_wide()
4304 .chain(std::iter::once(0))
4305 .collect::<Vec<_>>();
4306 let target_wide = target
4307 .as_os_str()
4308 .encode_wide()
4309 .chain(std::iter::once(0))
4310 .collect::<Vec<_>>();
4311 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
4312 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
4315 if ok == 0 {
4316 return Err(std::io::Error::last_os_error())
4317 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()));
4318 }
4319 Ok(())
4320}
4321
4322#[cfg(not(windows))]
4323fn replace_file(source: &Path, target: &Path) -> Result<()> {
4324 fs::rename(source, target)
4325 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
4326}
4327
4328fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
4329 source_state_dir(app_root, source).join("rest-information.json")
4330}
4331
4332fn rest_manifest_cache_path_with_root(
4333 app_root: &Path,
4334 source: &SourceRecord,
4335 package_id: &str,
4336 version: &str,
4337 channel: &str,
4338) -> PathBuf {
4339 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
4340 let digest = sha256_hex(key.as_bytes());
4341 cache_root(app_root)
4342 .join("REST_M")
4343 .join(sanitize_path_segment(&source.identifier))
4344 .join(format!("{digest}.json"))
4345}
4346
4347fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
4348 cache_root(app_root)
4349 .join(bucket)
4350 .join(sanitize_path_segment(identifier))
4351}
4352
4353fn load_store(app_root: &Path) -> Result<SourceStore> {
4354 if uses_packaged_layout(app_root)
4355 && let Some(store) = load_packaged_store(app_root)?
4356 {
4357 return Ok(store);
4358 }
4359
4360 let path = store_path(app_root);
4361 if !path.exists() {
4362 let store = SourceStore::default();
4363 save_store(app_root, &store)?;
4364 return Ok(store);
4365 }
4366
4367 let bytes = fs::read(path).context("failed to read source store")?;
4368 serde_json::from_slice(&bytes).context("failed to parse source store")
4369}
4370
4371fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4372 if uses_system_winget_source_commands(app_root) {
4373 bail!("System WinGet source settings must be modified through winget.");
4374 }
4375
4376 if uses_packaged_layout(app_root) {
4377 return save_packaged_store(app_root, store);
4378 }
4379
4380 write_json(store_path(app_root), store)
4381}
4382
4383fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
4384 let user_sources_path = packaged_user_sources_path(app_root)?;
4385 let metadata_path = app_root.join("sources_metadata");
4386 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
4387 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
4388 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
4389}
4390
4391fn load_packaged_store_from_streams(
4392 user_sources_yaml: Option<&str>,
4393 metadata_yaml: Option<&str>,
4394) -> Result<Option<SourceStore>> {
4395 if is_secure_settings_stub(user_sources_yaml) {
4396 return load_system_winget_source_store().map(Some);
4397 }
4398
4399 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
4400}
4401
4402fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
4403 let user_sources_path = packaged_user_sources_path(app_root)?;
4404 if let Some(parent) = user_sources_path.parent() {
4405 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
4406 }
4407
4408 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
4409 .context("failed to write packaged user_sources stream")?;
4410 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
4411 .context("failed to write packaged source metadata")?;
4412 Ok(())
4413}
4414
4415fn uses_system_winget_source_commands(app_root: &Path) -> bool {
4416 if !uses_packaged_layout(app_root) {
4417 return false;
4418 }
4419
4420 #[cfg(windows)]
4421 {
4422 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
4423 return false;
4424 };
4425
4426 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
4427 is_secure_settings_stub(user_sources_yaml.as_deref())
4428 }
4429
4430 #[cfg(not(windows))]
4431 {
4432 false
4433 }
4434}
4435
4436fn is_secure_settings_stub(value: Option<&str>) -> bool {
4437 value
4438 .map(str::trim_start)
4439 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
4440}
4441
4442fn load_system_winget_source_store() -> Result<SourceStore> {
4443 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
4444 Ok(SourceStore {
4445 sources: parse_system_winget_source_export(&result.stdout)?,
4446 })
4447}
4448
4449fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
4450 run_checked_winget_source_command(
4451 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
4452 &format!("add WinGet source '{name}'"),
4453 )?;
4454 Ok(())
4455}
4456
4457fn system_winget_remove_source(name: &str) -> Result<()> {
4458 run_checked_winget_source_command(
4459 vec![
4460 "source".to_owned(),
4461 "remove".to_owned(),
4462 "--name".to_owned(),
4463 name.to_owned(),
4464 "--disable-interactivity".to_owned(),
4465 ],
4466 &format!("remove WinGet source '{name}'"),
4467 )?;
4468 Ok(())
4469}
4470
4471fn system_winget_reset_source(name: &str) -> Result<()> {
4472 run_checked_winget_source_command(
4473 vec![
4474 "source".to_owned(),
4475 "reset".to_owned(),
4476 "--name".to_owned(),
4477 name.to_owned(),
4478 "--force".to_owned(),
4479 "--disable-interactivity".to_owned(),
4480 ],
4481 &format!("reset WinGet source '{name}'"),
4482 )?;
4483 Ok(())
4484}
4485
4486fn system_winget_reset_sources() -> Result<()> {
4487 run_checked_winget_source_command(
4488 vec![
4489 "source".to_owned(),
4490 "reset".to_owned(),
4491 "--force".to_owned(),
4492 "--disable-interactivity".to_owned(),
4493 ],
4494 "reset WinGet sources",
4495 )?;
4496 Ok(())
4497}
4498
4499fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
4500 let mut args = vec!["source".to_owned(), "update".to_owned()];
4501 if let Some(name) = name {
4502 args.push("--name".to_owned());
4503 args.push(name.to_owned());
4504 }
4505
4506 args.push("--disable-interactivity".to_owned());
4507 let result = run_checked_winget_source_command(
4508 args,
4509 if name.is_some() {
4510 "update WinGet source"
4511 } else {
4512 "update WinGet sources"
4513 },
4514 )?;
4515 let detail = result.stdout.trim();
4516 Ok(if detail.is_empty() {
4517 "Done".to_owned()
4518 } else {
4519 detail.to_owned()
4520 })
4521}
4522
4523fn build_winget_source_export_arguments() -> Vec<String> {
4524 vec![
4525 "source".to_owned(),
4526 "export".to_owned(),
4527 "--disable-interactivity".to_owned(),
4528 ]
4529}
4530
4531fn build_winget_source_add_arguments(
4532 name: &str,
4533 arg: &str,
4534 kind: SourceKind,
4535 trust_level: &str,
4536 explicit: bool,
4537) -> Vec<String> {
4538 let mut args = vec![
4539 "source".to_owned(),
4540 "add".to_owned(),
4541 "--name".to_owned(),
4542 name.to_owned(),
4543 "--arg".to_owned(),
4544 arg.to_owned(),
4545 "--type".to_owned(),
4546 packaged_source_type(kind).to_owned(),
4547 "--disable-interactivity".to_owned(),
4548 ];
4549
4550 if !trust_level.trim().is_empty() {
4551 args.push("--trust-level".to_owned());
4552 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
4553 "trusted".to_owned()
4554 } else {
4555 "none".to_owned()
4556 });
4557 }
4558
4559 if explicit {
4560 args.push("--explicit".to_owned());
4561 }
4562
4563 args
4564}
4565
4566#[derive(Debug)]
4567struct WingetSourceCommandResult {
4568 exit_code: i32,
4569 stdout: String,
4570 stderr: String,
4571}
4572
4573type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
4574
4575static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
4576 RwLock::new(run_winget_source_command_process);
4577
4578fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
4579 let result = run_winget_source_command(&args)?;
4580 if result.exit_code == 0 {
4581 return Ok(result);
4582 }
4583
4584 let detail = [result.stderr.trim(), result.stdout.trim()]
4585 .into_iter()
4586 .filter(|value| !value.is_empty())
4587 .collect::<Vec<_>>()
4588 .join("\n");
4589 if detail.is_empty() {
4590 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
4591 }
4592
4593 bail!(
4594 "Failed to {action}; winget exited with code {}:\n{}",
4595 result.exit_code,
4596 detail
4597 );
4598}
4599
4600fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
4601 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
4602 .read()
4603 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
4604 runner(args)
4605}
4606
4607fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
4608 use std::process::Command;
4609
4610 let output = Command::new("winget")
4611 .args(args)
4612 .output()
4613 .context("failed to run winget source command")?;
4614
4615 Ok(WingetSourceCommandResult {
4616 exit_code: output.status.code().unwrap_or(-1),
4617 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
4618 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
4619 })
4620}
4621
4622fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
4623 let trimmed = output.trim();
4624 if trimmed.is_empty() {
4625 return Ok(Vec::new());
4626 }
4627
4628 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
4629 && let Some(sources) = parse_system_winget_source_export_value(&value)?
4630 {
4631 return Ok(order_sources(sources));
4632 }
4633
4634 let mut sources = Vec::new();
4635 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
4636 if line.is_empty() {
4637 continue;
4638 }
4639
4640 let value: JsonValue = serde_json::from_str(line)
4641 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
4642 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
4643 bail!("WinGet source export returned a non-source line: {line}");
4644 };
4645 sources.append(&mut parsed);
4646 }
4647
4648 Ok(order_sources(sources))
4649}
4650
4651fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
4652 match value {
4653 JsonValue::Array(items) => items
4654 .iter()
4655 .map(parse_system_winget_source_record)
4656 .collect::<Result<Vec<_>>>()
4657 .map(Some),
4658 JsonValue::Object(object) => {
4659 if let Some(JsonValue::Array(items)) = object.get("Sources") {
4660 items
4661 .iter()
4662 .map(parse_system_winget_source_record)
4663 .collect::<Result<Vec<_>>>()
4664 .map(Some)
4665 } else if object.contains_key("Name") {
4666 Ok(Some(vec![parse_system_winget_source_record(value)?]))
4667 } else {
4668 Ok(None)
4669 }
4670 }
4671 _ => Ok(None),
4672 }
4673}
4674
4675fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
4676 let object = value
4677 .as_object()
4678 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
4679 let name = required_json_string(object, "Name")?;
4680 let source_type = required_json_string(object, "Type")?;
4681 let arg = required_json_string(object, "Arg")?;
4682 let kind = parse_system_winget_source_kind(&source_type)?;
4683 let identifier = optional_json_string(object, "Identifier")
4684 .or_else(|| optional_json_string(object, "Data"))
4685 .unwrap_or_else(|| name.clone());
4686
4687 Ok(SourceRecord {
4688 name,
4689 kind,
4690 arg,
4691 identifier,
4692 trust_level: parse_system_winget_trust_level(object),
4693 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
4694 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
4695 last_update: None,
4696 source_version: None,
4697 })
4698}
4699
4700fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
4701 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
4702}
4703
4704fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
4705 match object.get(key)? {
4706 JsonValue::String(value) => Some(value.clone()),
4707 JsonValue::Number(value) => Some(value.to_string()),
4708 _ => None,
4709 }
4710}
4711
4712fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
4713 match object.get(key)? {
4714 JsonValue::Bool(value) => Some(*value),
4715 JsonValue::String(value) => value.parse().ok(),
4716 _ => None,
4717 }
4718}
4719
4720fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
4721 match object.get(key)? {
4722 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
4723 JsonValue::String(value) => value.parse().ok(),
4724 _ => None,
4725 }
4726}
4727
4728fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
4729 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4730 Ok(SourceKind::PreIndexed)
4731 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4732 Ok(SourceKind::Rest)
4733 } else {
4734 bail!("Unsupported WinGet source type '{value}'.")
4735 }
4736}
4737
4738fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
4739 let Some(value) = object.get("TrustLevel") else {
4740 return "None".to_owned();
4741 };
4742
4743 match value {
4744 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
4745 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
4746 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
4747 _ => "None".to_owned(),
4748 }
4749}
4750
4751fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
4752 value
4753 .as_str()
4754 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
4755}
4756
4757fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
4758 sources.sort_by(|left, right| {
4759 left.explicit
4760 .cmp(&right.explicit)
4761 .then_with(|| right.priority.cmp(&left.priority))
4762 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
4763 });
4764 sources
4765}
4766
4767fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
4768 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
4769 return None;
4770 }
4771
4772 let mut sources = SourceStore::default().sources;
4773 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
4774 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4775 continue;
4776 };
4777
4778 if yaml_bool(entry.get("IsTombstone")) {
4779 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
4780 continue;
4781 }
4782
4783 let Some(mapped) = map_packaged_source_entry(&entry) else {
4784 continue;
4785 };
4786
4787 if let Some(existing) = sources
4788 .iter_mut()
4789 .find(|source| source.name.eq_ignore_ascii_case(&name))
4790 {
4791 *existing = mapped;
4792 } else {
4793 sources.push(mapped);
4794 }
4795 }
4796
4797 for entry in parse_packaged_yaml_entries(metadata_yaml) {
4798 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
4799 continue;
4800 };
4801
4802 let Some(source) = sources
4803 .iter_mut()
4804 .find(|source| source.name.eq_ignore_ascii_case(&name))
4805 else {
4806 continue;
4807 };
4808
4809 if let Some(last_update) = entry
4810 .get("LastUpdate")
4811 .and_then(|value| value.as_deref())
4812 .and_then(parse_packaged_datetime)
4813 {
4814 source.last_update = Some(last_update);
4815 }
4816
4817 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
4818 && !source_version.is_empty()
4819 {
4820 source.source_version = Some(source_version);
4821 }
4822 }
4823
4824 Some(SourceStore { sources })
4825}
4826
4827fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
4828 let Some(yaml) = yaml else {
4829 return Vec::new();
4830 };
4831
4832 let mut entries = Vec::new();
4833 let mut current: Option<BTreeMap<String, Option<String>>> = None;
4834 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
4835 let line = raw_line.trim_end();
4836 let trimmed = line.trim();
4837 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
4838 continue;
4839 }
4840
4841 if let Some(remainder) = trimmed.strip_prefix("- ") {
4842 if let Some(entry) = current.take() {
4843 entries.push(entry);
4844 }
4845
4846 let mut entry = BTreeMap::new();
4847 add_packaged_yaml_key_value(&mut entry, remainder);
4848 current = Some(entry);
4849 continue;
4850 }
4851
4852 if let Some(entry) = current.as_mut() {
4853 add_packaged_yaml_key_value(entry, trimmed);
4854 }
4855 }
4856
4857 if let Some(entry) = current {
4858 entries.push(entry);
4859 }
4860
4861 entries
4862}
4863
4864fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
4865 let Some((key, value)) = line.split_once(':') else {
4866 return;
4867 };
4868
4869 entry.insert(
4870 key.trim().to_owned(),
4871 Some(unquote_yaml_scalar(value.trim()).to_owned()),
4872 );
4873}
4874
4875fn unquote_yaml_scalar(value: &str) -> &str {
4876 if value.len() >= 2 {
4877 let bytes = value.as_bytes();
4878 let first = bytes[0] as char;
4879 let last = bytes[value.len() - 1] as char;
4880 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
4881 return &value[1..value.len() - 1];
4882 }
4883 }
4884
4885 value
4886}
4887
4888fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
4889 let name = entry.get("Name").and_then(|value| value.clone())?;
4890 let source_type = entry.get("Type").and_then(|value| value.clone())?;
4891 let arg = entry.get("Arg").and_then(|value| value.clone())?;
4892 let kind = parse_packaged_source_kind(&source_type)?;
4893
4894 Some(SourceRecord {
4895 name: name.clone(),
4896 kind,
4897 arg,
4898 identifier: entry
4899 .get("Identifier")
4900 .and_then(|value| value.clone())
4901 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
4902 .unwrap_or(name),
4903 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
4904 explicit: yaml_bool(entry.get("Explicit")),
4905 priority: entry
4906 .get("Priority")
4907 .and_then(|value| value.as_deref())
4908 .and_then(|value| value.parse::<i32>().ok())
4909 .unwrap_or_default(),
4910 last_update: None,
4911 source_version: None,
4912 })
4913}
4914
4915fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
4916 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
4917 Some(SourceKind::PreIndexed)
4918 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
4919 Some(SourceKind::Rest)
4920 } else {
4921 None
4922 }
4923}
4924
4925fn parse_packaged_trust_level(value: Option<&str>) -> String {
4926 match value {
4927 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
4928 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
4929 _ => "None".to_owned(),
4930 }
4931}
4932
4933fn yaml_bool(value: Option<&Option<String>>) -> bool {
4934 value
4935 .and_then(|raw| raw.as_deref())
4936 .and_then(|raw| raw.parse::<bool>().ok())
4937 .unwrap_or(false)
4938}
4939
4940fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
4941 if let Ok(unix_seconds) = value.parse::<i64>() {
4942 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
4943 }
4944
4945 DateTime::parse_from_rfc3339(value)
4946 .ok()
4947 .map(|value| value.with_timezone(&Utc))
4948}
4949
4950fn render_packaged_sources_yaml(store: &SourceStore) -> String {
4951 let mut lines = vec!["Sources:".to_owned()];
4952 for source in &store.sources {
4953 lines.push(format!(" - Name: {}", source.name));
4954 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
4955 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
4956 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
4957 lines.push(format!(
4958 " TrustLevel: {}",
4959 if source.trust_level.eq_ignore_ascii_case("Trusted") {
4960 1
4961 } else {
4962 0
4963 }
4964 ));
4965 lines.push(format!(" Explicit: {}", source.explicit));
4966 lines.push(format!(" Priority: {}", source.priority));
4967 lines.push(" IsTombstone: false".to_owned());
4968 }
4969 lines.push(String::new());
4970 lines.join("\n")
4971}
4972
4973fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
4974 let mut lines = vec!["Sources:".to_owned()];
4975 for source in &store.sources {
4976 if source.last_update.is_none() && source.source_version.is_none() {
4977 continue;
4978 }
4979
4980 lines.push(format!(" - Name: {}", source.name));
4981 if let Some(last_update) = source.last_update {
4982 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
4983 }
4984 if let Some(source_version) = &source.source_version {
4985 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
4986 }
4987 }
4988 lines.push(String::new());
4989 lines.join("\n")
4990}
4991
4992fn yaml_scalar(value: &str) -> String {
4993 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
4994 format!("\"{}\"", value.replace('"', "\\\""))
4995 } else {
4996 value.to_owned()
4997 }
4998}
4999
5000fn uses_packaged_layout(app_root: &Path) -> bool {
5001 #[cfg(windows)]
5002 {
5003 try_get_packaged_local_state_root(app_root).is_some()
5004 }
5005
5006 #[cfg(not(windows))]
5007 {
5008 let _ = app_root;
5009 false
5010 }
5011}
5012
5013#[cfg(windows)]
5014fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
5015 let local_state = app_root.file_name()?.to_string_lossy();
5016 if !local_state.eq_ignore_ascii_case("LocalState") {
5017 return None;
5018 }
5019
5020 let family = app_root.parent()?.file_name()?.to_string_lossy();
5021 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
5022 return None;
5023 }
5024
5025 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
5026 if !packages.eq_ignore_ascii_case("Packages") {
5027 return None;
5028 }
5029
5030 Some(app_root.to_path_buf())
5031}
5032
5033#[cfg(windows)]
5034fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
5035 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
5036 Ok(packaged_secure_settings_root()?.join("user_sources"))
5037}
5038
5039#[cfg(not(windows))]
5040fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
5041 bail!("packaged WinGet source streams are only available on Windows")
5042}
5043
5044#[cfg(windows)]
5045fn packaged_secure_settings_root() -> Result<PathBuf> {
5046 let program_data = std::env::var_os("PROGRAMDATA")
5047 .map(PathBuf::from)
5048 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
5049 Ok(program_data
5050 .join("Microsoft")
5051 .join("WinGet")
5052 .join(current_user_sid()?)
5053 .join("settings")
5054 .join("pkg")
5055 .join(PACKAGED_NAME))
5056}
5057
5058#[cfg(windows)]
5059#[allow(clippy::multiple_unsafe_ops_per_block)]
5060fn current_user_sid() -> Result<String> {
5061 fn close_token(token: HANDLE) {
5062 if !token.is_null() {
5063 unsafe {
5065 CloseHandle(token);
5066 }
5067 }
5068 }
5069
5070 let mut token: HANDLE = std::ptr::null_mut();
5071 let current_process = unsafe { GetCurrentProcess() };
5073 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
5075 bail!("failed to open current process token")
5076 }
5077
5078 let mut length = 0;
5079 unsafe {
5081 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
5082 }
5083
5084 let element_size = size_of::<TOKEN_USER>();
5085 let element_count = (length as usize).div_ceil(element_size);
5086 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
5087 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
5089 close_token(token);
5090 bail!("failed to get current user token information")
5091 }
5092
5093 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
5095 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
5096 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
5098 close_token(token);
5099 bail!("failed to convert current user SID")
5100 }
5101
5102 let mut len = 0usize;
5103 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
5105 len += 1;
5106 }
5107
5108 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
5110 let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
5111 unsafe {
5113 LocalFree(sid_ptr.cast());
5114 }
5115 close_token(token);
5116 Ok(sid)
5117}
5118
5119fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
5120 app_root.join("Microsoft").join("Windows Package Manager")
5121}
5122
5123fn cache_root(app_root: &Path) -> PathBuf {
5124 if uses_packaged_layout(app_root) {
5125 packaged_file_cache_root(app_root)
5126 } else {
5127 default_cache_root_fallback()
5128 }
5129}
5130
5131fn default_cache_root_fallback() -> PathBuf {
5132 std::env::temp_dir().join("cache")
5133}
5134
5135fn packaged_source_type(kind: SourceKind) -> &'static str {
5136 match kind {
5137 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
5138 SourceKind::Rest => "Microsoft.Rest",
5139 }
5140}
5141
5142fn sanitize_path_segment(value: &str) -> String {
5143 value
5144 .chars()
5145 .map(|ch| match ch {
5146 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
5147 _ => ch,
5148 })
5149 .collect()
5150}
5151
5152fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
5153 if !path.exists() {
5154 return Ok(JsonMap::new());
5155 }
5156
5157 let bytes = fs::read(path).context("failed to read JSON file")?;
5158 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
5159 Ok(match value {
5160 JsonValue::Object(object) => object,
5161 _ => JsonMap::new(),
5162 })
5163}
5164
5165fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
5166 write_json(path, value)
5167}
5168
5169fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
5170 if let Some(parent) = path.parent() {
5171 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
5172 }
5173
5174 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
5175 fs::write(path, bytes).context("failed to write JSON file")
5176}
5177
5178fn merge_json_objects(
5179 current: &JsonMap<String, JsonValue>,
5180 update: &JsonMap<String, JsonValue>,
5181) -> JsonMap<String, JsonValue> {
5182 let mut merged = current.clone();
5183 for (key, value) in update {
5184 match (merged.get(key), value) {
5185 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
5186 merged.insert(
5187 key.clone(),
5188 JsonValue::Object(merge_json_objects(current_object, update_object)),
5189 );
5190 }
5191 _ => {
5192 merged.insert(key.clone(), value.clone());
5193 }
5194 }
5195 }
5196
5197 merged
5198}
5199
5200fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
5201 match expected {
5202 JsonValue::Object(expected_object) => match current {
5203 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
5204 current_object
5205 .get(key)
5206 .is_some_and(|current_child| json_contains(current_child, expected_child))
5207 }),
5208 _ => false,
5209 },
5210 JsonValue::Array(expected_array) => match current {
5211 JsonValue::Array(current_array) => {
5212 current_array.len() == expected_array.len()
5213 && current_array
5214 .iter()
5215 .zip(expected_array.iter())
5216 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
5217 }
5218 _ => false,
5219 },
5220 _ => current == expected,
5221 }
5222}
5223
5224fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
5225 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
5226 "none" | "default" => Ok("None".to_owned()),
5227 "trusted" => Ok("Trusted".to_owned()),
5228 other => bail!("unsupported source trust level: {other}"),
5229 }
5230}
5231
5232fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
5233 SUPPORTED_ADMIN_SETTINGS
5234 .iter()
5235 .copied()
5236 .find(|setting| setting.eq_ignore_ascii_case(name))
5237 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
5238}
5239
5240fn default_source_trust_level() -> String {
5241 "None".to_owned()
5242}
5243
5244fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
5245where
5246 F: FnMut(&SqlRow) -> Result<T>,
5247{
5248 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5249 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5250 let mut result = Vec::new();
5251
5252 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
5253 result.push(map(&row)?);
5254 }
5255
5256 Ok(result)
5257}
5258
5259fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
5260where
5261 F: FnOnce(&SqlRow) -> Result<T>,
5262{
5263 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
5264 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
5265
5266 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
5267 Some(row) => Ok(Some(map(&row)?)),
5268 None => Ok(None),
5269 }
5270}
5271
5272fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
5273 pollster::block_on(connection.execute(sql, params_from_iter(params)))
5274 .with_context(|| format!("failed to execute SQL statement: {sql}"))
5275}
5276
5277fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
5278 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
5279}
5280
5281fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
5282 row.get_value(index)
5283 .with_context(|| format!("failed to read SQL column {index}"))
5284}
5285
5286fn row_string(row: &SqlRow, index: usize) -> Result<String> {
5287 match row_value(row, index)? {
5288 SqlValue::Text(value) => Ok(value),
5289 SqlValue::Integer(value) => Ok(value.to_string()),
5290 SqlValue::Real(value) => Ok(value.to_string()),
5291 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
5292 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5293 }
5294}
5295
5296fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5297 match row_value(row, index)? {
5298 SqlValue::Null => Ok(None),
5299 SqlValue::Text(value) => Ok(Some(value)),
5300 SqlValue::Integer(value) => Ok(Some(value.to_string())),
5301 SqlValue::Real(value) => Ok(Some(value.to_string())),
5302 SqlValue::Blob(value) => Ok(Some(
5303 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
5304 )),
5305 }
5306}
5307
5308fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
5309 match row_value(row, index)? {
5310 SqlValue::Integer(value) => Ok(value),
5311 SqlValue::Text(value) => value
5312 .parse::<i64>()
5313 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
5314 SqlValue::Real(value) => f64_to_i64_checked(value, index),
5315 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5316 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
5317 }
5318}
5319
5320fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
5321 match row_value(row, index)? {
5322 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
5323 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
5324 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
5325 SqlValue::Integer(value) => Ok(format!("{value:x}")),
5326 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
5327 }
5328}
5329
5330fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
5331 match row_value(row, index)? {
5332 SqlValue::Null => Ok(None),
5333 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
5334 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
5335 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
5336 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
5337 }
5338}
5339
5340fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
5341 if !value.is_finite() {
5342 bail!("SQL column {index} contained a non-finite floating-point value");
5343 }
5344
5345 if value.fract() != 0.0 {
5346 bail!("SQL column {index} contained a non-integer floating-point value");
5347 }
5348
5349 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
5350 bail!("SQL column {index} contained a floating-point value outside the i64 range");
5351 }
5352
5353 format!("{value:.0}")
5354 .parse::<i64>()
5355 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
5356}
5357
5358fn bytes_to_hex(bytes: &[u8]) -> String {
5359 let mut result = String::with_capacity(bytes.len() * 2);
5360 for byte in bytes {
5361 result.push_str(&format!("{byte:02x}"));
5362 }
5363 result
5364}
5365
5366fn query_v2_matches(
5367 connection: &Connection,
5368 query: &PackageQuery,
5369 semantics: SearchSemantics,
5370) -> Result<(Vec<V2SearchRow>, bool)> {
5371 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
5372 let limit = source_fetch_results(query, semantics);
5373 let sql = format!(
5374 "SELECT rowid, id, name, moniker, latest_version, hash \
5375 FROM packages WHERE {where_clause} LIMIT {}",
5376 limit + 1
5377 );
5378 let mut rows = query_rows(
5379 connection,
5380 &sql,
5381 params.into_iter().map(SqlValue::Text).collect(),
5382 |row| {
5383 Ok(V2SearchRow {
5384 package_rowid: row_i64(row, 0)?,
5385 id: row_string(row, 1)?,
5386 name: row_string(row, 2)?,
5387 moniker: row_opt_string(row, 3)?,
5388 version: row_string(row, 4)?,
5389 package_hash: row_hex_string(row, 5)?,
5390 match_criteria: None,
5391 })
5392 },
5393 )?;
5394 for row in &mut rows {
5395 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
5396 }
5397 let truncated = rows.len() > limit;
5398 rows.truncate(limit);
5399 Ok((rows, truncated))
5400}
5401
5402fn query_v1_matches(
5403 connection: &Connection,
5404 query: &PackageQuery,
5405 semantics: SearchSemantics,
5406) -> Result<(Vec<V1SearchRow>, bool)> {
5407 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
5408 let limit = source_fetch_results(query, semantics);
5409 let sql = format!(
5410 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
5411 FROM manifest \
5412 JOIN ids ON manifest.id = ids.rowid \
5413 JOIN names ON manifest.name = names.rowid \
5414 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
5415 JOIN versions ON manifest.version = versions.rowid \
5416 JOIN channels ON manifest.channel = channels.rowid \
5417 WHERE {where_clause} LIMIT {}",
5418 limit + 1
5419 );
5420 let mut rows = query_rows(
5421 connection,
5422 &sql,
5423 params.into_iter().map(SqlValue::Text).collect(),
5424 |row| {
5425 Ok(V1SearchRow {
5426 manifest_rowid: row_i64(row, 0)?,
5427 package_rowid: row_i64(row, 1)?,
5428 version: row_string(row, 2)?,
5429 channel: row_string(row, 3)?,
5430 id: row_string(row, 4)?,
5431 name: row_string(row, 5)?,
5432 moniker: row_opt_string(row, 6)?,
5433 match_criteria: None,
5434 })
5435 },
5436 )?;
5437 for row in &mut rows {
5438 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
5439 }
5440 let truncated = rows.len() > limit;
5441 rows.truncate(limit);
5442 Ok((rows, truncated))
5443}
5444
5445fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
5446 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
5447
5448 for row in rows {
5449 match grouped.get(&row.package_rowid) {
5450 Some(existing)
5451 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
5452 != Ordering::Less => {}
5453 _ => {
5454 grouped.insert(row.package_rowid, row);
5455 }
5456 }
5457 }
5458
5459 grouped.into_values().collect()
5460}
5461
5462fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
5463 let has_hash = table_has_column(connection, "manifest", "hash")?;
5464 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
5465 let sql = format!(
5466 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
5467 FROM manifest \
5468 JOIN versions ON manifest.version = versions.rowid \
5469 JOIN channels ON manifest.channel = channels.rowid \
5470 WHERE manifest.id = ?1"
5471 );
5472 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
5473 Ok(V1VersionRow {
5474 version: row_string(row, 0)?,
5475 channel: row_string(row, 1)?,
5476 pathpart_id: row_i64(row, 2)?,
5477 manifest_hash: row_opt_hex_string(row, 3)?,
5478 })
5479 })
5480}
5481
5482fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
5483 let mut parts = Vec::new();
5484 let mut current = Some(pathpart_id);
5485
5486 while let Some(id) = current {
5487 let (parent, pathpart) = query_optional_value(
5488 connection,
5489 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
5490 vec![SqlValue::Integer(id)],
5491 |row| {
5492 Ok((
5493 row_opt_string(row, 0)?
5494 .and_then(|value| value.parse::<i64>().ok())
5495 .filter(|value| *value != 0),
5496 row_string(row, 1)?,
5497 ))
5498 },
5499 )?
5500 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
5501 parts.push(pathpart);
5502 current = parent;
5503 }
5504
5505 parts.reverse();
5506 Ok(parts.join("/"))
5507}
5508
5509fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
5510 format!(
5511 "packages/{}/{}/versionData.mszyml",
5512 package_id,
5513 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
5514 )
5515}
5516
5517fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
5518 let message = format!("{error:#}").to_ascii_lowercase();
5519 message.contains("no such table") && message.contains("packages")
5520}
5521
5522fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
5523 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
5524 row_string(row, 1)
5525 })? {
5526 if value.eq_ignore_ascii_case(column) {
5527 return Ok(true);
5528 }
5529 }
5530
5531 Ok(false)
5532}
5533
5534fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
5535 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
5536 let id_column = if v2 { "id" } else { "ids.id" };
5537 let name_column = if v2 { "name" } else { "names.name" };
5538 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
5539 let exact_match = query.exact || semantics == SearchSemantics::Single;
5540 let mut params = Vec::new();
5541
5542 if let Some(value) = &query.id {
5543 return (
5544 single_field_condition(id_column, value, exact_match, &mut params),
5545 params,
5546 );
5547 }
5548 if let Some(value) = &query.name {
5549 return (
5550 single_field_condition(name_column, value, exact_match, &mut params),
5551 params,
5552 );
5553 }
5554 if let Some(value) = &query.moniker {
5555 return (
5556 single_field_condition(moniker_column, value, exact_match, &mut params),
5557 params,
5558 );
5559 }
5560 if let Some(value) = &query.tag {
5561 return (
5562 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
5563 params,
5564 );
5565 }
5566 if let Some(value) = &query.command {
5567 return (
5568 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
5569 params,
5570 );
5571 }
5572 if let Some(value) = &query.query {
5573 if exact_match {
5574 let conditions = [
5575 single_field_condition(id_column, value, true, &mut params),
5576 single_field_condition(name_column, value, true, &mut params),
5577 single_field_condition(moniker_column, value, true, &mut params),
5578 ];
5579 return (format!("({})", conditions.join(" OR ")), params);
5580 }
5581
5582 let mut conditions = vec![
5583 single_field_condition(id_column, value, false, &mut params),
5584 single_field_condition(name_column, value, false, &mut params),
5585 single_field_condition(moniker_column, value, false, &mut params),
5586 ];
5587 if semantics == SearchSemantics::Many {
5588 conditions.push(mapped_field_condition(
5589 v2,
5590 "tag",
5591 value,
5592 rowid_column,
5593 false,
5594 &mut params,
5595 ));
5596 conditions.push(mapped_field_condition(
5597 v2,
5598 "command",
5599 value,
5600 rowid_column,
5601 false,
5602 &mut params,
5603 ));
5604 }
5605 return (format!("({})", conditions.join(" OR ")), params);
5606 }
5607
5608 ("1 = 1".to_owned(), Vec::new())
5609}
5610
5611fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
5612 params.push(match_parameter(value, exact));
5613 format!("{column} LIKE ?{}", params.len())
5614}
5615
5616fn mapped_field_condition(
5617 v2: bool,
5618 value_name: &str,
5619 value: &str,
5620 rowid_column: &str,
5621 exact: bool,
5622 params: &mut Vec<String>,
5623) -> String {
5624 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
5625 (
5626 format!("{value_name}s2"),
5627 format!("{value_name}s2_map"),
5628 value_name.to_owned(),
5629 "package".to_owned(),
5630 )
5631 } else {
5632 (
5633 format!("{value_name}s"),
5634 format!("{value_name}s_map"),
5635 value_name.to_owned(),
5636 "manifest".to_owned(),
5637 )
5638 };
5639 params.push(match_parameter(value, exact));
5640 let parameter = params.len();
5641 format!(
5642 "EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
5643 {map_table_name}.{value_name} = {table_name}.rowid \
5644 WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
5645 AND {table_name}.{map_value_column} LIKE ?{parameter})"
5646 )
5647}
5648
5649fn match_parameter(value: &str, exact: bool) -> String {
5650 if exact { value.to_owned() } else { format!("%{value}%") }
5651}
5652
5653fn build_rest_search_body(
5654 query: &PackageQuery,
5655 info: &RestInformation,
5656 semantics: SearchSemantics,
5657) -> Result<JsonValue> {
5658 let mut root = serde_json::Map::new();
5659 root.insert(
5660 "MaximumResults".to_owned(),
5661 JsonValue::from(source_fetch_results(query, semantics) as u64),
5662 );
5663 let exact_match = query.exact || semantics == SearchSemantics::Single;
5664
5665 if let Some(value) = &query.query {
5666 if semantics == SearchSemantics::Single {
5667 let mut filters = vec![
5668 rest_filter("PackageIdentifier", value, true),
5669 rest_filter("PackageName", value, true),
5670 rest_filter("Moniker", value, true),
5671 ];
5672 append_required_rest_filters(&mut filters, info);
5673 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5674 return Ok(JsonValue::Object(root));
5675 }
5676
5677 root.insert(
5678 "Query".to_owned(),
5679 serde_json::json!({
5680 "KeyWord": value,
5681 "MatchType": if exact_match { "Exact" } else { "Substring" },
5682 }),
5683 );
5684 }
5685
5686 let mut filters = Vec::new();
5687 if let Some(value) = &query.id {
5688 filters.push(rest_filter("PackageIdentifier", value, exact_match));
5689 }
5690 if let Some(value) = &query.name {
5691 filters.push(rest_filter("PackageName", value, exact_match));
5692 }
5693 if let Some(value) = &query.moniker {
5694 filters.push(rest_filter("Moniker", value, exact_match));
5695 }
5696 if let Some(value) = &query.tag {
5697 filters.push(rest_filter("Tag", value, true));
5698 }
5699 if let Some(value) = &query.command {
5700 filters.push(rest_filter("Command", value, true));
5701 }
5702 append_required_rest_filters(&mut filters, info);
5703
5704 if !filters.is_empty() {
5705 root.insert("Filters".to_owned(), JsonValue::Array(filters));
5706 }
5707
5708 Ok(JsonValue::Object(root))
5709}
5710
5711fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
5712 if info
5713 .required_package_match_fields
5714 .iter()
5715 .any(|field| field.eq_ignore_ascii_case("Market"))
5716 {
5717 filters.push(rest_filter("Market", &default_market(), true));
5718 }
5719}
5720
5721fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
5722 serde_json::json!({
5723 "PackageMatchField": field,
5724 "RequestMatch": {
5725 "KeyWord": value,
5726 "MatchType": if exact { "Exact" } else { "Substring" },
5727 }
5728 })
5729}
5730
5731fn max_results(query: &PackageQuery) -> usize {
5732 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
5733}
5734
5735fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
5736 match semantics {
5737 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
5738 SearchSemantics::Single => max_results(query),
5739 }
5740}
5741
5742fn infer_preindexed_match_criteria_v2(
5743 connection: &Connection,
5744 row: &V2SearchRow,
5745 query: &PackageQuery,
5746 semantics: SearchSemantics,
5747) -> Result<Option<String>> {
5748 infer_match_criteria(
5749 &row.id,
5750 &row.name,
5751 row.moniker.as_deref(),
5752 query,
5753 semantics,
5754 |value| {
5755 find_mapped_value_v2(
5756 connection,
5757 "tags2",
5758 "tags2_map",
5759 "tag",
5760 row.package_rowid,
5761 value,
5762 query.exact,
5763 )
5764 },
5765 |value| {
5766 find_mapped_value_v2(
5767 connection,
5768 "commands2",
5769 "commands2_map",
5770 "command",
5771 row.package_rowid,
5772 value,
5773 query.exact,
5774 )
5775 },
5776 )
5777}
5778
5779fn infer_preindexed_match_criteria_v1(
5780 connection: &Connection,
5781 row: &V1SearchRow,
5782 query: &PackageQuery,
5783 semantics: SearchSemantics,
5784) -> Result<Option<String>> {
5785 infer_match_criteria(
5786 &row.id,
5787 &row.name,
5788 row.moniker.as_deref(),
5789 query,
5790 semantics,
5791 |value| {
5792 find_mapped_value_v1(
5793 connection,
5794 "tags",
5795 "tags_map",
5796 "tag",
5797 row.manifest_rowid,
5798 value,
5799 query.exact,
5800 )
5801 },
5802 |value| {
5803 find_mapped_value_v1(
5804 connection,
5805 "commands",
5806 "commands_map",
5807 "command",
5808 row.manifest_rowid,
5809 value,
5810 query.exact,
5811 )
5812 },
5813 )
5814}
5815
5816fn infer_match_criteria<FTag, FCommand>(
5817 id: &str,
5818 name: &str,
5819 moniker: Option<&str>,
5820 query: &PackageQuery,
5821 semantics: SearchSemantics,
5822 find_tag: FTag,
5823 find_command: FCommand,
5824) -> Result<Option<String>>
5825where
5826 FTag: FnOnce(&str) -> Result<Option<String>>,
5827 FCommand: FnOnce(&str) -> Result<Option<String>>,
5828{
5829 if let Some(value) = &query.tag {
5830 return Ok(Some(format_match_criteria("Tag", value)));
5831 }
5832 if let Some(value) = &query.command {
5833 return Ok(Some(format_match_criteria("Command", value)));
5834 }
5835 if let Some(value) = &query.moniker {
5836 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
5837 }
5838 if semantics == SearchSemantics::Single {
5839 return Ok(None);
5840 }
5841 if let Some(value) = &query.query {
5842 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
5843 return Ok(None);
5844 }
5845 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
5846 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
5847 }
5848 if let Some(tag) = find_tag(value)? {
5849 return Ok(Some(format_match_criteria("Tag", &tag)));
5850 }
5851 if let Some(command) = find_command(value)? {
5852 return Ok(Some(format_match_criteria("Command", &command)));
5853 }
5854 }
5855 Ok(None)
5856}
5857
5858fn find_mapped_value_v2(
5859 connection: &Connection,
5860 table_name: &str,
5861 map_table_name: &str,
5862 value_name: &str,
5863 package_rowid: i64,
5864 query: &str,
5865 exact: bool,
5866) -> Result<Option<String>> {
5867 let sql = format!(
5868 "SELECT {table_name}.{value_name} FROM {map_table_name} \
5869 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5870 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
5871 );
5872 let values = query_rows(
5873 connection,
5874 &sql,
5875 vec![
5876 SqlValue::Integer(package_rowid),
5877 SqlValue::Text(match_parameter(query, exact)),
5878 ],
5879 |row| row_string(row, 0),
5880 )?;
5881 Ok(select_best_text_match(values, query, exact))
5882}
5883
5884fn find_mapped_value_v1(
5885 connection: &Connection,
5886 table_name: &str,
5887 map_table_name: &str,
5888 value_name: &str,
5889 manifest_rowid: i64,
5890 query: &str,
5891 exact: bool,
5892) -> Result<Option<String>> {
5893 let sql = format!(
5894 "SELECT {table_name}.{value_name} FROM {map_table_name} \
5895 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
5896 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
5897 );
5898 let values = query_rows(
5899 connection,
5900 &sql,
5901 vec![
5902 SqlValue::Integer(manifest_rowid),
5903 SqlValue::Text(match_parameter(query, exact)),
5904 ],
5905 |row| row_string(row, 0),
5906 )?;
5907 Ok(select_best_text_match(values, query, exact))
5908}
5909
5910fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
5911 if let Some(value) = &query.tag {
5912 return Some(format_match_criteria("Tag", value));
5913 }
5914 if let Some(value) = &query.command {
5915 return Some(format_match_criteria("Command", value));
5916 }
5917 if let Some(value) = &query.moniker {
5918 return Some(format_match_criteria("Moniker", value));
5919 }
5920 if semantics == SearchSemantics::Single {
5921 return None;
5922 }
5923 let value = query.query.as_deref()?;
5924 if json_string(item, "PackageIdentifier")
5925 .as_deref()
5926 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5927 || json_string(item, "PackageName")
5928 .as_deref()
5929 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
5930 {
5931 return None;
5932 }
5933 json_string(item, "Moniker")
5934 .filter(|candidate| matches_text(candidate, value, query.exact))
5935 .map(|candidate| format_match_criteria("Moniker", &candidate))
5936}
5937
5938fn format_match_criteria(field: &str, value: &str) -> String {
5939 format!("{field}: {value}")
5940}
5941
5942fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
5943 if exact {
5944 candidate.eq_ignore_ascii_case(query)
5945 } else {
5946 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
5947 }
5948}
5949
5950fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
5951 let mut score = 0;
5952
5953 if let Some(value) = &query.query {
5954 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
5955 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
5956 if let Some(moniker) = candidate.moniker.as_deref() {
5957 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
5958 }
5959 }
5960
5961 if let Some(value) = &query.id {
5962 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
5963 }
5964 if let Some(value) = &query.name {
5965 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
5966 }
5967 if let Some(value) = &query.moniker
5968 && let Some(moniker) = candidate.moniker.as_deref()
5969 {
5970 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
5971 }
5972 if let Some(value) = &query.tag
5973 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5974 {
5975 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
5976 }
5977 if let Some(value) = &query.command
5978 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
5979 {
5980 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
5981 }
5982
5983 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
5984 score += 5;
5985 }
5986 if search_match_has_unknown_version(candidate) {
5987 score = score.saturating_sub(10);
5988 }
5989
5990 score
5991}
5992
5993fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
5994 candidate
5995 .version
5996 .as_deref()
5997 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
5998}
5999
6000fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
6009 let mut document = match documents {
6010 JsonValue::Object(map) => map.clone(),
6011 _ => serde_json::Map::new(),
6012 };
6013
6014 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
6015 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
6016 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
6017 if !manifest.channel.is_empty() {
6018 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
6019 } else {
6020 document.remove("Channel");
6021 }
6022
6023 let mut single_installer = serde_json::Map::new();
6026 if let Some(arch) = installer.architecture.as_ref() {
6027 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
6028 }
6029 if let Some(installer_type) = installer.installer_type.as_ref() {
6030 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
6031 }
6032 if let Some(url) = installer.url.as_ref() {
6033 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
6034 }
6035 if let Some(sha) = installer.sha256.as_ref() {
6036 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
6037 }
6038 if let Some(scope) = installer.scope.as_ref() {
6039 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
6040 }
6041 if let Some(locale) = installer.locale.as_ref() {
6042 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
6043 }
6044 if let Some(product_code) = installer.product_code.as_ref() {
6045 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
6046 }
6047 if let Some(release_date) = installer.release_date.as_ref() {
6048 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
6049 }
6050
6051 document.insert(
6052 "Installers".to_owned(),
6053 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
6054 );
6055 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
6056
6057 let value = JsonValue::Object(document);
6058 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
6059}
6060
6061fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
6066 let mut filename = derive_download_filename_stem(manifest, installer);
6067 filename.push_str(".yaml");
6068 filename
6069}
6070
6071fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
6072 let mut stem = sanitize_filename_component(&manifest.name);
6073 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
6074 stem.push('_');
6075 stem.push_str(&sanitize_filename_component(&manifest.version));
6076 }
6077 if let Some(scope) = installer.scope.as_deref() {
6078 stem.push('_');
6079 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
6080 }
6081 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
6082 stem.push('_');
6083 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
6084
6085 let installer_type = installer.installer_type.as_deref().unwrap_or("");
6086 if !installer_type.is_empty() {
6087 stem.push('_');
6088 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
6089 }
6090 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
6091 stem.push('_');
6092 stem.push_str(&sanitize_filename_component(locale));
6093 }
6094
6095 stem
6096}
6097
6098fn capitalize_first(value: &str) -> String {
6099 let mut chars = value.chars();
6100 match chars.next() {
6101 Some(first) => {
6102 let mut out = first.to_ascii_uppercase().to_string();
6103 out.push_str(&chars.as_str().to_ascii_lowercase());
6104 out
6105 }
6106 None => String::new(),
6107 }
6108}
6109
6110fn format_architecture(value: &str) -> String {
6111 match value.to_ascii_lowercase().as_str() {
6112 "x64" => "X64".to_owned(),
6113 "x86" => "X86".to_owned(),
6114 "arm" => "Arm".to_owned(),
6115 "arm64" => "Arm64".to_owned(),
6116 "neutral" => "Neutral".to_owned(),
6117 other => capitalize_first(other),
6118 }
6119}
6120
6121fn sanitize_filename_component(value: &str) -> String {
6122 value
6123 .chars()
6124 .map(|ch| match ch {
6125 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
6126 ch if ch.is_control() => '_',
6127 ch => ch,
6128 })
6129 .collect()
6130}
6131
6132fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
6133 if manifest_path.is_file() {
6134 return Ok(manifest_path.to_path_buf());
6135 }
6136
6137 if !manifest_path.is_dir() {
6138 bail!("Manifest path not found: {}", manifest_path.display());
6139 }
6140
6141 let candidate = fs::read_dir(manifest_path)?
6142 .filter_map(|entry| entry.ok())
6143 .map(|entry| entry.path())
6144 .filter(|path| {
6145 path.extension()
6146 .and_then(|value| value.to_str())
6147 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
6148 })
6149 .min();
6150
6151 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
6152}
6153
6154fn score_text_match(
6155 candidate: &str,
6156 query: &str,
6157 exact: bool,
6158 exact_score: usize,
6159 prefix_score: usize,
6160 substring_score: usize,
6161) -> usize {
6162 if candidate.eq_ignore_ascii_case(query) {
6163 return exact_score;
6164 }
6165 if exact {
6166 return 0;
6167 }
6168
6169 let candidate_lower = candidate.to_ascii_lowercase();
6170 let query_lower = query.to_ascii_lowercase();
6171 if candidate_lower.starts_with(&query_lower) {
6172 prefix_score
6173 } else if candidate_lower.contains(&query_lower) {
6174 substring_score
6175 } else {
6176 0
6177 }
6178}
6179
6180fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
6181where
6182 I: IntoIterator<Item = String>,
6183{
6184 values
6185 .into_iter()
6186 .filter_map(|candidate| {
6187 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
6188 (score > 0).then_some((score, candidate))
6189 })
6190 .max_by(|(left_score, left_value), (right_score, right_value)| {
6191 left_score
6192 .cmp(right_score)
6193 .then_with(|| right_value.len().cmp(&left_value.len()))
6194 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
6195 })
6196 .map(|(_, candidate)| candidate)
6197}
6198
6199fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
6200 let (field, value) = criteria.split_once(": ")?;
6201 Some((field, value))
6202}
6203
6204fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
6205 let versions = item
6206 .get("Versions")
6207 .and_then(JsonValue::as_array)
6208 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
6209 let mut result = Vec::new();
6210 for version in versions {
6211 let value = json_string(version, "PackageVersion")
6212 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
6213 let channel = json_string(version, "Channel").unwrap_or_default();
6214 result.push(VersionKey {
6215 version: value,
6216 channel,
6217 });
6218 }
6219 Ok(result)
6220}
6221
6222fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
6223 let mut merged = YamlMapping::new();
6224 let mut documents = Vec::new();
6225 for document in serde_yaml::Deserializer::from_slice(bytes) {
6226 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
6227 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
6228 if let Some(mapping) = value.as_mapping() {
6229 merge_yaml_mapping(&mut merged, mapping);
6230 }
6231 }
6232
6233 let id = yaml_string_from_root(&merged, "PackageIdentifier")
6234 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
6235 let version =
6236 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
6237 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
6238
6239 let installers = parse_yaml_installers(&merged);
6240 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
6241 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6242
6243 Ok((
6244 Manifest {
6245 id,
6246 name,
6247 version,
6248 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
6249 publisher: yaml_localized_string(&merged, "Publisher"),
6250 description: yaml_localized_string(&merged, "Description")
6251 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
6252 moniker: yaml_string_from_root(&merged, "Moniker"),
6253 package_url: yaml_localized_string(&merged, "PackageUrl"),
6254 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
6255 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
6256 license: yaml_localized_string(&merged, "License"),
6257 license_url: yaml_localized_string(&merged, "LicenseUrl"),
6258 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
6259 author: yaml_localized_string(&merged, "Author"),
6260 copyright: yaml_localized_string(&merged, "Copyright"),
6261 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
6262 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
6263 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
6264 tags: yaml_string_list(&merged, "Tags"),
6265 agreements: yaml_agreement_list(&merged),
6266 package_dependencies: yaml_package_dependencies(&merged),
6267 documentation: yaml_documentation_list(&merged),
6268 installers,
6269 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6270 },
6271 collapse_structured_document(&JsonValue::Array(documents)),
6272 ))
6273}
6274
6275fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
6276 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
6277}
6278
6279fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
6280 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
6281 let data = root
6282 .get("Data")
6283 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
6284 let versions = data
6285 .get("Versions")
6286 .and_then(JsonValue::as_array)
6287 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
6288 let selected = versions
6289 .iter()
6290 .find(|item| {
6291 json_string(item, "PackageVersion").as_deref() == Some(version)
6292 && json_string(item, "Channel").unwrap_or_default() == channel
6293 })
6294 .or_else(|| versions.first())
6295 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
6296
6297 let default_locale = selected
6298 .get("DefaultLocale")
6299 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
6300 let name = json_string(default_locale, "PackageName")
6301 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
6302 let installer_switch_defaults = json_installer_switches(selected);
6303 let top_platforms = json_string_list(selected, "Platform");
6304 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
6305 let top_installer_type = json_string(selected, "InstallerType");
6306 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
6307 let top_nested_installer_files = json_nested_installer_files(selected);
6308
6309 let installers = selected
6310 .get("Installers")
6311 .and_then(JsonValue::as_array)
6312 .map(|items| {
6313 items
6314 .iter()
6315 .map(|item| Installer {
6316 platforms: {
6317 let platforms = json_string_list(item, "Platform");
6318 if platforms.is_empty() {
6319 top_platforms.clone()
6320 } else {
6321 platforms
6322 }
6323 },
6324 minimum_os_version: json_string(item, "MinimumOSVersion")
6325 .or_else(|| top_minimum_os_version.clone()),
6326 architecture: json_string(item, "Architecture"),
6327 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
6328 nested_installer_type: json_string(item, "NestedInstallerType")
6329 .or_else(|| top_nested_installer_type.clone()),
6330 nested_installer_files: {
6331 let files = json_nested_installer_files(item);
6332 if files.is_empty() {
6333 top_nested_installer_files.clone()
6334 } else {
6335 files
6336 }
6337 },
6338 url: json_string(item, "InstallerUrl"),
6339 sha256: json_string(item, "InstallerSha256"),
6340 product_code: json_string(item, "ProductCode"),
6341 locale: json_string(item, "InstallerLocale"),
6342 scope: json_string(item, "Scope"),
6343 release_date: json_string(item, "ReleaseDate"),
6344 package_family_name: json_string(item, "PackageFamilyName"),
6345 upgrade_code: json_string(item, "UpgradeCode"),
6346 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
6347 commands: json_string_list(item, "Commands"),
6348 package_dependencies: json_package_dependencies(item),
6349 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
6350 })
6351 .collect::<Vec<_>>()
6352 })
6353 .unwrap_or_default();
6354 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
6355 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
6356
6357 let manifest = Manifest {
6358 id: package_id.to_owned(),
6359 name,
6360 version: version.to_owned(),
6361 channel: channel.to_owned(),
6362 publisher: json_string(default_locale, "Publisher"),
6363 description: json_string(default_locale, "Description")
6364 .or_else(|| json_string(default_locale, "ShortDescription")),
6365 moniker: json_string(default_locale, "Moniker"),
6366 package_url: json_string(default_locale, "PackageUrl"),
6367 publisher_url: json_string(default_locale, "PublisherUrl"),
6368 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
6369 license: json_string(default_locale, "License"),
6370 license_url: json_string(default_locale, "LicenseUrl"),
6371 privacy_url: json_string(default_locale, "PrivacyUrl"),
6372 author: json_string(default_locale, "Author"),
6373 copyright: json_string(default_locale, "Copyright"),
6374 copyright_url: json_string(default_locale, "CopyrightUrl"),
6375 release_notes: json_string(default_locale, "ReleaseNotes"),
6376 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
6377 tags: json_string_list(default_locale, "Tags"),
6378 agreements: json_agreement_list(default_locale),
6379 package_dependencies: json_package_dependencies(selected),
6380 documentation: json_documentation_list(default_locale),
6381 installers,
6382 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
6383 };
6384
6385 Ok((
6386 manifest,
6387 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
6388 ))
6389}
6390
6391fn collapse_structured_document(document: &JsonValue) -> JsonValue {
6392 match document {
6393 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
6394 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
6395 }
6396 JsonValue::Array(documents) => merge_manifest_documents(documents),
6397 JsonValue::Object(_) if is_merged_manifest_document(document) => {
6398 merge_manifest_documents(&split_merged_manifest_document(document))
6399 }
6400 _ => document.clone(),
6401 }
6402}
6403
6404fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
6405 documents.iter().map(collapse_structured_document).collect()
6406}
6407
6408fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
6409 let version = documents.iter().find(|document| {
6410 document
6411 .get("ManifestType")
6412 .and_then(JsonValue::as_str)
6413 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
6414 });
6415 let default_locale = documents.iter().find(|document| {
6416 document
6417 .get("ManifestType")
6418 .and_then(JsonValue::as_str)
6419 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
6420 });
6421 let installer = documents.iter().find(|document| {
6422 document
6423 .get("ManifestType")
6424 .and_then(JsonValue::as_str)
6425 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
6426 });
6427
6428 let mut singleton = JsonMap::new();
6429 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
6430 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
6431 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
6432
6433 if !singleton.contains_key("PackageLocale")
6434 && let Some(package_locale) = default_locale
6435 .and_then(|document| json_string(document, "PackageLocale"))
6436 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
6437 {
6438 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6439 }
6440
6441 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
6442 singleton.insert(
6443 "ManifestVersion".to_owned(),
6444 JsonValue::String(
6445 installer
6446 .and_then(|document| json_string(document, "ManifestVersion"))
6447 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
6448 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
6449 .unwrap_or_else(|| "1.10.0".to_owned()),
6450 ),
6451 );
6452 singleton.remove("DefaultLocale");
6453
6454 JsonValue::Object(singleton)
6455}
6456
6457fn is_merged_manifest_document(document: &JsonValue) -> bool {
6458 document
6459 .get("ManifestType")
6460 .and_then(JsonValue::as_str)
6461 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
6462}
6463
6464fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
6465 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
6466 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
6467 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6468 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
6469
6470 let version_document = serde_json::json!({
6471 "PackageIdentifier": package_identifier,
6472 "PackageVersion": package_version,
6473 "DefaultLocale": package_locale,
6474 "ManifestType": "version",
6475 "ManifestVersion": manifest_version,
6476 });
6477
6478 let mut default_locale_document = project_manifest_document(
6479 merged,
6480 &[
6481 "PackageIdentifier",
6482 "PackageVersion",
6483 "PackageLocale",
6484 "Publisher",
6485 "PublisherUrl",
6486 "PublisherSupportUrl",
6487 "PrivacyUrl",
6488 "Author",
6489 "PackageName",
6490 "PackageUrl",
6491 "License",
6492 "LicenseUrl",
6493 "Copyright",
6494 "CopyrightUrl",
6495 "ShortDescription",
6496 "Description",
6497 "Moniker",
6498 "Tags",
6499 "Agreements",
6500 "ReleaseNotes",
6501 "ReleaseNotesUrl",
6502 "PurchaseUrl",
6503 "InstallationNotes",
6504 "Documentations",
6505 "Icons",
6506 ],
6507 );
6508 default_locale_document.insert(
6509 "PackageIdentifier".to_owned(),
6510 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6511 );
6512 default_locale_document.insert(
6513 "PackageVersion".to_owned(),
6514 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6515 );
6516 default_locale_document.insert(
6517 "PackageLocale".to_owned(),
6518 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
6519 );
6520 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6521 default_locale_document.insert(
6522 "ManifestVersion".to_owned(),
6523 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6524 );
6525
6526 let mut installer_document = project_manifest_document(
6527 merged,
6528 &[
6529 "PackageIdentifier",
6530 "PackageVersion",
6531 "Channel",
6532 "InstallerLocale",
6533 "Platform",
6534 "MinimumOSVersion",
6535 "InstallerType",
6536 "NestedInstallerType",
6537 "NestedInstallerFiles",
6538 "Scope",
6539 "InstallModes",
6540 "InstallerSwitches",
6541 "InstallerSuccessCodes",
6542 "ExpectedReturnCodes",
6543 "UpgradeBehavior",
6544 "Commands",
6545 "Protocols",
6546 "FileExtensions",
6547 "Dependencies",
6548 "PackageFamilyName",
6549 "ProductCode",
6550 "Capabilities",
6551 "RestrictedCapabilities",
6552 "Markets",
6553 "InstallerAbortsTerminal",
6554 "ReleaseDate",
6555 "InstallLocationRequired",
6556 "RequireExplicitUpgrade",
6557 "DisplayInstallWarnings",
6558 "UnsupportedOSArchitectures",
6559 "UnsupportedArguments",
6560 "AppsAndFeaturesEntries",
6561 "ElevationRequirement",
6562 "InstallationMetadata",
6563 "DownloadCommandProhibited",
6564 "RepairBehavior",
6565 "ArchiveBinariesDependOnPath",
6566 "Authentication",
6567 "Installers",
6568 ],
6569 );
6570 installer_document.insert(
6571 "PackageIdentifier".to_owned(),
6572 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
6573 );
6574 installer_document.insert(
6575 "PackageVersion".to_owned(),
6576 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
6577 );
6578 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6579 installer_document.insert(
6580 "ManifestVersion".to_owned(),
6581 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
6582 );
6583
6584 vec![
6585 version_document,
6586 JsonValue::Object(default_locale_document),
6587 JsonValue::Object(installer_document),
6588 ]
6589}
6590
6591fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
6592 let mut result = JsonMap::new();
6593 if let Some(source) = source.as_object() {
6594 for key in keys {
6595 if let Some(value) = source.get(*key) {
6596 result.insert((*key).to_owned(), value.clone());
6597 }
6598 }
6599 }
6600
6601 result
6602}
6603
6604fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
6605 if let Some(source) = source.and_then(JsonValue::as_object) {
6606 for key in keys {
6607 if let Some(value) = source.get(*key) {
6608 target.insert((*key).to_owned(), value.clone());
6609 }
6610 }
6611 }
6612}
6613
6614fn copy_all_manifest_keys_except(
6615 target: &mut JsonMap<String, JsonValue>,
6616 source: Option<&JsonValue>,
6617 excluded_keys: &[&str],
6618) {
6619 if let Some(source) = source.and_then(JsonValue::as_object) {
6620 for (key, value) in source {
6621 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
6622 target.insert(key.clone(), value.clone());
6623 }
6624 }
6625 }
6626}
6627
6628fn build_rest_manifest_documents(
6629 data: &JsonValue,
6630 installer_source: &JsonValue,
6631 default_locale: &JsonValue,
6632 package_id: &str,
6633 version: &str,
6634 channel: &str,
6635) -> JsonValue {
6636 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
6637 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
6638 let manifest_version = json_string(installer_source, "ManifestVersion")
6639 .or_else(|| json_string(default_locale, "ManifestVersion"))
6640 .or_else(|| json_string(data, "ManifestVersion"))
6641 .unwrap_or_else(|| "1.10.0".to_owned());
6642
6643 let mut version_document = JsonMap::new();
6644 version_document.insert(
6645 "PackageIdentifier".to_owned(),
6646 JsonValue::String(package_identifier.clone()),
6647 );
6648 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6649 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
6650 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
6651 version_document.insert(
6652 "ManifestVersion".to_owned(),
6653 JsonValue::String(manifest_version.clone()),
6654 );
6655
6656 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
6657 default_locale_document.insert(
6658 "PackageIdentifier".to_owned(),
6659 JsonValue::String(package_identifier.clone()),
6660 );
6661 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6662 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
6663 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
6664 default_locale_document.insert(
6665 "ManifestVersion".to_owned(),
6666 JsonValue::String(manifest_version.clone()),
6667 );
6668
6669 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
6670 installer_document.remove("DefaultLocale");
6671 installer_document.remove("Locales");
6672 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
6673 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
6674 if !channel.is_empty() {
6675 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
6676 }
6677 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
6678 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
6679
6680 let documents = vec![
6681 JsonValue::Object(version_document),
6682 JsonValue::Object(default_locale_document),
6683 JsonValue::Object(installer_document),
6684 ];
6685
6686 merge_manifest_documents(&documents)
6687}
6688
6689fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
6690 let base = installer_defaults(root);
6691 let base_switches = yaml_installer_switches(root);
6692 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
6693 let installers = items
6694 .iter()
6695 .filter_map(YamlValue::as_mapping)
6696 .map(|item| {
6697 let mut merged = base.clone();
6698 merge_yaml_mapping(&mut merged, item);
6699 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
6700 installer_from_yaml(&merged, switches)
6701 })
6702 .collect::<Vec<_>>();
6703 if !installers.is_empty() {
6704 return installers;
6705 }
6706 }
6707
6708 if base.is_empty() {
6709 Vec::new()
6710 } else {
6711 vec![installer_from_yaml(&base, base_switches)]
6712 }
6713}
6714
6715fn installer_defaults(root: &YamlMapping) -> YamlMapping {
6716 let keys = [
6717 "Architecture",
6718 "InstallerType",
6719 "NestedInstallerType",
6720 "NestedInstallerFiles",
6721 "InstallerUrl",
6722 "InstallerSha256",
6723 "ProductCode",
6724 "InstallerLocale",
6725 "Platform",
6726 "MinimumOSVersion",
6727 "Scope",
6728 "ReleaseDate",
6729 "PackageFamilyName",
6730 "UpgradeCode",
6731 "Commands",
6732 ];
6733 let mut defaults = YamlMapping::new();
6734 for key in keys {
6735 if let Some(value) = root.get(YamlValue::from(key)) {
6736 defaults.insert(YamlValue::from(key), value.clone());
6737 }
6738 }
6739 defaults
6740}
6741
6742fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
6743 Installer {
6744 architecture: yaml_string(root, "Architecture"),
6745 installer_type: yaml_string(root, "InstallerType"),
6746 nested_installer_type: yaml_string(root, "NestedInstallerType"),
6747 nested_installer_files: yaml_nested_installer_files(root),
6748 url: yaml_string(root, "InstallerUrl"),
6749 sha256: yaml_string(root, "InstallerSha256"),
6750 product_code: yaml_string(root, "ProductCode"),
6751 locale: yaml_string(root, "InstallerLocale"),
6752 scope: yaml_string(root, "Scope"),
6753 release_date: yaml_string(root, "ReleaseDate"),
6754 package_family_name: yaml_string(root, "PackageFamilyName"),
6755 upgrade_code: yaml_string(root, "UpgradeCode"),
6756 platforms: yaml_string_list(root, "Platform"),
6757 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
6758 switches,
6759 commands: yaml_string_list(root, "Commands"),
6760 package_dependencies: yaml_package_dependencies(root),
6761 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
6762 }
6763}
6764
6765fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
6768 let Some(value) = root.get(YamlValue::from(key)) else {
6769 return false;
6770 };
6771 match value {
6772 YamlValue::Bool(b) => *b,
6773 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
6774 _ => false,
6775 }
6776}
6777
6778fn json_bool(value: &JsonValue, key: &str) -> bool {
6779 match value.get(key) {
6780 Some(JsonValue::Bool(b)) => *b,
6781 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
6782 _ => false,
6783 }
6784}
6785
6786fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
6787 let mapping = root
6788 .get(YamlValue::from("InstallerSwitches"))
6789 .or_else(|| root.get(YamlValue::from("Switches")))
6790 .and_then(YamlValue::as_mapping);
6791 yaml_installer_switches_from_mapping(mapping)
6792}
6793
6794fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
6795 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
6796
6797 InstallerSwitches {
6798 silent: string("Silent"),
6799 silent_with_progress: string("SilentWithProgress"),
6800 interactive: string("Interactive"),
6801 custom: string("Custom"),
6802 log: string("Log"),
6803 install_location: string("InstallLocation"),
6804 }
6805}
6806
6807fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
6808 yaml_string(root, key).or_else(|| {
6809 root.get(YamlValue::from("DefaultLocale"))
6810 .and_then(YamlValue::as_mapping)
6811 .and_then(|mapping| yaml_string(mapping, key))
6812 })
6813}
6814
6815fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
6816 yaml_string(root, key)
6817}
6818
6819fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
6820 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
6821}
6822
6823fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
6824 match value {
6825 YamlValue::Null => None,
6826 YamlValue::Bool(value) => Some(value.to_string()),
6827 YamlValue::Number(value) => Some(value.to_string()),
6828 YamlValue::String(value) => Some(value.clone()),
6829 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
6830 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
6831 }
6832}
6833
6834fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
6835 root.get(YamlValue::from(key))
6836 .and_then(YamlValue::as_sequence)
6837 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
6838 .unwrap_or_default()
6839}
6840
6841fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
6842 root.get(YamlValue::from("Documentations"))
6843 .and_then(YamlValue::as_sequence)
6844 .map(|items| {
6845 items
6846 .iter()
6847 .filter_map(YamlValue::as_mapping)
6848 .filter_map(|item| {
6849 let url = yaml_string(item, "DocumentUrl")?;
6850 Some(Documentation {
6851 label: yaml_string(item, "DocumentLabel"),
6852 url,
6853 })
6854 })
6855 .collect()
6856 })
6857 .unwrap_or_default()
6858}
6859
6860fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
6861 root.get(YamlValue::from("Agreements"))
6862 .and_then(YamlValue::as_sequence)
6863 .map(|items| {
6864 items
6865 .iter()
6866 .filter_map(YamlValue::as_mapping)
6867 .map(|item| PackageAgreement {
6868 label: yaml_string(item, "AgreementLabel"),
6869 text: yaml_string(item, "Agreement"),
6870 url: yaml_string(item, "AgreementUrl"),
6871 })
6872 .collect()
6873 })
6874 .unwrap_or_default()
6875}
6876
6877fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
6878 root.get(YamlValue::from("Dependencies"))
6879 .and_then(YamlValue::as_mapping)
6880 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
6881 .and_then(YamlValue::as_sequence)
6882 .map(|items| {
6883 items
6884 .iter()
6885 .filter_map(YamlValue::as_mapping)
6886 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
6887 .collect()
6888 })
6889 .unwrap_or_default()
6890}
6891
6892fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
6893 root.get(YamlValue::from("NestedInstallerFiles"))
6894 .and_then(YamlValue::as_sequence)
6895 .map(|items| {
6896 items
6897 .iter()
6898 .filter_map(YamlValue::as_mapping)
6899 .filter_map(|item| {
6900 let relative = yaml_string(item, "RelativeFilePath")?;
6901 Some(NestedInstallerFile {
6902 relative_file_path: relative,
6903 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
6904 })
6905 })
6906 .collect()
6907 })
6908 .unwrap_or_default()
6909}
6910
6911fn json_string(value: &JsonValue, key: &str) -> Option<String> {
6912 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
6913}
6914
6915fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
6916 value
6917 .get(key)
6918 .and_then(JsonValue::as_array)
6919 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
6920 .unwrap_or_default()
6921}
6922
6923fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
6924 value
6925 .get("Documentations")
6926 .and_then(JsonValue::as_array)
6927 .map(|items| {
6928 items
6929 .iter()
6930 .filter_map(|item| {
6931 let url = json_string(item, "DocumentUrl")?;
6932 Some(Documentation {
6933 label: json_string(item, "DocumentLabel"),
6934 url,
6935 })
6936 })
6937 .collect()
6938 })
6939 .unwrap_or_default()
6940}
6941
6942fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
6943 value
6944 .get("Agreements")
6945 .and_then(JsonValue::as_array)
6946 .map(|items| {
6947 items
6948 .iter()
6949 .map(|item| PackageAgreement {
6950 label: json_string(item, "AgreementLabel"),
6951 text: json_string(item, "Agreement"),
6952 url: json_string(item, "AgreementUrl"),
6953 })
6954 .collect()
6955 })
6956 .unwrap_or_default()
6957}
6958
6959fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
6960 value
6961 .get("Dependencies")
6962 .and_then(JsonValue::as_object)
6963 .and_then(|deps| deps.get("PackageDependencies"))
6964 .and_then(JsonValue::as_array)
6965 .map(|items| {
6966 items
6967 .iter()
6968 .filter_map(|item| json_string(item, "PackageIdentifier"))
6969 .collect()
6970 })
6971 .unwrap_or_default()
6972}
6973
6974fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
6975 value
6976 .get("NestedInstallerFiles")
6977 .and_then(JsonValue::as_array)
6978 .map(|items| {
6979 items
6980 .iter()
6981 .filter_map(|item| {
6982 let relative = json_string(item, "RelativeFilePath")?;
6983 Some(NestedInstallerFile {
6984 relative_file_path: relative,
6985 portable_command_alias: json_string(item, "PortableCommandAlias"),
6986 })
6987 })
6988 .collect()
6989 })
6990 .unwrap_or_default()
6991}
6992
6993fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
6994 let switches = value
6995 .get("InstallerSwitches")
6996 .or_else(|| value.get("Switches"))
6997 .and_then(JsonValue::as_object);
6998
6999 let string = |key: &str| {
7000 switches
7001 .and_then(|switches| switches.get(key))
7002 .and_then(JsonValue::as_str)
7003 .map(str::to_owned)
7004 };
7005
7006 InstallerSwitches {
7007 silent: string("Silent"),
7008 silent_with_progress: string("SilentWithProgress"),
7009 interactive: string("Interactive"),
7010 custom: string("Custom"),
7011 log: string("Log"),
7012 install_location: string("InstallLocation"),
7013 }
7014}
7015
7016fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
7017 for (key, value) in source {
7018 target.insert(key.clone(), value.clone());
7019 }
7020}
7021
7022fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
7023 let reader = Cursor::new(bytes.to_vec());
7024 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
7025 let mut file = archive
7026 .by_name(member_name)
7027 .with_context(|| format!("zip payload missing {member_name}"))?;
7028 let mut output = Vec::new();
7029 file.read_to_end(&mut output)
7030 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
7031 Ok(output)
7032}
7033
7034fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
7035 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
7036 return Ok(output);
7037 }
7038
7039 if !bytes.starts_with(b"CK")
7040 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
7041 {
7042 return decompress_mszyml_payload(&bytes[offset + 2..]);
7043 }
7044
7045 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
7046}
7047
7048fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
7049 let mut decoder = flate2::read::DeflateDecoder::new(payload);
7050 let mut output = String::new();
7051 decoder.read_to_string(&mut output)?;
7052 Ok(output)
7053}
7054
7055fn cache_control_max_age(response: &Response) -> u64 {
7056 response
7057 .headers()
7058 .get(reqwest::header::CACHE_CONTROL)
7059 .and_then(|value| value.to_str().ok())
7060 .and_then(|header| {
7061 header.split(',').find_map(|part| {
7062 let trimmed = part.trim();
7063 trimmed
7064 .strip_prefix("max-age=")
7065 .and_then(|value| value.parse::<u64>().ok())
7066 })
7067 })
7068 .unwrap_or(60)
7069}
7070
7071fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
7072 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
7073 let (major, minor) = major_minor(candidate);
7074 server_versions.iter().any(|version| {
7075 let (server_major, server_minor) = major_minor(version);
7076 server_major == major && server_minor == minor
7077 })
7078 })
7079}
7080
7081fn major_minor(version: &str) -> (String, String) {
7082 let mut parts = version.split('.');
7083 (
7084 parts.next().unwrap_or_default().to_owned(),
7085 parts.next().unwrap_or_default().to_owned(),
7086 )
7087}
7088
7089fn default_market() -> String {
7090 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
7091}
7092
7093fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
7094 let requested_locale = query.locale.as_deref();
7095 let requested_architecture = query.installer_architecture.as_deref();
7096 let requested_type = query.installer_type.as_deref();
7097 let requested_scope = query.install_scope.as_deref();
7098 let requested_platform = query.platform.as_deref();
7099 let requested_os_version = query.os_version.as_deref();
7100 let system_architecture = current_architecture();
7101 let system_platform = current_platform();
7102 let current_os_version = current_os_version();
7103
7104 installers
7105 .iter()
7106 .enumerate()
7107 .filter(|(_, installer)| {
7108 installer_matches_requested(
7109 installer,
7110 requested_type,
7111 requested_scope,
7112 requested_platform,
7113 requested_os_version,
7114 system_platform,
7115 current_os_version.as_deref(),
7116 )
7117 })
7118 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
7119 .max_by_key(|(index, installer)| {
7120 (
7121 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
7122 std::cmp::Reverse(*index),
7123 )
7124 })
7125 .map(|(_, installer)| installer)
7126 .cloned()
7127}
7128
7129fn installer_matches_requested(
7130 installer: &Installer,
7131 requested_type: Option<&str>,
7132 requested_scope: Option<&str>,
7133 requested_platform: Option<&str>,
7134 requested_os_version: Option<&str>,
7135 system_platform: Option<&str>,
7136 current_os_version: Option<&str>,
7137) -> bool {
7138 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
7139 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
7140 && installer_matches_platform(installer, requested_platform, system_platform)
7141 && installer_matches_os_version(installer, requested_os_version, current_os_version)
7142}
7143
7144fn installer_matches_platform(
7145 installer: &Installer,
7146 requested_platform: Option<&str>,
7147 system_platform: Option<&str>,
7148) -> bool {
7149 if installer.platforms.is_empty() {
7150 return true;
7151 }
7152
7153 let Some(effective_platform) = requested_platform.or(system_platform) else {
7154 return true;
7155 };
7156
7157 installer
7158 .platforms
7159 .iter()
7160 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
7161}
7162
7163fn installer_matches_os_version(
7164 installer: &Installer,
7165 requested_os_version: Option<&str>,
7166 current_os_version: Option<&str>,
7167) -> bool {
7168 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
7169 return true;
7170 };
7171
7172 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
7173 return true;
7174 };
7175
7176 match (
7177 parse_dotted_version(minimum_os_version),
7178 parse_dotted_version(actual_os_version),
7179 ) {
7180 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
7181 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
7182 }
7183}
7184
7185fn installer_matches_architecture(
7186 installer: &Installer,
7187 requested_architecture: Option<&str>,
7188 system_architecture: &str,
7189) -> bool {
7190 let Some(architecture) = installer.architecture.as_deref() else {
7191 return true;
7192 };
7193
7194 let architecture = architecture.to_ascii_lowercase();
7195 if let Some(requested) = requested_architecture {
7196 return architecture.eq_ignore_ascii_case(requested);
7197 }
7198
7199 preferred_architectures(system_architecture)
7200 .iter()
7201 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
7202}
7203
7204fn installer_rank(
7205 installer: &Installer,
7206 requested_locale: Option<&str>,
7207 requested_architecture: Option<&str>,
7208 system_architecture: &str,
7209) -> (i32, i32, i32) {
7210 let architecture_rank = architecture_rank(
7211 installer.architecture.as_deref(),
7212 requested_architecture.unwrap_or(system_architecture),
7213 requested_architecture.is_some(),
7214 system_architecture,
7215 );
7216 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
7217 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
7218 (architecture_rank, locale_rank, command_rank)
7219}
7220
7221fn architecture_rank(
7222 installer_architecture: Option<&str>,
7223 preferred_architecture: &str,
7224 strict: bool,
7225 system_architecture: &str,
7226) -> i32 {
7227 let Some(value) = installer_architecture else {
7228 return 0;
7229 };
7230 if value.eq_ignore_ascii_case(preferred_architecture) {
7231 return 5;
7232 }
7233 if value.eq_ignore_ascii_case("neutral") {
7234 return 4;
7235 }
7236 if strict {
7237 return -1;
7238 }
7239
7240 preferred_architectures(system_architecture)
7241 .iter()
7242 .rev()
7243 .position(|candidate| value.eq_ignore_ascii_case(candidate))
7244 .and_then(|index| i32::try_from(index).ok())
7245 .and_then(|index| index.checked_add(1))
7246 .unwrap_or(-1)
7247}
7248
7249fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
7253 if manifest.installers.is_empty() {
7254 return true;
7255 }
7256 let allowed = preferred_architectures(current_architecture());
7257 manifest.installers.iter().any(|installer| {
7258 let arch = installer.architecture.as_deref().unwrap_or("neutral");
7259 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
7260 })
7261}
7262
7263fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
7264 match system_architecture {
7265 "arm64" => &["arm64", "neutral", "x64", "x86"],
7266 "x64" => &["x64", "neutral", "x86"],
7267 "x86" => &["x86", "neutral"],
7268 _ => &["neutral"],
7269 }
7270}
7271
7272fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
7273 match (installer_locale, requested_locale) {
7274 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
7275 (Some(installer), Some(requested))
7276 if installer
7277 .split('-')
7278 .next()
7279 .zip(requested.split('-').next())
7280 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
7281 {
7282 2
7283 }
7284 (None, Some(_)) => 1,
7285 (Some(_), None) => 1,
7286 (None, None) => 0,
7287 _ => -1,
7288 }
7289}
7290
7291fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
7292 match requested {
7293 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
7294 None => true,
7295 }
7296}
7297
7298#[cfg(windows)]
7299fn current_architecture() -> &'static str {
7300 use std::sync::OnceLock;
7301 static CELL: OnceLock<&'static str> = OnceLock::new();
7302 CELL.get_or_init(|| {
7303 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
7308 let mut process_machine: u16 = 0;
7309 let mut native_machine: u16 = 0;
7310 let handle = unsafe { GetCurrentProcess() };
7313 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
7315 if ok != 0 {
7316 match native_machine {
7317 0xAA64 => return "arm64",
7318 0x8664 => return "x64",
7319 0x014C => return "x86",
7320 _ => {}
7321 }
7322 }
7323 compiled_architecture()
7324 })
7325}
7326
7327#[cfg(not(windows))]
7328fn current_architecture() -> &'static str {
7329 compiled_architecture()
7330}
7331
7332fn compiled_architecture() -> &'static str {
7333 match std::env::consts::ARCH {
7334 "x86_64" => "x64",
7335 "x86" => "x86",
7336 "aarch64" => "arm64",
7337 _ => "neutral",
7338 }
7339}
7340
7341fn current_platform() -> Option<&'static str> {
7342 if cfg!(windows) { Some("Windows.Desktop") } else { None }
7343}
7344
7345#[cfg(windows)]
7346fn current_os_version() -> Option<String> {
7347 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7348 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
7349 let build: String = key.get_value("CurrentBuildNumber").ok()?;
7350 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
7351 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
7352 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
7353 Some(format!("{major}.{minor}.{build}.{ubr}"))
7354}
7355
7356#[cfg(not(windows))]
7357fn current_os_version() -> Option<String> {
7358 None
7359}
7360
7361fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
7362 let mut parsed = Vec::new();
7363 for component in version.split('.') {
7364 if component.is_empty() {
7365 continue;
7366 }
7367 parsed.push(component.parse().ok()?);
7368 }
7369
7370 if parsed.is_empty() { None } else { Some(parsed) }
7371}
7372
7373fn select_v1_version(
7374 versions: &[V1VersionRow],
7375 requested_version: Option<&str>,
7376 requested_channel: Option<&str>,
7377 package_id: &str,
7378 source_name: &str,
7379) -> Result<V1VersionRow> {
7380 let mut ordered = versions.to_vec();
7381 ordered.sort_by(|left, right| {
7382 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7383 });
7384
7385 if let Some(version) = requested_version {
7386 let channel = requested_channel.unwrap_or_default();
7387 return ordered
7388 .iter()
7389 .find(|candidate| {
7390 candidate.version.eq_ignore_ascii_case(version)
7391 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7392 })
7393 .cloned()
7394 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7395 }
7396
7397 if let Some(channel) = requested_channel {
7398 return ordered
7399 .iter()
7400 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7401 .cloned()
7402 .ok_or_else(|| {
7403 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7404 });
7405 }
7406
7407 ordered
7408 .into_iter()
7409 .next()
7410 .ok_or_else(|| anyhow!("package had no versions"))
7411}
7412
7413fn select_v2_version(
7414 versions: &[PackageVersionDataEntry],
7415 requested_version: Option<&str>,
7416 requested_channel: Option<&str>,
7417 package_id: &str,
7418 source_name: &str,
7419) -> Result<PackageVersionDataEntry> {
7420 let mut ordered = versions.to_vec();
7421 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
7422
7423 if let Some(channel) = requested_channel {
7424 bail!(
7425 "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."
7426 );
7427 }
7428
7429 if let Some(version) = requested_version {
7430 return ordered
7431 .iter()
7432 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
7433 .cloned()
7434 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7435 }
7436
7437 ordered
7438 .into_iter()
7439 .next()
7440 .ok_or_else(|| anyhow!("package had no V2 versions"))
7441}
7442
7443fn missing_package_version(
7444 package_id: &str,
7445 source_name: &str,
7446 requested_version: &str,
7447 requested_channel: Option<&str>,
7448) -> anyhow::Error {
7449 anyhow!(MissingPackageVersionError {
7450 package_id: package_id.to_owned(),
7451 source_name: source_name.to_owned(),
7452 requested_version: requested_version.to_owned(),
7453 requested_channel: requested_channel.map(str::to_owned),
7454 })
7455}
7456
7457fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
7458 if let Some(refresh_error) = refresh.error.as_deref() {
7459 let missing_error = error.to_string();
7460 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
7461 }
7462
7463 Err(error)
7464}
7465
7466fn select_rest_version<'a>(
7467 versions: &'a [VersionKey],
7468 requested_version: Option<&str>,
7469 requested_channel: Option<&str>,
7470 package_id: &str,
7471 source_name: &str,
7472) -> Result<&'a VersionKey> {
7473 if let Some(version) = requested_version {
7474 let channel = requested_channel.unwrap_or_default();
7475 return versions
7476 .iter()
7477 .find(|candidate| {
7478 candidate.version.eq_ignore_ascii_case(version)
7479 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
7480 })
7481 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
7482 }
7483
7484 if let Some(channel) = requested_channel {
7485 return versions
7486 .iter()
7487 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
7488 .ok_or_else(|| {
7489 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
7490 });
7491 }
7492
7493 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
7494}
7495
7496fn sort_versions_desc(versions: &mut [VersionKey]) {
7497 versions.sort_by(|left, right| {
7498 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
7499 });
7500}
7501
7502fn compare_version_and_channel(
7503 left_version: &str,
7504 left_channel: &str,
7505 right_version: &str,
7506 right_channel: &str,
7507) -> Ordering {
7508 compare_version(left_version, right_version).then_with(|| {
7509 left_channel
7510 .to_ascii_lowercase()
7511 .cmp(&right_channel.to_ascii_lowercase())
7512 })
7513}
7514
7515fn compare_version(left: &str, right: &str) -> Ordering {
7516 let left_parts = tokenize_version(left);
7517 let right_parts = tokenize_version(right);
7518 let max_len = left_parts.len().max(right_parts.len());
7519
7520 for index in 0..max_len {
7521 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
7522 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
7523 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
7524
7525 let ordering = if let Some((left_number, right_number)) = numeric {
7526 left_number.cmp(&right_number)
7527 } else {
7528 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
7529 };
7530
7531 if ordering != Ordering::Equal {
7532 return ordering;
7533 }
7534 }
7535
7536 Ordering::Equal
7537}
7538
7539fn tokenize_version(value: &str) -> Vec<String> {
7540 let mut parts = Vec::new();
7541 let mut current = String::new();
7542 let mut current_is_digit = None;
7543
7544 for ch in value.chars() {
7545 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
7546 if !current.is_empty() {
7547 parts.push(std::mem::take(&mut current));
7548 }
7549 current_is_digit = None;
7550 continue;
7551 }
7552
7553 let is_digit = ch.is_ascii_digit();
7554 match current_is_digit {
7555 Some(previous) if previous != is_digit => {
7556 parts.push(std::mem::take(&mut current));
7557 current.push(ch);
7558 current_is_digit = Some(is_digit);
7559 }
7560 Some(_) => current.push(ch),
7561 None => {
7562 current.push(ch);
7563 current_is_digit = Some(is_digit);
7564 }
7565 }
7566 }
7567
7568 if !current.is_empty() {
7569 parts.push(current);
7570 }
7571
7572 parts
7573}
7574
7575fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
7576 if !hash_matches(Some(expected_hash), bytes) {
7577 bail!("downloaded file hash did not match expected SHA256");
7578 }
7579 Ok(())
7580}
7581
7582fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
7583 let Some(expected_hash) = expected_hash else {
7584 return Ok(());
7585 };
7586 if ignore_security_hash {
7587 return Ok(());
7588 }
7589
7590 if !hash_matches(Some(expected_hash), bytes) {
7591 let actual = sha256_hex(bytes);
7592 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
7593 }
7594 Ok(())
7595}
7596
7597fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
7598 expected_hash
7599 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
7600 .unwrap_or(true)
7601}
7602
7603fn sha256_hex(bytes: &[u8]) -> String {
7604 let digest = Sha256::digest(bytes);
7605 let mut output = String::with_capacity(digest.len() * 2);
7606 for byte in digest {
7607 output.push_str(&format!("{byte:02X}"));
7608 }
7609 output
7610}
7611
7612fn pins_db_path(app_root: &Path) -> PathBuf {
7613 if uses_packaged_layout(app_root) {
7614 app_root.join("pinning.db")
7615 } else {
7616 app_root.join("pins.db")
7617 }
7618}
7619
7620fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
7621 let mut has_current_column = false;
7622 let mut has_legacy_column = false;
7623
7624 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
7625 match column_name.as_str() {
7626 "type" => has_current_column = true,
7627 "pin_type" => has_legacy_column = true,
7628 _ => {}
7629 }
7630 }
7631
7632 Ok(if has_current_column {
7633 Some("type")
7634 } else if has_legacy_column {
7635 Some("pin_type")
7636 } else {
7637 None
7638 })
7639}
7640
7641fn decode_pin_type(pin_type_int: i64) -> PinType {
7642 match pin_type_int {
7643 4 => PinType::Blocking,
7644 3 => PinType::Gating,
7645 _ => PinType::Pinning,
7646 }
7647}
7648
7649#[cfg(windows)]
7650fn dispatch_installer(
7651 installer_path: &Path,
7652 installer_type: &str,
7653 request: &InstallRequest,
7654 manifest: &Manifest,
7655 installer: &Installer,
7656) -> Result<i32> {
7657 use std::process::Command;
7658
7659 match installer_type {
7660 "msi" | "wix" => {
7661 let mut cmd = Command::new("msiexec");
7662 cmd.args(installer_command_arguments(
7663 installer_type,
7664 request,
7665 manifest,
7666 installer_path,
7667 installer,
7668 ));
7669 let status = cmd.status().context("failed to run msiexec")?;
7670 Ok(status.code().unwrap_or(-1))
7671 }
7672 "msix" | "appx" => {
7673 let mut cmd = Command::new("powershell");
7674 cmd.arg("-NoProfile")
7675 .arg("-Command")
7676 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
7677 let status = cmd.status().context("failed to run Add-AppxPackage")?;
7678 Ok(status.code().unwrap_or(-1))
7679 }
7680 "zip" => {
7681 if installer
7687 .nested_installer_type
7688 .as_deref()
7689 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7690 {
7691 return install_portable(installer_path, request, manifest, installer);
7692 }
7693 let target = dirs::data_local_dir()
7694 .unwrap_or_else(|| PathBuf::from("."))
7695 .join("Programs");
7696 fs::create_dir_all(&target)?;
7697 let file = fs::File::open(installer_path)?;
7698 let mut archive = ZipArchive::new(file)?;
7699 archive.extract(&target)?;
7700 Ok(0)
7701 }
7702 "portable" => install_portable(installer_path, request, manifest, installer),
7703 _ => {
7705 let mut cmd = Command::new(installer_path);
7706 cmd.args(installer_command_arguments(
7707 installer_type,
7708 request,
7709 manifest,
7710 installer_path,
7711 installer,
7712 ));
7713 let status = cmd.status().context("failed to run installer")?;
7714 Ok(status.code().unwrap_or(-1))
7715 }
7716 }
7717}
7718
7719#[cfg(not(windows))]
7720fn dispatch_installer(
7721 _installer_path: &Path,
7722 _installer_type: &str,
7723 _request: &InstallRequest,
7724 _manifest: &Manifest,
7725 _installer: &Installer,
7726) -> Result<i32> {
7727 bail!("Installing packages is only supported on Windows")
7728}
7729
7730#[cfg(any(windows, test))]
7731fn installer_command_arguments(
7732 installer_type: &str,
7733 request: &InstallRequest,
7734 _manifest: &Manifest,
7735 installer_path: &Path,
7736 installer: &Installer,
7737) -> Vec<String> {
7738 if let Some(override_args) = request.override_args.as_deref() {
7739 return split_installer_switches(override_args);
7740 }
7741
7742 let mut args = Vec::new();
7743 match installer_type {
7744 "msi" | "wix" => {
7745 args.push("/i".to_owned());
7746 args.push(installer_path.display().to_string());
7747 }
7748 "msix" | "appx" | "zip" => return Vec::new(),
7749 _ => {}
7750 }
7751
7752 let configured = match request.mode {
7753 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
7754 InstallerMode::SilentWithProgress => installer
7755 .switches
7756 .silent_with_progress
7757 .as_deref()
7758 .or(installer.switches.silent.as_deref()),
7759 InstallerMode::Silent => installer
7760 .switches
7761 .silent
7762 .as_deref()
7763 .or(installer.switches.silent_with_progress.as_deref()),
7764 };
7765
7766 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
7767 args.extend(split_installer_switches(value));
7768 } else {
7769 args.extend(default_installer_arguments(installer_type, request.mode));
7770 }
7771
7772 append_switches(
7773 &mut args,
7774 resolve_template_switch(
7775 installer.switches.log.as_deref(),
7776 default_log_switch(installer_type),
7777 request.log_path.as_ref().map(|path| path.display().to_string()),
7778 "<LOGPATH>",
7779 ),
7780 );
7781 append_switches(&mut args, installer.switches.custom.clone());
7782 append_switches(&mut args, request.custom.clone());
7783 append_switches(
7784 &mut args,
7785 resolve_template_switch(
7786 installer.switches.install_location.as_deref(),
7787 default_install_location_switch(installer_type),
7788 request.install_location.clone(),
7789 "<INSTALLPATH>",
7790 ),
7791 );
7792 args
7793}
7794
7795#[cfg(any(windows, test))]
7796fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
7797 match mode {
7798 InstallerMode::Interactive => Vec::new(),
7799 InstallerMode::SilentWithProgress => match installer_type {
7800 "inno" => vec![
7801 "/SP-".to_owned(),
7802 "/SILENT".to_owned(),
7803 "/SUPPRESSMSGBOXES".to_owned(),
7804 "/NORESTART".to_owned(),
7805 ],
7806 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
7807 "nullsoft" | "nsis" => vec!["/S".to_owned()],
7808 _ => vec!["/SILENT".to_owned()],
7809 },
7810 InstallerMode::Silent => match installer_type {
7811 "inno" => vec![
7812 "/SP-".to_owned(),
7813 "/VERYSILENT".to_owned(),
7814 "/SUPPRESSMSGBOXES".to_owned(),
7815 "/NORESTART".to_owned(),
7816 ],
7817 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
7818 "nullsoft" | "nsis" => vec!["/S".to_owned()],
7819 _ => vec!["/S".to_owned()],
7820 },
7821 }
7822}
7823
7824#[cfg(any(windows, test))]
7825fn default_log_switch(installer_type: &str) -> Option<&'static str> {
7826 match installer_type {
7827 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
7828 "inno" => Some("/LOG=\"<LOGPATH>\""),
7829 _ => None,
7830 }
7831}
7832
7833#[cfg(any(windows, test))]
7834fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
7835 match installer_type {
7836 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
7837 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
7838 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
7839 _ => None,
7840 }
7841}
7842
7843#[cfg(any(windows, test))]
7844fn resolve_template_switch(
7845 manifest_value: Option<&str>,
7846 fallback: Option<&str>,
7847 replacement: Option<String>,
7848 token: &str,
7849) -> Option<String> {
7850 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
7851 let replacement = replacement?;
7852 Some(template.replace(token, &replacement))
7853}
7854
7855#[cfg(any(windows, test))]
7856fn append_switches(args: &mut Vec<String>, value: Option<String>) {
7857 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
7858 args.extend(split_installer_switches(&value));
7859 }
7860}
7861
7862#[cfg(any(windows, test))]
7863fn split_installer_switches(value: &str) -> Vec<String> {
7864 let mut args = Vec::new();
7865 let mut current = String::new();
7866 let mut in_quotes = false;
7867
7868 for ch in value.chars() {
7869 match ch {
7870 '"' => in_quotes = !in_quotes,
7871 c if c.is_whitespace() && !in_quotes => {
7872 if !current.is_empty() {
7873 args.push(std::mem::take(&mut current));
7874 }
7875 }
7876 _ => current.push(ch),
7877 }
7878 }
7879
7880 if !current.is_empty() {
7881 args.push(current);
7882 }
7883
7884 args
7885}
7886
7887#[cfg(windows)]
7888fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
7889 if request.purge && request.preserve {
7890 bail!("--purge and --preserve cannot be used together.");
7891 }
7892
7893 if installed
7894 .installer_category
7895 .as_deref()
7896 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
7897 {
7898 return uninstall_portable(installed, request);
7899 }
7900
7901 if (request.purge || request.preserve) && !request.force {
7902 bail!("--purge and --preserve are currently only supported for portable packages.");
7903 }
7904
7905 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
7906 return Ok(exit_code);
7907 }
7908
7909 if let Some(exit_code) = try_uninstall_msix(installed)? {
7910 return Ok(exit_code);
7911 }
7912
7913 bail!(
7914 "No uninstall command found for installed package '{}' ({})",
7915 installed.name,
7916 installed.local_id
7917 );
7918}
7919
7920#[cfg(windows)]
7921fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
7922 use std::os::windows::process::CommandExt;
7923 use std::process::Command;
7924
7925 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
7926 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
7927
7928 let arp_paths = [
7929 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
7930 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
7931 ];
7932
7933 for root in [&hklm, &hkcu] {
7934 for arp_path in &arp_paths {
7935 if let Ok(key) = root.open_subkey(arp_path) {
7936 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
7937 if let Ok(subkey) = key.open_subkey(&subkey_name) {
7938 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
7939 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
7940 if !registry_entry_matches_installed_package(
7941 &subkey_name,
7942 &display_name,
7943 product_code.as_deref(),
7944 installed,
7945 ) {
7946 continue;
7947 }
7948
7949 if let Some(exit_code) =
7950 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
7951 {
7952 return Ok(Some(exit_code));
7953 }
7954
7955 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
7956 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
7957 subkey.get_value("UninstallString").ok()
7958 } else {
7959 quiet_uninstall_cmd
7960 .clone()
7961 .or_else(|| subkey.get_value("UninstallString").ok())
7962 }
7963 .context("No uninstall command found in registry")?;
7964
7965 if is_winget_uninstall_command(&uninstall_cmd)
7966 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
7967 {
7968 return Ok(Some(exit_code));
7969 }
7970
7971 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
7972 let uninstall_line = build_uninstall_command_with_mode(
7973 &uninstall_cmd,
7974 request.mode,
7975 quiet_uninstall_cmd.is_some(),
7976 log_path.as_deref(),
7977 );
7978 let mut cmd = Command::new("cmd");
7987 cmd.raw_arg("/S /C");
7988 cmd.raw_arg(format!("\"{uninstall_line}\""));
7989 let status = cmd.status().context("failed to run uninstaller")?;
7990 return Ok(Some(status.code().unwrap_or(-1)));
7991 }
7992 }
7993 }
7994 }
7995 }
7996
7997 Ok(None)
7998}
7999
8000#[cfg(windows)]
8001fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
8002 use std::process::Command;
8003
8004 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
8005 return Ok(None);
8006 }
8007
8008 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
8009 let mut cmd = Command::new("powershell");
8010 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
8011 package_full_name,
8012 &installed.package_family_names,
8013 ));
8014 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
8015 Ok(Some(status.code().unwrap_or(-1)))
8016}
8017
8018#[cfg(windows)]
8019fn arp_subkey_name(local_id: &str) -> Option<&str> {
8020 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
8021}
8022
8023#[cfg(windows)]
8024fn registry_entry_matches_installed_package(
8025 subkey_name: &str,
8026 display_name: &str,
8027 product_code: Option<&str>,
8028 installed: &ListMatch,
8029) -> bool {
8030 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
8031 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
8032 {
8033 return true;
8034 }
8035
8036 if installed.product_codes.iter().any(|code| {
8037 code.eq_ignore_ascii_case(subkey_name)
8038 || product_code
8039 .map(|value| code.eq_ignore_ascii_case(value))
8040 .unwrap_or(false)
8041 }) {
8042 return true;
8043 }
8044
8045 display_name.eq_ignore_ascii_case(&installed.name)
8046}
8047
8048#[cfg(all(windows, test))]
8049fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
8050 build_uninstall_command_with_mode(
8051 uninstall_cmd,
8052 if silent {
8053 InstallerMode::Silent
8054 } else {
8055 InstallerMode::Interactive
8056 },
8057 has_quiet_command,
8058 None,
8059 )
8060}
8061
8062#[cfg(windows)]
8063fn build_uninstall_command_with_mode(
8064 uninstall_cmd: &str,
8065 mode: InstallerMode,
8066 has_quiet_command: bool,
8067 log_path: Option<&str>,
8068) -> String {
8069 let uninstall_cmd = log_path
8070 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
8071 .unwrap_or_else(|| uninstall_cmd.to_owned());
8072 if mode == InstallerMode::Interactive || has_quiet_command {
8073 return uninstall_cmd;
8074 }
8075
8076 let lower = uninstall_cmd.to_ascii_lowercase();
8077 if is_winget_uninstall_command_lower(&lower) {
8078 return uninstall_cmd;
8079 }
8080 if lower.contains("/quiet")
8081 || lower.contains("/passive")
8082 || lower.contains("/verysilent")
8083 || lower.contains("/silent")
8084 || lower.contains(" /s")
8085 {
8086 return uninstall_cmd;
8087 }
8088
8089 format!("{uninstall_cmd} /S")
8090}
8091
8092#[cfg(windows)]
8093fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
8094 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
8095 return Ok(None);
8096 };
8097
8098 let exit_code = run_winget_uninstall(&args)?;
8099 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
8100 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
8101 return Ok(Some(exit_code));
8102 };
8103
8104 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
8105 }
8106
8107 Ok(Some(exit_code))
8108}
8109
8110#[cfg(windows)]
8111fn run_winget_uninstall(args: &[String]) -> Result<i32> {
8112 use std::process::Command;
8113
8114 let status = Command::new("winget")
8115 .args(args)
8116 .status()
8117 .context("failed to run delegated winget uninstall")?;
8118 Ok(status.code().unwrap_or(-1))
8119}
8120
8121#[cfg(windows)]
8122fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
8123 build_winget_uninstall_arguments_with_scope(installed, request, true)
8124}
8125
8126#[cfg(windows)]
8127fn build_winget_uninstall_arguments_with_scope(
8128 installed: &ListMatch,
8129 request: &UninstallRequest,
8130 include_scope: bool,
8131) -> Option<Vec<String>> {
8132 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
8133 return None;
8134 }
8135
8136 let mut args = vec![
8137 "uninstall".to_owned(),
8138 "--id".to_owned(),
8139 installed.id.clone(),
8140 "--exact".to_owned(),
8141 "--disable-interactivity".to_owned(),
8142 ];
8143
8144 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
8145 args.push("--source".to_owned());
8146 args.push(source_name.to_owned());
8147 }
8148
8149 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
8150 args.push("--scope".to_owned());
8151 args.push(scope.to_owned());
8152 }
8153
8154 if request.mode == InstallerMode::Silent {
8155 args.push("--silent".to_owned());
8156 }
8157
8158 Some(args)
8159}
8160
8161#[cfg(windows)]
8162fn is_winget_uninstall_command(command: &str) -> bool {
8163 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
8164}
8165
8166#[cfg(windows)]
8167fn is_winget_uninstall_command_lower(command: &str) -> bool {
8168 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
8169}
8170
8171#[cfg(windows)]
8172fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
8173 let full_name_literal = package_full_name
8174 .map(|value| format!("'{}'", value.replace('\'', "''")))
8175 .unwrap_or_else(|| "$null".to_owned());
8176 let family_names_literal = if package_family_names.is_empty() {
8177 "@()".to_owned()
8178 } else {
8179 format!(
8180 "@({})",
8181 package_family_names
8182 .iter()
8183 .map(|value| format!("'{}'", value.replace('\'', "''")))
8184 .collect::<Vec<_>>()
8185 .join(", ")
8186 )
8187 };
8188
8189 format!(
8190 "$fullName = {full_name_literal}; \
8191 $familyNames = {family_names_literal}; \
8192 $targets = Get-AppxPackage | Where-Object {{ \
8193 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
8194 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
8195 if (-not $targets) {{ exit 1 }}; \
8196 $targets | Remove-AppxPackage"
8197 )
8198}
8199
8200#[cfg(windows)]
8201fn try_run_msi_uninstall(
8202 installed: &ListMatch,
8203 subkey_name: &str,
8204 product_code: Option<&str>,
8205 request: &UninstallRequest,
8206) -> Result<Option<i32>> {
8207 use std::process::Command;
8208
8209 let uninstall_code = installed
8210 .product_codes
8211 .iter()
8212 .map(String::as_str)
8213 .chain(product_code)
8214 .chain(Some(subkey_name))
8215 .find(|value| is_product_code_like(value));
8216
8217 let Some(uninstall_code) = uninstall_code else {
8218 return Ok(None);
8219 };
8220
8221 let mut cmd = Command::new("msiexec");
8222 cmd.arg("/x").arg(uninstall_code);
8223 match request.mode {
8224 InstallerMode::Interactive => {}
8225 InstallerMode::SilentWithProgress => {
8226 cmd.arg("/passive").arg("/norestart");
8227 }
8228 InstallerMode::Silent => {
8229 cmd.arg("/quiet").arg("/norestart");
8230 }
8231 }
8232 if let Some(log_path) = &request.log_path {
8233 cmd.arg("/log").arg(log_path);
8234 }
8235
8236 let status = cmd.status().context("failed to run msiexec uninstall")?;
8237 Ok(Some(status.code().unwrap_or(-1)))
8238}
8239
8240#[cfg(windows)]
8241fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
8242 let known_symlink = read_portable_symlink_full_path(installed);
8248
8249 let Some(location) = installed.install_location.as_deref() else {
8250 if request.force {
8251 try_remove_portable_symlinks(installed, &known_symlink, None);
8252 try_remove_portable_registry_entry(installed);
8253 return Ok(0);
8254 }
8255 bail!(
8256 "Portable package '{}' does not expose an install location.",
8257 installed.name
8258 );
8259 };
8260
8261 if request.preserve {
8262 return Ok(0);
8263 }
8264
8265 let path = Path::new(location);
8266 let removed = if path.is_dir() {
8267 fs::remove_dir_all(path)?;
8268 true
8269 } else if path.is_file() {
8270 fs::remove_file(path)?;
8271 true
8272 } else {
8273 false
8274 };
8275
8276 if removed || request.force {
8277 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
8280 try_remove_portable_registry_entry(installed);
8281 return Ok(0);
8282 }
8283
8284 bail!("Portable package location not found: {location}")
8285}
8286
8287#[cfg(windows)]
8293fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
8294 const ARP_PREFIX: &str = r"ARP\";
8295 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
8296 let mut parts = rest.splitn(3, '\\');
8297 let scope = parts.next().unwrap_or("");
8298 let _arch = parts.next();
8299 let subkey_name = parts.next().unwrap_or("");
8300 if subkey_name.is_empty() {
8301 return None;
8302 }
8303 let hive = if scope.eq_ignore_ascii_case("User") {
8304 RegKey::predef(HKEY_CURRENT_USER)
8305 } else {
8306 RegKey::predef(HKEY_LOCAL_MACHINE)
8307 };
8308 let subkey = hive
8309 .open_subkey(format!(
8310 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
8311 ))
8312 .ok()?;
8313 return read_reg_string(&subkey, "PortableSymlinkFullPath");
8314 }
8315 None
8316}
8317
8318#[cfg(windows)]
8324fn try_remove_portable_symlinks(
8325 _installed: &ListMatch,
8326 known_symlink: &Option<String>,
8327 install_location: Option<&Path>,
8328) {
8329 if let Some(path) = known_symlink.as_deref() {
8330 let _ = fs::remove_file(path);
8331 }
8332
8333 let Some(install_dir) = install_location else { return };
8338
8339 let Some(local) = dirs::data_local_dir() else { return };
8340 let links_root = local.join("Microsoft").join("WinGet").join("Links");
8341 let Ok(entries) = fs::read_dir(&links_root) else { return };
8342 for entry in entries.flatten() {
8343 let path = entry.path();
8344 let Ok(target) = fs::read_link(&path) else { continue };
8345 if path_starts_with_case_insensitive(&target, install_dir) {
8346 let _ = fs::remove_file(&path);
8347 }
8348 }
8349}
8350
8351#[cfg(windows)]
8358fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
8359 let lower_components = |p: &Path| -> Vec<String> {
8360 p.components()
8361 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
8362 .collect()
8363 };
8364 let parent_components = lower_components(parent);
8365 let child_components = lower_components(child);
8366 if parent_components.is_empty() || parent_components.len() > child_components.len() {
8367 return false;
8368 }
8369 parent_components
8370 .iter()
8371 .zip(child_components.iter())
8372 .all(|(a, b)| a == b)
8373}
8374
8375#[cfg(windows)]
8382fn try_remove_portable_registry_entry(installed: &ListMatch) {
8383 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
8386 let mut parts = rest.splitn(3, '\\');
8388 let scope = parts.next().unwrap_or("");
8389 let _arch = parts.next();
8390 let subkey_name = parts.next().unwrap_or("");
8391 if subkey_name.is_empty() {
8392 return;
8393 }
8394 let is_hkcu = scope.eq_ignore_ascii_case("User");
8395 vec![(
8396 is_hkcu,
8397 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
8398 )]
8399 } else {
8400 let mut found = Vec::new();
8402 for (is_hkcu, hive) in [
8403 (true, RegKey::predef(HKEY_CURRENT_USER)),
8404 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
8405 ] {
8406 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
8407 continue;
8408 };
8409 for name in uninstall.enum_keys().flatten() {
8410 let Ok(subkey) = uninstall.open_subkey(&name) else {
8411 continue;
8412 };
8413 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
8414 found.push((
8415 is_hkcu,
8416 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
8417 ));
8418 }
8419 }
8420 }
8421 found
8422 };
8423
8424 for (is_hkcu, path) in candidates {
8425 let hive = if is_hkcu {
8426 RegKey::predef(HKEY_CURRENT_USER)
8427 } else {
8428 RegKey::predef(HKEY_LOCAL_MACHINE)
8429 };
8430 let _ = hive.delete_subkey_all(&path);
8431 }
8432}
8433
8434#[cfg(windows)]
8435fn is_product_code_like(value: &str) -> bool {
8436 value.starts_with('{') && value.ends_with('}')
8437}
8438
8439#[cfg(not(windows))]
8440fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
8441 bail!("Uninstalling packages is only supported on Windows")
8442}
8443
8444#[cfg(windows)]
8466fn install_portable(
8467 installer_path: &Path,
8468 request: &InstallRequest,
8469 manifest: &Manifest,
8470 installer: &Installer,
8471) -> Result<i32> {
8472 let source_identifier = portable_source_identifier(&request.query);
8473 let existing_entry = read_existing_portable_entry(&manifest.id);
8474 let subkey_name = existing_entry
8477 .as_ref()
8478 .map(|entry| entry.subkey_name.clone())
8479 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
8480
8481 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
8482
8483 let prev_dir_created = existing_entry
8488 .as_ref()
8489 .and_then(|entry| entry.install_directory_created)
8490 == Some(1);
8491 let dir_existed = target_dir.is_dir();
8492 if prev_dir_created && dir_existed {
8493 clean_directory_contents(&target_dir).with_context(|| {
8494 format!(
8495 "failed to clean existing portable install directory: {}",
8496 target_dir.display()
8497 )
8498 })?;
8499 }
8500
8501 fs::create_dir_all(&target_dir)
8502 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
8503 let install_directory_created = !dir_existed || prev_dir_created;
8507
8508 let installer_type = installer.installer_type.as_deref().unwrap_or("");
8509 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
8510 let file = fs::File::open(installer_path)
8511 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
8512 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
8513 archive
8514 .extract(&target_dir)
8515 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
8516 installer
8520 .nested_installer_files
8521 .first()
8522 .map(|file| target_dir.join(&file.relative_file_path))
8523 .unwrap_or_else(|| target_dir.clone())
8524 } else {
8525 let basename = installer_path
8529 .file_name()
8530 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
8531 let copied = target_dir.join(basename);
8532 fs::copy(installer_path, &copied)
8533 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
8534 copied
8535 };
8536
8537 let alias = determine_portable_alias(installer);
8542 let symlink_full_path = match alias.as_deref() {
8543 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
8544 _ => None,
8545 };
8546
8547 let added_to_path = if symlink_full_path.is_some() {
8551 try_add_links_dir_to_user_path()
8552 } else {
8553 false
8554 };
8555
8556 write_portable_arp_entry(&PortableArpEntry {
8557 subkey_name: &subkey_name,
8558 install_location: &target_dir,
8559 portable_target_full_path: &portable_target_full_path,
8560 portable_symlink_full_path: symlink_full_path.as_deref(),
8561 install_directory_created,
8562 added_to_path,
8563 source_identifier: &source_identifier,
8564 manifest,
8565 })?;
8566
8567 Ok(0)
8568}
8569
8570#[cfg(windows)]
8571struct PortableArpEntry<'a> {
8572 subkey_name: &'a str,
8573 install_location: &'a Path,
8574 portable_target_full_path: &'a Path,
8575 portable_symlink_full_path: Option<&'a Path>,
8576 install_directory_created: bool,
8577 added_to_path: bool,
8578 source_identifier: &'a str,
8579 manifest: &'a Manifest,
8580}
8581
8582#[cfg(windows)]
8588fn determine_portable_alias(installer: &Installer) -> Option<String> {
8589 installer
8590 .nested_installer_files
8591 .iter()
8592 .find_map(|file| file.portable_command_alias.clone())
8593 .or_else(|| installer.commands.first().cloned())
8594}
8595
8596#[cfg(windows)]
8597fn winget_links_dir() -> PathBuf {
8598 dirs::data_local_dir()
8599 .unwrap_or_else(|| PathBuf::from("."))
8600 .join("Microsoft")
8601 .join("WinGet")
8602 .join("Links")
8603}
8604
8605#[cfg(windows)]
8610fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
8611 use std::os::windows::fs::symlink_file;
8612 let links_dir = winget_links_dir();
8613 if fs::create_dir_all(&links_dir).is_err() {
8614 return None;
8615 }
8616 let link_path = links_dir.join(format!("{alias}.exe"));
8617
8618 if link_path.symlink_metadata().is_ok() {
8621 let _ = fs::remove_file(&link_path);
8622 }
8623
8624 symlink_file(target, &link_path).ok().map(|()| link_path)
8629}
8630
8631#[cfg(windows)]
8637fn try_add_links_dir_to_user_path() -> bool {
8638 let links_dir = winget_links_dir();
8639 let links_str = links_dir.to_string_lossy().to_string();
8640 let normalized_links = links_str.to_ascii_lowercase();
8641
8642 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8643 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
8644 return false;
8645 };
8646 let existing: String = env_key.get_value("Path").unwrap_or_default();
8647 let already_present = existing
8648 .split(';')
8649 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
8650 if already_present {
8651 return false;
8652 }
8653
8654 let new_path = if existing.is_empty() {
8655 links_str
8656 } else if existing.ends_with(';') {
8657 format!("{existing}{links_str}")
8658 } else {
8659 format!("{existing};{links_str}")
8660 };
8661
8662 if env_key.set_value("Path", &new_path).is_err() {
8663 return false;
8664 }
8665
8666 broadcast_environment_change();
8667 true
8668}
8669
8670#[cfg(windows)]
8671fn broadcast_environment_change() {
8672 use std::ffi::OsStr;
8673 use std::os::windows::ffi::OsStrExt;
8674
8675 use windows_sys::Win32::UI::WindowsAndMessaging::{
8676 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
8677 };
8678
8679 let param: Vec<u16> = OsStr::new("Environment")
8680 .encode_wide()
8681 .chain(std::iter::once(0))
8682 .collect();
8683 unsafe {
8687 let mut result: usize = 0;
8688 SendMessageTimeoutW(
8689 HWND_BROADCAST,
8690 WM_SETTINGCHANGE,
8691 0usize,
8692 param.as_ptr() as isize,
8693 SMTO_ABORTIFHUNG,
8694 5000,
8695 &mut result,
8696 );
8697 }
8698}
8699
8700#[cfg(windows)]
8705fn portable_source_identifier(query: &PackageQuery) -> String {
8706 match query.source.as_deref() {
8707 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8708 Some(name) if !name.is_empty() => name.to_owned(),
8709 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
8710 }
8711}
8712
8713#[cfg(windows)]
8714fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
8715 format!("{package_id}_{source_identifier}")
8716}
8717
8718#[cfg(windows)]
8719struct ExistingPortableEntry {
8720 install_location: Option<PathBuf>,
8721 install_directory_created: Option<u32>,
8722 subkey_name: String,
8723}
8724
8725#[cfg(windows)]
8726fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
8727 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8728 let uninstall = hkcu
8729 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
8730 .ok()?;
8731 for name in uninstall.enum_keys().flatten() {
8732 let Ok(subkey) = uninstall.open_subkey(&name) else {
8733 continue;
8734 };
8735 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
8736 continue;
8737 }
8738 if !read_reg_string(&subkey, "WinGetInstallerType")
8739 .as_deref()
8740 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
8741 {
8742 continue;
8743 }
8744 return Some(ExistingPortableEntry {
8745 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
8746 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
8747 subkey_name: name,
8748 });
8749 }
8750 None
8751}
8752
8753#[cfg(windows)]
8754fn resolve_portable_install_location(
8755 request: &InstallRequest,
8756 existing: &Option<ExistingPortableEntry>,
8757 subkey_name: &str,
8758) -> PathBuf {
8759 resolve_portable_install_location_pure(
8760 request.install_location.as_deref(),
8761 existing
8762 .as_ref()
8763 .and_then(|e| e.install_location.as_deref())
8764 .and_then(Path::to_str),
8765 subkey_name,
8766 &default_user_portable_root(),
8767 )
8768}
8769
8770#[cfg(windows)]
8775fn resolve_portable_install_location_pure(
8776 request_location: Option<&str>,
8777 existing_location: Option<&str>,
8778 subkey_name: &str,
8779 default_user_portable_root: &Path,
8780) -> PathBuf {
8781 if let Some(custom) = request_location
8782 && !custom.is_empty()
8783 {
8784 return PathBuf::from(custom);
8785 }
8786 if let Some(loc) = existing_location
8787 && !loc.is_empty()
8788 {
8789 return PathBuf::from(loc);
8790 }
8791 default_user_portable_root.join(subkey_name)
8792}
8793
8794#[cfg(windows)]
8795fn default_user_portable_root() -> PathBuf {
8796 dirs::data_local_dir()
8797 .unwrap_or_else(|| PathBuf::from("."))
8798 .join("Microsoft")
8799 .join("WinGet")
8800 .join("Packages")
8801}
8802
8803#[cfg(windows)]
8804fn clean_directory_contents(dir: &Path) -> Result<()> {
8805 if !dir.is_dir() {
8806 return Ok(());
8807 }
8808 for entry in fs::read_dir(dir)? {
8809 let entry = entry?;
8810 let path = entry.path();
8811 if path.is_dir() {
8812 fs::remove_dir_all(&path)?;
8813 } else {
8814 fs::remove_file(&path)?;
8815 }
8816 }
8817 Ok(())
8818}
8819
8820#[cfg(windows)]
8824fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
8825 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
8826 let uninstall_path = format!(
8827 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
8828 entry.subkey_name
8829 );
8830 let (subkey, _) = hkcu
8831 .create_subkey(&uninstall_path)
8832 .context("failed to create portable ARP registry subkey")?;
8833
8834 let manifest = entry.manifest;
8835 let install_location_str = entry.install_location.to_string_lossy().to_string();
8836 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
8837 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
8838 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
8839 subkey.set_value("InstallLocation", &install_location_str)?;
8840 subkey.set_value(
8841 "PortableTargetFullPath",
8842 &entry.portable_target_full_path.to_string_lossy().to_string(),
8843 )?;
8844 if let Some(symlink_path) = entry.portable_symlink_full_path {
8845 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
8846 }
8847 subkey.set_value(
8848 "InstallDirectoryAddedToPath",
8849 &(if entry.added_to_path { 1u32 } else { 0u32 }),
8850 )?;
8851 subkey.set_value(
8852 "InstallDirectoryCreated",
8853 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
8854 )?;
8855 subkey.set_value(
8856 "DisplayName",
8857 &if manifest.name.is_empty() {
8858 manifest.id.clone()
8859 } else {
8860 manifest.name.clone()
8861 },
8862 )?;
8863 subkey.set_value("DisplayVersion", &manifest.version)?;
8864 if let Some(publisher) = manifest.publisher.as_ref() {
8865 subkey.set_value("Publisher", publisher)?;
8866 }
8867 subkey.set_value(
8868 "UninstallString",
8869 &format!("winget uninstall --product-code {}", entry.subkey_name),
8870 )?;
8871 let today = Utc::now().format("%Y%m%d").to_string();
8872 subkey.set_value("InstallDate", &today)?;
8873 if let Some(url) = manifest.package_url.as_ref() {
8874 subkey.set_value("URLInfoAbout", url)?;
8875 }
8876
8877 Ok(())
8878}
8879
8880#[cfg(test)]
8881mod tests {
8882 use std::io::{Read as _, Write};
8883 use std::net::{SocketAddr, TcpListener, TcpStream};
8884 use std::path::{Path, PathBuf};
8885 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
8886 use std::thread::{self, JoinHandle};
8887
8888 use flate2::Compression;
8889 use flate2::write::DeflateEncoder;
8890 use zip::write::SimpleFileOptions;
8891
8892 use super::*;
8893
8894 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
8895 Manifest {
8896 id: id.to_owned(),
8897 name: name.to_owned(),
8898 version: version.to_owned(),
8899 channel: String::new(),
8900 publisher: Some("Acme".to_owned()),
8901 description: Some("desc".to_owned()),
8902 moniker: None,
8903 package_url: None,
8904 publisher_url: None,
8905 publisher_support_url: None,
8906 license: None,
8907 license_url: None,
8908 privacy_url: None,
8909 author: None,
8910 copyright: None,
8911 copyright_url: None,
8912 release_notes: None,
8913 release_notes_url: None,
8914 tags: Vec::new(),
8915 agreements: Vec::new(),
8916 package_dependencies: Vec::new(),
8917 documentation: Vec::new(),
8918 installers: Vec::new(),
8919 require_explicit_upgrade: false,
8920 }
8921 }
8922
8923 #[test]
8924 fn derive_download_filename_matches_winget_pattern() {
8925 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
8926 let installer = Installer {
8927 architecture: Some("arm64".to_owned()),
8928 installer_type: Some("exe".to_owned()),
8929 scope: Some("user".to_owned()),
8930 locale: Some("en-US".to_owned()),
8931 ..Installer::default()
8932 };
8933 assert_eq!(
8934 derive_download_manifest_filename(&manifest, &installer),
8935 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
8936 );
8937 }
8938
8939 #[test]
8940 fn derive_download_filename_omits_optional_components() {
8941 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
8942 let installer = Installer {
8943 architecture: Some("x64".to_owned()),
8944 installer_type: Some("exe".to_owned()),
8945 ..Installer::default()
8946 };
8947 assert_eq!(
8949 derive_download_manifest_filename(&manifest, &installer),
8950 "7-Zip_26.01_X64_exe.yaml"
8951 );
8952 }
8953
8954 #[test]
8955 fn derive_download_filename_skips_unknown_version() {
8956 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
8957 let installer = Installer {
8958 architecture: Some("neutral".to_owned()),
8959 installer_type: Some("msi".to_owned()),
8960 ..Installer::default()
8961 };
8962 assert_eq!(
8963 derive_download_manifest_filename(&manifest, &installer),
8964 "Test_Neutral_msi.yaml"
8965 );
8966 }
8967
8968 #[test]
8969 fn derive_download_filename_sanitizes_illegal_chars() {
8970 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
8971 let installer = Installer {
8972 architecture: Some("x64".to_owned()),
8973 installer_type: Some("exe".to_owned()),
8974 ..Installer::default()
8975 };
8976 let filename = derive_download_manifest_filename(&manifest, &installer);
8978 assert!(!filename.contains('/') && !filename.contains(':'));
8979 assert!(filename.starts_with("Some_Bad_Name_1.0"));
8980 }
8981
8982 #[test]
8983 fn build_download_manifest_yaml_overrides_identity_fields() {
8984 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
8985 let documents = serde_json::json!({
8988 "PackageIdentifier": "stale-id",
8989 "PackageVersion": "",
8990 "PackageName": "Old Name",
8991 "Channel": "preview",
8992 "Installers": [
8993 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
8994 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
8995 ],
8996 });
8997 let installer = Installer {
8998 architecture: Some("x64".to_owned()),
8999 installer_type: Some("exe".to_owned()),
9000 url: Some("https://example.com/a".to_owned()),
9001 ..Installer::default()
9002 };
9003 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9004 assert!(yaml.contains("PackageIdentifier: App.Id"));
9005 assert!(yaml.contains("PackageVersion: 2.0.0"));
9006 assert!(yaml.contains("PackageName: App Name"));
9007 assert!(yaml.contains("ManifestType: merged"));
9008 assert!(!yaml.contains("Channel: preview"));
9011 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
9013 assert!(yaml.contains("https://example.com/a"));
9014 assert!(!yaml.contains("https://example.com/b"));
9015 }
9016
9017 #[test]
9018 fn build_download_manifest_yaml_preserves_typed_channel() {
9019 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
9020 manifest.channel = "beta".to_owned();
9021 let documents = serde_json::json!({
9022 "PackageIdentifier": "App.Id",
9023 "PackageVersion": "2.0.0",
9024 "Installers": [],
9025 });
9026 let installer = Installer::default();
9027 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
9028 assert!(yaml.contains("Channel: beta"));
9029 }
9030
9031 fn temp_app_root(label: &str) -> PathBuf {
9032 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
9033 std::env::temp_dir().join(format!(
9034 "pinget-rs-tests-{label}-{}-{}",
9035 std::process::id(),
9036 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
9037 ))
9038 }
9039
9040 #[cfg(windows)]
9041 #[test]
9042 fn path_prefix_check_rejects_string_prefix_collisions() {
9043 assert!(!path_starts_with_case_insensitive(
9047 Path::new(r"C:\Foobar\bin.exe"),
9048 Path::new(r"C:\Foo"),
9049 ));
9050 assert!(!path_starts_with_case_insensitive(
9051 Path::new(r"C:\Foo\bin.exe.bak"),
9052 Path::new(r"C:\Foo\bin.exe"),
9053 ));
9054 }
9055
9056 #[cfg(windows)]
9057 #[test]
9058 fn path_prefix_check_accepts_real_children_and_self() {
9059 assert!(path_starts_with_case_insensitive(
9061 Path::new(r"C:\Foo\sub\bin.exe"),
9062 Path::new(r"C:\Foo"),
9063 ));
9064 assert!(path_starts_with_case_insensitive(
9066 Path::new(r"C:\Foo\bin.exe"),
9067 Path::new(r"C:\Foo\bin.exe"),
9068 ));
9069 }
9070
9071 #[cfg(windows)]
9072 #[test]
9073 fn path_prefix_check_is_case_insensitive() {
9074 assert!(path_starts_with_case_insensitive(
9077 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
9078 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
9079 ));
9080 }
9081
9082 #[cfg(windows)]
9083 #[test]
9084 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
9085 let query = PackageQuery {
9086 source: Some("winget".to_owned()),
9087 ..PackageQuery::default()
9088 };
9089 assert_eq!(
9090 portable_source_identifier(&query),
9091 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9092 );
9093 let query = PackageQuery {
9095 source: Some("WINGET".to_owned()),
9096 ..PackageQuery::default()
9097 };
9098 assert_eq!(
9099 portable_source_identifier(&query),
9100 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9101 );
9102 }
9103
9104 #[cfg(windows)]
9105 #[test]
9106 fn portable_source_identifier_passes_through_custom_sources() {
9107 let query = PackageQuery {
9108 source: Some("internal-feed".to_owned()),
9109 ..PackageQuery::default()
9110 };
9111 assert_eq!(portable_source_identifier(&query), "internal-feed");
9112 }
9113
9114 #[cfg(windows)]
9115 #[test]
9116 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
9117 let query = PackageQuery {
9118 source: None,
9119 ..PackageQuery::default()
9120 };
9121 assert_eq!(
9122 portable_source_identifier(&query),
9123 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9124 );
9125 let query = PackageQuery {
9126 source: Some(String::new()),
9127 ..PackageQuery::default()
9128 };
9129 assert_eq!(
9130 portable_source_identifier(&query),
9131 "Microsoft.Winget.Source_8wekyb3d8bbwe"
9132 );
9133 }
9134
9135 #[cfg(windows)]
9136 #[test]
9137 fn portable_subkey_name_matches_winget_format() {
9138 assert_eq!(
9139 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
9140 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
9141 );
9142 }
9143
9144 #[cfg(windows)]
9145 #[test]
9146 fn determine_portable_alias_prefers_nested_installer_alias() {
9147 let installer = Installer {
9148 nested_installer_files: vec![NestedInstallerFile {
9149 relative_file_path: "ripgrep/rg.exe".to_owned(),
9150 portable_command_alias: Some("rg".to_owned()),
9151 }],
9152 commands: vec!["other-name".to_owned()],
9153 ..Installer::default()
9154 };
9155 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
9156 }
9157
9158 #[cfg(windows)]
9159 #[test]
9160 fn determine_portable_alias_falls_back_to_commands() {
9161 let installer = Installer {
9162 commands: vec!["nuget".to_owned()],
9164 ..Installer::default()
9165 };
9166 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
9167
9168 let installer = Installer {
9170 nested_installer_files: vec![NestedInstallerFile {
9171 relative_file_path: "subdir/bin.exe".to_owned(),
9172 portable_command_alias: None,
9173 }],
9174 commands: vec!["bin".to_owned()],
9175 ..Installer::default()
9176 };
9177 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
9178 }
9179
9180 #[cfg(windows)]
9181 #[test]
9182 fn determine_portable_alias_returns_none_without_alias_or_commands() {
9183 let installer = Installer::default();
9184 assert!(determine_portable_alias(&installer).is_none());
9185 }
9186
9187 #[cfg(windows)]
9188 #[test]
9189 fn resolve_portable_install_location_prefers_request_override() {
9190 let result = resolve_portable_install_location_pure(
9191 Some(r"D:\Tools\rg"),
9192 Some(r"C:\Software\rg-test"),
9193 "Sub_Source",
9194 Path::new(r"C:\default"),
9195 );
9196 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
9197 }
9198
9199 #[cfg(windows)]
9200 #[test]
9201 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
9202 let result = resolve_portable_install_location_pure(
9206 None,
9207 Some(r"C:\Software\rg-test"),
9208 "Sub_Source",
9209 Path::new(r"C:\default"),
9210 );
9211 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
9212 }
9213
9214 #[cfg(windows)]
9215 #[test]
9216 fn resolve_portable_install_location_falls_back_to_default_root() {
9217 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
9218 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9219 }
9220
9221 #[cfg(windows)]
9222 #[test]
9223 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
9224 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
9227 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
9228 }
9229
9230 #[cfg(windows)]
9231 #[test]
9232 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
9233 let root = std::env::temp_dir().join(format!(
9234 "pinget-clean-test-{}",
9235 Utc::now().timestamp_nanos_opt().unwrap_or_default()
9236 ));
9237 fs::create_dir_all(&root).expect("create temp dir");
9238 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
9239 fs::create_dir_all(root.join("nested")).expect("create nested dir");
9240 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
9241
9242 clean_directory_contents(&root).expect("clean root contents");
9243
9244 assert!(root.is_dir(), "root dir itself must survive");
9245 assert_eq!(
9246 fs::read_dir(&root).expect("read root").count(),
9247 0,
9248 "root must be empty after clean"
9249 );
9250 let _ = fs::remove_dir_all(&root);
9251 }
9252
9253 #[cfg(windows)]
9254 #[test]
9255 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
9256 let bogus = std::env::temp_dir().join(format!(
9257 "pinget-clean-test-bogus-{}",
9258 Utc::now().timestamp_nanos_opt().unwrap_or_default()
9259 ));
9260 assert!(!bogus.exists());
9261 clean_directory_contents(&bogus).expect("clean nonexistent dir");
9262 assert!(!bogus.exists());
9264 }
9265
9266 #[cfg(windows)]
9267 #[test]
9268 fn path_prefix_check_handles_mixed_separators() {
9269 assert!(path_starts_with_case_insensitive(
9272 Path::new(r"C:\Foo\sub/bin.exe"),
9273 Path::new(r"C:\Foo"),
9274 ));
9275 }
9276
9277 #[test]
9278 fn repository_options_capture_custom_host_settings() {
9279 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
9280 .with_user_agent("pinget-rs-tests/1.0")
9281 .with_pre_indexed_source_auto_update_interval(None);
9282
9283 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
9284 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
9285 assert!(options.pre_indexed_source_auto_update_interval.is_none());
9286 }
9287
9288 #[test]
9289 fn dependency_install_requests_are_silent_and_not_forced() {
9290 let mut parent = InstallRequest::new(PackageQuery {
9291 source: Some("winget".to_owned()),
9292 ..PackageQuery::default()
9293 });
9294 parent.mode = InstallerMode::SilentWithProgress;
9295 parent.force = true;
9296 parent.accept_package_agreements = true;
9297 parent.ignore_security_hash = true;
9298 parent.dependency_source = Some("dependencies".to_owned());
9299
9300 let dependency =
9301 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
9302
9303 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
9304 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
9305 assert_eq!(dependency.mode, InstallerMode::Silent);
9306 assert!(!dependency.force);
9307 assert!(dependency.no_upgrade);
9308 assert!(dependency.accept_package_agreements);
9309 assert!(dependency.ignore_security_hash);
9310 }
9311
9312 #[test]
9313 fn storage_paths_use_configured_app_root() {
9314 let app_root = PathBuf::from(r"C:\temp\pinget-test");
9315 let source = SourceRecord {
9316 name: "winget/test".to_owned(),
9317 kind: SourceKind::PreIndexed,
9318 arg: "https://example.com/cache".to_owned(),
9319 identifier: "Test.Source".to_owned(),
9320 trust_level: "None".to_owned(),
9321 explicit: false,
9322 priority: 0,
9323 last_update: None,
9324 source_version: None,
9325 };
9326
9327 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
9328 assert_eq!(
9329 source_state_dir(&app_root, &source),
9330 app_root.join("sources").join("winget_test")
9331 );
9332 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
9333 }
9334
9335 #[cfg(windows)]
9336 #[test]
9337 fn packaged_layout_paths_resolve_from_packaged_app_root() {
9338 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
9339 let app_root = local_app_data
9340 .join("Packages")
9341 .join(PACKAGED_FAMILY_NAME)
9342 .join("LocalState");
9343 assert!(uses_packaged_layout(&app_root));
9344 assert!(
9345 user_settings_path(&app_root)
9346 .display()
9347 .to_string()
9348 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
9349 );
9350 assert!(
9351 packaged_file_cache_root(&app_root)
9352 .display()
9353 .to_string()
9354 .ends_with(&format!(
9355 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
9356 PACKAGED_FAMILY_NAME
9357 ))
9358 );
9359 }
9360
9361 #[cfg(windows)]
9362 #[test]
9363 fn default_app_root_outside_package_avoids_packaged_layout() {
9364 let prior = std::env::var_os("PINGET_APPROOT");
9368 unsafe { std::env::remove_var("PINGET_APPROOT") };
9370
9371 let result = default_app_root();
9372
9373 if let Some(prior) = prior {
9374 unsafe { std::env::set_var("PINGET_APPROOT", prior) };
9376 }
9377
9378 let app_root = result.expect("default app root");
9379 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
9380 assert!(
9381 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
9382 "unexpected fallback app root: {app_root:?}"
9383 );
9384 }
9385
9386 #[test]
9387 fn packaged_source_stream_overlays_defaults_and_metadata() {
9388 let store = parse_packaged_source_store(
9389 Some(
9390 "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",
9391 ),
9392 Some(
9393 "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n",
9394 ),
9395 )
9396 .expect("packaged store");
9397
9398 assert!(!store.sources.iter().any(|source| source.name == "winget"));
9399 let corp = store
9400 .sources
9401 .iter()
9402 .find(|source| source.name == "corp")
9403 .expect("corp source");
9404 assert_eq!(corp.kind, SourceKind::Rest);
9405 assert_eq!(corp.identifier, "Contoso.Rest");
9406 assert_eq!(corp.trust_level, "Trusted");
9407 assert!(corp.explicit);
9408 assert_eq!(corp.priority, 7);
9409 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
9410 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
9411 }
9412
9413 #[test]
9414 fn system_winget_export_parses_json_lines() {
9415 let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9416{"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"}
9417"#;
9418
9419 let sources = parse_system_winget_source_export(output).expect("source export");
9420
9421 assert_eq!(
9422 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
9423 vec!["contoso", "contoso-cache"]
9424 );
9425 assert_eq!(sources[0].kind, SourceKind::Rest);
9426 assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
9427 assert_eq!(sources[0].identifier, "api.contoso.test");
9428 assert_eq!(sources[0].trust_level, "Trusted");
9429 assert!(!sources[0].explicit);
9430 assert_eq!(sources[1].kind, SourceKind::PreIndexed);
9431 assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
9432 assert!(sources[1].explicit);
9433 }
9434
9435 #[test]
9436 fn system_winget_export_parses_wrapped_sources() {
9437 let output = r#"{
9438 "Sources": [
9439 {
9440 "Name": "corp",
9441 "Type": "Microsoft.Rest",
9442 "Arg": "https://packages.contoso.test/api",
9443 "Identifier": "Contoso.Rest",
9444 "TrustLevel": "Trusted",
9445 "Explicit": true,
9446 "Priority": 9
9447 }
9448 ]
9449}"#;
9450
9451 let sources = parse_system_winget_source_export(output).expect("source export");
9452 let source = sources.first().expect("source");
9453 assert_eq!(sources.len(), 1);
9454 assert_eq!(source.name, "corp");
9455 assert_eq!(source.kind, SourceKind::Rest);
9456 assert_eq!(source.identifier, "Contoso.Rest");
9457 assert_eq!(source.trust_level, "Trusted");
9458 assert!(source.explicit);
9459 assert_eq!(source.priority, 9);
9460 }
9461
9462 #[test]
9463 fn system_winget_source_store_detects_secure_settings_stub() {
9464 assert!(is_secure_settings_stub(Some(
9465 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
9466 )));
9467 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
9468 }
9469
9470 #[test]
9471 fn system_winget_source_store_builds_add_arguments() {
9472 let args = build_winget_source_add_arguments(
9473 "corp",
9474 "https://packages.contoso.test/api",
9475 SourceKind::Rest,
9476 "Trusted",
9477 true,
9478 );
9479
9480 assert_eq!(
9481 args,
9482 vec![
9483 "source",
9484 "add",
9485 "--name",
9486 "corp",
9487 "--arg",
9488 "https://packages.contoso.test/api",
9489 "--type",
9490 "Microsoft.Rest",
9491 "--disable-interactivity",
9492 "--trust-level",
9493 "trusted",
9494 "--explicit",
9495 ]
9496 );
9497 }
9498
9499 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
9500 Ok(WingetSourceCommandResult {
9501 exit_code: 0,
9502 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
9503"#
9504 .to_owned(),
9505 stderr: String::new(),
9506 })
9507 }
9508
9509 #[test]
9510 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
9511 let original_runner = {
9512 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9513 let original = *runner;
9514 *runner = fake_system_winget_source_export;
9515 original
9516 };
9517
9518 let result = load_packaged_store_from_streams(
9519 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
9520 None,
9521 );
9522
9523 {
9524 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
9525 *runner = original_runner;
9526 }
9527
9528 let store = result.expect("packaged store").expect("packaged store");
9529 let source = store.sources.first().expect("source");
9530 assert_eq!(store.sources.len(), 1);
9531 assert_eq!(source.name, "contoso");
9532 assert_eq!(source.kind, SourceKind::Rest);
9533 }
9534
9535 #[test]
9536 fn source_metadata_and_named_reset_round_trip() {
9537 let app_root = temp_app_root("source-reset");
9538 let result = (|| -> Result<()> {
9539 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9540 repository.add_source_with_metadata(
9541 "test",
9542 "https://example.com/test",
9543 SourceKind::Rest,
9544 Some("trusted"),
9545 true,
9546 4,
9547 )?;
9548
9549 repository.edit_source("test", Some(false), None)?;
9550 let edited = repository
9551 .list_sources()
9552 .into_iter()
9553 .find(|source| source.name == "test")
9554 .expect("source");
9555 assert_eq!(edited.trust_level, "Trusted");
9556 assert!(!edited.explicit);
9557 assert_eq!(edited.priority, 4);
9558
9559 repository.reset_source("test")?;
9560 let reset = repository
9561 .list_sources()
9562 .into_iter()
9563 .find(|source| source.name == "test")
9564 .expect("source");
9565 assert_eq!(reset.trust_level, "Trusted");
9566 assert!(!reset.explicit);
9567 assert_eq!(reset.priority, 4);
9568 assert!(reset.last_update.is_none());
9569 assert!(reset.source_version.is_none());
9570 Ok(())
9571 })();
9572
9573 let _ = fs::remove_dir_all(&app_root);
9574 result.expect("source metadata");
9575 }
9576
9577 #[test]
9578 fn explicit_sources_are_skipped_by_default_search_resolution() {
9579 let app_root = temp_app_root("source-resolution");
9580 let result = (|| -> Result<()> {
9581 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9582 repository.add_source_with_metadata(
9583 "explicit-test",
9584 "https://example.com/explicit",
9585 SourceKind::Rest,
9586 Some("trusted"),
9587 true,
9588 10,
9589 )?;
9590
9591 let indexes = repository.resolve_source_indexes(None)?;
9592 let names: Vec<_> = indexes
9593 .into_iter()
9594 .map(|index| repository.list_sources()[index].name.clone())
9595 .collect();
9596 assert!(!names.iter().any(|name| name == "explicit-test"));
9597 Ok(())
9598 })();
9599
9600 let _ = fs::remove_dir_all(&app_root);
9601 result.expect("source resolution");
9602 }
9603
9604 #[test]
9605 fn user_and_admin_settings_round_trip() {
9606 let app_root = temp_app_root("settings");
9607 let result = (|| -> Result<()> {
9608 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
9609 repository.set_user_settings(
9610 &serde_json::json!({
9611 "visual": {
9612 "progressBar": "retro"
9613 }
9614 }),
9615 false,
9616 )?;
9617 repository.set_user_settings(
9618 &serde_json::json!({
9619 "experimentalFeatures": {
9620 "directMSI": true
9621 }
9622 }),
9623 true,
9624 )?;
9625
9626 let user_settings = repository.get_user_settings()?;
9627 assert_eq!(
9628 user_settings["visual"]["progressBar"],
9629 serde_json::Value::String("retro".to_owned())
9630 );
9631 assert_eq!(
9632 user_settings["experimentalFeatures"]["directMSI"],
9633 serde_json::Value::Bool(true)
9634 );
9635 assert!(repository.test_user_settings(
9636 &serde_json::json!({
9637 "experimentalFeatures": {
9638 "directMSI": true
9639 }
9640 }),
9641 true
9642 )?);
9643
9644 repository.set_admin_setting("LocalManifestFiles", true)?;
9645 repository.set_admin_setting("InstallerHashOverride", true)?;
9646 let admin_settings = repository.get_admin_settings()?;
9647 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
9648 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
9649
9650 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
9651 let reset_one = repository.get_admin_settings()?;
9652 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
9653
9654 repository.reset_admin_setting(None, true)?;
9655 let reset_all = repository.get_admin_settings()?;
9656 for name in Repository::supported_admin_settings() {
9657 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
9658 }
9659 Ok(())
9660 })();
9661
9662 let _ = fs::remove_dir_all(&app_root);
9663 result.expect("settings");
9664 }
9665
9666 #[test]
9667 fn show_result_structured_document_is_manifest_oriented() {
9668 let result = ShowResult {
9669 package: SearchMatch {
9670 source_name: "winget".to_owned(),
9671 source_kind: SourceKind::PreIndexed,
9672 id: "Test.Package".to_owned(),
9673 name: "Test Package".to_owned(),
9674 moniker: Some("testpkg".to_owned()),
9675 version: Some("1.2.3".to_owned()),
9676 channel: Some("stable".to_owned()),
9677 match_criteria: Some("Id".to_owned()),
9678 },
9679 manifest: Manifest {
9680 id: "Test.Package".to_owned(),
9681 name: "Test Package".to_owned(),
9682 version: "1.2.3".to_owned(),
9683 channel: "stable".to_owned(),
9684 publisher: Some("Contoso".to_owned()),
9685 description: Some("Structured output".to_owned()),
9686 moniker: Some("testpkg".to_owned()),
9687 package_url: None,
9688 publisher_url: None,
9689 publisher_support_url: None,
9690 license: None,
9691 license_url: None,
9692 privacy_url: None,
9693 author: None,
9694 copyright: None,
9695 copyright_url: None,
9696 release_notes: None,
9697 release_notes_url: None,
9698 tags: vec!["utility".to_owned()],
9699 agreements: Vec::new(),
9700 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
9701 documentation: vec![Documentation {
9702 label: Some("Docs".to_owned()),
9703 url: "https://example.test/docs".to_owned(),
9704 }],
9705 installers: vec![Installer {
9706 architecture: Some("x64".to_owned()),
9707 installer_type: Some("msix".to_owned()),
9708 url: Some("https://example.test/Test.Package.msix".to_owned()),
9709 sha256: Some("ABC123".to_owned()),
9710 product_code: None,
9711 locale: Some("en-US".to_owned()),
9712 scope: Some("machine".to_owned()),
9713 release_date: None,
9714 package_family_name: None,
9715 upgrade_code: None,
9716 platforms: Vec::new(),
9717 minimum_os_version: None,
9718 switches: InstallerSwitches {
9719 silent: Some("/quiet".to_owned()),
9720 ..InstallerSwitches::default()
9721 },
9722 commands: vec!["testpkg".to_owned()],
9723 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9724 require_explicit_upgrade: false,
9725 nested_installer_type: None,
9726 nested_installer_files: Vec::new(),
9727 }],
9728 require_explicit_upgrade: false,
9729 },
9730 selected_installer: Some(Installer {
9731 architecture: Some("x64".to_owned()),
9732 installer_type: Some("msix".to_owned()),
9733 url: Some("https://example.test/Test.Package.msix".to_owned()),
9734 sha256: Some("ABC123".to_owned()),
9735 product_code: None,
9736 locale: Some("en-US".to_owned()),
9737 scope: Some("machine".to_owned()),
9738 release_date: None,
9739 package_family_name: None,
9740 upgrade_code: None,
9741 platforms: Vec::new(),
9742 minimum_os_version: None,
9743 switches: InstallerSwitches {
9744 silent: Some("/quiet".to_owned()),
9745 ..InstallerSwitches::default()
9746 },
9747 commands: vec!["testpkg".to_owned()],
9748 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
9749 require_explicit_upgrade: false,
9750 nested_installer_type: None,
9751 nested_installer_files: Vec::new(),
9752 }),
9753 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
9754 warnings: vec!["cache warmed".to_owned()],
9755 manifest_documents: JsonValue::Array(vec![
9756 serde_json::json!({
9757 "PackageIdentifier": "Test.Package",
9758 "PackageVersion": "1.2.3",
9759 "DefaultLocale": "en-US",
9760 "ManifestType": "version",
9761 "ManifestVersion": "1.10.0"
9762 }),
9763 serde_json::json!({
9764 "PackageIdentifier": "Test.Package",
9765 "PackageVersion": "1.2.3",
9766 "PackageLocale": "en-US",
9767 "PackageName": "Test Package",
9768 "Publisher": "Example",
9769 "License": "MIT",
9770 "ShortDescription": "Structured output",
9771 "ManifestType": "defaultLocale",
9772 "ManifestVersion": "1.10.0"
9773 }),
9774 serde_json::json!({
9775 "PackageIdentifier": "Test.Package",
9776 "PackageVersion": "1.2.3",
9777 "ManifestType": "installer",
9778 "ManifestVersion": "1.10.0",
9779 "Installers": [
9780 {
9781 "Architecture": "x64",
9782 "InstallerType": "msix",
9783 "InstallerUrl": "https://example.test/Test.Package.msix",
9784 "InstallerSha256": "ABC123",
9785 "Commands": ["testpkg"],
9786 "InstallerSwitches": { "Silent": "/quiet" },
9787 "Dependencies": {
9788 "PackageDependencies": [
9789 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
9790 ]
9791 }
9792 }
9793 ]
9794 }),
9795 ]),
9796 };
9797
9798 let document = result.structured_document();
9799
9800 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
9801 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
9802 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
9803 assert_eq!(
9804 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
9805 Some("Microsoft.VCRedist.2015+.x64")
9806 );
9807 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
9808 assert_eq!(
9809 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
9810 Some("/quiet")
9811 );
9812 }
9813
9814 #[test]
9815 fn manifest_parses_require_explicit_upgrade_at_top_level() {
9816 let yaml = r#"
9821PackageIdentifier: Test.Package
9822PackageVersion: 1.2.3
9823DefaultLocale: en-US
9824ManifestType: singleton
9825ManifestVersion: 1.10.0
9826PackageLocale: en-US
9827PackageName: Test Package
9828Publisher: Example
9829License: MIT
9830ShortDescription: explicit-upgrade fixture
9831RequireExplicitUpgrade: true
9832Installers:
9833 - Architecture: x64
9834 InstallerType: exe
9835 InstallerUrl: https://example.test/Test.Package.exe
9836 InstallerSha256: ABC123
9837"#;
9838 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9839 assert!(manifest.require_explicit_upgrade);
9840 }
9841
9842 #[test]
9843 fn manifest_parses_require_explicit_upgrade_on_installer() {
9844 let yaml = r#"
9848PackageIdentifier: Test.Package
9849PackageVersion: 1.2.3
9850DefaultLocale: en-US
9851ManifestType: singleton
9852ManifestVersion: 1.10.0
9853PackageLocale: en-US
9854PackageName: Test Package
9855Publisher: Example
9856License: MIT
9857ShortDescription: explicit-upgrade fixture
9858Installers:
9859 - Architecture: x64
9860 InstallerType: exe
9861 InstallerUrl: https://example.test/Test.Package.x64.exe
9862 InstallerSha256: ABC123
9863 - Architecture: arm64
9864 InstallerType: exe
9865 InstallerUrl: https://example.test/Test.Package.arm64.exe
9866 InstallerSha256: DEF456
9867 RequireExplicitUpgrade: true
9868"#;
9869 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9870 assert!(manifest.require_explicit_upgrade);
9871 assert!(!manifest.installers[0].require_explicit_upgrade);
9872 assert!(manifest.installers[1].require_explicit_upgrade);
9873 }
9874
9875 #[test]
9876 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
9877 let yaml = r#"
9878PackageIdentifier: Test.Package
9879PackageVersion: 1.2.3
9880DefaultLocale: en-US
9881ManifestType: singleton
9882ManifestVersion: 1.10.0
9883PackageLocale: en-US
9884PackageName: Test Package
9885Publisher: Example
9886License: MIT
9887ShortDescription: baseline fixture
9888Installers:
9889 - Architecture: x64
9890 InstallerType: exe
9891 InstallerUrl: https://example.test/Test.Package.exe
9892 InstallerSha256: ABC123
9893"#;
9894 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
9895 assert!(!manifest.require_explicit_upgrade);
9896 }
9897
9898 #[test]
9899 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
9900 let mut pkg = InstalledPackage {
9904 name: "Edge".to_owned(),
9905 local_id: r"ARP\Machine\X64\Edge".to_owned(),
9906 installed_version: "100.0".to_owned(),
9907 publisher: None,
9908 scope: Some("Machine".to_owned()),
9909 installer_category: Some("exe".to_owned()),
9910 install_location: None,
9911 package_family_names: Vec::new(),
9912 product_codes: Vec::new(),
9913 upgrade_codes: Vec::new(),
9914 correlated: Some(SearchMatch {
9915 source_name: "winget".to_owned(),
9916 source_kind: SourceKind::PreIndexed,
9917 id: "Microsoft.Edge".to_owned(),
9918 name: "Microsoft Edge".to_owned(),
9919 moniker: None,
9920 version: Some("110.0".to_owned()),
9921 channel: None,
9922 match_criteria: None,
9923 }),
9924 installed_version_canonical: false,
9925 correlated_requires_explicit_upgrade: true,
9926 correlated_lacks_compatible_installer: false,
9927 };
9928 let bulk_query = ListQuery {
9929 upgrade_only: true,
9930 ..ListQuery::default()
9931 };
9932 assert!(
9933 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
9934 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
9935 );
9936
9937 let filtered_query = ListQuery {
9940 upgrade_only: true,
9941 id: Some("Microsoft.Edge".to_owned()),
9942 ..ListQuery::default()
9943 };
9944 assert!(
9945 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
9946 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
9947 );
9948
9949 pkg.correlated_requires_explicit_upgrade = false;
9951 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9952 }
9953
9954 #[test]
9955 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
9956 let mut pkg = InstalledPackage {
9957 name: "Foo".to_owned(),
9958 local_id: r"ARP\Machine\X64\Foo".to_owned(),
9959 installed_version: "1.0".to_owned(),
9960 publisher: None,
9961 scope: Some("Machine".to_owned()),
9962 installer_category: Some("exe".to_owned()),
9963 install_location: None,
9964 package_family_names: Vec::new(),
9965 product_codes: Vec::new(),
9966 upgrade_codes: Vec::new(),
9967 correlated: Some(SearchMatch {
9968 source_name: "winget".to_owned(),
9969 source_kind: SourceKind::PreIndexed,
9970 id: "Test.Foo".to_owned(),
9971 name: "Foo".to_owned(),
9972 moniker: None,
9973 version: Some("2.0".to_owned()),
9974 channel: None,
9975 match_criteria: None,
9976 }),
9977 installed_version_canonical: false,
9978 correlated_requires_explicit_upgrade: false,
9979 correlated_lacks_compatible_installer: true,
9980 };
9981 let bulk_query = ListQuery {
9982 upgrade_only: true,
9983 ..ListQuery::default()
9984 };
9985 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9986
9987 let filtered_query = ListQuery {
9989 upgrade_only: true,
9990 id: Some("Test.Foo".to_owned()),
9991 ..ListQuery::default()
9992 };
9993 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
9994
9995 pkg.correlated_lacks_compatible_installer = false;
9997 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
9998 }
9999
10000 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
10001 let installers = arches
10002 .iter()
10003 .map(|a| Installer {
10004 architecture: Some((*a).to_owned()),
10005 installer_type: None,
10006 url: None,
10007 sha256: None,
10008 product_code: None,
10009 locale: None,
10010 scope: None,
10011 release_date: None,
10012 package_family_name: None,
10013 upgrade_code: None,
10014 platforms: Vec::new(),
10015 minimum_os_version: None,
10016 switches: InstallerSwitches::default(),
10017 commands: Vec::new(),
10018 package_dependencies: Vec::new(),
10019 require_explicit_upgrade: false,
10020 nested_installer_type: None,
10021 nested_installer_files: Vec::new(),
10022 })
10023 .collect();
10024 Manifest {
10025 id: "Test".to_owned(),
10026 name: "Test".to_owned(),
10027 version: "1.0".to_owned(),
10028 channel: String::new(),
10029 publisher: None,
10030 description: None,
10031 moniker: None,
10032 package_url: None,
10033 publisher_url: None,
10034 publisher_support_url: None,
10035 license: None,
10036 license_url: None,
10037 privacy_url: None,
10038 author: None,
10039 copyright: None,
10040 copyright_url: None,
10041 release_notes: None,
10042 release_notes_url: None,
10043 tags: Vec::new(),
10044 agreements: Vec::new(),
10045 package_dependencies: Vec::new(),
10046 documentation: Vec::new(),
10047 installers,
10048 require_explicit_upgrade: false,
10049 }
10050 }
10051
10052 #[test]
10053 fn manifest_compatible_installer_neutral_always_matches() {
10054 assert!(manifest_has_compatible_installer(
10056 &synthetic_manifest_with_installer_arches(&["neutral"])
10057 ));
10058 }
10059
10060 #[test]
10061 fn manifest_compatible_installer_empty_installers_pass_through() {
10062 assert!(manifest_has_compatible_installer(
10065 &synthetic_manifest_with_installer_arches(&[])
10066 ));
10067 }
10068
10069 #[test]
10070 fn manifest_compatible_installer_rejects_alien_arch_only() {
10071 assert!(!manifest_has_compatible_installer(
10073 &synthetic_manifest_with_installer_arches(&["ppc"])
10074 ));
10075 }
10076
10077 #[test]
10078 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
10079 assert!(manifest_has_compatible_installer(
10081 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
10082 ));
10083 }
10084
10085 #[test]
10086 fn lookup_unique_normalized_identity_returns_unique_match() {
10087 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10089 execute_batch_sql(
10090 &connection,
10091 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10092 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10093 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
10094 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
10095 )
10096 .expect("seed schema");
10097
10098 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
10099 .expect("query")
10100 .expect("unique match");
10101 assert_eq!(rowid, 100);
10102 }
10103
10104 #[test]
10105 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
10106 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10111 execute_batch_sql(
10112 &connection,
10113 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10114 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10115 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10116 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
10117 )
10118 .expect("seed schema");
10119
10120 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10121 assert!(rowid.is_none(), "ambiguous match must not correlate");
10122 }
10123
10124 #[test]
10125 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
10126 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10130 execute_batch_sql(
10131 &connection,
10132 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10133 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10134 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
10135 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
10136 )
10137 .expect("seed schema");
10138
10139 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
10140 assert_eq!(rowid, Some(100));
10141 }
10142
10143 #[test]
10144 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
10145 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
10148 execute_batch_sql(
10149 &connection,
10150 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
10151 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
10152 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
10153 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
10154 )
10155 .expect("seed schema");
10156
10157 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
10158 assert!(
10159 rowid.is_none(),
10160 "name matches package 100 but publisher matches 200 — no intersection"
10161 );
10162 }
10163
10164 #[test]
10165 fn parse_yaml_manifest_bundle_returns_singleton_document() {
10166 let yaml = r#"
10167PackageIdentifier: Test.Package
10168PackageVersion: 1.2.3
10169DefaultLocale: en-US
10170ManifestType: version
10171ManifestVersion: 1.10.0
10172---
10173PackageIdentifier: Test.Package
10174PackageVersion: 1.2.3
10175PackageLocale: en-US
10176PackageName: Test Package
10177Publisher: Example
10178License: MIT
10179ShortDescription: Structured output
10180ManifestType: defaultLocale
10181ManifestVersion: 1.10.0
10182---
10183PackageIdentifier: Test.Package
10184PackageVersion: 1.2.3
10185ManifestType: installer
10186ManifestVersion: 1.10.0
10187Installers:
10188 - Architecture: x64
10189 InstallerType: exe
10190 InstallerUrl: https://example.test/Test.Package.exe
10191 InstallerSha256: ABC123
10192"#;
10193
10194 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
10195
10196 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
10197 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
10198 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
10199 }
10200
10201 #[test]
10202 fn collapse_structured_documents_returns_plural_show_documents() {
10203 let documents = collapse_structured_documents(&[
10204 JsonValue::Array(vec![
10205 serde_json::json!({
10206 "PackageIdentifier": "Test.Package.One",
10207 "PackageVersion": "1.0.0",
10208 "DefaultLocale": "en-US",
10209 "ManifestType": "version",
10210 "ManifestVersion": "1.10.0"
10211 }),
10212 serde_json::json!({
10213 "PackageIdentifier": "Test.Package.One",
10214 "PackageVersion": "1.0.0",
10215 "PackageLocale": "en-US",
10216 "PackageName": "Test Package One",
10217 "ManifestType": "defaultLocale",
10218 "ManifestVersion": "1.10.0"
10219 }),
10220 serde_json::json!({
10221 "PackageIdentifier": "Test.Package.One",
10222 "PackageVersion": "1.0.0",
10223 "ManifestType": "installer",
10224 "ManifestVersion": "1.10.0",
10225 "Installers": [
10226 {
10227 "Architecture": "x64",
10228 "InstallerType": "exe",
10229 "InstallerUrl": "https://example.test/one.exe",
10230 "InstallerSha256": "ABC123"
10231 }
10232 ]
10233 }),
10234 ]),
10235 serde_json::json!({
10236 "PackageIdentifier": "Test.Package.Two",
10237 "PackageVersion": "2.0.0",
10238 "PackageLocale": "en-US",
10239 "PackageName": "Test Package Two",
10240 "ManifestType": "singleton",
10241 "ManifestVersion": "1.12.0"
10242 }),
10243 ]);
10244
10245 assert_eq!(documents.len(), 2);
10246 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
10247 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
10248 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
10249 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
10250 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
10251 }
10252
10253 #[test]
10254 fn unsupported_action_result_marks_noop_and_warning() {
10255 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
10256
10257 assert!(result.success);
10258 assert!(result.no_op);
10259 assert_eq!(result.exit_code, 0);
10260 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
10261 }
10262
10263 #[test]
10264 fn create_install_no_op_result_honors_no_upgrade() {
10265 let mut request = InstallRequest::new(PackageQuery {
10266 id: Some("Contoso.App".to_owned()),
10267 ..PackageQuery::default()
10268 });
10269 request.no_upgrade = true;
10270
10271 let manifest = Manifest {
10272 id: "Contoso.App".to_owned(),
10273 name: "Contoso App".to_owned(),
10274 version: "2.0.0".to_owned(),
10275 channel: String::new(),
10276 publisher: None,
10277 description: None,
10278 moniker: None,
10279 package_url: None,
10280 publisher_url: None,
10281 publisher_support_url: None,
10282 license: None,
10283 license_url: None,
10284 privacy_url: None,
10285 author: None,
10286 copyright: None,
10287 copyright_url: None,
10288 release_notes: None,
10289 release_notes_url: None,
10290 tags: Vec::new(),
10291 agreements: Vec::new(),
10292 package_dependencies: Vec::new(),
10293 documentation: Vec::new(),
10294 installers: Vec::new(),
10295 require_explicit_upgrade: false,
10296 };
10297 let existing = ListMatch {
10298 name: "Contoso App".to_owned(),
10299 id: "Contoso.App".to_owned(),
10300 local_id: "Contoso.App".to_owned(),
10301 installed_version: "1.0.0".to_owned(),
10302 available_version: Some("2.0.0".to_owned()),
10303 source_name: Some("winget".to_owned()),
10304 publisher: None,
10305 scope: None,
10306 installer_category: None,
10307 install_location: None,
10308 package_family_names: Vec::new(),
10309 product_codes: Vec::new(),
10310 upgrade_codes: Vec::new(),
10311 };
10312
10313 let result =
10314 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10315
10316 assert!(result.success);
10317 assert!(result.no_op);
10318 assert_eq!(result.version, "1.0.0");
10319 assert_eq!(
10320 result.warnings,
10321 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
10322 );
10323 }
10324
10325 #[test]
10326 fn create_install_no_op_result_skips_reinstall_when_current() {
10327 let request = InstallRequest::new(PackageQuery {
10328 id: Some("Contoso.App".to_owned()),
10329 ..PackageQuery::default()
10330 });
10331
10332 let manifest = Manifest {
10333 id: "Contoso.App".to_owned(),
10334 name: "Contoso App".to_owned(),
10335 version: "2.0.0".to_owned(),
10336 channel: String::new(),
10337 publisher: None,
10338 description: None,
10339 moniker: None,
10340 package_url: None,
10341 publisher_url: None,
10342 publisher_support_url: None,
10343 license: None,
10344 license_url: None,
10345 privacy_url: None,
10346 author: None,
10347 copyright: None,
10348 copyright_url: None,
10349 release_notes: None,
10350 release_notes_url: None,
10351 tags: Vec::new(),
10352 agreements: Vec::new(),
10353 package_dependencies: Vec::new(),
10354 documentation: Vec::new(),
10355 installers: Vec::new(),
10356 require_explicit_upgrade: false,
10357 };
10358 let existing = ListMatch {
10359 name: "Contoso App".to_owned(),
10360 id: "Contoso.App".to_owned(),
10361 local_id: "Contoso.App".to_owned(),
10362 installed_version: "2.0.0".to_owned(),
10363 available_version: Some("2.0.0".to_owned()),
10364 source_name: Some("winget".to_owned()),
10365 publisher: None,
10366 scope: None,
10367 installer_category: None,
10368 install_location: None,
10369 package_family_names: Vec::new(),
10370 product_codes: Vec::new(),
10371 upgrade_codes: Vec::new(),
10372 };
10373
10374 let result =
10375 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
10376
10377 assert!(result.success);
10378 assert!(result.no_op);
10379 assert_eq!(result.version, "2.0.0");
10380 assert_eq!(
10381 result.warnings,
10382 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
10383 );
10384 }
10385
10386 #[test]
10387 fn collapse_structured_document_projects_merged_manifest() {
10388 let document = collapse_structured_document(&serde_json::json!({
10389 "PackageIdentifier": "Test.Package",
10390 "PackageVersion": "1.2.3",
10391 "PackageLocale": "en-US",
10392 "PackageName": "Test Package",
10393 "Publisher": "Example",
10394 "InstallerType": "exe",
10395 "Installers": [
10396 {
10397 "Architecture": "x64",
10398 "InstallerUrl": "https://example.test/Test.Package.exe",
10399 "InstallerSha256": "ABC123"
10400 }
10401 ],
10402 "ManifestType": "merged",
10403 "ManifestVersion": "1.10.0"
10404 }));
10405
10406 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
10407 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
10408 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
10409 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
10410 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
10411 }
10412
10413 #[test]
10414 fn parses_fixture_manifest() {
10415 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
10416 .join("tests")
10417 .join("fixtures")
10418 .join("ManifestV1_28-Singleton.yaml");
10419 let bytes = fs::read(root).expect("fixture bytes");
10420 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
10421 assert_eq!(manifest.id, "microsoft.msixsdk");
10422 assert_eq!(manifest.version, "1.7.32");
10423 assert_eq!(manifest.name, "MSIX SDK");
10424 assert!(!manifest.installers.is_empty());
10425 }
10426
10427 #[test]
10428 fn parses_installer_switches_from_yaml_manifest() {
10429 let yaml = r#"
10430PackageIdentifier: Test.Package
10431PackageVersion: 1.2.3
10432PackageName: Test Package
10433InstallerSwitches:
10434 SilentWithProgress: /SILENT
10435Installers:
10436 - Architecture: x64
10437 InstallerType: inno
10438 InstallerUrl: https://example.test/Test.Package.exe
10439 InstallerSha256: ABC123
10440 InstallerSwitches:
10441 Silent: /VERYSILENT
10442 Interactive: /HELP
10443"#;
10444
10445 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10446 let installer = &manifest.installers[0];
10447
10448 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
10449 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
10450 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
10451 }
10452
10453 #[test]
10454 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
10455 let yaml = r#"
10456PackageIdentifier: Test.Package
10457PackageVersion: 1.2.3
10458PackageName: Test Package
10459Platform:
10460 - Windows.Desktop
10461MinimumOSVersion: 10.0.19041.0
10462Installers:
10463 - Architecture: x64
10464 InstallerType: exe
10465 InstallerUrl: https://example.test/Test.Package.exe
10466 InstallerSha256: ABC123
10467 - Architecture: x64
10468 InstallerType: msix
10469 Platform:
10470 - Windows.Universal
10471 MinimumOSVersion: 10.0.22621.0
10472 InstallerUrl: https://example.test/Test.Package.msix
10473 InstallerSha256: DEF456
10474"#;
10475
10476 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
10477
10478 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
10479 assert_eq!(
10480 manifest.installers[0].minimum_os_version.as_deref(),
10481 Some("10.0.19041.0")
10482 );
10483 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
10484 assert_eq!(
10485 manifest.installers[1].minimum_os_version.as_deref(),
10486 Some("10.0.22621.0")
10487 );
10488 }
10489
10490 #[test]
10491 fn installer_switch_arguments_prefer_manifest_switches() {
10492 let installer = Installer {
10493 architecture: None,
10494 installer_type: Some("inno".to_owned()),
10495 url: None,
10496 sha256: None,
10497 product_code: None,
10498 locale: None,
10499 scope: None,
10500 release_date: None,
10501 package_family_name: None,
10502 upgrade_code: None,
10503 platforms: Vec::new(),
10504 minimum_os_version: None,
10505 switches: InstallerSwitches {
10506 silent: Some("/mysilent".to_owned()),
10507 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
10508 interactive: Some("/myinteractive".to_owned()),
10509 ..InstallerSwitches::default()
10510 },
10511 commands: Vec::new(),
10512 package_dependencies: Vec::new(),
10513 require_explicit_upgrade: false,
10514 nested_installer_type: None,
10515 nested_installer_files: Vec::new(),
10516 };
10517 let manifest = Manifest {
10518 id: "Test.Package".to_owned(),
10519 name: "Test Package".to_owned(),
10520 version: "1.0.0".to_owned(),
10521 channel: String::new(),
10522 publisher: None,
10523 description: None,
10524 moniker: None,
10525 package_url: None,
10526 publisher_url: None,
10527 publisher_support_url: None,
10528 license: None,
10529 license_url: None,
10530 privacy_url: None,
10531 author: None,
10532 copyright: None,
10533 copyright_url: None,
10534 release_notes: None,
10535 release_notes_url: None,
10536 tags: Vec::new(),
10537 agreements: Vec::new(),
10538 package_dependencies: Vec::new(),
10539 documentation: Vec::new(),
10540 installers: Vec::new(),
10541 require_explicit_upgrade: false,
10542 };
10543
10544 let mut silent_request = InstallRequest::new(PackageQuery::default());
10545 silent_request.mode = InstallerMode::Silent;
10546 let mut progress_request = InstallRequest::new(PackageQuery::default());
10547 progress_request.mode = InstallerMode::SilentWithProgress;
10548 let mut interactive_request = InstallRequest::new(PackageQuery::default());
10549 interactive_request.mode = InstallerMode::Interactive;
10550 assert_eq!(
10551 installer_command_arguments(
10552 "inno",
10553 &silent_request,
10554 &manifest,
10555 Path::new("installer.exe"),
10556 &installer
10557 ),
10558 vec!["/mysilent".to_owned()]
10559 );
10560 assert_eq!(
10561 installer_command_arguments(
10562 "inno",
10563 &progress_request,
10564 &manifest,
10565 Path::new("installer.exe"),
10566 &installer
10567 ),
10568 vec!["/mysilentwithprogress".to_owned()]
10569 );
10570 assert_eq!(
10571 installer_command_arguments(
10572 "inno",
10573 &interactive_request,
10574 &manifest,
10575 Path::new("installer.exe"),
10576 &installer
10577 ),
10578 vec!["/myinteractive".to_owned()]
10579 );
10580 }
10581
10582 #[test]
10583 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
10584 let installer = Installer {
10585 architecture: None,
10586 installer_type: Some("inno".to_owned()),
10587 url: None,
10588 sha256: None,
10589 product_code: None,
10590 locale: None,
10591 scope: None,
10592 release_date: None,
10593 package_family_name: None,
10594 upgrade_code: None,
10595 platforms: Vec::new(),
10596 minimum_os_version: None,
10597 switches: InstallerSwitches::default(),
10598 commands: Vec::new(),
10599 package_dependencies: Vec::new(),
10600 require_explicit_upgrade: false,
10601 nested_installer_type: None,
10602 nested_installer_files: Vec::new(),
10603 };
10604 let manifest = Manifest {
10605 id: "Test.Package".to_owned(),
10606 name: "Test Package".to_owned(),
10607 version: "1.0.0".to_owned(),
10608 channel: String::new(),
10609 publisher: None,
10610 description: None,
10611 moniker: None,
10612 package_url: None,
10613 publisher_url: None,
10614 publisher_support_url: None,
10615 license: None,
10616 license_url: None,
10617 privacy_url: None,
10618 author: None,
10619 copyright: None,
10620 copyright_url: None,
10621 release_notes: None,
10622 release_notes_url: None,
10623 tags: Vec::new(),
10624 agreements: Vec::new(),
10625 package_dependencies: Vec::new(),
10626 documentation: Vec::new(),
10627 installers: Vec::new(),
10628 require_explicit_upgrade: false,
10629 };
10630 let mut progress_request = InstallRequest::new(PackageQuery::default());
10631 progress_request.mode = InstallerMode::SilentWithProgress;
10632 let mut silent_request = InstallRequest::new(PackageQuery::default());
10633 silent_request.mode = InstallerMode::Silent;
10634
10635 assert_eq!(
10636 installer_command_arguments(
10637 "inno",
10638 &progress_request,
10639 &manifest,
10640 Path::new("installer.exe"),
10641 &installer
10642 ),
10643 vec![
10644 "/SP-".to_owned(),
10645 "/SILENT".to_owned(),
10646 "/SUPPRESSMSGBOXES".to_owned(),
10647 "/NORESTART".to_owned()
10648 ]
10649 );
10650 assert_eq!(
10651 installer_command_arguments(
10652 "inno",
10653 &silent_request,
10654 &manifest,
10655 Path::new("installer.exe"),
10656 &installer
10657 ),
10658 vec![
10659 "/SP-".to_owned(),
10660 "/VERYSILENT".to_owned(),
10661 "/SUPPRESSMSGBOXES".to_owned(),
10662 "/NORESTART".to_owned()
10663 ]
10664 );
10665 }
10666
10667 #[test]
10668 fn installer_command_arguments_append_manifest_and_cli_switches() {
10669 let installer = Installer {
10670 architecture: None,
10671 installer_type: Some("msi".to_owned()),
10672 url: None,
10673 sha256: None,
10674 product_code: None,
10675 locale: None,
10676 scope: None,
10677 release_date: None,
10678 package_family_name: None,
10679 upgrade_code: None,
10680 platforms: Vec::new(),
10681 minimum_os_version: None,
10682 switches: InstallerSwitches {
10683 custom: Some("ADDLOCAL=Core".to_owned()),
10684 log: Some("/log \"<LOGPATH>\"".to_owned()),
10685 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
10686 ..InstallerSwitches::default()
10687 },
10688 commands: Vec::new(),
10689 package_dependencies: Vec::new(),
10690 require_explicit_upgrade: false,
10691 nested_installer_type: None,
10692 nested_installer_files: Vec::new(),
10693 };
10694 let manifest = Manifest {
10695 id: "ShareX.ShareX".to_owned(),
10696 name: "ShareX".to_owned(),
10697 version: "19.0.2".to_owned(),
10698 channel: String::new(),
10699 publisher: None,
10700 description: None,
10701 moniker: None,
10702 package_url: None,
10703 publisher_url: None,
10704 publisher_support_url: None,
10705 license: None,
10706 license_url: None,
10707 privacy_url: None,
10708 author: None,
10709 copyright: None,
10710 copyright_url: None,
10711 release_notes: None,
10712 release_notes_url: None,
10713 tags: Vec::new(),
10714 agreements: Vec::new(),
10715 package_dependencies: Vec::new(),
10716 documentation: Vec::new(),
10717 installers: Vec::new(),
10718 require_explicit_upgrade: false,
10719 };
10720 let mut request = InstallRequest::new(PackageQuery::default());
10721 request.mode = InstallerMode::Silent;
10722 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
10723 request.custom = Some("REBOOT=ReallySuppress".to_owned());
10724 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
10725
10726 assert_eq!(
10727 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
10728 vec![
10729 "/i".to_owned(),
10730 r"C:\temp\ShareX.msi".to_owned(),
10731 "/quiet".to_owned(),
10732 "/norestart".to_owned(),
10733 "/log".to_owned(),
10734 r"C:\temp\winget.log".to_owned(),
10735 "ADDLOCAL=Core".to_owned(),
10736 "REBOOT=ReallySuppress".to_owned(),
10737 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
10738 ]
10739 );
10740 }
10741
10742 #[test]
10743 fn decompresses_mszyml_payload() {
10744 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
10745 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10746 encoder.write_all(payload.as_bytes()).expect("write payload");
10747 let compressed = encoder.finish().expect("finish payload");
10748 let mut mszip = b"CK".to_vec();
10749 mszip.extend_from_slice(&compressed);
10750
10751 let decompressed = decompress_mszyml(&mszip).expect("decompress");
10752 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10753 assert_eq!(parsed.versions[0].version, "1.2.3");
10754 }
10755
10756 #[test]
10757 fn decompresses_mszyml_payload_with_prefix() {
10758 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
10759 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
10760 encoder.write_all(payload.as_bytes()).expect("write payload");
10761 let compressed = encoder.finish().expect("finish payload");
10762 let mut prefixed = vec![0u8; 28];
10763 prefixed.extend_from_slice(b"CK");
10764 prefixed.extend_from_slice(&compressed);
10765
10766 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
10767 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
10768 assert_eq!(parsed.versions[0].version, "1.2.3");
10769 }
10770
10771 #[test]
10772 fn compares_versions_naturally() {
10773 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
10774 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
10775 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
10776 }
10777
10778 #[test]
10779 fn installer_hash_verification_fails_on_mismatch_without_override() {
10780 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
10781 assert!(result.is_err());
10782 assert!(
10783 result
10784 .expect_err("hash mismatch")
10785 .to_string()
10786 .contains("Installer hash mismatch")
10787 );
10788 }
10789
10790 #[test]
10791 fn installer_hash_verification_allows_mismatch_with_override() {
10792 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
10793 verify_installer_hash(None, b"pinget", false).expect("missing hash");
10794 }
10795
10796 #[test]
10797 fn search_query_many_includes_tag_and_command_conditions() {
10798 let query = PackageQuery {
10799 query: Some("terminal".to_owned()),
10800 ..PackageQuery::default()
10801 };
10802
10803 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10804
10805 assert!(where_clause.contains("tags2"));
10806 assert!(where_clause.contains("commands2"));
10807 assert_eq!(params.len(), 5);
10808 }
10809
10810 #[test]
10811 fn show_query_single_omits_tag_and_command_conditions() {
10812 let query = PackageQuery {
10813 query: Some("terminal".to_owned()),
10814 ..PackageQuery::default()
10815 };
10816
10817 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
10818
10819 assert!(!where_clause.contains("tags2"));
10820 assert!(!where_clause.contains("commands2"));
10821 assert_eq!(params.len(), 3);
10822 }
10823
10824 #[test]
10825 fn explicit_tag_filter_uses_exact_match() {
10826 let query = PackageQuery {
10827 tag: Some("terminal".to_owned()),
10828 ..PackageQuery::default()
10829 };
10830
10831 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
10832
10833 assert!(where_clause.contains("tags2"));
10834 assert_eq!(params, vec!["terminal".to_owned()]);
10835 }
10836
10837 #[test]
10838 fn rest_tag_filter_uses_exact_match() {
10839 let query = PackageQuery {
10840 tag: Some("terminal".to_owned()),
10841 ..PackageQuery::default()
10842 };
10843 let info = RestInformation {
10844 required_package_match_fields: Vec::new(),
10845 unsupported_package_match_fields: Vec::new(),
10846 required_query_parameters: Vec::new(),
10847 ..RestInformation::default()
10848 };
10849
10850 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
10851 let filters = body["Filters"].as_array().expect("filters");
10852
10853 assert_eq!(filters.len(), 1);
10854 assert_eq!(filters[0]["PackageMatchField"], "Tag");
10855 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
10856 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
10857 }
10858
10859 #[test]
10860 fn selects_installer_using_requested_filters() {
10861 let installers = vec![
10862 Installer {
10863 architecture: Some("x86".to_owned()),
10864 installer_type: Some("zip".to_owned()),
10865 url: None,
10866 sha256: None,
10867 product_code: None,
10868 locale: Some("en-US".to_owned()),
10869 scope: Some("user".to_owned()),
10870 release_date: None,
10871 package_family_name: None,
10872 upgrade_code: None,
10873 platforms: Vec::new(),
10874 minimum_os_version: None,
10875 switches: InstallerSwitches::default(),
10876 commands: Vec::new(),
10877 package_dependencies: Vec::new(),
10878 require_explicit_upgrade: false,
10879 nested_installer_type: None,
10880 nested_installer_files: Vec::new(),
10881 },
10882 Installer {
10883 architecture: Some("x64".to_owned()),
10884 installer_type: Some("msix".to_owned()),
10885 url: None,
10886 sha256: None,
10887 product_code: None,
10888 locale: Some("en-US".to_owned()),
10889 scope: Some("user".to_owned()),
10890 release_date: None,
10891 package_family_name: None,
10892 upgrade_code: None,
10893 platforms: Vec::new(),
10894 minimum_os_version: None,
10895 switches: InstallerSwitches::default(),
10896 commands: vec!["demo".to_owned()],
10897 package_dependencies: Vec::new(),
10898 require_explicit_upgrade: false,
10899 nested_installer_type: None,
10900 nested_installer_files: Vec::new(),
10901 },
10902 ];
10903 let query = PackageQuery {
10904 installer_type: Some("msix".to_owned()),
10905 installer_architecture: Some("x64".to_owned()),
10906 install_scope: Some("user".to_owned()),
10907 locale: Some("en-US".to_owned()),
10908 ..PackageQuery::default()
10909 };
10910
10911 let selected = select_installer(&installers, &query).expect("selected installer");
10912 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
10913 assert_eq!(selected.architecture.as_deref(), Some("x64"));
10914 }
10915
10916 #[test]
10917 fn selects_first_installer_when_rank_ties() {
10918 let installers = vec![
10919 Installer {
10920 architecture: Some("x64".to_owned()),
10921 installer_type: Some("exe".to_owned()),
10922 url: None,
10923 sha256: None,
10924 product_code: None,
10925 locale: Some("en-US".to_owned()),
10926 scope: Some("machine".to_owned()),
10927 release_date: None,
10928 package_family_name: None,
10929 upgrade_code: None,
10930 platforms: Vec::new(),
10931 minimum_os_version: None,
10932 switches: InstallerSwitches::default(),
10933 commands: Vec::new(),
10934 package_dependencies: Vec::new(),
10935 require_explicit_upgrade: false,
10936 nested_installer_type: None,
10937 nested_installer_files: Vec::new(),
10938 },
10939 Installer {
10940 architecture: Some("x64".to_owned()),
10941 installer_type: Some("exe".to_owned()),
10942 url: None,
10943 sha256: None,
10944 product_code: None,
10945 locale: Some("en-US".to_owned()),
10946 scope: Some("user".to_owned()),
10947 release_date: None,
10948 package_family_name: None,
10949 upgrade_code: None,
10950 platforms: Vec::new(),
10951 minimum_os_version: None,
10952 switches: InstallerSwitches::default(),
10953 commands: Vec::new(),
10954 package_dependencies: Vec::new(),
10955 require_explicit_upgrade: false,
10956 nested_installer_type: None,
10957 nested_installer_files: Vec::new(),
10958 },
10959 ];
10960
10961 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
10962 assert_eq!(selected.scope.as_deref(), Some("machine"));
10963 }
10964
10965 #[test]
10966 fn selects_installer_using_requested_platform() {
10967 let installers = vec![
10968 Installer {
10969 architecture: Some("x64".to_owned()),
10970 installer_type: Some("exe".to_owned()),
10971 url: None,
10972 sha256: None,
10973 product_code: None,
10974 locale: Some("en-US".to_owned()),
10975 scope: Some("user".to_owned()),
10976 release_date: None,
10977 package_family_name: None,
10978 upgrade_code: None,
10979 platforms: vec!["Windows.Universal".to_owned()],
10980 minimum_os_version: None,
10981 switches: InstallerSwitches::default(),
10982 commands: Vec::new(),
10983 package_dependencies: Vec::new(),
10984 require_explicit_upgrade: false,
10985 nested_installer_type: None,
10986 nested_installer_files: Vec::new(),
10987 },
10988 Installer {
10989 architecture: Some("x64".to_owned()),
10990 installer_type: Some("exe".to_owned()),
10991 url: None,
10992 sha256: None,
10993 product_code: None,
10994 locale: Some("en-US".to_owned()),
10995 scope: Some("user".to_owned()),
10996 release_date: None,
10997 package_family_name: None,
10998 upgrade_code: None,
10999 platforms: vec!["Windows.Desktop".to_owned()],
11000 minimum_os_version: None,
11001 switches: InstallerSwitches::default(),
11002 commands: Vec::new(),
11003 package_dependencies: Vec::new(),
11004 require_explicit_upgrade: false,
11005 nested_installer_type: None,
11006 nested_installer_files: Vec::new(),
11007 },
11008 ];
11009 let query = PackageQuery {
11010 installer_type: Some("exe".to_owned()),
11011 platform: Some("Windows.Desktop".to_owned()),
11012 ..PackageQuery::default()
11013 };
11014
11015 let selected = select_installer(&installers, &query).expect("selected installer");
11016 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
11017 }
11018
11019 #[test]
11020 fn selects_installer_using_requested_os_version() {
11021 let installers = vec![
11022 Installer {
11023 architecture: Some("x64".to_owned()),
11024 installer_type: Some("exe".to_owned()),
11025 url: None,
11026 sha256: None,
11027 product_code: None,
11028 locale: Some("en-US".to_owned()),
11029 scope: Some("user".to_owned()),
11030 release_date: None,
11031 package_family_name: None,
11032 upgrade_code: None,
11033 platforms: Vec::new(),
11034 minimum_os_version: Some("10.0.22621.0".to_owned()),
11035 switches: InstallerSwitches::default(),
11036 commands: Vec::new(),
11037 package_dependencies: Vec::new(),
11038 require_explicit_upgrade: false,
11039 nested_installer_type: None,
11040 nested_installer_files: Vec::new(),
11041 },
11042 Installer {
11043 architecture: Some("x64".to_owned()),
11044 installer_type: Some("exe".to_owned()),
11045 url: None,
11046 sha256: None,
11047 product_code: None,
11048 locale: Some("en-US".to_owned()),
11049 scope: Some("user".to_owned()),
11050 release_date: None,
11051 package_family_name: None,
11052 upgrade_code: None,
11053 platforms: Vec::new(),
11054 minimum_os_version: Some("10.0.19041.0".to_owned()),
11055 switches: InstallerSwitches::default(),
11056 commands: Vec::new(),
11057 package_dependencies: Vec::new(),
11058 require_explicit_upgrade: false,
11059 nested_installer_type: None,
11060 nested_installer_files: Vec::new(),
11061 },
11062 ];
11063 let query = PackageQuery {
11064 installer_type: Some("exe".to_owned()),
11065 os_version: Some("10.0.19045.0".to_owned()),
11066 ..PackageQuery::default()
11067 };
11068
11069 let selected = select_installer(&installers, &query).expect("selected installer");
11070 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
11071 }
11072
11073 #[test]
11074 fn derives_correlation_name_candidates() {
11075 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
11076 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
11077 assert!(candidates.contains(&"PowerToys".to_owned()));
11078 }
11079
11080 #[test]
11081 fn correlates_installed_package_to_available_match() {
11082 let installed = InstalledPackage {
11083 name: "PowerToys (Preview) x64".to_owned(),
11084 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11085 installed_version: "0.98.1".to_owned(),
11086 publisher: None,
11087 scope: Some("Machine".to_owned()),
11088 installer_category: Some("exe".to_owned()),
11089 install_location: None,
11090 package_family_names: Vec::new(),
11091 product_codes: Vec::new(),
11092 upgrade_codes: Vec::new(),
11093 correlated: None,
11094 installed_version_canonical: false,
11095 correlated_requires_explicit_upgrade: false,
11096 correlated_lacks_compatible_installer: false,
11097 };
11098 let candidates = vec![SearchMatch {
11099 source_name: "winget".to_owned(),
11100 source_kind: SourceKind::PreIndexed,
11101 id: "Microsoft.PowerToys".to_owned(),
11102 name: "PowerToys".to_owned(),
11103 moniker: None,
11104 version: Some("0.98.1".to_owned()),
11105 channel: None,
11106 match_criteria: None,
11107 }];
11108
11109 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11110 assert_eq!(correlated.id, "Microsoft.PowerToys");
11111 }
11112
11113 #[test]
11114 fn list_query_uses_available_lookup_for_tag_filters() {
11115 let query = ListQuery {
11116 tag: Some("terminal".to_owned()),
11117 ..ListQuery::default()
11118 };
11119
11120 assert!(list_query_needs_available_lookup(&query));
11121 }
11122
11123 #[test]
11124 fn list_tag_filter_requires_correlated_match() {
11125 let package = InstalledPackage {
11126 name: "PowerToys (Preview) x64".to_owned(),
11127 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
11128 installed_version: "0.98.1".to_owned(),
11129 publisher: None,
11130 scope: Some("Machine".to_owned()),
11131 installer_category: Some("exe".to_owned()),
11132 install_location: None,
11133 package_family_names: Vec::new(),
11134 product_codes: Vec::new(),
11135 upgrade_codes: Vec::new(),
11136 correlated: None,
11137 installed_version_canonical: false,
11138 correlated_requires_explicit_upgrade: false,
11139 correlated_lacks_compatible_installer: false,
11140 };
11141 let query = ListQuery {
11142 tag: Some("powertoys".to_owned()),
11143 ..ListQuery::default()
11144 };
11145
11146 assert!(!list_package_matches(&package, &query));
11147 }
11148
11149 #[test]
11150 fn list_lookup_ignores_display_count() {
11151 let query = ListQuery {
11152 query: Some("git".to_owned()),
11153 count: Some(5),
11154 ..ListQuery::default()
11155 };
11156
11157 let package_query = package_query_from_list_query(&query);
11158 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
11159 }
11160
11161 #[test]
11162 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
11163 let installed = InstalledPackage {
11168 name: "Notepad++ (ARM 64-bit)".to_owned(),
11169 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
11170 installed_version: "8.8.9".to_owned(),
11171 publisher: None,
11172 scope: Some("Machine".to_owned()),
11173 installer_category: Some("exe".to_owned()),
11174 install_location: None,
11175 package_family_names: Vec::new(),
11176 product_codes: Vec::new(),
11177 upgrade_codes: Vec::new(),
11178 correlated: None,
11179 installed_version_canonical: false,
11180 correlated_requires_explicit_upgrade: false,
11181 correlated_lacks_compatible_installer: false,
11182 };
11183 let candidates = vec![
11184 SearchMatch {
11185 source_name: "winget".to_owned(),
11186 source_kind: SourceKind::PreIndexed,
11187 id: "ndd.Notepad--".to_owned(),
11188 name: "Notepad--".to_owned(),
11189 moniker: None,
11190 version: Some("1.0.0".to_owned()),
11191 channel: None,
11192 match_criteria: None,
11193 },
11194 SearchMatch {
11195 source_name: "winget".to_owned(),
11196 source_kind: SourceKind::PreIndexed,
11197 id: "Notepad++.Notepad++".to_owned(),
11198 name: "Notepad++".to_owned(),
11199 moniker: None,
11200 version: Some("8.9.5".to_owned()),
11201 channel: None,
11202 match_criteria: None,
11203 },
11204 ];
11205
11206 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11207 assert_eq!(correlated.id, "Notepad++.Notepad++");
11208 }
11209
11210 #[test]
11211 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
11212 let installed = InstalledPackage {
11217 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
11218 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
11219 installed_version: "40.10.18029".to_owned(),
11220 publisher: None,
11221 scope: Some("Machine".to_owned()),
11222 installer_category: Some("msi".to_owned()),
11223 install_location: None,
11224 package_family_names: Vec::new(),
11225 product_codes: Vec::new(),
11226 upgrade_codes: Vec::new(),
11227 correlated: None,
11228 installed_version_canonical: false,
11229 correlated_requires_explicit_upgrade: false,
11230 correlated_lacks_compatible_installer: false,
11231 };
11232 let candidates = vec![
11233 SearchMatch {
11234 source_name: "winget".to_owned(),
11235 source_kind: SourceKind::PreIndexed,
11236 id: "ZeroBrane.Studio".to_owned(),
11237 name: "Studio".to_owned(),
11238 moniker: None,
11239 version: Some("1.0".to_owned()),
11240 channel: None,
11241 match_criteria: None,
11242 },
11243 SearchMatch {
11244 source_name: "winget".to_owned(),
11245 source_kind: SourceKind::PreIndexed,
11246 id: "BrickLink.Studio".to_owned(),
11247 name: "Studio".to_owned(),
11248 moniker: None,
11249 version: Some("1.0".to_owned()),
11250 channel: None,
11251 match_criteria: None,
11252 },
11253 SearchMatch {
11254 source_name: "winget".to_owned(),
11255 source_kind: SourceKind::PreIndexed,
11256 id: "Microsoft.DotNet.SDK.10".to_owned(),
11257 name: "Microsoft .NET SDK 10.0".to_owned(),
11258 moniker: None,
11259 version: Some("10.0.204".to_owned()),
11260 channel: None,
11261 match_criteria: None,
11262 },
11263 ];
11264
11265 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
11266 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
11267 }
11268
11269 #[test]
11270 fn msix_packages_do_not_correlate_via_name() {
11271 let installed = InstalledPackage {
11279 name: "Microsoft Teams".to_owned(),
11280 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
11281 installed_version: "25290.205.4069.4894".to_owned(),
11282 publisher: None,
11283 scope: Some("User".to_owned()),
11284 installer_category: Some("msix".to_owned()),
11285 install_location: None,
11286 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
11287 product_codes: Vec::new(),
11288 upgrade_codes: Vec::new(),
11289 correlated: None,
11290 installed_version_canonical: false,
11291 correlated_requires_explicit_upgrade: false,
11292 correlated_lacks_compatible_installer: false,
11293 };
11294 let candidates = vec![SearchMatch {
11295 source_name: "winget".to_owned(),
11296 source_kind: SourceKind::PreIndexed,
11297 id: "Microsoft.Teams".to_owned(),
11298 name: "Microsoft Teams".to_owned(),
11299 moniker: None,
11300 version: Some("26106.1906.4665.7308".to_owned()),
11301 channel: None,
11302 match_criteria: None,
11303 }];
11304
11305 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11306 }
11307
11308 #[test]
11309 fn name_fallback_refuses_ambiguous_winners() {
11310 let installed = InstalledPackage {
11318 name: "Git".to_owned(),
11319 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
11320 installed_version: "2.53.0".to_owned(),
11321 publisher: Some("The Git Development Community".to_owned()),
11322 scope: Some("Machine".to_owned()),
11323 installer_category: Some("exe".to_owned()),
11324 install_location: None,
11325 package_family_names: Vec::new(),
11326 product_codes: Vec::new(),
11327 upgrade_codes: Vec::new(),
11328 correlated: None,
11329 installed_version_canonical: false,
11330 correlated_requires_explicit_upgrade: false,
11331 correlated_lacks_compatible_installer: false,
11332 };
11333 let candidates = vec![
11334 SearchMatch {
11335 source_name: "winget".to_owned(),
11336 source_kind: SourceKind::PreIndexed,
11337 id: "Git.Git".to_owned(),
11338 name: "Git".to_owned(),
11339 moniker: None,
11340 version: Some("2.54.0".to_owned()),
11341 channel: None,
11342 match_criteria: None,
11343 },
11344 SearchMatch {
11345 source_name: "winget".to_owned(),
11346 source_kind: SourceKind::PreIndexed,
11347 id: "Microsoft.Git".to_owned(),
11348 name: "Git".to_owned(),
11349 moniker: None,
11350 version: Some("2.53.0.0.7".to_owned()),
11351 channel: None,
11352 match_criteria: None,
11353 },
11354 ];
11355
11356 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11357 }
11358
11359 #[test]
11360 fn msix_with_resource_string_name_does_not_correlate() {
11361 let installed = InstalledPackage {
11365 name: "ms-resource:appDisplayName".to_owned(),
11366 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
11367 installed_version: "1.28.239.0".to_owned(),
11368 publisher: None,
11369 scope: Some("User".to_owned()),
11370 installer_category: Some("msix".to_owned()),
11371 install_location: None,
11372 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
11373 product_codes: Vec::new(),
11374 upgrade_codes: Vec::new(),
11375 correlated: None,
11376 installed_version_canonical: false,
11377 correlated_requires_explicit_upgrade: false,
11378 correlated_lacks_compatible_installer: false,
11379 };
11380 let candidates = vec![SearchMatch {
11381 source_name: "winget".to_owned(),
11382 source_kind: SourceKind::PreIndexed,
11383 id: "Microsoft.AppInstaller".to_owned(),
11384 name: "App Installer".to_owned(),
11385 moniker: None,
11386 version: Some("1.28.240.0".to_owned()),
11387 channel: None,
11388 match_criteria: None,
11389 }];
11390
11391 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
11392 }
11393
11394 #[test]
11395 fn strict_list_correlation_avoids_short_substring_matches() {
11396 let installed = InstalledPackage {
11397 name: "GitHub".to_owned(),
11398 local_id: r"ARP\User\X64\GitHub".to_owned(),
11399 installed_version: "1.0.0".to_owned(),
11400 publisher: None,
11401 scope: Some("User".to_owned()),
11402 installer_category: Some("exe".to_owned()),
11403 install_location: None,
11404 package_family_names: Vec::new(),
11405 product_codes: Vec::new(),
11406 upgrade_codes: Vec::new(),
11407 correlated: None,
11408 installed_version_canonical: false,
11409 correlated_requires_explicit_upgrade: false,
11410 correlated_lacks_compatible_installer: false,
11411 };
11412 let candidates = vec![SearchMatch {
11413 source_name: "winget".to_owned(),
11414 source_kind: SourceKind::PreIndexed,
11415 id: "Git.Git.PreRelease".to_owned(),
11416 name: "Git".to_owned(),
11417 moniker: None,
11418 version: Some("2.54.0".to_owned()),
11419 channel: None,
11420 match_criteria: None,
11421 }];
11422
11423 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
11424 }
11425
11426 #[test]
11427 fn detects_available_upgrade_from_correlated_package() {
11428 let package = InstalledPackage {
11429 name: "AzCopy v10".to_owned(),
11430 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
11431 installed_version: "10.32.2".to_owned(),
11432 publisher: None,
11433 scope: Some("Machine".to_owned()),
11434 installer_category: Some("exe".to_owned()),
11435 install_location: None,
11436 package_family_names: Vec::new(),
11437 product_codes: Vec::new(),
11438 upgrade_codes: Vec::new(),
11439 correlated: Some(SearchMatch {
11440 source_name: "winget".to_owned(),
11441 source_kind: SourceKind::PreIndexed,
11442 id: "Microsoft.Azure.AZCopy.10".to_owned(),
11443 name: "AzCopy v10".to_owned(),
11444 moniker: None,
11445 version: Some("10.32.3".to_owned()),
11446 channel: None,
11447 match_criteria: None,
11448 }),
11449 installed_version_canonical: false,
11450 correlated_requires_explicit_upgrade: false,
11451 correlated_lacks_compatible_installer: false,
11452 };
11453
11454 assert!(installed_package_has_upgrade(&package));
11455 }
11456
11457 #[test]
11458 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
11459 let package = InstalledPackage {
11460 name: "Example Tool".to_owned(),
11461 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
11462 installed_version: "Unknown".to_owned(),
11463 publisher: None,
11464 scope: Some("User".to_owned()),
11465 installer_category: Some("exe".to_owned()),
11466 install_location: None,
11467 package_family_names: Vec::new(),
11468 product_codes: Vec::new(),
11469 upgrade_codes: Vec::new(),
11470 correlated: Some(SearchMatch {
11471 source_name: "winget".to_owned(),
11472 source_kind: SourceKind::PreIndexed,
11473 id: "Contoso.ExampleTool".to_owned(),
11474 name: "Example Tool".to_owned(),
11475 moniker: None,
11476 version: Some("2.0.0".to_owned()),
11477 channel: None,
11478 match_criteria: None,
11479 }),
11480 installed_version_canonical: false,
11481 correlated_requires_explicit_upgrade: false,
11482 correlated_lacks_compatible_installer: false,
11483 };
11484 let query = ListQuery {
11485 upgrade_only: true,
11486 include_unknown: true,
11487 ..Default::default()
11488 };
11489
11490 assert!(installed_package_matches_upgrade_filter(&package, &query));
11491 assert_eq!(
11492 list_match_from_installed(package).available_version.as_deref(),
11493 Some("2.0.0")
11494 );
11495 }
11496
11497 #[test]
11498 fn create_repair_list_query_includes_installed_selectors() {
11499 let request = RepairRequest {
11500 query: PackageQuery {
11501 query: Some("powertoys".to_owned()),
11502 id: Some("Microsoft.PowerToys".to_owned()),
11503 name: Some("PowerToys".to_owned()),
11504 moniker: Some("powertoys".to_owned()),
11505 source: Some("winget".to_owned()),
11506 exact: true,
11507 version: Some("0.98.1".to_owned()),
11508 install_scope: Some("Machine".to_owned()),
11509 ..PackageQuery::default()
11510 },
11511 manifest_path: None,
11512 product_code: Some("{GUID}".to_owned()),
11513 mode: InstallerMode::Silent,
11514 log_path: Some(PathBuf::from("repair.log")),
11515 accept_package_agreements: true,
11516 force: true,
11517 ignore_security_hash: true,
11518 };
11519
11520 let query = Repository::create_repair_list_query(&request);
11521 assert_eq!(query.query.as_deref(), Some("powertoys"));
11522 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
11523 assert_eq!(query.name.as_deref(), Some("PowerToys"));
11524 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
11525 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
11526 assert_eq!(query.version.as_deref(), Some("0.98.1"));
11527 assert_eq!(query.source.as_deref(), Some("winget"));
11528 assert!(query.exact);
11529 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
11530 assert_eq!(query.count, Some(100));
11531 }
11532
11533 #[test]
11534 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
11535 let request = RepairRequest {
11536 query: PackageQuery {
11537 name: Some("PowerToys".to_owned()),
11538 source: Some("winget".to_owned()),
11539 locale: Some("en-US".to_owned()),
11540 installer_architecture: Some("x64".to_owned()),
11541 install_scope: Some("Machine".to_owned()),
11542 ..PackageQuery::default()
11543 },
11544 manifest_path: None,
11545 product_code: None,
11546 mode: InstallerMode::SilentWithProgress,
11547 log_path: Some(PathBuf::from("repair.log")),
11548 accept_package_agreements: true,
11549 force: false,
11550 ignore_security_hash: true,
11551 };
11552 let installed = ListMatch {
11553 name: "PowerToys".to_owned(),
11554 id: "Microsoft.PowerToys".to_owned(),
11555 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
11556 installed_version: "0.98.1".to_owned(),
11557 available_version: Some("0.98.2".to_owned()),
11558 source_name: Some("winget".to_owned()),
11559 publisher: None,
11560 scope: Some("Machine".to_owned()),
11561 installer_category: Some("exe".to_owned()),
11562 install_location: None,
11563 package_family_names: Vec::new(),
11564 product_codes: Vec::new(),
11565 upgrade_codes: Vec::new(),
11566 };
11567
11568 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
11569 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
11570 assert!(install_request.query.query.is_none());
11571 assert!(install_request.query.name.is_none());
11572 assert!(install_request.query.moniker.is_none());
11573 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
11574 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
11575 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
11576 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
11577 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
11578 assert!(install_request.query.exact);
11579 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
11580 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
11581 assert!(install_request.accept_package_agreements);
11582 assert!(install_request.force);
11583 assert!(install_request.ignore_security_hash);
11584 }
11585
11586 #[test]
11587 fn source_scoped_pin_operations_round_trip() {
11588 let app_root = temp_app_root("pins");
11589 let result = (|| -> Result<()> {
11590 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11591 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
11592 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
11593 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
11594
11595 assert_eq!(repository.list_pins(None)?.len(), 3);
11596 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
11597 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
11598
11599 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
11600 assert_eq!(repository.list_pins(None)?.len(), 2);
11601 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
11602
11603 repository.reset_pins(Some("test"))?;
11604 let remaining = repository.list_pins(None)?;
11605 assert_eq!(remaining.len(), 1);
11606 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
11607
11608 repository.reset_pins(None)?;
11609 assert!(repository.list_pins(None)?.is_empty());
11610 Ok(())
11611 })();
11612
11613 let _ = fs::remove_dir_all(&app_root);
11614 result.expect("pin round trip");
11615 }
11616
11617 #[test]
11618 fn pin_operations_work_with_packaged_pin_schema() {
11619 let app_root = temp_app_root("pins_packaged_schema");
11620 let result = (|| -> Result<()> {
11621 let db_path = pins_db_path(&app_root);
11622 if let Some(parent) = db_path.parent() {
11623 fs::create_dir_all(parent)?;
11624 }
11625
11626 let conn = Repository::open_sqlite_connection(db_path.clone())?;
11627 execute_batch_sql(
11628 &conn,
11629 "CREATE TABLE pin (
11630 package_id TEXT NOT NULL,
11631 source_id TEXT NOT NULL,
11632 type INTEGER NOT NULL,
11633 version TEXT NOT NULL,
11634 PRIMARY KEY (package_id, source_id)
11635 )",
11636 )?;
11637 conn.cacheflush().context("failed to flush pin database")?;
11638 drop(conn);
11639
11640 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11641 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
11642
11643 let pins = repository.list_pins(None)?;
11644 assert_eq!(pins.len(), 1);
11645 assert_eq!(pins[0].package_id, "Contoso.Tool");
11646 assert_eq!(pins[0].version, "1.2.*");
11647 assert_eq!(pins[0].source_id, "winget");
11648 assert_eq!(pins[0].pin_type, PinType::Gating);
11649
11650 let conn = Repository::open_sqlite_connection(db_path)?;
11651 let stored_pin = query_optional_value(
11652 &conn,
11653 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
11654 vec![
11655 SqlValue::Text("Contoso.Tool".to_owned()),
11656 SqlValue::Text("winget".to_owned()),
11657 ],
11658 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
11659 )?
11660 .ok_or_else(|| anyhow!("expected stored pin"))?;
11661 assert_eq!(stored_pin.0, 3);
11662 assert_eq!(stored_pin.1, "1.2.*");
11663
11664 Ok(())
11665 })();
11666
11667 let _ = fs::remove_dir_all(&app_root);
11668 result.expect("packaged pin schema round trip");
11669 }
11670
11671 #[test]
11672 fn version_pin_patterns_match_exact_and_prefix_values() {
11673 assert!(version_matches_pin_pattern("1.2.3", "*"));
11674 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
11675 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
11676 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
11677 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
11678 }
11679
11680 #[test]
11681 fn source_specific_pins_override_source_agnostic_pins() {
11682 let item = ListMatch {
11683 name: "Contoso Tool".to_owned(),
11684 id: "Contoso.Tool".to_owned(),
11685 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11686 installed_version: "1.0.0".to_owned(),
11687 available_version: Some("2.0.0".to_owned()),
11688 source_name: Some("winget".to_owned()),
11689 publisher: None,
11690 scope: None,
11691 installer_category: None,
11692 install_location: None,
11693 package_family_names: Vec::new(),
11694 product_codes: Vec::new(),
11695 upgrade_codes: Vec::new(),
11696 };
11697 let pins = vec![
11698 PinRecord {
11699 package_id: "Contoso.Tool".to_owned(),
11700 version: "*".to_owned(),
11701 source_id: String::new(),
11702 pin_type: PinType::Blocking,
11703 },
11704 PinRecord {
11705 package_id: "Contoso.Tool".to_owned(),
11706 version: "2.0.*".to_owned(),
11707 source_id: "winget".to_owned(),
11708 pin_type: PinType::Pinning,
11709 },
11710 ];
11711
11712 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
11713 }
11714
11715 #[test]
11716 fn blocking_pin_blocks_upgrade() {
11717 let item = ListMatch {
11718 name: "Contoso Tool".to_owned(),
11719 id: "Contoso.Tool".to_owned(),
11720 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
11721 installed_version: "1.0.0".to_owned(),
11722 available_version: Some("2.0.0".to_owned()),
11723 source_name: Some("winget".to_owned()),
11724 publisher: None,
11725 scope: None,
11726 installer_category: None,
11727 install_location: None,
11728 package_family_names: Vec::new(),
11729 product_codes: Vec::new(),
11730 upgrade_codes: Vec::new(),
11731 };
11732 let pins = vec![PinRecord {
11733 package_id: "Contoso.Tool".to_owned(),
11734 version: "*".to_owned(),
11735 source_id: "winget".to_owned(),
11736 pin_type: PinType::Blocking,
11737 }];
11738
11739 assert!(is_upgrade_blocked_by_pin(&item, &pins));
11740 }
11741
11742 #[cfg(windows)]
11743 #[test]
11744 fn parses_msix_package_full_name_into_version_and_family() {
11745 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
11746 .expect("package metadata");
11747
11748 assert_eq!(parsed.version, "0.98.1.0");
11749 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
11750 }
11751
11752 #[cfg(windows)]
11753 #[test]
11754 fn recognizes_windows_system_paths() {
11755 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
11756 assert!(!is_windows_system_path(
11757 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
11758 ));
11759 }
11760
11761 #[test]
11762 fn search_ranking_prefers_exact_name_over_tag_match() {
11763 let query = PackageQuery {
11764 query: Some("PowerToys".to_owned()),
11765 ..Default::default()
11766 };
11767 let exact_name = SearchMatch {
11768 source_name: "winget".to_owned(),
11769 source_kind: SourceKind::PreIndexed,
11770 id: "Microsoft.PowerToys".to_owned(),
11771 name: "PowerToys".to_owned(),
11772 moniker: Some("powertoys".to_owned()),
11773 version: Some("0.98.1".to_owned()),
11774 channel: None,
11775 match_criteria: Some("Moniker: powertoys".to_owned()),
11776 };
11777 let tag_match = SearchMatch {
11778 source_name: "winget".to_owned(),
11779 source_kind: SourceKind::PreIndexed,
11780 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
11781 name: "QR Codes for Command Palette".to_owned(),
11782 moniker: None,
11783 version: Some("0.4.0.0".to_owned()),
11784 channel: None,
11785 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
11786 };
11787
11788 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
11789 }
11790
11791 #[test]
11792 fn select_best_text_match_prefers_exact_tag_value() {
11793 let values = vec![
11794 "microsoft-powertoys".to_owned(),
11795 "powertoys-run".to_owned(),
11796 "powertoys".to_owned(),
11797 ];
11798
11799 assert_eq!(
11800 select_best_text_match(values, "PowerToys", false).as_deref(),
11801 Some("powertoys")
11802 );
11803 }
11804
11805 #[test]
11806 fn search_match_criteria_is_blank_for_name_match() {
11807 let query = PackageQuery {
11808 query: Some("PowerToys".to_owned()),
11809 ..Default::default()
11810 };
11811
11812 let result = infer_match_criteria(
11813 "Microsoft.PowerToys",
11814 "PowerToys",
11815 Some("powertoys"),
11816 &query,
11817 SearchSemantics::Many,
11818 |_| Ok(Some("powertoys".to_owned())),
11819 |_| Ok(None),
11820 )
11821 .expect("match criteria");
11822
11823 assert_eq!(result, None);
11824 }
11825
11826 #[test]
11827 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
11828 let query = PackageQuery {
11829 query: Some("PowerToys".to_owned()),
11830 ..Default::default()
11831 };
11832 let unknown_version = SearchMatch {
11833 source_name: "msstore".to_owned(),
11834 source_kind: SourceKind::Rest,
11835 id: "XP89DCGQ3K6VLD".to_owned(),
11836 name: "Microsoft PowerToys".to_owned(),
11837 moniker: None,
11838 version: Some("Unknown".to_owned()),
11839 channel: None,
11840 match_criteria: None,
11841 };
11842 let tag_match = SearchMatch {
11843 source_name: "winget".to_owned(),
11844 source_kind: SourceKind::PreIndexed,
11845 id: "Riri.QRCodeforCmdPal".to_owned(),
11846 name: "QR Code for CmdPal".to_owned(),
11847 moniker: None,
11848 version: Some("0.0.1.0".to_owned()),
11849 channel: None,
11850 match_criteria: Some("Tag: powertoys".to_owned()),
11851 };
11852
11853 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
11854 }
11855
11856 #[test]
11857 fn search_source_fetch_results_ignore_small_display_count() {
11858 let query = PackageQuery {
11859 count: Some(5),
11860 ..Default::default()
11861 };
11862
11863 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
11864 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
11865 }
11866
11867 #[test]
11868 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
11869 let query = PackageQuery {
11870 query: Some("PowerToys".to_owned()),
11871 ..Default::default()
11872 };
11873 let prefix_name = SearchMatch {
11874 source_name: "winget".to_owned(),
11875 source_kind: SourceKind::PreIndexed,
11876 id: "advaith.CurrencyConverterPowerToys".to_owned(),
11877 name: "PowerToys-Run-Currency-Converter".to_owned(),
11878 moniker: None,
11879 version: Some("1.5.4".to_owned()),
11880 channel: None,
11881 match_criteria: None,
11882 };
11883 let exact_tag = SearchMatch {
11884 source_name: "winget".to_owned(),
11885 source_kind: SourceKind::PreIndexed,
11886 id: "Riri.QRCodeforCmdPal".to_owned(),
11887 name: "QR Code for CmdPal".to_owned(),
11888 moniker: None,
11889 version: Some("0.0.1.0".to_owned()),
11890 channel: None,
11891 match_criteria: Some("Tag: powertoys".to_owned()),
11892 };
11893
11894 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
11895 }
11896
11897 #[test]
11898 fn list_sort_prefers_main_package_then_sparse_app() {
11899 let main = InstalledPackage {
11900 name: "PowerToys (Preview) x64".to_owned(),
11901 local_id: r"ARP\User\X64\PowerToys".to_owned(),
11902 installed_version: "0.98.1".to_owned(),
11903 publisher: None,
11904 scope: Some("User".to_owned()),
11905 installer_category: Some("exe".to_owned()),
11906 install_location: None,
11907 package_family_names: Vec::new(),
11908 product_codes: Vec::new(),
11909 upgrade_codes: Vec::new(),
11910 correlated: None,
11911 installed_version_canonical: false,
11912 correlated_requires_explicit_upgrade: false,
11913 correlated_lacks_compatible_installer: false,
11914 };
11915 let sparse = InstalledPackage {
11916 name: "PowerToys.SparseApp".to_owned(),
11917 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11918 installed_version: "0.98.1.0".to_owned(),
11919 publisher: None,
11920 scope: Some("User".to_owned()),
11921 installer_category: Some("msix".to_owned()),
11922 install_location: None,
11923 package_family_names: Vec::new(),
11924 product_codes: Vec::new(),
11925 upgrade_codes: Vec::new(),
11926 correlated: None,
11927 installed_version_canonical: false,
11928 correlated_requires_explicit_upgrade: false,
11929 correlated_lacks_compatible_installer: false,
11930 };
11931 let extension = InstalledPackage {
11932 name: "PowerToys FileLocksmith Context Menu".to_owned(),
11933 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
11934 installed_version: "0.98.1.0".to_owned(),
11935 publisher: None,
11936 scope: Some("User".to_owned()),
11937 installer_category: Some("msix".to_owned()),
11938 install_location: None,
11939 package_family_names: Vec::new(),
11940 product_codes: Vec::new(),
11941 upgrade_codes: Vec::new(),
11942 correlated: None,
11943 installed_version_canonical: false,
11944 correlated_requires_explicit_upgrade: false,
11945 correlated_lacks_compatible_installer: false,
11946 };
11947
11948 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
11949 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
11950 }
11951
11952 #[test]
11953 #[cfg(windows)]
11954 fn arp_subkey_name_extracts_registry_subkey() {
11955 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
11956 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
11957 }
11958
11959 #[test]
11960 #[cfg(windows)]
11961 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
11962 let installed = ListMatch {
11963 name: "ShareX".to_owned(),
11964 id: "ShareX.ShareX".to_owned(),
11965 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
11966 installed_version: "19.0.2".to_owned(),
11967 available_version: None,
11968 source_name: Some("winget".to_owned()),
11969 publisher: Some("ShareX Team".to_owned()),
11970 scope: Some("Machine".to_owned()),
11971 installer_category: Some("exe".to_owned()),
11972 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
11973 package_family_names: Vec::new(),
11974 product_codes: Vec::new(),
11975 upgrade_codes: Vec::new(),
11976 };
11977
11978 assert!(registry_entry_matches_installed_package(
11979 "ShareX", "ShareX", None, &installed
11980 ));
11981 assert!(!registry_entry_matches_installed_package(
11982 "ShareX.ShareX",
11983 "ShareX.ShareX",
11984 None,
11985 &installed
11986 ));
11987 }
11988
11989 #[test]
11990 #[cfg(windows)]
11991 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
11992 assert_eq!(
11993 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
11994 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
11995 );
11996 assert_eq!(
11997 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
11998 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
11999 );
12000 assert_eq!(
12001 r#""C:\Program Files\ShareX\unins000.exe""#,
12002 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
12003 );
12004 assert_eq!(
12005 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12006 build_uninstall_command(
12007 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
12008 true,
12009 false,
12010 )
12011 );
12012 }
12013
12014 #[test]
12015 #[cfg(windows)]
12016 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
12017 let installed = ListMatch {
12018 name: "lazygit".to_owned(),
12019 id: "JesseDuffield.lazygit".to_owned(),
12020 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
12021 installed_version: "0.61.1".to_owned(),
12022 available_version: None,
12023 source_name: Some("winget".to_owned()),
12024 publisher: Some("Jesse Duffield".to_owned()),
12025 scope: Some("User".to_owned()),
12026 installer_category: Some("exe".to_owned()),
12027 install_location: Some(
12028 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
12029 ),
12030 package_family_names: Vec::new(),
12031 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
12032 upgrade_codes: Vec::new(),
12033 };
12034 let mut request = UninstallRequest::new(PackageQuery {
12035 id: Some("JesseDuffield.lazygit".to_owned()),
12036 install_scope: Some("user".to_owned()),
12037 ..PackageQuery::default()
12038 });
12039 request.mode = InstallerMode::Silent;
12040
12041 assert_eq!(
12042 Some(vec![
12043 "uninstall".to_owned(),
12044 "--id".to_owned(),
12045 "JesseDuffield.lazygit".to_owned(),
12046 "--exact".to_owned(),
12047 "--disable-interactivity".to_owned(),
12048 "--source".to_owned(),
12049 "winget".to_owned(),
12050 "--scope".to_owned(),
12051 "user".to_owned(),
12052 "--silent".to_owned(),
12053 ]),
12054 build_winget_uninstall_arguments(&installed, &request)
12055 );
12056
12057 assert_eq!(
12058 Some(vec![
12059 "uninstall".to_owned(),
12060 "--id".to_owned(),
12061 "JesseDuffield.lazygit".to_owned(),
12062 "--exact".to_owned(),
12063 "--disable-interactivity".to_owned(),
12064 "--source".to_owned(),
12065 "winget".to_owned(),
12066 "--silent".to_owned(),
12067 ]),
12068 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
12069 );
12070 }
12071
12072 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
12073 PackageVersionDataEntry {
12074 version: version.to_owned(),
12075 manifest_relative_path: format!("manifests/x/{version}"),
12076 manifest_hash: "00".repeat(32),
12077 arp_min_version: arp_min.map(str::to_owned),
12078 arp_max_version: arp_max.map(str::to_owned),
12079 }
12080 }
12081
12082 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
12083 V1VersionRow {
12084 version: version.to_owned(),
12085 channel: channel.to_owned(),
12086 pathpart_id: 0,
12087 manifest_hash: None,
12088 }
12089 }
12090
12091 #[derive(Clone)]
12092 struct TestHttpResponse {
12093 status: u16,
12094 body: Vec<u8>,
12095 source_version: Option<String>,
12096 }
12097
12098 struct TestHttpServer {
12099 addr: SocketAddr,
12100 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12101 counts: Arc<Mutex<HashMap<String, usize>>>,
12102 running: Arc<AtomicBool>,
12103 handle: Option<JoinHandle<()>>,
12104 }
12105
12106 impl TestHttpServer {
12107 fn start() -> Self {
12108 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
12109 let addr = listener.local_addr().expect("test HTTP server address");
12110 let routes = Arc::new(Mutex::new(HashMap::new()));
12111 let counts = Arc::new(Mutex::new(HashMap::new()));
12112 let running = Arc::new(AtomicBool::new(true));
12113 let thread_routes = Arc::clone(&routes);
12114 let thread_counts = Arc::clone(&counts);
12115 let thread_running = Arc::clone(&running);
12116 let handle = thread::spawn(move || {
12117 while thread_running.load(AtomicOrdering::SeqCst) {
12118 let Ok((stream, _)) = listener.accept() else {
12119 continue;
12120 };
12121 handle_test_http_request(stream, &thread_routes, &thread_counts);
12122 }
12123 });
12124
12125 Self {
12126 addr,
12127 routes,
12128 counts,
12129 running,
12130 handle: Some(handle),
12131 }
12132 }
12133
12134 fn url(&self) -> String {
12135 format!("http://{}", self.addr)
12136 }
12137
12138 fn set_bytes(&self, path: &str, body: Vec<u8>) {
12139 self.set_response(
12140 path,
12141 TestHttpResponse {
12142 status: 200,
12143 body,
12144 source_version: None,
12145 },
12146 );
12147 }
12148
12149 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
12150 self.set_response(
12151 "/source.msix",
12152 TestHttpResponse {
12153 status: 200,
12154 body,
12155 source_version: Some(source_version.to_owned()),
12156 },
12157 );
12158 }
12159
12160 fn set_status(&self, path: &str, status: u16) {
12161 self.set_response(
12162 path,
12163 TestHttpResponse {
12164 status,
12165 body: Vec::new(),
12166 source_version: None,
12167 },
12168 );
12169 }
12170
12171 fn set_response(&self, path: &str, response: TestHttpResponse) {
12172 self.routes
12173 .lock()
12174 .expect("test HTTP routes")
12175 .insert(path.to_owned(), response);
12176 }
12177
12178 fn request_count(&self, path: &str) -> usize {
12179 self.counts
12180 .lock()
12181 .expect("test HTTP counts")
12182 .get(path)
12183 .copied()
12184 .unwrap_or_default()
12185 }
12186 }
12187
12188 impl Drop for TestHttpServer {
12189 fn drop(&mut self) {
12190 self.running.store(false, AtomicOrdering::SeqCst);
12191 let _ = TcpStream::connect(self.addr);
12192 if let Some(handle) = self.handle.take() {
12193 let _ = handle.join();
12194 }
12195 }
12196 }
12197
12198 fn handle_test_http_request(
12199 mut stream: TcpStream,
12200 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
12201 counts: &Arc<Mutex<HashMap<String, usize>>>,
12202 ) {
12203 let mut buffer = [0u8; 2048];
12204 let read = stream.read(&mut buffer).unwrap_or(0);
12205 let request = String::from_utf8_lossy(&buffer[..read]);
12206 let path = request
12207 .lines()
12208 .next()
12209 .and_then(|line| line.split_whitespace().nth(1))
12210 .unwrap_or("/");
12211 *counts
12212 .lock()
12213 .expect("test HTTP counts")
12214 .entry(path.to_owned())
12215 .or_default() += 1;
12216 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
12217 let response = response.unwrap_or(TestHttpResponse {
12218 status: 404,
12219 body: Vec::new(),
12220 source_version: None,
12221 });
12222 let reason = if response.status == 200 { "OK" } else { "Not Found" };
12223 let mut headers = format!(
12224 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
12225 response.status,
12226 reason,
12227 response.body.len()
12228 );
12229 if let Some(source_version) = response.source_version {
12230 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
12231 }
12232 headers.push_str("\r\n");
12233 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
12234 stream.write_all(&response.body).expect("write test HTTP body");
12235 }
12236
12237 fn test_source_record(source_url: String) -> SourceRecord {
12238 SourceRecord {
12239 name: "test-winget".to_owned(),
12240 kind: SourceKind::PreIndexed,
12241 arg: source_url,
12242 identifier: "Test.Source".to_owned(),
12243 trust_level: "Trusted".to_owned(),
12244 explicit: false,
12245 priority: 0,
12246 last_update: Some(Utc::now()),
12247 source_version: None,
12248 }
12249 }
12250
12251 fn open_test_preindexed_repository(
12252 app_root: &Path,
12253 source: &SourceRecord,
12254 interval: Option<Duration>,
12255 ) -> Repository {
12256 fs::create_dir_all(app_root).expect("create app root");
12257 save_store(
12258 app_root,
12259 &SourceStore {
12260 sources: vec![source.clone()],
12261 },
12262 )
12263 .expect("save source store");
12264 Repository::open_with_options(
12265 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
12266 )
12267 .expect("open repository")
12268 }
12269
12270 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
12271 let index_path = preindexed_index_path(app_root, source);
12272 if let Some(parent) = index_path.parent() {
12273 fs::create_dir_all(parent).expect("create source state directory");
12274 }
12275 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
12276 }
12277
12278 #[test]
12279 fn replace_file_failure_keeps_existing_target() {
12280 let root = temp_app_root("replace_file_failure");
12281 fs::create_dir_all(&root).expect("create replace root");
12282 let target = root.join("index.db");
12283 let missing_source = root.join("missing.tmp");
12284 fs::write(&target, b"old-index").expect("write existing target");
12285
12286 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
12287
12288 assert!(format!("{error:#}").contains("failed to replace"));
12289 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
12290 let _ = fs::remove_dir_all(root);
12291 }
12292
12293 fn make_source_package(versions: &[&str]) -> Vec<u8> {
12294 let cursor = Cursor::new(Vec::new());
12295 let mut archive = zip::ZipWriter::new(cursor);
12296 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
12297 archive
12298 .start_file("Public/index.db", options)
12299 .expect("start index entry");
12300 archive
12301 .write_all(&make_v1_index_bytes(versions))
12302 .expect("write index entry");
12303 archive.finish().expect("finish source package").into_inner()
12304 }
12305
12306 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
12307 let root = temp_app_root("preindexed_index_bytes");
12308 fs::create_dir_all(&root).expect("create index temp root");
12309 let db_path = root.join("index.db");
12310 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
12311 let mut sql = String::from(
12312 "CREATE TABLE ids (id TEXT);
12313 CREATE TABLE names (name TEXT);
12314 CREATE TABLE monikers (moniker TEXT);
12315 CREATE TABLE versions (version TEXT);
12316 CREATE TABLE channels (channel TEXT);
12317 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
12318 CREATE TABLE manifest (
12319 id INT64,
12320 name INT64,
12321 moniker INT64,
12322 version INT64,
12323 channel INT64,
12324 pathpart INT64
12325 );
12326 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
12327 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
12328 INSERT INTO channels(rowid, channel) VALUES (1, '');
12329 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
12330 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
12331 );
12332 for (index, version) in versions.iter().enumerate() {
12333 let rowid = index + 1;
12334 sql.push_str(&format!(
12335 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
12336 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
12337 ));
12338 }
12339 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
12340 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
12341 .expect("checkpoint preindexed V1 database");
12342 connection.cacheflush().expect("flush preindexed V1 database");
12343 drop(connection);
12344 let bytes = fs::read(&db_path).expect("read index bytes");
12345 let _ = fs::remove_dir_all(root);
12346 bytes
12347 }
12348
12349 fn test_manifest_yaml(version: &str) -> Vec<u8> {
12350 format!(
12351 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
12352 )
12353 .into_bytes()
12354 }
12355
12356 fn show_test_package(version: Option<&str>) -> PackageQuery {
12357 PackageQuery {
12358 id: Some("Test.Package".to_owned()),
12359 source: Some("test-winget".to_owned()),
12360 exact: true,
12361 version: version.map(str::to_owned),
12362 ..PackageQuery::default()
12363 }
12364 }
12365
12366 #[test]
12367 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
12368 let app_root = temp_app_root("preindexed_explicit_refresh");
12369 let server = TestHttpServer::start();
12370 server.set_status("/source2.msix", 404);
12371 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
12372 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12373 let source = test_source_record(server.url());
12374 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12375 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12376
12377 let result = repository
12378 .show(&show_test_package(Some("2.0.0")))
12379 .expect("show after refresh");
12380
12381 assert_eq!(result.manifest.version, "2.0.0");
12382 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
12383 let _ = fs::remove_dir_all(app_root);
12384 }
12385
12386 #[test]
12387 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
12388 let app_root = temp_app_root("preindexed_explicit_still_missing");
12389 let server = TestHttpServer::start();
12390 server.set_status("/source2.msix", 404);
12391 server.set_source_package(make_source_package(&["1.0.0"]), "same");
12392 let source = test_source_record(server.url());
12393 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12394 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
12395
12396 let error = repository
12397 .show(&show_test_package(Some("2.0.0")))
12398 .expect_err("missing explicit version must fail");
12399
12400 assert!(format!("{error:#}").contains("2.0.0"));
12401 let _ = fs::remove_dir_all(app_root);
12402 }
12403
12404 #[test]
12405 fn preindexed_stale_index_refreshes_for_latest_requests() {
12406 let app_root = temp_app_root("preindexed_ttl_refresh");
12407 let server = TestHttpServer::start();
12408 server.set_status("/source2.msix", 404);
12409 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12410 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
12411 let mut source = test_source_record(server.url());
12412 source.last_update = Some(Utc::now() - Duration::days(2));
12413 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12414 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12415
12416 let result = repository
12417 .show(&show_test_package(None))
12418 .expect("show latest after TTL refresh");
12419
12420 assert_eq!(result.manifest.version, "2.0.0");
12421 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
12422 let _ = fs::remove_dir_all(app_root);
12423 }
12424
12425 #[test]
12426 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
12427 let app_root = temp_app_root("preindexed_ttl_fallback");
12428 let server = TestHttpServer::start();
12429 server.set_status("/source2.msix", 404);
12430 server.set_status("/source.msix", 404);
12431 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
12432 let mut source = test_source_record(server.url());
12433 source.last_update = Some(Utc::now() - Duration::days(2));
12434 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12435 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12436
12437 let result = repository
12438 .show(&show_test_package(None))
12439 .expect("show latest from stale cache after refresh failure");
12440
12441 assert_eq!(result.manifest.version, "1.0.0");
12442 let _ = fs::remove_dir_all(app_root);
12443 }
12444
12445 #[test]
12446 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
12447 let app_root = temp_app_root("preindexed_no_double_refresh");
12448 let server = TestHttpServer::start();
12449 server.set_status("/source2.msix", 404);
12450 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
12451 let mut source = test_source_record(server.url());
12452 source.last_update = Some(Utc::now() - Duration::days(2));
12453 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
12454 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
12455
12456 let error = repository
12457 .show(&show_test_package(Some("3.0.0")))
12458 .expect_err("still-missing explicit version must fail after one stale refresh");
12459
12460 assert!(format!("{error:#}").contains("3.0.0"));
12461 assert_eq!(server.request_count("/source.msix"), 1);
12462 let _ = fs::remove_dir_all(app_root);
12463 }
12464
12465 #[test]
12466 fn select_v2_version_without_explicit_filters_returns_latest() {
12467 let versions = vec![
12468 version_entry("1.0.0", None, None),
12469 version_entry("1.2.0", None, None),
12470 version_entry("1.1.0", None, None),
12471 ];
12472
12473 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
12474
12475 assert_eq!(selected.version, "1.2.0");
12476 }
12477
12478 #[test]
12479 fn select_v2_version_with_missing_requested_version_throws() {
12480 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
12481
12482 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
12483 .expect_err("missing requested version");
12484
12485 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12486 assert!(error.to_string().contains("9.9.9"));
12487 }
12488
12489 #[test]
12490 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
12491 let versions = vec![version_entry("1.2.0", None, None)];
12492
12493 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12494 .expect_err("unsupported channel request");
12495
12496 assert!(error.to_string().contains("beta"));
12497 assert!(error.to_string().contains("channel metadata"));
12498 }
12499
12500 #[test]
12501 fn select_v1_version_channel_only_returns_latest_matching_channel() {
12502 let versions = vec![
12503 v1_version("1.2.0", "stable"),
12504 v1_version("1.3.0", "stable"),
12505 v1_version("2.0.0", "preview"),
12506 ];
12507
12508 let selected =
12509 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
12510
12511 assert_eq!(selected.version, "1.3.0");
12512 }
12513
12514 #[test]
12515 fn select_rest_version_channel_mismatch_throws() {
12516 let versions = vec![
12517 VersionKey {
12518 version: "1.2.0".to_owned(),
12519 channel: "stable".to_owned(),
12520 },
12521 VersionKey {
12522 version: "1.1.0".to_owned(),
12523 channel: "beta".to_owned(),
12524 },
12525 ];
12526
12527 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
12528 .expect_err("missing version and channel pair");
12529
12530 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
12531 }
12532
12533 #[test]
12534 fn map_arp_version_returns_catalog_version_inside_range() {
12535 let entries = vec![
12540 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12541 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12542 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
12543 ];
12544
12545 assert_eq!(
12546 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
12547 Some("10.0.108")
12548 );
12549 }
12550
12551 #[test]
12552 fn map_arp_version_returns_none_when_no_range_matches() {
12553 let entries = vec![
12558 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
12559 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
12560 ];
12561
12562 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
12563 }
12564
12565 #[test]
12566 fn map_arp_version_skips_entries_missing_arp_bounds() {
12567 let entries = vec![
12571 version_entry("9.0.300", None, None),
12572 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
12573 ];
12574
12575 assert_eq!(
12576 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
12577 Some("9.0.117")
12578 );
12579 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
12580 }
12581
12582 #[test]
12583 fn map_arp_version_handles_inclusive_range_endpoints() {
12584 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
12587
12588 assert_eq!(
12589 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
12590 Some("3.14.5")
12591 );
12592 assert_eq!(
12593 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
12594 Some("3.14.5")
12595 );
12596 assert_eq!(
12597 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
12598 Some("3.14.5")
12599 );
12600 }
12601
12602 #[test]
12603 fn map_arp_version_ignores_unknown_marker() {
12604 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
12608
12609 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
12610 assert!(map_arp_version_to_catalog(&entries, "").is_none());
12611 }
12612
12613 #[test]
12614 fn latest_arp_anchored_skips_internal_versions() {
12615 let entries = vec![
12621 version_entry("8000.836.2153.0", None, None),
12622 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
12623 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
12624 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
12625 ];
12626
12627 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
12628 }
12629
12630 #[test]
12631 fn latest_arp_anchored_returns_none_without_bounds() {
12632 let entries = vec![
12636 version_entry("1.28.240.0", None, None),
12637 version_entry("1.27.470.0", None, None),
12638 ];
12639
12640 assert!(latest_arp_anchored_version(&entries).is_none());
12641 }
12642
12643 #[test]
12644 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
12645 let package = InstalledPackage {
12646 name: "AzCopy v10".to_owned(),
12647 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
12648 installed_version: "10.32.2".to_owned(),
12649 publisher: Some("Microsoft".to_owned()),
12650 scope: Some("Machine".to_owned()),
12651 installer_category: Some("exe".to_owned()),
12652 install_location: None,
12653 package_family_names: Vec::new(),
12654 product_codes: Vec::new(),
12655 upgrade_codes: Vec::new(),
12656 correlated: Some(SearchMatch {
12657 source_name: "winget".to_owned(),
12658 source_kind: SourceKind::PreIndexed,
12659 id: "Microsoft.Azure.AZCopy.10".to_owned(),
12660 name: "AzCopy v10".to_owned(),
12661 moniker: None,
12662 version: Some("10.32.3".to_owned()),
12663 channel: None,
12664 match_criteria: None,
12665 }),
12666 installed_version_canonical: false,
12667 correlated_requires_explicit_upgrade: false,
12668 correlated_lacks_compatible_installer: false,
12669 };
12670
12671 let item = list_match_from_installed(package);
12672
12673 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
12674 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
12675 assert_eq!(item.source_name.as_deref(), Some("winget"));
12676 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
12677 }
12678
12679 #[test]
12680 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
12681 let package = InstalledPackage {
12682 name: "Atlassian CLI".to_owned(),
12683 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
12684 installed_version: "1.3.18-stable".to_owned(),
12685 publisher: Some("Atlassian".to_owned()),
12686 scope: Some("User".to_owned()),
12687 installer_category: Some("exe".to_owned()),
12688 install_location: None,
12689 package_family_names: Vec::new(),
12690 product_codes: Vec::new(),
12691 upgrade_codes: Vec::new(),
12692 correlated: Some(SearchMatch {
12693 source_name: "winget".to_owned(),
12694 source_kind: SourceKind::PreIndexed,
12695 id: "Atlassian.AtlassianCLI".to_owned(),
12696 name: "Atlassian CLI".to_owned(),
12697 moniker: None,
12698 version: Some("1.3.18-stable".to_owned()),
12699 channel: None,
12700 match_criteria: Some("ProductCode".to_owned()),
12701 }),
12702 installed_version_canonical: false,
12703 correlated_requires_explicit_upgrade: false,
12704 correlated_lacks_compatible_installer: false,
12705 };
12706
12707 let item = list_match_from_installed(package);
12708
12709 assert_eq!(item.id, "Atlassian.AtlassianCLI");
12710 assert_eq!(
12711 item.local_id,
12712 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
12713 );
12714 assert_ne!(item.id, item.local_id);
12715 assert_eq!(item.source_name.as_deref(), Some("winget"));
12716 }
12717
12718 #[test]
12719 fn list_response_serialization_keeps_pascal_case_fields() {
12720 let response = ListResponse {
12721 matches: vec![ListMatch {
12722 name: "PowerToys".to_owned(),
12723 id: "Microsoft.PowerToys".to_owned(),
12724 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
12725 installed_version: "0.98.1".to_owned(),
12726 available_version: Some("0.99.0".to_owned()),
12727 source_name: Some("winget".to_owned()),
12728 publisher: Some("Microsoft".to_owned()),
12729 scope: Some("Machine".to_owned()),
12730 installer_category: Some("msi".to_owned()),
12731 install_location: None,
12732 package_family_names: Vec::new(),
12733 product_codes: Vec::new(),
12734 upgrade_codes: Vec::new(),
12735 }],
12736 warnings: Vec::new(),
12737 truncated: false,
12738 };
12739
12740 let value = serde_json::to_value(&response).expect("serialize list response");
12741 let match_value = &value["Matches"][0];
12742
12743 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
12744 assert_eq!(match_value["InstalledVersion"], "0.98.1");
12745 assert_eq!(match_value["AvailableVersion"], "0.99.0");
12746 assert_eq!(match_value["SourceName"], "winget");
12747 assert!(match_value.get("local_id").is_none());
12748 assert!(match_value.get("installed_version").is_none());
12749 assert!(match_value.get("available_version").is_none());
12750 assert!(match_value.get("source_name").is_none());
12751 }
12752
12753 #[test]
12754 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
12755 let response = SearchResponse {
12756 matches: vec![SearchMatch {
12757 source_name: "msstore".to_owned(),
12758 source_kind: SourceKind::Rest,
12759 id: "9WZDNCRFJBMP".to_owned(),
12760 name: "Microsoft To Do".to_owned(),
12761 moniker: None,
12762 version: Some("2.123.456.0".to_owned()),
12763 channel: None,
12764 match_criteria: None,
12765 }],
12766 warnings: Vec::new(),
12767 truncated: false,
12768 };
12769
12770 let value = serde_json::to_value(&response).expect("serialize search response");
12771 let match_value = &value["Matches"][0];
12772
12773 assert_eq!(match_value["Name"], "Microsoft To Do");
12774 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
12775 assert_eq!(match_value["Version"], "2.123.456.0");
12776 assert_eq!(match_value["SourceName"], "msstore");
12777 assert!(match_value.get("source_name").is_none());
12778 }
12779
12780 #[test]
12781 fn versions_result_serialization_keeps_pascal_case_fields() {
12782 let result = VersionsResult {
12783 package: SearchMatch {
12784 source_name: "winget".to_owned(),
12785 source_kind: SourceKind::PreIndexed,
12786 id: "Microsoft.PowerToys".to_owned(),
12787 name: "PowerToys".to_owned(),
12788 moniker: None,
12789 version: Some("0.99.0".to_owned()),
12790 channel: None,
12791 match_criteria: None,
12792 },
12793 versions: vec![
12794 VersionKey {
12795 version: "0.99.0".to_owned(),
12796 channel: "stable".to_owned(),
12797 },
12798 VersionKey {
12799 version: "0.100.0-preview".to_owned(),
12800 channel: "preview".to_owned(),
12801 },
12802 ],
12803 warnings: Vec::new(),
12804 };
12805
12806 let value = serde_json::to_value(&result).expect("serialize versions result");
12807
12808 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
12809 assert_eq!(value["Versions"][0]["Channel"], "stable");
12810 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
12811 assert_eq!(value["Versions"][1]["Channel"], "preview");
12812 assert!(value.get("versions").is_none());
12813 }
12814
12815 #[test]
12816 fn version_data_parses_arp_bounds_from_winget_payload() {
12817 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";
12821
12822 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
12823 assert_eq!(document.versions.len(), 2);
12824 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
12825 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
12826 assert!(document.versions[1].arp_min_version.is_none());
12827 assert!(document.versions[1].arp_max_version.is_none());
12828 }
12829
12830 #[test]
12831 fn msix_resource_string_name_resolves_to_catalog_name() {
12832 let mut package = InstalledPackage {
12837 name: "ms-resource:appDisplayName".to_owned(),
12838 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
12839 installed_version: "1.28.239.0".to_owned(),
12840 publisher: None,
12841 scope: Some("User".to_owned()),
12842 installer_category: Some("msix".to_owned()),
12843 install_location: None,
12844 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
12845 product_codes: Vec::new(),
12846 upgrade_codes: Vec::new(),
12847 correlated: Some(SearchMatch {
12848 source_name: "winget".to_owned(),
12849 source_kind: SourceKind::PreIndexed,
12850 id: "Microsoft.AppInstaller".to_owned(),
12851 name: "App Installer".to_owned(),
12852 moniker: None,
12853 version: Some("1.28.240.0".to_owned()),
12854 channel: None,
12855 match_criteria: Some("PackageFamilyName".to_owned()),
12856 }),
12857 installed_version_canonical: false,
12858 correlated_requires_explicit_upgrade: false,
12859 correlated_lacks_compatible_installer: false,
12860 };
12861 apply_msix_resource_string_name_fix(&mut package);
12862 assert_eq!(package.name, "App Installer");
12863 }
12864
12865 #[test]
12866 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
12867 let mut package = InstalledPackage {
12872 name: "ms-resource:appDisplayName".to_owned(),
12873 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
12874 installed_version: "1.0".to_owned(),
12875 publisher: None,
12876 scope: Some("Machine".to_owned()),
12877 installer_category: Some("msi".to_owned()),
12878 install_location: None,
12879 package_family_names: Vec::new(),
12880 product_codes: Vec::new(),
12881 upgrade_codes: Vec::new(),
12882 correlated: Some(SearchMatch {
12883 source_name: "winget".to_owned(),
12884 source_kind: SourceKind::PreIndexed,
12885 id: "Some.Package".to_owned(),
12886 name: "Should Not Apply".to_owned(),
12887 moniker: None,
12888 version: Some("1.0".to_owned()),
12889 channel: None,
12890 match_criteria: None,
12891 }),
12892 installed_version_canonical: false,
12893 correlated_requires_explicit_upgrade: false,
12894 correlated_lacks_compatible_installer: false,
12895 };
12896 apply_msix_resource_string_name_fix(&mut package);
12897 assert_eq!(package.name, "ms-resource:appDisplayName");
12898 }
12899
12900 #[test]
12901 fn msix_resource_string_fix_skips_resolved_names() {
12902 let mut package = InstalledPackage {
12907 name: "Microsoft Teams".to_owned(),
12908 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
12909 installed_version: "25290.205.4069.4894".to_owned(),
12910 publisher: None,
12911 scope: Some("User".to_owned()),
12912 installer_category: Some("msix".to_owned()),
12913 install_location: None,
12914 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
12915 product_codes: Vec::new(),
12916 upgrade_codes: Vec::new(),
12917 correlated: Some(SearchMatch {
12918 source_name: "winget".to_owned(),
12919 source_kind: SourceKind::PreIndexed,
12920 id: "Microsoft.Teams".to_owned(),
12921 name: "Microsoft Teams Catalog Name".to_owned(),
12922 moniker: None,
12923 version: Some("26106.1906.4665.7308".to_owned()),
12924 channel: None,
12925 match_criteria: Some("PackageFamilyName".to_owned()),
12926 }),
12927 installed_version_canonical: false,
12928 correlated_requires_explicit_upgrade: false,
12929 correlated_lacks_compatible_installer: false,
12930 };
12931 apply_msix_resource_string_name_fix(&mut package);
12932 assert_eq!(package.name, "Microsoft Teams");
12933 }
12934
12935 #[cfg(windows)]
12936 #[test]
12937 fn unflip_packed_guid_reverses_msi_installer_packing() {
12938 assert_eq!(
12945 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
12946 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
12947 );
12948 assert_eq!(
12951 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
12952 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
12953 );
12954 assert!(unflip_packed_guid("nothex").is_none());
12956 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
12958 }
12959
12960 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
12961 InstalledPackage {
12962 name: format!("{id} install"),
12963 local_id: format!(r"ARP\Machine\X64\{id}"),
12964 installed_version: installed_version.to_owned(),
12965 publisher: None,
12966 scope: Some("Machine".to_owned()),
12967 installer_category: Some("msi".to_owned()),
12968 install_location: None,
12969 package_family_names: Vec::new(),
12970 product_codes: Vec::new(),
12971 upgrade_codes: Vec::new(),
12972 correlated: Some(SearchMatch {
12973 source_name: "winget".to_owned(),
12974 source_kind: SourceKind::PreIndexed,
12975 id: id.to_owned(),
12976 name: id.to_owned(),
12977 moniker: None,
12978 version: Some("99.0.0".to_owned()),
12979 channel: None,
12980 match_criteria: None,
12981 }),
12982 installed_version_canonical: canonical,
12983 correlated_requires_explicit_upgrade: false,
12984 correlated_lacks_compatible_installer: false,
12985 }
12986 }
12987
12988 #[test]
12989 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
12990 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
12997 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
12998
12999 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
13000 assert_eq!(result.len(), 1);
13001 assert_eq!(result[0].installed_version, "10.0.108");
13002 assert!(result[0].installed_version_canonical);
13003 }
13004
13005 #[test]
13006 fn dedupe_keeps_highest_among_canonical_rows() {
13007 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
13008 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
13009
13010 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
13011 assert_eq!(result.len(), 1);
13012 assert_eq!(result[0].installed_version, "1.7.9");
13013 }
13014
13015 #[test]
13016 fn dedupe_leaves_uncorrelated_rows_alone() {
13017 let uncorrelated = InstalledPackage {
13021 name: "Foo".to_owned(),
13022 local_id: r"ARP\Machine\X64\Foo".to_owned(),
13023 installed_version: "1.0".to_owned(),
13024 publisher: None,
13025 scope: Some("Machine".to_owned()),
13026 installer_category: Some("exe".to_owned()),
13027 install_location: None,
13028 package_family_names: Vec::new(),
13029 product_codes: Vec::new(),
13030 upgrade_codes: Vec::new(),
13031 correlated: None,
13032 installed_version_canonical: false,
13033 correlated_requires_explicit_upgrade: false,
13034 correlated_lacks_compatible_installer: false,
13035 };
13036 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
13037 assert_eq!(result.len(), 1);
13038 }
13039}