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