1mod name_normalization;
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::ffi::OsString;
6use std::fmt::{Display, Formatter};
7use std::fs::{self, OpenOptions};
8use std::io::{Cursor, Read, Write};
9#[cfg(windows)]
10use std::mem::{MaybeUninit, size_of};
11#[cfg(windows)]
12use std::os::windows::ffi::{OsStrExt, OsStringExt};
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, OnceLock, RwLock};
15use std::time::{Duration as StdDuration, Instant};
16
17use anyhow::{Context, Result, anyhow, bail};
18use chrono::{DateTime, Duration, Utc};
19use reqwest::StatusCode;
20use reqwest::blocking::{Client, Response};
21use reqwest::header::{
22 ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MODIFIED_SINCE,
23 IF_NONE_MATCH, IF_RANGE, LAST_MODIFIED, RANGE,
24};
25use serde::{Deserialize, Serialize};
26use serde_json::{Map as JsonMap, Value as JsonValue};
27use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
28use sha2::{Digest, Sha256};
29use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
30#[cfg(windows)]
31use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
32#[cfg(windows)]
33use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
34#[cfg(windows)]
35use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
36#[cfg(windows)]
37use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
38#[cfg(windows)]
39use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
40#[cfg(windows)]
41use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
42#[cfg(windows)]
43use winreg::{RegKey, enums::*};
44use zip::ZipArchive;
45
46const DEFAULT_MARKET: &str = "US";
47const DEFAULT_MAX_RESULTS: usize = 50;
48const LIST_LOOKUP_MAX_RESULTS: usize = 500;
49const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
50const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
51const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 15;
52const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
53const SYSTEM_WINGET_MIRROR_STORE_FILE_NAME: &str = "system-sources.json";
54const SYSTEM_WINGET_MIRROR_EXPORT_STAMP_FILE_NAME: &str = "system-sources.stamp";
55const APP_ROOT_ENV_VAR: &str = "PINGET_APPROOT";
56const SOURCE_MODE_ENV_VAR: &str = "PINGET_SOURCE_MODE";
57const SYSTEM_WINGET_PROGRAM_ENV_VAR: &str = "PINGET_WINGET_PATH";
58const SYSTEM_WINGET_MIRROR_AUTO_REFRESH_MINUTES: u64 = 15;
59#[cfg(windows)]
60const SYSTEM_WINGET_PROGRAM_NAME: &str = "winget.exe";
61#[cfg(not(windows))]
62const SYSTEM_WINGET_PROGRAM_NAME: &str = "winget";
63#[cfg(windows)]
64const APPMODEL_PACKAGES_PATH: &str =
65 r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
66#[cfg(windows)]
67const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
68#[cfg(windows)]
69const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
70const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
71 "Installed package discovery is not supported on this platform; returning no installed packages.";
72const INSTALL_UNSUPPORTED_WARNING: &str =
73 "Installing packages is not supported on this platform; no changes were made.";
74const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
75const REPAIR_REINSTALL_WARNING: &str =
76 "Pinget repair currently re-runs the package install flow for the selected package.";
77const UNINSTALL_UNSUPPORTED_WARNING: &str =
78 "Uninstalling packages is not supported on this platform; no changes were made.";
79#[cfg(windows)]
80const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
81const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
82 "LocalManifestFiles",
83 "BypassCertificatePinningForMicrosoftStore",
84 "InstallerHashOverride",
85 "LocalArchiveMalwareScanOverride",
86 "ProxyCommandLineOptions",
87];
88const REST_SUPPORTED_CONTRACTS: &[&str] = &[
89 "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",
90];
91
92#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
93pub enum SourceKind {
94 #[serde(rename = "PreIndexed", alias = "preIndexed")]
95 PreIndexed,
96 #[serde(rename = "Rest", alias = "rest")]
97 Rest,
98}
99
100impl Display for SourceKind {
101 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
102 match self {
103 SourceKind::PreIndexed => f.write_str("preindexed"),
104 SourceKind::Rest => f.write_str("rest"),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct SourceRecord {
111 pub name: String,
112 pub kind: SourceKind,
113 pub arg: String,
114 pub identifier: String,
115 #[serde(default = "default_source_trust_level")]
116 pub trust_level: String,
117 #[serde(default)]
118 pub explicit: bool,
119 #[serde(default)]
120 pub priority: i32,
121 #[serde(default)]
122 pub last_update: Option<DateTime<Utc>>,
123 #[serde(default)]
124 pub source_version: Option<String>,
125 #[serde(default)]
126 pub etag: Option<String>,
127 #[serde(default)]
128 pub last_modified: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132struct SourceStore {
133 sources: Vec<SourceRecord>,
134}
135
136impl Default for SourceStore {
137 fn default() -> Self {
138 Self {
139 sources: vec![
140 SourceRecord {
141 name: "winget".to_owned(),
142 kind: SourceKind::PreIndexed,
143 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
144 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
145 trust_level: "Trusted".to_owned(),
146 explicit: false,
147 priority: 0,
148 last_update: None,
149 source_version: None,
150 etag: None,
151 last_modified: None,
152 },
153 SourceRecord {
154 name: "msstore".to_owned(),
155 kind: SourceKind::Rest,
156 arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
157 identifier: "StoreEdgeFD".to_owned(),
158 trust_level: "Trusted".to_owned(),
159 explicit: false,
160 priority: 0,
161 last_update: None,
162 source_version: None,
163 etag: None,
164 last_modified: None,
165 },
166 ],
167 }
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct RepositoryOptions {
174 pub app_root: PathBuf,
175 pub user_agent: String,
176 pub source_mode: SourceMode,
177 pub pre_indexed_source_auto_update_interval: Option<Duration>,
178 pub install_progress: Option<fn(&InstallProgress)>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum SourceMode {
183 Auto,
184 Private,
185 SystemWingetMirror,
186}
187
188impl SourceMode {
189 fn parse(value: &str) -> Option<Self> {
190 match value.trim().to_ascii_lowercase().as_str() {
191 "auto" => Some(Self::Auto),
192 "private" => Some(Self::Private),
193 "system-winget-mirror" | "systemwingetmirror" => Some(Self::SystemWingetMirror),
194 _ => None,
195 }
196 }
197
198 fn from_env() -> Option<Self> {
199 Self::parse(&std::env::var(SOURCE_MODE_ENV_VAR).ok()?)
200 }
201
202 fn resolve(from_env: Option<Self>, has_app_root_override: bool) -> Self {
203 from_env.unwrap_or(if has_app_root_override {
204 Self::Private
205 } else {
206 Self::Auto
207 })
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212enum EffectiveSourceMode {
213 Private,
214 SystemWingetDirect,
215 SystemWingetMirror,
216}
217
218impl RepositoryOptions {
219 pub fn new(app_root: impl Into<PathBuf>) -> Self {
221 Self {
222 app_root: app_root.into(),
223 user_agent: DEFAULT_USER_AGENT.to_owned(),
224 source_mode: SourceMode::Private,
225 pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
226 install_progress: None,
227 }
228 }
229
230 pub fn for_current_user() -> Result<Self> {
232 let source_mode = SourceMode::resolve(SourceMode::from_env(), std::env::var_os(APP_ROOT_ENV_VAR).is_some());
233 Ok(Self::new(default_app_root()?).with_source_mode(source_mode))
234 }
235
236 #[must_use]
238 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
239 self.user_agent = user_agent.into();
240 self
241 }
242
243 #[must_use]
244 pub fn with_source_mode(mut self, source_mode: SourceMode) -> Self {
245 self.source_mode = source_mode;
246 self
247 }
248
249 #[must_use]
251 pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
252 self.pre_indexed_source_auto_update_interval = interval;
253 self
254 }
255
256 #[must_use]
260 pub fn with_install_progress(mut self, progress: fn(&InstallProgress)) -> Self {
261 self.install_progress = Some(progress);
262 self
263 }
264}
265
266#[derive(Debug, Clone, Default)]
267pub struct PackageQuery {
268 pub query: Option<String>,
269 pub id: Option<String>,
270 pub name: Option<String>,
271 pub moniker: Option<String>,
272 pub tag: Option<String>,
273 pub command: Option<String>,
274 pub source: Option<String>,
275 pub count: Option<usize>,
276 pub exact: bool,
277 pub version: Option<String>,
278 pub channel: Option<String>,
279 pub locale: Option<String>,
280 pub installer_type: Option<String>,
281 pub installer_architecture: Option<String>,
282 pub platform: Option<String>,
283 pub os_version: Option<String>,
284 pub install_scope: Option<String>,
285}
286
287#[derive(Debug, Clone, Default)]
288pub struct ListQuery {
289 pub query: Option<String>,
290 pub id: Option<String>,
291 pub name: Option<String>,
292 pub moniker: Option<String>,
293 pub tag: Option<String>,
294 pub command: Option<String>,
295 pub product_code: Option<String>,
296 pub version: Option<String>,
297 pub source: Option<String>,
298 pub count: Option<usize>,
299 pub exact: bool,
300 pub install_scope: Option<String>,
301 pub upgrade_only: bool,
302 pub include_unknown: bool,
303 pub include_pinned: bool,
304}
305
306#[derive(Debug, Clone, serde::Serialize)]
307#[serde(rename_all = "PascalCase")]
308pub struct SearchMatch {
309 pub source_name: String,
310 pub source_kind: SourceKind,
311 pub id: String,
312 pub name: String,
313 pub moniker: Option<String>,
314 pub version: Option<String>,
315 pub channel: Option<String>,
316 pub match_criteria: Option<String>,
317}
318
319#[derive(Debug, Clone, serde::Serialize)]
320#[serde(rename_all = "PascalCase")]
321pub struct SearchResponse {
322 pub matches: Vec<SearchMatch>,
323 pub warnings: Vec<String>,
324 pub truncated: bool,
325}
326
327#[derive(Debug, Clone, serde::Serialize)]
328#[serde(rename_all = "PascalCase")]
329pub struct ListMatch {
330 pub name: String,
331 pub id: String,
332 pub local_id: String,
333 pub installed_version: String,
334 pub available_version: Option<String>,
335 pub source_name: Option<String>,
336 pub publisher: Option<String>,
337 pub scope: Option<String>,
338 pub installer_category: Option<String>,
339 pub install_location: Option<String>,
340 pub package_family_names: Vec<String>,
341 pub product_codes: Vec<String>,
342 pub upgrade_codes: Vec<String>,
343}
344
345#[derive(Debug, Clone, serde::Serialize)]
346#[serde(rename_all = "PascalCase")]
347pub struct ListResponse {
348 pub matches: Vec<ListMatch>,
349 pub warnings: Vec<String>,
350 pub truncated: bool,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
354#[serde(rename_all = "PascalCase")]
355pub struct VersionKey {
356 pub version: String,
357 pub channel: String,
358}
359
360#[derive(Debug, Clone, serde::Serialize)]
361pub struct Manifest {
362 pub id: String,
363 pub name: String,
364 pub version: String,
365 pub channel: String,
366 pub publisher: Option<String>,
367 pub description: Option<String>,
368 pub moniker: Option<String>,
369 pub package_url: Option<String>,
370 pub publisher_url: Option<String>,
371 pub publisher_support_url: Option<String>,
372 pub license: Option<String>,
373 pub license_url: Option<String>,
374 pub privacy_url: Option<String>,
375 pub author: Option<String>,
376 pub copyright: Option<String>,
377 pub copyright_url: Option<String>,
378 pub release_notes: Option<String>,
379 pub release_notes_url: Option<String>,
380 pub tags: Vec<String>,
381 pub agreements: Vec<PackageAgreement>,
382 pub package_dependencies: Vec<String>,
383 pub documentation: Vec<Documentation>,
384 pub icons: Vec<PackageIcon>,
385 pub installers: Vec<Installer>,
386 pub require_explicit_upgrade: bool,
391}
392
393impl Manifest {
394 pub fn icon(&self) -> Option<&PackageIcon> {
395 select_best_icon(&self.icons)
396 }
397
398 pub fn icon_url(&self) -> Option<&str> {
399 self.icon().and_then(|icon| icon.icon_url.as_deref())
400 }
401}
402
403#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
404#[serde(rename_all = "PascalCase")]
405pub struct PackageIcon {
406 pub icon_url: Option<String>,
407 pub icon_file_type: Option<String>,
408 pub icon_resolution: Option<String>,
409 pub icon_theme: Option<String>,
410 pub icon_sha256: Option<String>,
411}
412
413impl PackageIcon {
414 fn is_empty(&self) -> bool {
415 self.icon_url.as_deref().is_none_or(|value| value.trim().is_empty())
416 && self
417 .icon_file_type
418 .as_deref()
419 .is_none_or(|value| value.trim().is_empty())
420 && self
421 .icon_resolution
422 .as_deref()
423 .is_none_or(|value| value.trim().is_empty())
424 && self.icon_theme.as_deref().is_none_or(|value| value.trim().is_empty())
425 && self.icon_sha256.as_deref().is_none_or(|value| value.trim().is_empty())
426 }
427}
428
429fn select_best_icon(icons: &[PackageIcon]) -> Option<&PackageIcon> {
430 icons
431 .iter()
432 .enumerate()
433 .filter(|(_, icon)| icon.icon_url.as_deref().is_some_and(|url| !url.trim().is_empty()))
434 .max_by(|(left_index, left), (right_index, right)| {
435 icon_theme_score(left.icon_theme.as_deref())
436 .cmp(&icon_theme_score(right.icon_theme.as_deref()))
437 .then_with(|| {
438 icon_resolution_score(left.icon_resolution.as_deref())
439 .cmp(&icon_resolution_score(right.icon_resolution.as_deref()))
440 })
441 .then_with(|| {
442 icon_file_type_score(left.icon_file_type.as_deref())
443 .cmp(&icon_file_type_score(right.icon_file_type.as_deref()))
444 })
445 .then_with(|| right_index.cmp(left_index))
446 })
447 .map(|(_, icon)| icon)
448}
449
450fn icon_theme_score(theme: Option<&str>) -> usize {
451 match theme {
452 None => 1,
453 Some(value) if value.trim().is_empty() || value.eq_ignore_ascii_case("default") => 1,
454 Some(_) => 0,
455 }
456}
457
458fn icon_file_type_score(file_type: Option<&str>) -> usize {
459 match file_type {
460 Some(value) if value.eq_ignore_ascii_case("ico") => 2,
461 Some(value) if value.eq_ignore_ascii_case("png") => 1,
462 _ => 0,
463 }
464}
465
466fn icon_resolution_score(resolution: Option<&str>) -> usize {
467 let Some(resolution) = resolution else {
468 return 0;
469 };
470 let resolution = resolution.trim();
471 let Some(separator) = resolution.find(['x', 'X']) else {
472 return 0;
473 };
474 let (width, height) = resolution.split_at(separator);
475 let height = &height[1..];
476 let (Ok(width), Ok(height)) = (width.parse::<usize>(), height.parse::<usize>()) else {
477 return 0;
478 };
479 if width == height { width } else { 0 }
480}
481
482#[derive(Debug, Clone, serde::Serialize)]
483pub struct Documentation {
484 pub label: Option<String>,
485 pub url: String,
486}
487
488#[derive(Debug, Clone, serde::Serialize)]
489pub struct PackageAgreement {
490 pub label: Option<String>,
491 pub text: Option<String>,
492 pub url: Option<String>,
493}
494
495#[derive(Debug, Clone, Default, serde::Serialize)]
496pub struct Installer {
497 pub architecture: Option<String>,
498 pub installer_type: Option<String>,
499 pub nested_installer_type: Option<String>,
500 pub nested_installer_files: Vec<NestedInstallerFile>,
501 pub url: Option<String>,
502 pub sha256: Option<String>,
503 pub product_code: Option<String>,
504 pub locale: Option<String>,
505 pub scope: Option<String>,
506 pub release_date: Option<String>,
507 pub package_family_name: Option<String>,
508 pub upgrade_code: Option<String>,
509 pub platforms: Vec<String>,
510 pub minimum_os_version: Option<String>,
511 pub switches: InstallerSwitches,
512 pub commands: Vec<String>,
513 pub package_dependencies: Vec<String>,
514 pub require_explicit_upgrade: bool,
515}
516
517#[derive(Debug, Clone, Default, serde::Serialize)]
518pub struct NestedInstallerFile {
519 pub relative_file_path: String,
521 pub portable_command_alias: Option<String>,
525}
526
527#[derive(Debug, Clone, Default, serde::Serialize)]
528pub struct InstallerSwitches {
529 pub silent: Option<String>,
530 pub silent_with_progress: Option<String>,
531 pub interactive: Option<String>,
532 pub custom: Option<String>,
533 pub log: Option<String>,
534 pub install_location: Option<String>,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538pub enum InstallerMode {
539 Interactive,
540 SilentWithProgress,
541 Silent,
542}
543
544#[derive(Debug, Clone)]
545pub struct InstallRequest {
546 pub query: PackageQuery,
547 pub manifest_path: Option<PathBuf>,
548 pub mode: InstallerMode,
549 pub log_path: Option<PathBuf>,
550 pub custom: Option<String>,
551 pub override_args: Option<String>,
552 pub install_location: Option<String>,
553 pub skip_dependencies: bool,
554 pub dependencies_only: bool,
555 pub accept_package_agreements: bool,
556 pub force: bool,
557 pub rename: Option<String>,
558 pub uninstall_previous: bool,
559 pub ignore_security_hash: bool,
560 pub dependency_source: Option<String>,
561 pub no_upgrade: bool,
562}
563
564impl InstallRequest {
565 pub fn new(query: PackageQuery) -> Self {
566 Self {
567 query,
568 manifest_path: None,
569 mode: InstallerMode::SilentWithProgress,
570 log_path: None,
571 custom: None,
572 override_args: None,
573 install_location: None,
574 skip_dependencies: false,
575 dependencies_only: false,
576 accept_package_agreements: false,
577 force: false,
578 rename: None,
579 uninstall_previous: false,
580 ignore_security_hash: false,
581 dependency_source: None,
582 no_upgrade: false,
583 }
584 }
585}
586
587#[derive(Debug, Clone)]
588pub struct UninstallRequest {
589 pub query: PackageQuery,
590 pub manifest_path: Option<PathBuf>,
591 pub product_code: Option<String>,
592 pub mode: InstallerMode,
593 pub all_versions: bool,
594 pub force: bool,
595 pub purge: bool,
596 pub preserve: bool,
597 pub log_path: Option<PathBuf>,
598}
599
600impl UninstallRequest {
601 pub fn new(query: PackageQuery) -> Self {
602 Self {
603 query,
604 manifest_path: None,
605 product_code: None,
606 mode: InstallerMode::SilentWithProgress,
607 all_versions: false,
608 force: false,
609 purge: false,
610 preserve: false,
611 log_path: None,
612 }
613 }
614}
615
616#[derive(Debug, Clone)]
617pub struct RepairRequest {
618 pub query: PackageQuery,
619 pub manifest_path: Option<PathBuf>,
620 pub product_code: Option<String>,
621 pub mode: InstallerMode,
622 pub log_path: Option<PathBuf>,
623 pub accept_package_agreements: bool,
624 pub force: bool,
625 pub ignore_security_hash: bool,
626}
627
628impl RepairRequest {
629 pub fn new(query: PackageQuery) -> Self {
630 Self {
631 query,
632 manifest_path: None,
633 product_code: None,
634 mode: InstallerMode::SilentWithProgress,
635 log_path: None,
636 accept_package_agreements: false,
637 force: false,
638 ignore_security_hash: false,
639 }
640 }
641}
642
643#[derive(Debug, Clone, serde::Serialize)]
644pub struct ShowResult {
645 pub package: SearchMatch,
646 pub manifest: Manifest,
647 pub selected_installer: Option<Installer>,
648 pub cached_files: Vec<PathBuf>,
649 pub warnings: Vec<String>,
650 #[serde(skip_serializing)]
651 manifest_documents: JsonValue,
652}
653
654impl ShowResult {
655 pub fn structured_document(&self) -> JsonValue {
656 add_icon_fields_to_structured_document(collapse_structured_document(&self.manifest_documents), &self.manifest)
657 }
658}
659
660#[derive(Debug, Clone, serde::Serialize)]
665pub struct DownloadOutput {
666 pub manifest: Manifest,
667 pub installer_path: PathBuf,
668 pub manifest_path: PathBuf,
669}
670
671struct FetchedInstaller {
675 manifest: Manifest,
676 manifest_documents: JsonValue,
677 installer: Installer,
678 installer_path: PathBuf,
679}
680
681#[derive(Debug, Clone, serde::Serialize)]
682#[serde(rename_all = "PascalCase")]
683pub struct VersionsResult {
684 pub package: SearchMatch,
685 pub versions: Vec<VersionKey>,
686 pub warnings: Vec<String>,
687}
688
689#[derive(Debug, Clone, serde::Serialize)]
690#[serde(rename_all = "PascalCase")]
691pub struct CacheWarmResult {
692 pub package: SearchMatch,
693 pub cached_files: Vec<PathBuf>,
694 pub warnings: Vec<String>,
695}
696
697#[derive(Debug, Clone, serde::Serialize)]
698#[serde(rename_all = "PascalCase")]
699pub struct SourceUpdateResult {
700 pub name: String,
701 pub kind: SourceKind,
702 pub detail: String,
703}
704
705#[derive(Debug, Clone, serde::Serialize)]
706#[serde(rename_all = "PascalCase")]
707pub struct PinRecord {
708 pub package_id: String,
709 pub version: String,
710 pub source_id: String,
711 pub pin_type: PinType,
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
715pub enum PinType {
716 Pinning,
717 Blocking,
718 Gating,
719}
720
721impl Display for PinType {
722 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
723 match self {
724 PinType::Pinning => f.write_str("Pinning"),
725 PinType::Blocking => f.write_str("Blocking"),
726 PinType::Gating => f.write_str("Gating"),
727 }
728 }
729}
730
731#[derive(Debug, Clone, serde::Serialize)]
733pub struct InstallResult {
734 pub package_id: String,
735 pub version: String,
736 pub installer_path: PathBuf,
737 pub installer_type: String,
738 pub exit_code: i32,
739 pub success: bool,
740 pub no_op: bool,
741 pub warnings: Vec<String>,
742}
743
744#[derive(Debug, Clone)]
745pub enum InstallProgress {
746 ResolvingManifest,
747 ResolvedManifest { package_id: String, version: String },
748 CheckingInstalled,
749 InstallingDependencies,
750 SelectingInstaller,
751 DownloadingInstaller { url: String, path: PathBuf },
752 VerifyingInstaller { path: PathBuf },
753 StartingInstaller { installer_type: String, path: PathBuf },
754 WaitingForInstaller { installer_type: String },
755 InstallerFinished { exit_code: i32 },
756}
757
758impl InstallerSwitches {
759 fn with_fallback(&self, fallback: &Self) -> Self {
760 Self {
761 silent: self.silent.clone().or_else(|| fallback.silent.clone()),
762 silent_with_progress: self
763 .silent_with_progress
764 .clone()
765 .or_else(|| fallback.silent_with_progress.clone()),
766 interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
767 custom: self.custom.clone().or_else(|| fallback.custom.clone()),
768 log: self.log.clone().or_else(|| fallback.log.clone()),
769 install_location: self
770 .install_location
771 .clone()
772 .or_else(|| fallback.install_location.clone()),
773 }
774 }
775}
776
777fn installed_package_discovery_supported() -> bool {
778 cfg!(windows)
779}
780
781fn package_actions_supported() -> bool {
782 cfg!(windows)
783}
784
785fn unsupported_action_result(
786 package_id: impl Into<String>,
787 version: impl Into<String>,
788 installer_type: impl Into<String>,
789 warning: &str,
790) -> InstallResult {
791 InstallResult {
792 package_id: package_id.into(),
793 version: version.into(),
794 installer_path: PathBuf::new(),
795 installer_type: installer_type.into(),
796 exit_code: 0,
797 success: true,
798 no_op: true,
799 warnings: vec![warning.to_owned()],
800 }
801}
802
803#[derive(Debug, Clone)]
804struct InstalledPackage {
805 name: String,
806 local_id: String,
807 installed_version: String,
808 publisher: Option<String>,
809 scope: Option<String>,
810 installer_category: Option<String>,
811 install_location: Option<String>,
812 winget_package_identifier: Option<String>,
813 winget_source_identifier: Option<String>,
814 package_family_names: Vec<String>,
815 product_codes: Vec<String>,
816 upgrade_codes: Vec<String>,
817 correlated: Option<SearchMatch>,
818 installed_version_canonical: bool,
825 correlated_requires_explicit_upgrade: bool,
829 correlated_lacks_compatible_installer: bool,
833}
834
835#[derive(Debug, Clone)]
836struct LocatedMatch {
837 display: SearchMatch,
838 source_index: usize,
839 locator: MatchLocator,
840}
841
842#[derive(Debug)]
843struct SearchSourceMatches {
844 matches: Vec<LocatedMatch>,
845 truncated: bool,
846}
847
848#[derive(Debug, Clone)]
849struct SourceSearchFailure {
850 source_name: String,
851 message: String,
852}
853
854#[derive(Debug)]
855struct SearchLocatedResult {
856 matches: Vec<LocatedMatch>,
857 warnings: Vec<String>,
858 failures: Vec<SourceSearchFailure>,
859 truncated: bool,
860}
861
862fn single_match_from_located(
863 located: SearchLocatedResult,
864 report_first_source_failure: bool,
865) -> Result<(LocatedMatch, Vec<String>)> {
866 if located.matches.is_empty() {
867 if report_first_source_failure && let Some(failure) = located.failures.first() {
868 bail!("failed to search source '{}': {}", failure.source_name, failure.message);
869 }
870 bail!("no package matched the supplied query");
871 }
872
873 if located.matches.len() > 1 {
874 let choices = located
875 .matches
876 .iter()
877 .take(10)
878 .map(|item| {
879 format!(
880 "{} [{}] ({})",
881 item.display.name, item.display.id, item.display.source_name
882 )
883 })
884 .collect::<Vec<_>>()
885 .join(", ");
886 bail!("multiple packages matched: {choices}");
887 }
888
889 Ok((located.matches.into_iter().next().expect("one match"), located.warnings))
890}
891
892fn should_retry_show_across_system_winget_sources(
893 uses_system_winget_sources: bool,
894 query: &PackageQuery,
895 located: &SearchLocatedResult,
896) -> bool {
897 uses_system_winget_sources
898 && query
899 .source
900 .as_deref()
901 .is_some_and(|source| source.eq_ignore_ascii_case("winget"))
902 && located.matches.is_empty()
903 && located.failures.is_empty()
904}
905
906#[derive(Debug, Clone)]
907enum MatchLocator {
908 PreIndexedV1 {
909 package_rowid: i64,
910 },
911 PreIndexedV2 {
912 package_rowid: i64,
913 package_hash: String,
914 },
915 Rest {
916 package_id: String,
917 versions: Vec<VersionKey>,
918 },
919}
920
921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922enum SearchSemantics {
923 Many,
924 Single,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
928struct RestInfoCache {
929 expires_at: DateTime<Utc>,
930 value: RestInformation,
931}
932
933#[derive(Debug, Clone, Default, Serialize, Deserialize)]
934struct RestInformation {
935 #[serde(rename = "SourceIdentifier")]
936 source_identifier: String,
937 #[serde(rename = "ServerSupportedVersions", default)]
938 server_supported_versions: Vec<String>,
939 #[serde(rename = "RequiredPackageMatchFields", default)]
940 required_package_match_fields: Vec<String>,
941 #[serde(rename = "UnsupportedPackageMatchFields", default)]
942 unsupported_package_match_fields: Vec<String>,
943 #[serde(rename = "RequiredQueryParameters", default)]
944 required_query_parameters: Vec<String>,
945 #[serde(rename = "UnsupportedQueryParameters", default)]
946 unsupported_query_parameters: Vec<String>,
947}
948
949#[derive(Debug, Clone, Deserialize)]
950struct PackageVersionDataDocument {
951 #[serde(rename = "vD", default)]
952 versions: Vec<PackageVersionDataEntry>,
953}
954
955#[derive(Debug, Clone, Deserialize)]
956struct PackageVersionDataEntry {
957 #[serde(rename = "v")]
958 version: String,
959 #[serde(rename = "rP")]
960 manifest_relative_path: String,
961 #[serde(rename = "s256H")]
962 manifest_hash: String,
963 #[serde(rename = "aMiV", default)]
968 arp_min_version: Option<String>,
969 #[serde(rename = "aMaV", default)]
970 arp_max_version: Option<String>,
971}
972
973pub struct Repository {
974 app_root: PathBuf,
975 user_agent: String,
976 client: Client,
977 store: SourceStore,
978 source_mode: EffectiveSourceMode,
979 install_progress: Option<fn(&InstallProgress)>,
980 pre_indexed_source_auto_update_interval: Option<Duration>,
981 preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
982}
983
984#[derive(Debug, Clone)]
985struct PreindexedRefreshAttempt {
986 attempted_at: DateTime<Utc>,
987 succeeded: bool,
988}
989
990#[derive(Debug, Clone, Default)]
991struct PreindexedRefreshOutcome {
992 attempted: bool,
993 reused_recent_attempt: bool,
994 error: Option<String>,
995}
996
997struct PreindexedOpenConnection {
998 connection: Connection,
999 refresh: PreindexedRefreshOutcome,
1000}
1001
1002#[derive(Debug)]
1003struct MissingPackageVersionError {
1004 package_id: String,
1005 source_name: String,
1006 requested_version: String,
1007 requested_channel: Option<String>,
1008}
1009
1010impl Display for MissingPackageVersionError {
1011 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1012 let channel = self
1013 .requested_channel
1014 .as_deref()
1015 .filter(|value| !value.is_empty())
1016 .map(|value| format!(" with channel '{value}'"))
1017 .unwrap_or_default();
1018 write!(
1019 f,
1020 "Version '{}'{} was not found for package '{}' in source '{}'.",
1021 self.requested_version, channel, self.package_id, self.source_name
1022 )
1023 }
1024}
1025
1026impl std::error::Error for MissingPackageVersionError {}
1027
1028fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
1029 static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
1030 LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
1031}
1032
1033impl Repository {
1034 pub fn open() -> Result<Self> {
1036 Self::open_with_options(RepositoryOptions::for_current_user()?)
1037 }
1038
1039 pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
1041 ensure_app_dirs(&options.app_root)?;
1042 let (source_mode, store) = load_effective_source_store(&options.app_root, options.source_mode)?;
1043 let client = Client::builder()
1044 .user_agent(&options.user_agent)
1045 .timeout(StdDuration::from_secs(30 * 60))
1046 .build()
1047 .context("failed to build HTTP client")?;
1048 Ok(Self {
1049 app_root: options.app_root,
1050 user_agent: options.user_agent,
1051 client,
1052 store,
1053 source_mode,
1054 install_progress: options.install_progress,
1055 pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
1056 preindexed_refresh_attempts: HashMap::new(),
1057 })
1058 }
1059
1060 fn uses_system_winget_sources(&self) -> bool {
1061 self.source_mode != EffectiveSourceMode::Private
1062 }
1063
1064 fn mirrors_system_winget_sources(&self) -> bool {
1065 self.source_mode == EffectiveSourceMode::SystemWingetMirror
1066 }
1067
1068 fn emit_install_progress(&self, progress: InstallProgress) {
1069 if let Some(callback) = self.install_progress {
1070 callback(&progress);
1071 }
1072 }
1073
1074 pub fn app_root(&self) -> &Path {
1075 &self.app_root
1076 }
1077
1078 pub fn list_sources(&self) -> Vec<SourceRecord> {
1079 self.store.sources.clone()
1080 }
1081
1082 pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
1083 self.add_source_with_metadata(name, arg, kind, None, false, 0)
1084 }
1085
1086 pub fn add_source_with_metadata(
1087 &mut self,
1088 name: &str,
1089 arg: &str,
1090 kind: SourceKind,
1091 trust_level: Option<&str>,
1092 explicit: bool,
1093 priority: i32,
1094 ) -> Result<()> {
1095 if self.uses_system_winget_sources() {
1096 system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
1097 self.refresh_system_winget_sources()?;
1098 return Ok(());
1099 }
1100
1101 if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
1102 bail!("A source with name '{name}' already exists.");
1103 }
1104 if self.store.sources.iter().any(|s| s.arg == arg) {
1105 bail!("A source with argument '{arg}' already exists.");
1106 }
1107 let identifier = name.to_owned();
1108 self.store.sources.push(SourceRecord {
1109 name: name.to_owned(),
1110 kind,
1111 arg: arg.to_owned(),
1112 identifier,
1113 trust_level: normalize_source_trust_level(trust_level)?,
1114 explicit,
1115 priority,
1116 last_update: None,
1117 source_version: None,
1118 etag: None,
1119 last_modified: None,
1120 });
1121 if self.source_mode != EffectiveSourceMode::SystemWingetDirect {
1122 self.save_store()?;
1123 }
1124 Ok(())
1125 }
1126
1127 pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
1128 if self.uses_system_winget_sources() {
1129 bail!("Editing system WinGet sources is not supported by winget source commands.");
1130 }
1131
1132 let source = self
1133 .store
1134 .sources
1135 .iter_mut()
1136 .find(|source| source.name.eq_ignore_ascii_case(name))
1137 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1138
1139 if let Some(explicit) = explicit {
1140 source.explicit = explicit;
1141 }
1142
1143 if trust_level.is_some() {
1144 source.trust_level = normalize_source_trust_level(trust_level)?;
1145 }
1146
1147 self.save_store()?;
1148 Ok(())
1149 }
1150
1151 pub fn remove_source(&mut self, name: &str) -> Result<()> {
1152 if self.uses_system_winget_sources() {
1153 system_winget_remove_source(name)?;
1154 self.refresh_system_winget_sources()?;
1155 return Ok(());
1156 }
1157
1158 let idx = self
1159 .store
1160 .sources
1161 .iter()
1162 .position(|s| s.name.eq_ignore_ascii_case(name))
1163 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1164 let source = self.store.sources.remove(idx);
1165 let state_dir = self.source_state_dir(&source);
1167 if state_dir.exists() {
1168 let _ = fs::remove_dir_all(&state_dir);
1169 }
1170 self.save_store()?;
1171 Ok(())
1172 }
1173
1174 pub fn reset_source(&mut self, name: &str) -> Result<()> {
1175 if self.uses_system_winget_sources() {
1176 system_winget_reset_source(name)?;
1177 self.refresh_system_winget_sources()?;
1178 return Ok(());
1179 }
1180
1181 let idx = self
1182 .store
1183 .sources
1184 .iter()
1185 .position(|source| source.name.eq_ignore_ascii_case(name))
1186 .ok_or_else(|| anyhow!("Source '{name}' not found."))?;
1187
1188 let source = self.store.sources[idx].clone();
1189 let state_dir = self.source_state_dir(&source);
1190 if state_dir.exists() {
1191 let _ = fs::remove_dir_all(&state_dir);
1192 }
1193
1194 if let Some(default_source) = SourceStore::default()
1195 .sources
1196 .into_iter()
1197 .find(|source| source.name.eq_ignore_ascii_case(name))
1198 {
1199 self.store.sources[idx] = default_source;
1200 } else {
1201 self.store.sources[idx].last_update = None;
1202 self.store.sources[idx].source_version = None;
1203 self.store.sources[idx].etag = None;
1204 self.store.sources[idx].last_modified = None;
1205 }
1206
1207 self.save_store()?;
1208 Ok(())
1209 }
1210
1211 pub fn reset_sources(&mut self) -> Result<()> {
1212 if self.uses_system_winget_sources() {
1213 system_winget_reset_sources()?;
1214 self.refresh_system_winget_sources()?;
1215 return Ok(());
1216 }
1217
1218 for source in &self.store.sources {
1220 let state_dir = self.source_state_dir(source);
1221 if state_dir.exists() {
1222 let _ = fs::remove_dir_all(&state_dir);
1223 }
1224 }
1225 self.store = SourceStore::default();
1226 self.save_store()?;
1227 Ok(())
1228 }
1229
1230 pub fn supported_admin_settings() -> &'static [&'static str] {
1231 SUPPORTED_ADMIN_SETTINGS
1232 }
1233
1234 pub fn get_user_settings(&self) -> Result<JsonValue> {
1235 Ok(JsonValue::Object(load_json_object(&user_settings_path(
1236 &self.app_root,
1237 ))?))
1238 }
1239
1240 pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
1241 let update = match user_settings {
1242 JsonValue::Object(object) => object.clone(),
1243 _ => bail!("user settings must be a JSON object"),
1244 };
1245
1246 let effective = if merge {
1247 merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
1248 } else {
1249 update
1250 };
1251 save_json_object(user_settings_path(&self.app_root), &effective)?;
1252 Ok(JsonValue::Object(effective))
1253 }
1254
1255 pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
1256 let current = self.get_user_settings()?;
1257 Ok(if ignore_not_set {
1258 json_contains(¤t, expected)
1259 } else {
1260 current == *expected
1261 })
1262 }
1263
1264 pub fn get_admin_settings(&self) -> Result<JsonValue> {
1265 let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
1266 for name in SUPPORTED_ADMIN_SETTINGS {
1267 settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
1268 }
1269
1270 Ok(JsonValue::Object(settings))
1271 }
1272
1273 pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
1274 let normalized = normalize_admin_setting_name(name)?;
1275 let mut settings = match self.get_admin_settings()? {
1276 JsonValue::Object(settings) => settings,
1277 _ => JsonMap::new(),
1278 };
1279 settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
1280 save_json_object(admin_settings_path(&self.app_root), &settings)
1281 }
1282
1283 pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
1284 if !reset_all && name.is_none() {
1285 bail!("resetting admin settings requires a setting name or reset-all");
1286 }
1287
1288 let mut settings = match self.get_admin_settings()? {
1289 JsonValue::Object(settings) => settings,
1290 _ => JsonMap::new(),
1291 };
1292
1293 if reset_all {
1294 for setting_name in SUPPORTED_ADMIN_SETTINGS {
1295 settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
1296 }
1297 } else {
1298 settings.insert(
1299 normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
1300 JsonValue::Bool(false),
1301 );
1302 }
1303
1304 save_json_object(admin_settings_path(&self.app_root), &settings)
1305 }
1306
1307 pub fn ensure_settings_files(&self) -> Result<()> {
1308 let user_settings = user_settings_path(&self.app_root);
1309 if !user_settings.exists() {
1310 save_json_object(user_settings, &JsonMap::new())?;
1311 }
1312
1313 let admin_settings = admin_settings_path(&self.app_root);
1314 if !admin_settings.exists() {
1315 let mut values = JsonMap::new();
1316 for name in SUPPORTED_ADMIN_SETTINGS {
1317 values.insert((*name).to_owned(), JsonValue::Bool(false));
1318 }
1319 save_json_object(admin_settings, &values)?;
1320 }
1321
1322 Ok(())
1323 }
1324
1325 pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
1326 if self.source_mode == EffectiveSourceMode::SystemWingetDirect {
1327 return self.update_system_winget_sources(source_name);
1328 }
1329
1330 if self.mirrors_system_winget_sources() {
1331 self.refresh_system_winget_sources()?;
1332 }
1333
1334 let indexes = self.resolve_source_indexes(source_name)?;
1335 let mut results = Vec::new();
1336
1337 for index in indexes {
1338 let detail = match self.store.sources[index].kind {
1339 SourceKind::PreIndexed => self.update_preindexed(index)?,
1340 SourceKind::Rest => self.update_rest(index)?,
1341 };
1342
1343 results.push(SourceUpdateResult {
1344 name: self.store.sources[index].name.clone(),
1345 kind: self.store.sources[index].kind,
1346 detail,
1347 });
1348 }
1349
1350 self.save_store()?;
1351 Ok(results)
1352 }
1353
1354 pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
1355 let located = self.search_located(query, SearchSemantics::Many)?;
1356 Ok(SearchResponse {
1357 matches: located.matches.into_iter().map(|item| item.display).collect(),
1358 warnings: located.warnings,
1359 truncated: located.truncated,
1360 })
1361 }
1362
1363 pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
1364 let located = self.search_located(query, SearchSemantics::Many)?;
1365 let mut structured_documents = Vec::with_capacity(located.matches.len());
1366 for located in located.matches {
1367 let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
1368 structured_documents.push(manifest_documents);
1369 }
1370
1371 Ok(collapse_structured_documents(&structured_documents))
1372 }
1373
1374 pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
1375 if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
1376 bail!("--include-unknown and --include-pinned require --upgrade-available");
1377 }
1378 if query.source.is_some()
1379 && !query.upgrade_only
1380 && query.query.is_none()
1381 && query.id.is_none()
1382 && query.name.is_none()
1383 && query.moniker.is_none()
1384 && query.tag.is_none()
1385 && query.command.is_none()
1386 {
1387 bail!("list --source currently requires a query or explicit filter");
1388 }
1389
1390 let source_refresh_warning = self.refresh_system_winget_sources_for_query();
1391
1392 let has_filter = list_query_needs_available_lookup(query);
1393 let needs_available = has_filter || query.upgrade_only;
1394
1395 let mut warnings = Vec::new();
1396 warnings.extend(source_refresh_warning);
1397 if !installed_package_discovery_supported() {
1398 warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
1399 }
1400 let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
1401
1402 warnings.extend(self.correlate_installed_via_installed_db(&mut installed, query.source.as_deref())?);
1407 warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
1408 warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
1409
1410 if needs_available && has_filter {
1411 let available_query = package_query_from_list_query(query);
1414 let located = self.search_located(&available_query, SearchSemantics::Many)?;
1415 warnings.extend(located.warnings);
1416 let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1417 for package in &mut installed {
1418 if package.correlated.is_some() {
1419 continue;
1420 }
1421 package.correlated =
1422 correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
1423 }
1424 } else if needs_available && !query.upgrade_only {
1425 warnings.extend(self.correlate_all_installed(&mut installed)?);
1430 }
1431
1432 if needs_available {
1433 warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
1444 }
1445
1446 if query.upgrade_only {
1453 installed = dedupe_correlated_for_upgrade(installed);
1454 }
1455
1456 let mut matches = installed
1457 .into_iter()
1458 .filter(|package| {
1459 list_package_matches(package, query)
1460 && (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
1461 })
1462 .collect::<Vec<_>>();
1463
1464 matches.sort_by(|left, right| {
1465 list_sort_weight(left)
1466 .cmp(&list_sort_weight(right))
1467 .then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
1468 .then_with(|| left.local_id.cmp(&right.local_id))
1469 });
1470
1471 let pins = if query.upgrade_only {
1472 self.list_pins(query.source.as_deref())?
1473 } else {
1474 Vec::new()
1475 };
1476 let mut list_matches = matches
1477 .into_iter()
1478 .map(|package| list_match_from_installed(package, &self.store.sources))
1479 .filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
1480 .collect::<Vec<_>>();
1481 if !query.upgrade_only {
1482 list_matches = suppress_duplicate_available_versions(list_matches);
1483 }
1484 let truncated = if let Some(limit) = query.count {
1485 let was_truncated = list_matches.len() > limit;
1486 list_matches.truncate(limit);
1487 was_truncated
1488 } else {
1489 false
1490 };
1491
1492 Ok(ListResponse {
1493 matches: list_matches,
1494 warnings,
1495 truncated,
1496 })
1497 }
1498
1499 fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
1503 if installed.iter().all(|p| p.correlated.is_some()) {
1504 return Ok(Vec::new());
1505 }
1506 let all_query = PackageQuery {
1507 query: None,
1508 id: None,
1509 name: None,
1510 moniker: None,
1511 tag: None,
1512 command: None,
1513 source: None,
1514 count: Some(100_000), exact: false,
1516 version: None,
1517 channel: None,
1518 locale: None,
1519 installer_type: None,
1520 installer_architecture: None,
1521 platform: None,
1522 os_version: None,
1523 install_scope: None,
1524 };
1525 let located = self.search_located(&all_query, SearchSemantics::Many)?;
1526 let candidates: Vec<SearchMatch> = located.matches.into_iter().map(|c| c.display).collect();
1527
1528 for package in installed.iter_mut() {
1529 if package.correlated.is_some() {
1530 continue;
1531 }
1532 package.correlated = correlate_installed_package(package, &candidates, true);
1533 }
1534
1535 Ok(located.warnings)
1536 }
1537
1538 fn correlate_installed_via_installed_db(
1543 &mut self,
1544 installed: &mut [InstalledPackage],
1545 requested_source: Option<&str>,
1546 ) -> Result<Vec<String>> {
1547 let source_indices = self
1548 .store
1549 .sources
1550 .iter()
1551 .enumerate()
1552 .filter(|(_, source)| {
1553 requested_source
1554 .map(|name| source.name.eq_ignore_ascii_case(name))
1555 .unwrap_or(true)
1556 })
1557 .map(|(index, _)| index)
1558 .collect::<Vec<_>>();
1559
1560 for source_index in source_indices {
1561 let source = self.source_clone(source_index);
1562 let installed_db_path = self.source_installed_db_path(&source);
1563 if !installed_db_path.exists() {
1564 continue;
1565 }
1566
1567 let connection = Self::open_sqlite_connection(installed_db_path)?;
1568 if !installed_db_identity_tables_present(&connection)? {
1569 continue;
1570 }
1571
1572 for package in installed.iter_mut() {
1573 if package.correlated.is_some() {
1574 continue;
1575 }
1576 let Some((id, name, matched_by)) = lookup_installed_db_identity_match(&connection, package)? else {
1577 continue;
1578 };
1579 let (id, name, moniker, version) = if source.kind == SourceKind::PreIndexed {
1580 match self
1581 .open_preindexed_connection(source_index)
1582 .and_then(|index_connection| {
1583 let Some(meta) = lookup_v2_metadata_by_id(&index_connection, &id)? else {
1584 return Ok((id.clone(), name.clone(), None, None));
1585 };
1586 let (canonical_installed, anchored_latest) =
1587 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1588 Ok((entries, _)) => (
1589 map_arp_version_to_catalog(&entries, &package.installed_version)
1590 .or_else(|| {
1591 less_than_latest_arp_anchored_version(
1592 &entries,
1593 &package.installed_version,
1594 )
1595 })
1596 .or_else(|| {
1597 greater_than_latest_arp_anchored_version(
1598 &entries,
1599 &package.installed_version,
1600 &meta.latest_version,
1601 )
1602 }),
1603 latest_arp_anchored_version(&entries),
1604 ),
1605 Err(_) => (None, None),
1606 };
1607 let version = match (&canonical_installed, anchored_latest) {
1608 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1609 (Some(_), Some(anchored)) => Some(anchored),
1610 (Some(_), None) => None,
1611 _ => Some(meta.latest_version.clone()),
1612 };
1613 if let Some(version) = canonical_installed {
1614 package.installed_version = version;
1615 package.installed_version_canonical = true;
1616 }
1617 Ok((meta.id, meta.name, meta.moniker, version))
1618 }) {
1619 Ok(enriched) => enriched,
1620 Err(_) => (id, name, None, None),
1621 }
1622 } else {
1623 (id, name, None, None)
1624 };
1625 package.correlated = Some(SearchMatch {
1626 source_name: source.name.clone(),
1627 source_kind: source.kind,
1628 id,
1629 name,
1630 moniker,
1631 version,
1632 channel: None,
1633 match_criteria: Some(matched_by.to_owned()),
1634 });
1635 }
1636 }
1637
1638 Ok(Vec::new())
1639 }
1640
1641 fn correlate_installed_via_index(
1650 &mut self,
1651 installed: &mut [InstalledPackage],
1652 requested_source: Option<&str>,
1653 ) -> Result<Vec<String>> {
1654 let source_indices: Vec<usize> = self
1655 .store
1656 .sources
1657 .iter()
1658 .enumerate()
1659 .filter(|(_, source)| {
1660 source.kind == SourceKind::PreIndexed
1661 && match requested_source {
1662 Some(name) => source.name.eq_ignore_ascii_case(name),
1663 None => true,
1664 }
1665 })
1666 .map(|(index, _)| index)
1667 .collect();
1668
1669 for source_index in source_indices {
1670 let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
1671 let connection = match self.open_preindexed_connection(source_index) {
1672 Ok(c) => c,
1673 Err(_) => continue,
1674 };
1675 if !v2_identity_tables_present(&connection) {
1676 continue;
1677 }
1678 let mut acc = Vec::new();
1679 for (idx, package) in installed.iter().enumerate() {
1680 if package.correlated.is_some() {
1681 continue;
1682 }
1683 let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
1684 continue;
1685 };
1686 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1687 continue;
1688 };
1689 acc.push((idx, meta, by));
1690 }
1691 acc
1692 };
1693
1694 let source = self.source_clone(source_index);
1695 for (idx, meta, by) in resolved {
1696 let (canonical_installed, anchored_latest) =
1697 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1698 Ok((entries, _)) => (
1699 map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1700 .or_else(|| {
1701 less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1702 })
1703 .or_else(|| {
1704 greater_than_latest_arp_anchored_version(
1705 &entries,
1706 &installed[idx].installed_version,
1707 &meta.latest_version,
1708 )
1709 }),
1710 latest_arp_anchored_version(&entries),
1711 ),
1712 Err(_) => (None, None),
1713 };
1714 let available_version = match (&canonical_installed, anchored_latest) {
1721 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1722 (Some(_), Some(anchored)) => Some(anchored),
1723 (Some(_), None) => None,
1724 _ => Some(meta.latest_version),
1725 };
1726 let installed_pkg = &mut installed[idx];
1727 installed_pkg.correlated = Some(SearchMatch {
1728 source_name: source.name.clone(),
1729 source_kind: source.kind,
1730 id: meta.id,
1731 name: meta.name,
1732 moniker: meta.moniker,
1733 version: available_version,
1734 channel: None,
1735 match_criteria: Some(by.to_owned()),
1736 });
1737 if let Some(version) = canonical_installed {
1738 installed_pkg.installed_version = version;
1739 installed_pkg.installed_version_canonical = true;
1740 }
1741 }
1742 }
1743
1744 Ok(Vec::new())
1745 }
1746
1747 fn correlate_installed_by_normalized_identity(
1756 &mut self,
1757 installed: &mut [InstalledPackage],
1758 requested_source: Option<&str>,
1759 ) -> Result<Vec<String>> {
1760 let source_indices: Vec<usize> = self
1761 .store
1762 .sources
1763 .iter()
1764 .enumerate()
1765 .filter(|(_, source)| {
1766 source.kind == SourceKind::PreIndexed
1767 && match requested_source {
1768 Some(name) => source.name.eq_ignore_ascii_case(name),
1769 None => true,
1770 }
1771 })
1772 .map(|(index, _)| index)
1773 .collect();
1774
1775 for source_index in source_indices {
1776 let resolved: Vec<(usize, V2PackageMetadata)> = {
1777 let connection = match self.open_preindexed_connection(source_index) {
1778 Ok(c) => c,
1779 Err(_) => continue,
1780 };
1781 if !v2_normalized_identity_tables_present(&connection) {
1782 continue;
1783 }
1784 let mut acc = Vec::new();
1785 for (idx, pkg) in installed.iter().enumerate() {
1786 if pkg.correlated.is_some() {
1787 continue;
1788 }
1789 if pkg.local_id.starts_with("MSIX\\") {
1794 continue;
1795 }
1796 let Some(publisher) = pkg.publisher.as_deref() else {
1797 continue;
1798 };
1799 let normalized_name = name_normalization::normalize_name(&pkg.name).name;
1800 if normalized_name.is_empty() {
1801 continue;
1802 }
1803 let normalized_publisher = name_normalization::normalize_publisher(publisher);
1804 if normalized_publisher.is_empty() {
1805 continue;
1806 }
1807 let Some(rowid) =
1808 lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
1809 else {
1810 continue;
1811 };
1812 let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
1813 continue;
1814 };
1815 acc.push((idx, meta));
1816 }
1817 acc
1818 };
1819
1820 let source = self.source_clone(source_index);
1821 for (idx, meta) in resolved {
1822 let (canonical_installed, anchored_latest) =
1823 match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1824 Ok((entries, _)) => (
1825 map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1826 .or_else(|| {
1827 less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version)
1828 })
1829 .or_else(|| {
1830 greater_than_latest_arp_anchored_version(
1831 &entries,
1832 &installed[idx].installed_version,
1833 &meta.latest_version,
1834 )
1835 }),
1836 latest_arp_anchored_version(&entries),
1837 ),
1838 Err(_) => (None, None),
1839 };
1840 let available_version = match (&canonical_installed, anchored_latest) {
1841 (Some(canonical), _) if is_greater_than_version_marker(canonical) => None,
1842 (Some(_), Some(anchored)) => Some(anchored),
1843 (Some(_), None) => None,
1844 _ => Some(meta.latest_version),
1845 };
1846 let installed_pkg = &mut installed[idx];
1847 installed_pkg.correlated = Some(SearchMatch {
1848 source_name: source.name.clone(),
1849 source_kind: source.kind,
1850 id: meta.id,
1851 name: meta.name,
1852 moniker: meta.moniker,
1853 version: available_version,
1854 channel: None,
1855 match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
1856 });
1857 if let Some(version) = canonical_installed {
1858 installed_pkg.installed_version = version;
1859 installed_pkg.installed_version_canonical = true;
1860 }
1861 }
1862 }
1863
1864 Ok(Vec::new())
1865 }
1866
1867 fn enrich_correlated_via_index(
1879 &mut self,
1880 installed: &mut [InstalledPackage],
1881 requested_source: Option<&str>,
1882 ) -> Result<Vec<String>> {
1883 for package in installed.iter_mut() {
1885 apply_msix_resource_string_name_fix(package);
1886 }
1887
1888 let source_indices: Vec<usize> = self
1891 .store
1892 .sources
1893 .iter()
1894 .enumerate()
1895 .filter(|(_, source)| {
1896 source.kind == SourceKind::PreIndexed
1897 && match requested_source {
1898 Some(name) => source.name.eq_ignore_ascii_case(name),
1899 None => true,
1900 }
1901 })
1902 .map(|(index, _)| index)
1903 .collect();
1904
1905 for source_index in source_indices {
1906 let needs: Vec<(usize, V2PackageMetadata)> = {
1907 let connection = match self.open_preindexed_connection(source_index) {
1908 Ok(c) => c,
1909 Err(_) => continue,
1910 };
1911 if !v2_identity_tables_present(&connection) {
1912 continue;
1913 }
1914 let mut acc = Vec::new();
1915 for (idx, pkg) in installed.iter().enumerate() {
1916 let Some(candidate) = pkg.correlated.as_ref() else {
1917 continue;
1918 };
1919 if !candidate
1923 .source_name
1924 .eq_ignore_ascii_case(&self.store.sources[source_index].name)
1925 {
1926 continue;
1927 }
1928 let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
1929 continue;
1930 };
1931 acc.push((idx, meta));
1932 }
1933 acc
1934 };
1935
1936 let source = self.source_clone(source_index);
1937 for (idx, meta) in needs {
1938 let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
1943 Ok((entries, _)) => entries,
1944 Err(_) => continue,
1945 };
1946
1947 if !installed[idx].installed_version_canonical {
1948 let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version)
1949 .or_else(|| less_than_latest_arp_anchored_version(&entries, &installed[idx].installed_version))
1950 .or_else(|| {
1951 greater_than_latest_arp_anchored_version(
1952 &entries,
1953 &installed[idx].installed_version,
1954 &meta.latest_version,
1955 )
1956 });
1957 let anchored_latest = latest_arp_anchored_version(&entries);
1958 if let Some(canonical) = canonical_installed {
1959 let greater_than_latest = is_greater_than_version_marker(&canonical);
1960 installed[idx].installed_version = canonical;
1961 installed[idx].installed_version_canonical = true;
1962 if let Some(correlated) = installed[idx].correlated.as_mut() {
1963 correlated.version = if greater_than_latest { None } else { anchored_latest };
1964 }
1965 }
1966 }
1967
1968 if let Some(latest) = entries.first() {
1974 let bytes_result = self.get_cached_source_file(
1975 "V2_M",
1976 &source,
1977 &latest.manifest_relative_path,
1978 Some(latest.manifest_hash.as_str()),
1979 );
1980 if let Ok(bytes) = bytes_result
1981 && let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
1982 {
1983 installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
1984 installed[idx].correlated_lacks_compatible_installer =
1985 !manifest_has_compatible_installer(&manifest);
1986 }
1987 }
1988 }
1989 }
1990
1991 Ok(Vec::new())
1992 }
1993
1994 pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
1995 let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
1996 let versions = self.versions_for_match(&located, query)?;
1997 Ok(VersionsResult {
1998 package: located.display,
1999 versions,
2000 warnings,
2001 })
2002 }
2003
2004 pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
2005 let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
2006 let versions = self.versions_for_match(&located, query)?;
2007 Ok(VersionsResult {
2008 package: located.display,
2009 versions,
2010 warnings,
2011 })
2012 }
2013
2014 pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
2015 let (located, warnings) = self.find_single_match_for_show(query, SearchSemantics::Single)?;
2016 let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
2017 let selected_installer = select_installer(&manifest.installers, query);
2018
2019 Ok(ShowResult {
2020 package: located.display,
2021 manifest,
2022 selected_installer,
2023 cached_files,
2024 warnings,
2025 manifest_documents,
2026 })
2027 }
2028
2029 pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
2030 let (located, warnings) = self.find_single_match(query)?;
2031 let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
2032
2033 Ok(CacheWarmResult {
2034 package: located.display,
2035 cached_files,
2036 warnings,
2037 })
2038 }
2039
2040 pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
2043 let db_path = pins_db_path(&self.app_root);
2044 if !db_path.exists() {
2045 return Ok(Vec::new());
2046 }
2047 let conn = Self::open_sqlite_connection(db_path)?;
2048 let pin_type_column = match resolve_pin_type_column(&conn)? {
2049 Some(column) => column,
2050 None => return Ok(Vec::new()),
2051 };
2052 let sql = if source_id.is_some() {
2053 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
2054 } else {
2055 format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
2056 };
2057 let params = if let Some(source_id) = source_id {
2058 vec![SqlValue::Text(source_id.to_owned())]
2059 } else {
2060 Vec::new()
2061 };
2062 query_rows(&conn, &sql, params, |row| {
2063 let pin_type_int = row_i64(row, 3)?;
2064 Ok(PinRecord {
2065 package_id: row_string(row, 0)?,
2066 version: row_string(row, 1)?,
2067 source_id: row_string(row, 2)?,
2068 pin_type: decode_pin_type(pin_type_int),
2069 })
2070 })
2071 }
2072
2073 pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
2074 let db_path = pins_db_path(&self.app_root);
2075 let conn = Self::open_sqlite_connection(db_path)?;
2076 execute_batch_sql(
2077 &conn,
2078 "CREATE TABLE IF NOT EXISTS pin (
2079 package_id TEXT NOT NULL,
2080 version TEXT NOT NULL DEFAULT '*',
2081 source_id TEXT NOT NULL DEFAULT '',
2082 type INTEGER NOT NULL DEFAULT 0,
2083 PRIMARY KEY (package_id, source_id)
2084 )",
2085 )?;
2086 let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
2087 let type_int: i64 = match pin_type {
2088 PinType::Pinning => 2,
2089 PinType::Blocking => 4,
2090 PinType::Gating => 3,
2091 };
2092 execute_sql(
2093 &conn,
2094 &format!(
2095 "INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
2096 ),
2097 vec![
2098 SqlValue::Text(package_id.to_owned()),
2099 SqlValue::Text(version.to_owned()),
2100 SqlValue::Text(source_id.to_owned()),
2101 SqlValue::Integer(type_int),
2102 ],
2103 )?;
2104 conn.cacheflush().context("failed to flush pin database")?;
2105 Ok(())
2106 }
2107
2108 pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
2109 let db_path = pins_db_path(&self.app_root);
2110 if !db_path.exists() {
2111 return Ok(false);
2112 }
2113 let conn = Self::open_sqlite_connection(db_path)?;
2114 let count = if let Some(source_id) = source_id {
2115 execute_sql(
2116 &conn,
2117 "DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
2118 vec![
2119 SqlValue::Text(package_id.to_owned()),
2120 SqlValue::Text(source_id.to_owned()),
2121 ],
2122 )?
2123 } else {
2124 execute_sql(
2125 &conn,
2126 "DELETE FROM pin WHERE package_id = ?1",
2127 vec![SqlValue::Text(package_id.to_owned())],
2128 )?
2129 };
2130 conn.cacheflush().context("failed to flush pin database")?;
2131 Ok(count > 0)
2132 }
2133
2134 pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
2135 let db_path = pins_db_path(&self.app_root);
2136 if !db_path.exists() {
2137 return Ok(());
2138 }
2139
2140 if let Some(source_id) = source_id {
2141 let conn = Self::open_sqlite_connection(db_path)?;
2142 execute_sql(
2143 &conn,
2144 "DELETE FROM pin WHERE source_id = ?1",
2145 vec![SqlValue::Text(source_id.to_owned())],
2146 )?;
2147 conn.cacheflush().context("failed to flush pin database")?;
2148 } else {
2149 fs::remove_file(&db_path)?;
2150 }
2151 Ok(())
2152 }
2153
2154 fn save_store(&self) -> Result<()> {
2155 if self.mirrors_system_winget_sources() {
2156 save_system_winget_mirror_store(&self.app_root, &self.store)
2157 } else {
2158 save_store(&self.app_root, &self.store)
2159 }
2160 }
2161
2162 fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
2163 source_state_dir_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2164 }
2165
2166 fn source_installed_db_path(&self, source: &SourceRecord) -> PathBuf {
2167 source_installed_db_path_with_keying(&self.app_root, source, self.mirrors_system_winget_sources())
2168 }
2169
2170 fn preindexed_package_path(&self, source: &SourceRecord) -> PathBuf {
2171 self.source_state_dir(source).join("source.msix")
2172 }
2173
2174 fn preindexed_index_path(&self, source: &SourceRecord) -> PathBuf {
2175 self.source_state_dir(source).join("index.db")
2176 }
2177
2178 fn rest_information_cache_path(&self, source: &SourceRecord) -> PathBuf {
2179 self.source_state_dir(source).join("rest-information.json")
2180 }
2181
2182 fn rest_manifest_cache_path(
2183 &self,
2184 source: &SourceRecord,
2185 package_id: &str,
2186 version: &str,
2187 channel: &str,
2188 ) -> PathBuf {
2189 let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
2190 let digest = sha256_hex(key.as_bytes());
2191 cache_root(&self.app_root)
2192 .join("REST_M")
2193 .join(sanitize_path_segment(&source.identifier))
2194 .join(format!("{digest}.json"))
2195 }
2196
2197 pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
2200 self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
2201 }
2202
2203 pub fn download_installer_for_request(
2204 &mut self,
2205 request: &InstallRequest,
2206 download_dir: &Path,
2207 ) -> Result<DownloadOutput> {
2208 let fetched = self.fetch_installer_for_request(request, download_dir)?;
2209 let manifest_yaml =
2210 build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
2211 .context("failed to render manifest YAML")?;
2212 let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
2213 let manifest_path = download_dir.join(&manifest_filename);
2214 fs::write(&manifest_path, manifest_yaml.as_bytes())
2215 .with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
2216
2217 Ok(DownloadOutput {
2218 manifest: fetched.manifest,
2219 installer_path: fetched.installer_path,
2220 manifest_path,
2221 })
2222 }
2223
2224 fn fetch_installer_for_request(
2229 &mut self,
2230 request: &InstallRequest,
2231 download_dir: &Path,
2232 ) -> Result<FetchedInstaller> {
2233 let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
2234 let installer = select_installer(&manifest.installers, &request.query)
2235 .ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
2236 let url = installer
2237 .url
2238 .as_deref()
2239 .ok_or_else(|| anyhow!("Installer has no URL"))?;
2240
2241 fs::create_dir_all(download_dir)?;
2242 let filename = request.rename.as_deref().unwrap_or_else(|| {
2243 url.rsplit('/')
2244 .next()
2245 .unwrap_or("installer")
2246 .split('?')
2247 .next()
2248 .unwrap_or("installer")
2249 });
2250 let dest = download_dir.join(filename);
2251
2252 self.emit_install_progress(InstallProgress::DownloadingInstaller {
2253 url: url.to_owned(),
2254 path: dest.clone(),
2255 });
2256 let actual_hash = download_installer_to_file(&self.user_agent, url, &dest)?;
2257
2258 self.emit_install_progress(InstallProgress::VerifyingInstaller { path: dest.clone() });
2259 if let Err(error) =
2260 verify_installer_hash_hex(installer.sha256.as_deref(), &actual_hash, request.ignore_security_hash)
2261 {
2262 let _ = fs::remove_file(&dest);
2263 return Err(error);
2264 }
2265
2266 Ok(FetchedInstaller {
2267 manifest,
2268 manifest_documents,
2269 installer,
2270 installer_path: dest,
2271 })
2272 }
2273
2274 pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2275 let mut request = InstallRequest::new(query.clone());
2276 request.mode = if silent {
2277 InstallerMode::Silent
2278 } else {
2279 InstallerMode::SilentWithProgress
2280 };
2281 self.install_request(&request)
2282 }
2283
2284 pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
2285 let mut request = InstallRequest::new(query.clone());
2286 request.mode = mode;
2287 self.install_request(&request)
2288 }
2289
2290 pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
2291 self.emit_install_progress(InstallProgress::ResolvingManifest);
2292 let manifest = self.resolve_manifest_for_install(request)?;
2293 self.emit_install_progress(InstallProgress::ResolvedManifest {
2294 package_id: manifest.id.clone(),
2295 version: manifest.version.clone(),
2296 });
2297 if !package_actions_supported() {
2298 let installer_type = select_installer(&manifest.installers, &request.query)
2299 .and_then(|installer| installer.installer_type)
2300 .unwrap_or_else(|| "install".to_owned())
2301 .to_lowercase();
2302 return Ok(unsupported_action_result(
2303 manifest.id.clone(),
2304 manifest.version.clone(),
2305 installer_type,
2306 INSTALL_UNSUPPORTED_WARNING,
2307 ));
2308 }
2309
2310 self.emit_install_progress(InstallProgress::CheckingInstalled);
2311 let existing_match = self.find_installed_package_for_install(request, &manifest)?;
2312 if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
2313 return Ok(no_op_result);
2314 }
2315
2316 Self::ensure_package_agreements_accepted(&manifest, request)?;
2317 self.emit_install_progress(InstallProgress::InstallingDependencies);
2318 self.install_dependencies(&manifest, request, &mut HashSet::new())?;
2319
2320 if request.dependencies_only {
2321 return Ok(InstallResult {
2322 package_id: manifest.id.clone(),
2323 version: manifest.version.clone(),
2324 installer_path: PathBuf::new(),
2325 installer_type: "dependencies".to_owned(),
2326 exit_code: 0,
2327 success: true,
2328 no_op: false,
2329 warnings: Vec::new(),
2330 });
2331 }
2332
2333 self.emit_install_progress(InstallProgress::SelectingInstaller);
2334 let installer = select_installer(&manifest.installers, &request.query)
2335 .ok_or_else(|| anyhow!("No applicable installer found"))?;
2336
2337 if request.uninstall_previous {
2338 let mut uninstall_request = UninstallRequest::new(PackageQuery {
2339 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2340 query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
2341 source: request.query.source.clone(),
2342 exact: true,
2343 version: request.query.version.clone(),
2344 ..PackageQuery::default()
2345 });
2346 uninstall_request.product_code = installer.product_code.clone();
2347 uninstall_request.mode = InstallerMode::Silent;
2348 uninstall_request.all_versions = true;
2349 uninstall_request.force = true;
2350 let _ = self.uninstall_request(&uninstall_request);
2351 }
2352
2353 let temp_dir = std::env::temp_dir().join("pinget-install");
2354 let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
2355
2356 let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
2357
2358 self.emit_install_progress(InstallProgress::StartingInstaller {
2359 installer_type: installer_type.clone(),
2360 path: installer_path.clone(),
2361 });
2362 self.emit_install_progress(InstallProgress::WaitingForInstaller {
2363 installer_type: installer_type.clone(),
2364 });
2365 let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
2366 self.emit_install_progress(InstallProgress::InstallerFinished { exit_code });
2367
2368 Ok(InstallResult {
2369 package_id: manifest.id.clone(),
2370 version: manifest.version.clone(),
2371 installer_path,
2372 installer_type,
2373 exit_code,
2374 success: exit_code == 0,
2375 no_op: false,
2376 warnings: Vec::new(),
2377 })
2378 }
2379
2380 fn find_installed_package_for_install(
2381 &mut self,
2382 request: &InstallRequest,
2383 manifest: &Manifest,
2384 ) -> Result<Option<ListMatch>> {
2385 let installed_matches = self.list(&ListQuery {
2386 query: request.query.query.clone(),
2387 id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
2388 name: request.query.name.clone(),
2389 moniker: request.query.moniker.clone(),
2390 tag: None,
2391 command: None,
2392 product_code: None,
2393 version: None,
2394 source: request.query.source.clone(),
2395 count: Some(100),
2396 exact: request.query.exact || !manifest.id.trim().is_empty(),
2397 install_scope: request.query.install_scope.clone(),
2398 upgrade_only: false,
2399 include_unknown: false,
2400 include_pinned: false,
2401 })?;
2402
2403 Ok(installed_matches.matches.into_iter().find(|candidate| {
2404 candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
2405 }))
2406 }
2407
2408 fn create_install_no_op_result(
2409 request: &InstallRequest,
2410 manifest: &Manifest,
2411 existing_match: Option<&ListMatch>,
2412 ) -> Option<InstallResult> {
2413 let existing_match = existing_match?;
2414
2415 if request.no_upgrade {
2416 return Some(InstallResult {
2417 package_id: manifest.id.clone(),
2418 version: existing_match.installed_version.clone(),
2419 installer_path: PathBuf::new(),
2420 installer_type: "install".to_owned(),
2421 exit_code: 0,
2422 success: true,
2423 no_op: true,
2424 warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
2425 });
2426 }
2427
2428 if !request.force
2429 && !existing_match.installed_version.trim().is_empty()
2430 && compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
2431 {
2432 return Some(InstallResult {
2433 package_id: manifest.id.clone(),
2434 version: existing_match.installed_version.clone(),
2435 installer_path: PathBuf::new(),
2436 installer_type: "install".to_owned(),
2437 exit_code: 0,
2438 success: true,
2439 no_op: true,
2440 warnings: vec![
2441 "Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
2442 ],
2443 });
2444 }
2445
2446 None
2447 }
2448
2449 pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
2450 if request.manifest_path.is_none() && !package_actions_supported() {
2451 let package_id = request
2452 .query
2453 .id
2454 .clone()
2455 .or_else(|| request.query.name.clone())
2456 .or_else(|| request.query.moniker.clone())
2457 .or_else(|| request.query.query.clone())
2458 .or_else(|| request.product_code.clone())
2459 .unwrap_or_else(|| "repair".to_owned());
2460 return Ok(unsupported_action_result(
2461 package_id,
2462 request.query.version.clone().unwrap_or_default(),
2463 "repair".to_owned(),
2464 REPAIR_UNSUPPORTED_WARNING,
2465 ));
2466 }
2467
2468 let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
2469 let installed_match = if request.manifest_path.is_some() {
2470 None
2471 } else {
2472 let installed = self.list(&Self::create_repair_list_query(request))?;
2473 warnings.extend(installed.warnings);
2474 if installed.matches.is_empty() {
2475 bail!("No installed package matched the supplied repair query.");
2476 }
2477 if installed.matches.len() > 1 {
2478 bail!("Multiple installed packages matched the supplied repair query.");
2479 }
2480 installed.matches.into_iter().next()
2481 };
2482
2483 let mut result =
2484 self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
2485 warnings.extend(result.warnings);
2486 result.warnings = warnings;
2487 Ok(result)
2488 }
2489
2490 pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
2491 let mut request = UninstallRequest::new(query.clone());
2492 request.mode = if silent {
2493 InstallerMode::Silent
2494 } else {
2495 InstallerMode::SilentWithProgress
2496 };
2497 self.uninstall_request(&request)
2498 }
2499
2500 pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
2501 if !package_actions_supported() {
2502 let (package_id, version) = if let Some(path) = &request.manifest_path {
2503 let manifest = Self::load_manifest_from_path(path)?;
2504 (manifest.id, request.query.version.clone().unwrap_or(manifest.version))
2505 } else {
2506 (
2507 request
2508 .query
2509 .id
2510 .clone()
2511 .or_else(|| request.query.query.clone())
2512 .or_else(|| request.query.name.clone())
2513 .or_else(|| request.query.moniker.clone())
2514 .or_else(|| request.product_code.clone())
2515 .unwrap_or_else(|| "unknown".to_owned()),
2516 request.query.version.clone().unwrap_or_default(),
2517 )
2518 };
2519
2520 return Ok(unsupported_action_result(
2521 package_id,
2522 version,
2523 "uninstall",
2524 UNINSTALL_UNSUPPORTED_WARNING,
2525 ));
2526 }
2527
2528 let matches = self.resolve_uninstall_matches(request)?;
2529 let mut exit_code = 0;
2530
2531 for installed in &matches {
2532 exit_code = uninstall_package(installed, request)?;
2533 if exit_code != 0 && !request.all_versions {
2534 break;
2535 }
2536 }
2537
2538 let installed = matches
2539 .first()
2540 .ok_or_else(|| anyhow!("No installed package found matching the query"))?;
2541
2542 Ok(InstallResult {
2543 package_id: installed.id.clone(),
2544 version: installed.installed_version.clone(),
2545 installer_path: PathBuf::new(),
2546 installer_type: "uninstall".to_owned(),
2547 exit_code,
2548 success: exit_code == 0,
2549 no_op: false,
2550 warnings: Vec::new(),
2551 })
2552 }
2553
2554 fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
2555 self.resolve_manifest_and_documents_for_install(request)
2556 .map(|(manifest, _)| manifest)
2557 }
2558
2559 fn resolve_manifest_and_documents_for_install(
2560 &mut self,
2561 request: &InstallRequest,
2562 ) -> Result<(Manifest, JsonValue)> {
2563 if let Some(path) = &request.manifest_path {
2564 return Self::load_manifest_from_path_with_documents(path);
2565 }
2566
2567 let (located, _warnings) = self.find_single_match(&request.query)?;
2568 let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
2569 Ok((manifest, documents))
2570 }
2571
2572 fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
2573 if !request.accept_package_agreements && !manifest.agreements.is_empty() {
2574 bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
2575 }
2576 Ok(())
2577 }
2578
2579 fn install_dependencies(
2580 &mut self,
2581 manifest: &Manifest,
2582 request: &InstallRequest,
2583 visited: &mut HashSet<String>,
2584 ) -> Result<()> {
2585 if request.skip_dependencies {
2586 return Ok(());
2587 }
2588
2589 let dependency_ids = manifest
2590 .package_dependencies
2591 .iter()
2592 .chain(
2593 manifest
2594 .installers
2595 .iter()
2596 .flat_map(|installer| installer.package_dependencies.iter()),
2597 )
2598 .filter(|value| !value.trim().is_empty())
2599 .cloned()
2600 .collect::<BTreeSet<_>>();
2601
2602 for dependency_id in dependency_ids {
2603 if !visited.insert(dependency_id.clone()) {
2604 continue;
2605 }
2606
2607 let dependency_request = Self::create_dependency_install_request(dependency_id, request);
2608 self.install_request(&dependency_request)?;
2609 }
2610
2611 Ok(())
2612 }
2613
2614 fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
2615 let mut dependency_request = InstallRequest::new(PackageQuery {
2616 id: Some(dependency_id),
2617 source: request
2618 .dependency_source
2619 .clone()
2620 .or_else(|| request.query.source.clone()),
2621 exact: true,
2622 ..PackageQuery::default()
2623 });
2624 dependency_request.mode = match request.mode {
2625 InstallerMode::Interactive => InstallerMode::Interactive,
2626 InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
2627 };
2628 dependency_request.accept_package_agreements = request.accept_package_agreements;
2629 dependency_request.no_upgrade = true;
2630 dependency_request.ignore_security_hash = request.ignore_security_hash;
2631 dependency_request.dependency_source = request.dependency_source.clone();
2632 dependency_request
2633 }
2634
2635 fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
2636 ListQuery {
2637 query: request.query.query.clone(),
2638 id: request.query.id.clone(),
2639 name: request.query.name.clone(),
2640 moniker: request.query.moniker.clone(),
2641 product_code: request.product_code.clone(),
2642 version: request.query.version.clone(),
2643 source: request.query.source.clone(),
2644 count: Some(100),
2645 exact: request.query.exact,
2646 install_scope: request.query.install_scope.clone(),
2647 ..ListQuery::default()
2648 }
2649 }
2650
2651 fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
2652 let mut query = request.query.clone();
2653 query.query = if installed_match.is_none() {
2654 request.query.query.clone()
2655 } else {
2656 None
2657 };
2658 query.id = installed_match
2659 .map(|item| item.id.clone())
2660 .or_else(|| request.query.id.clone());
2661 query.name = if installed_match.is_none() {
2662 request.query.name.clone()
2663 } else {
2664 None
2665 };
2666 query.moniker = if installed_match.is_none() {
2667 request.query.moniker.clone()
2668 } else {
2669 None
2670 };
2671 query.source = request
2672 .query
2673 .source
2674 .clone()
2675 .or_else(|| installed_match.and_then(|item| item.source_name.clone()));
2676 query.exact = true;
2677 query.version = request
2678 .query
2679 .version
2680 .clone()
2681 .or_else(|| installed_match.map(|item| item.installed_version.clone()));
2682
2683 InstallRequest {
2684 query,
2685 manifest_path: request.manifest_path.clone(),
2686 mode: request.mode,
2687 log_path: request.log_path.clone(),
2688 custom: None,
2689 override_args: None,
2690 install_location: None,
2691 skip_dependencies: false,
2692 dependencies_only: false,
2693 accept_package_agreements: request.accept_package_agreements,
2694 force: true,
2695 rename: None,
2696 uninstall_previous: false,
2697 ignore_security_hash: request.ignore_security_hash,
2698 dependency_source: None,
2699 no_upgrade: false,
2700 }
2701 }
2702
2703 fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
2704 let mut effective_query = request.query.clone();
2705 let mut effective_product_code = request.product_code.clone();
2706
2707 if let Some(path) = &request.manifest_path {
2708 let manifest = Self::load_manifest_from_path(path)?;
2709 effective_query = PackageQuery {
2710 query: Some(manifest.id.clone()),
2711 id: Some(manifest.id.clone()),
2712 name: Some(manifest.name.clone()),
2713 exact: true,
2714 version: effective_query.version.clone().or(Some(manifest.version)),
2715 ..PackageQuery::default()
2716 };
2717 effective_product_code = effective_product_code.or_else(|| {
2718 manifest
2719 .installers
2720 .iter()
2721 .find_map(|installer| installer.product_code.clone())
2722 });
2723 }
2724
2725 let list_query = ListQuery {
2726 query: effective_query.query.clone(),
2727 id: effective_query.id.clone(),
2728 name: effective_query.name.clone(),
2729 moniker: effective_query.moniker.clone(),
2730 tag: None,
2731 command: None,
2732 product_code: effective_product_code,
2733 version: effective_query.version.clone(),
2734 source: effective_query.source.clone(),
2735 count: if request.all_versions { None } else { Some(100) },
2736 exact: effective_query.exact,
2737 install_scope: effective_query.install_scope,
2738 upgrade_only: false,
2739 include_unknown: false,
2740 include_pinned: false,
2741 };
2742
2743 let list_result = self.list(&list_query)?;
2744 if list_result.matches.is_empty() {
2745 bail!("No installed package found matching the query");
2746 }
2747 if !request.all_versions && list_result.matches.len() > 1 && !request.force {
2748 bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
2749 }
2750
2751 Ok(if request.all_versions {
2752 list_result.matches
2753 } else {
2754 vec![list_result.matches[0].clone()]
2755 })
2756 }
2757
2758 fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
2759 Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
2760 }
2761
2762 fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
2763 let resolved = resolve_manifest_path(manifest_path)?;
2764 let bytes =
2765 fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
2766 parse_yaml_manifest_bundle(&bytes)
2767 }
2768
2769 fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
2770 self.find_single_match_with_semantics(query, SearchSemantics::Single)
2771 }
2772
2773 fn find_single_match_for_show(
2774 &mut self,
2775 query: &PackageQuery,
2776 semantics: SearchSemantics,
2777 ) -> Result<(LocatedMatch, Vec<String>)> {
2778 let located = self.search_located(query, semantics)?;
2779 if self.should_retry_show_across_system_winget_sources(query, &located) {
2780 let mut fallback_query = query.clone();
2781 fallback_query.source = None;
2782 return single_match_from_located(self.search_located(&fallback_query, semantics)?, false);
2783 }
2784
2785 single_match_from_located(located, query.source.is_some())
2786 }
2787
2788 fn should_retry_show_across_system_winget_sources(
2789 &self,
2790 query: &PackageQuery,
2791 located: &SearchLocatedResult,
2792 ) -> bool {
2793 should_retry_show_across_system_winget_sources(self.uses_system_winget_sources(), query, located)
2794 }
2795
2796 fn find_single_match_with_semantics(
2797 &mut self,
2798 query: &PackageQuery,
2799 semantics: SearchSemantics,
2800 ) -> Result<(LocatedMatch, Vec<String>)> {
2801 let located = self.search_located(query, semantics)?;
2802 single_match_from_located(located, query.source.is_some())
2803 }
2804
2805 fn search_located(&mut self, query: &PackageQuery, semantics: SearchSemantics) -> Result<SearchLocatedResult> {
2806 let indexes = self.resolve_source_indexes(query.source.as_deref())?;
2807 let mut matches = Vec::new();
2808 let mut warnings = Vec::new();
2809 let mut failures = Vec::new();
2810 let mut truncated = false;
2811
2812 for index in indexes {
2813 match self.search_source(index, query, semantics) {
2814 Ok(mut source_matches) => {
2815 truncated |= source_matches.truncated;
2816 matches.append(&mut source_matches.matches);
2817 }
2818 Err(error) => {
2819 let source_name = self.store.sources[index].name.clone();
2820 let message = format!("{error:#}");
2821 warnings.push(format!(
2822 "Failed when searching source '{}'; results will not be included: {}",
2823 source_name, message
2824 ));
2825 failures.push(SourceSearchFailure { source_name, message });
2826 }
2827 }
2828 }
2829
2830 if semantics == SearchSemantics::Many {
2831 matches.sort_by(|left, right| {
2832 search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
2833 });
2834 let limit = max_results(query);
2835 if matches.len() > limit {
2836 truncated = true;
2837 matches.truncate(limit);
2838 }
2839 }
2840
2841 Ok(SearchLocatedResult {
2842 matches,
2843 warnings,
2844 failures,
2845 truncated,
2846 })
2847 }
2848
2849 fn search_source(
2850 &mut self,
2851 source_index: usize,
2852 query: &PackageQuery,
2853 semantics: SearchSemantics,
2854 ) -> Result<SearchSourceMatches> {
2855 match self.store.sources[source_index].kind {
2856 SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
2857 SourceKind::Rest => self.search_rest(source_index, query, semantics),
2858 }
2859 }
2860
2861 fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
2862 match &located.locator {
2863 MatchLocator::PreIndexedV1 { package_rowid } => {
2864 let source = self.source_clone(located.source_index);
2865 let connection = self.open_preindexed_connection(located.source_index)?;
2866 let versions = query_v1_versions(&connection, *package_rowid)?;
2867 let mut keys = versions
2868 .into_iter()
2869 .map(|row| VersionKey {
2870 version: row.version,
2871 channel: row.channel,
2872 })
2873 .collect::<Vec<_>>();
2874 sort_versions_desc(&mut keys);
2875 if keys.is_empty() {
2876 bail!("no versions found for {} in {}", located.display.id, source.name);
2877 }
2878 Ok(keys)
2879 }
2880 MatchLocator::PreIndexedV2 {
2881 package_rowid,
2882 package_hash,
2883 } => {
2884 let source = self.source_clone(located.source_index);
2885 let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2886 let mut keys = entries
2887 .into_iter()
2888 .map(|entry| VersionKey {
2889 version: entry.version,
2890 channel: String::new(),
2891 })
2892 .collect::<Vec<_>>();
2893 sort_versions_desc(&mut keys);
2894 Ok(keys)
2895 }
2896 MatchLocator::Rest { versions, .. } => {
2897 let mut keys = versions.clone();
2898 sort_versions_desc(&mut keys);
2899 Ok(keys)
2900 }
2901 }
2902 }
2903
2904 fn manifest_for_match(
2905 &mut self,
2906 located: &LocatedMatch,
2907 query: &PackageQuery,
2908 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2909 self.manifest_for_match_with_refresh(located, query, true)
2910 }
2911
2912 fn manifest_for_match_with_refresh(
2913 &mut self,
2914 located: &LocatedMatch,
2915 query: &PackageQuery,
2916 allow_explicit_version_refresh: bool,
2917 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
2918 match &located.locator {
2919 MatchLocator::PreIndexedV1 { package_rowid } => {
2920 let source = self.source_clone(located.source_index);
2921 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2922 let versions = query_v1_versions(&opened.connection, *package_rowid)?;
2923 let selected = match select_v1_version(
2924 &versions,
2925 query.version.as_deref(),
2926 query.channel.as_deref(),
2927 &located.display.id,
2928 &source.name,
2929 ) {
2930 Ok(selected) => selected,
2931 Err(error)
2932 if allow_explicit_version_refresh
2933 && query.version.is_some()
2934 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2935 {
2936 let open_refresh = opened.refresh.clone();
2937 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2938 return missing_error_after_open_refresh(error, &open_refresh);
2939 }
2940 if self.has_recent_successful_preindexed_refresh_attempt(
2941 &self.preindexed_refresh_lock_key(&source),
2942 ) {
2943 return Err(error);
2944 }
2945 drop(opened);
2946 return self.refresh_preindexed_and_retry_manifest(
2947 located.source_index,
2948 &located.display,
2949 query,
2950 error,
2951 );
2952 }
2953 Err(error) => return Err(error),
2954 };
2955 let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
2956 let bytes =
2957 self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
2958 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
2959 manifest.version = selected.version.clone();
2960 manifest.channel = selected.channel;
2961 Ok((manifest, manifest_documents, vec![bytes.path]))
2962 }
2963 MatchLocator::PreIndexedV2 {
2964 package_rowid,
2965 package_hash,
2966 } => {
2967 let source = self.source_clone(located.source_index);
2968 let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
2969 let (entries, version_data_file) =
2970 self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
2971 let selected = match select_v2_version(
2972 &entries,
2973 query.version.as_deref(),
2974 query.channel.as_deref(),
2975 &located.display.id,
2976 &source.name,
2977 ) {
2978 Ok(selected) => selected,
2979 Err(error)
2980 if allow_explicit_version_refresh
2981 && query.version.is_some()
2982 && error.downcast_ref::<MissingPackageVersionError>().is_some() =>
2983 {
2984 let open_refresh = opened.refresh.clone();
2985 if open_refresh.attempted && !open_refresh.reused_recent_attempt {
2986 return missing_error_after_open_refresh(error, &open_refresh);
2987 }
2988 if self.has_recent_successful_preindexed_refresh_attempt(
2989 &self.preindexed_refresh_lock_key(&source),
2990 ) {
2991 return Err(error);
2992 }
2993 drop(opened);
2994 return self.refresh_preindexed_and_retry_manifest(
2995 located.source_index,
2996 &located.display,
2997 query,
2998 error,
2999 );
3000 }
3001 Err(error) => return Err(error),
3002 };
3003 let manifest_bytes = self.get_cached_source_file(
3004 "V2_M",
3005 &source,
3006 &selected.manifest_relative_path,
3007 Some(selected.manifest_hash.as_str()),
3008 )?;
3009 let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
3010 manifest.version = selected.version;
3011 Ok((
3012 manifest,
3013 manifest_documents,
3014 vec![version_data_file, manifest_bytes.path],
3015 ))
3016 }
3017 MatchLocator::Rest { package_id, versions } => {
3018 let source = self.source_clone(located.source_index);
3019 let selected = select_rest_version(
3020 versions,
3021 query.version.as_deref(),
3022 query.channel.as_deref(),
3023 &located.display.id,
3024 &source.name,
3025 )?;
3026 let (bytes, cache_path) =
3027 self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
3028 let (manifest, manifest_documents) =
3029 parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
3030 Ok((manifest, manifest_documents, vec![cache_path]))
3031 }
3032 }
3033 }
3034
3035 fn refresh_preindexed_and_retry_manifest(
3036 &mut self,
3037 source_index: usize,
3038 previous_display: &SearchMatch,
3039 query: &PackageQuery,
3040 missing_error: anyhow::Error,
3041 ) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
3042 self.refresh_preindexed_source(source_index, true)
3043 .with_context(|| format!("{missing_error} The source refresh also failed"))?;
3044 let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
3045 self.manifest_for_match_with_refresh(&refreshed, query, false)
3046 }
3047
3048 fn find_refreshed_preindexed_match(
3049 &mut self,
3050 source_index: usize,
3051 previous_display: &SearchMatch,
3052 query: &PackageQuery,
3053 ) -> Result<LocatedMatch> {
3054 let source_name = self.store.sources[source_index].name.clone();
3055 let mut source_query = query.clone();
3056 source_query.source = Some(source_name.clone());
3057 let matches = self
3058 .search_preindexed(source_index, &source_query, SearchSemantics::Single)?
3059 .matches;
3060 if let Some(found) = matches
3061 .iter()
3062 .find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
3063 {
3064 return Ok(found.clone());
3065 }
3066 if matches.len() == 1 {
3067 return Ok(matches.into_iter().next().expect("single match"));
3068 }
3069 bail!(
3070 "Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
3071 previous_display.id,
3072 source_name
3073 )
3074 }
3075
3076 fn search_preindexed(
3077 &mut self,
3078 source_index: usize,
3079 query: &PackageQuery,
3080 semantics: SearchSemantics,
3081 ) -> Result<SearchSourceMatches> {
3082 let connection = self.open_preindexed_connection(source_index)?;
3083 let source = self.source_clone(source_index);
3084
3085 match query_v2_matches(&connection, query, semantics) {
3086 Ok((rows, truncated)) => Ok(SearchSourceMatches {
3087 truncated,
3088 matches: rows
3089 .into_iter()
3090 .map(|row| LocatedMatch {
3091 display: SearchMatch {
3092 source_name: source.name.clone(),
3093 source_kind: source.kind,
3094 id: row.id.clone(),
3095 name: row.name.clone(),
3096 moniker: row.moniker.clone(),
3097 version: Some(row.version.clone()),
3098 channel: None,
3099 match_criteria: row.match_criteria.clone(),
3100 },
3101 source_index,
3102 locator: MatchLocator::PreIndexedV2 {
3103 package_rowid: row.package_rowid,
3104 package_hash: row.package_hash,
3105 },
3106 })
3107 .collect(),
3108 }),
3109 Err(error) if can_fallback_to_v1(&error) => {
3110 let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
3111 let grouped = group_v1_rows(rows);
3112
3113 Ok(SearchSourceMatches {
3114 truncated,
3115 matches: grouped
3116 .into_iter()
3117 .map(|row| LocatedMatch {
3118 display: SearchMatch {
3119 source_name: source.name.clone(),
3120 source_kind: source.kind,
3121 id: row.id.clone(),
3122 name: row.name.clone(),
3123 moniker: row.moniker.clone(),
3124 version: Some(row.version.clone()),
3125 channel: Some(row.channel.clone()),
3126 match_criteria: row.match_criteria.clone(),
3127 },
3128 source_index,
3129 locator: MatchLocator::PreIndexedV1 {
3130 package_rowid: row.package_rowid,
3131 },
3132 })
3133 .collect(),
3134 })
3135 }
3136 Err(error) => Err(error),
3137 }
3138 }
3139
3140 fn search_rest(
3141 &mut self,
3142 source_index: usize,
3143 query: &PackageQuery,
3144 semantics: SearchSemantics,
3145 ) -> Result<SearchSourceMatches> {
3146 let source = self.source_clone(source_index);
3147 let info = self.load_rest_information(source_index)?;
3148 let contract = choose_contract(&info.server_supported_versions)
3149 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3150
3151 let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
3152 let body = build_rest_search_body(query, &info, semantics)?;
3153 let response = self
3154 .client
3155 .post(url)
3156 .header("Version", contract)
3157 .header(CONTENT_TYPE, "application/json")
3158 .body(serde_json::to_vec(&body).context("failed to serialize REST search request")?)
3159 .send()
3160 .context("REST search request failed")?
3161 .error_for_status()
3162 .context("REST search request returned an error")?;
3163 let json =
3164 serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST search response")?)
3165 .context("failed to parse REST search response")?;
3166 let data = json
3167 .get("Data")
3168 .and_then(JsonValue::as_array)
3169 .cloned()
3170 .unwrap_or_default();
3171 let max_results = source_fetch_results(query, semantics);
3172
3173 let mut results = Vec::new();
3174 for item in data {
3175 let package_id = json_string(&item, "PackageIdentifier")
3176 .ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
3177 let package_name =
3178 json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
3179 let mut versions = parse_rest_versions(&item)?;
3180 sort_versions_desc(&mut versions);
3181 let latest = versions
3182 .first()
3183 .cloned()
3184 .ok_or_else(|| anyhow!("REST search result had no versions"))?;
3185
3186 results.push(LocatedMatch {
3187 display: SearchMatch {
3188 source_name: source.name.clone(),
3189 source_kind: source.kind,
3190 id: package_id.clone(),
3191 name: package_name,
3192 moniker: json_string(&item, "Moniker"),
3193 version: Some(latest.version.clone()),
3194 channel: if latest.channel.is_empty() {
3195 None
3196 } else {
3197 Some(latest.channel.clone())
3198 },
3199 match_criteria: rest_match_criteria(&item, query, semantics),
3200 },
3201 source_index,
3202 locator: MatchLocator::Rest { package_id, versions },
3203 });
3204 }
3205
3206 Ok(SearchSourceMatches {
3207 truncated: results.len() >= max_results,
3208 matches: results,
3209 })
3210 }
3211
3212 fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
3213 Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
3214 }
3215
3216 fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
3217 let source = self.source_clone(source_index);
3218 let index_path = self.preindexed_index_path(&source);
3219 let mut refresh = PreindexedRefreshOutcome::default();
3220 if !index_path.exists() {
3221 let _ = self.refresh_preindexed_source(source_index, true)?;
3222 refresh.attempted = true;
3223 } else if self.is_preindexed_index_stale(&source, &index_path) {
3224 let lock_key = self.preindexed_refresh_lock_key(&source);
3225 if self.has_recent_preindexed_refresh_attempt(&lock_key) {
3226 refresh.attempted = true;
3227 refresh.reused_recent_attempt = true;
3228 } else {
3229 refresh.attempted = true;
3230 if let Err(error) = self.refresh_preindexed_source(source_index, false) {
3231 refresh.error = Some(format!("{error:#}"));
3232 }
3233 }
3234 }
3235
3236 Ok(PreindexedOpenConnection {
3237 connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
3238 refresh,
3239 })
3240 }
3241
3242 fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
3243 let Some(interval) = self.pre_indexed_source_auto_update_interval else {
3244 return false;
3245 };
3246
3247 let index_modified = || {
3248 fs::metadata(index_path)
3249 .ok()
3250 .and_then(|metadata| metadata.modified().ok())
3251 .map(DateTime::<Utc>::from)
3252 };
3253
3254 let last_update = match (source.last_update, index_modified()) {
3255 (Some(source_last_update), Some(index_last_write)) => Some(source_last_update.max(index_last_write)),
3256 (Some(source_last_update), None) => Some(source_last_update),
3257 (None, Some(index_last_write)) => Some(index_last_write),
3258 (None, None) => None,
3259 };
3260
3261 match last_update {
3262 Some(last_update) => Utc::now() - last_update >= interval,
3263 None => true,
3264 }
3265 }
3266
3267 fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
3268 format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
3269 }
3270
3271 fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3272 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3273 Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3274 })
3275 }
3276
3277 fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
3278 self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
3279 attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
3280 })
3281 }
3282
3283 fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
3284 self.preindexed_refresh_attempts.insert(
3285 lock_key,
3286 PreindexedRefreshAttempt {
3287 attempted_at: Utc::now(),
3288 succeeded,
3289 },
3290 );
3291 }
3292
3293 fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
3294 {
3295 let locks = preindexed_refresh_locks()
3296 .read()
3297 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3298 if let Some(existing) = locks.get(lock_key) {
3299 return Ok(Arc::clone(existing));
3300 }
3301 }
3302
3303 let mut locks = preindexed_refresh_locks()
3304 .write()
3305 .map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
3306 Ok(Arc::clone(
3307 locks
3308 .entry(lock_key.to_owned())
3309 .or_insert_with(|| Arc::new(Mutex::new(()))),
3310 ))
3311 }
3312
3313 fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
3314 let source = self.source_clone(source_index);
3315 let index_path = self.preindexed_index_path(&source);
3316 let lock_key = self.preindexed_refresh_lock_key(&source);
3317 let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
3318 let _guard = refresh_lock
3319 .lock()
3320 .map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
3321
3322 if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
3323 return Ok("source already refreshed".to_owned());
3324 }
3325
3326 let result = self.update_preindexed(source_index);
3327 self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
3328 let detail = result?;
3329 self.save_store()?;
3330 Ok(detail)
3331 }
3332
3333 fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
3334 let path_text = path.to_string_lossy().into_owned();
3335 let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
3336 .with_context(|| format!("failed to open SQLite database at {path_text}"))?;
3337 database
3338 .connect()
3339 .with_context(|| format!("failed to connect to SQLite database at {path_text}"))
3340 }
3341
3342 fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
3343 let source_snapshot = self.store.sources[source_index].clone();
3344 let state_dir = self.source_state_dir(&source_snapshot);
3345 fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
3346 let index_path = self.preindexed_index_path(&source_snapshot);
3347 let has_local_index = index_path.exists();
3348
3349 let mut last_error = None;
3350 for candidate in PREINDEXED_CANDIDATES {
3351 let url = format!("{}/{}", source_snapshot.arg.trim_end_matches('/'), candidate);
3352 if has_local_index
3353 && let Some(detail) =
3354 self.try_skip_unchanged_preindexed_by_source_version(source_index, &url, candidate)
3355 {
3356 return Ok(detail);
3357 }
3358
3359 let mut request = self.client.get(&url);
3360 if has_local_index {
3361 if let Some(etag) = &source_snapshot.etag
3362 && let Ok(value) = HeaderValue::from_str(etag)
3363 {
3364 request = request.header(IF_NONE_MATCH, value);
3365 }
3366 if let Some(last_modified) = &source_snapshot.last_modified
3367 && let Ok(value) = HeaderValue::from_str(last_modified)
3368 {
3369 request = request.header(IF_MODIFIED_SINCE, value);
3370 }
3371 }
3372
3373 match request.send() {
3374 Ok(response) if response.status() == StatusCode::NOT_MODIFIED && has_local_index => {
3375 let source = &mut self.store.sources[source_index];
3376 update_preindexed_http_validators(source, response.headers());
3377 source.last_update = Some(Utc::now());
3378 return Ok(format!("already up to date from {}", candidate));
3379 }
3380 Ok(response) if response.status().is_success() => {
3381 let header_version = response
3382 .headers()
3383 .get("x-ms-meta-sourceversion")
3384 .and_then(|value| value.to_str().ok())
3385 .map(str::to_owned);
3386 let headers = response.headers().clone();
3387 let bytes = response.bytes().context("failed to read preindexed package bytes")?;
3388 let payload = bytes.to_vec();
3389 let index_bytes = extract_zip_member(&payload, "Public/index.db")
3390 .context("preindexed package did not contain Public/index.db")?;
3391
3392 fs::write(self.preindexed_package_path(&source_snapshot), &payload)
3393 .context("failed to persist source package")?;
3394 let temp_index_path = state_dir.join(format!(
3395 "index.{}.tmp",
3396 Utc::now().timestamp_nanos_opt().unwrap_or_default()
3397 ));
3398 fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
3399 if let Err(error) = replace_file(&temp_index_path, &index_path) {
3400 let _ = fs::remove_file(&temp_index_path);
3401 return Err(error).context("failed to persist source index");
3402 }
3403 remove_sqlite_sidecars(&index_path);
3408 let source = &mut self.store.sources[source_index];
3409 source.last_update = Some(Utc::now());
3410 source.source_version = header_version;
3411 update_preindexed_http_validators(source, &headers);
3412 return Ok(format!("downloaded {}", candidate));
3413 }
3414 Ok(response) => {
3415 last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
3416 }
3417 Err(error) => {
3418 last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
3419 }
3420 }
3421 }
3422
3423 Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
3424 }
3425
3426 fn try_skip_unchanged_preindexed_by_source_version(
3427 &mut self,
3428 source_index: usize,
3429 url: &str,
3430 candidate: &str,
3431 ) -> Option<String> {
3432 let current_version = self.store.sources[source_index].source_version.as_deref()?;
3433 let response = self.client.head(url).send().ok()?;
3434 if !response.status().is_success() {
3435 return None;
3436 }
3437
3438 let header_version = response
3439 .headers()
3440 .get("x-ms-meta-sourceversion")
3441 .and_then(|value| value.to_str().ok())?;
3442 if !header_version.eq_ignore_ascii_case(current_version) {
3443 return None;
3444 }
3445
3446 let source = &mut self.store.sources[source_index];
3447 update_preindexed_http_validators(source, response.headers());
3448 source.last_update = Some(Utc::now());
3449 Some(format!("already up to date from {} (v{})", candidate, header_version))
3450 }
3451
3452 fn update_rest(&mut self, source_index: usize) -> Result<String> {
3453 let info = self.fetch_rest_information(source_index, true)?;
3454 let source = &mut self.store.sources[source_index];
3455 if !info.source_identifier.is_empty() {
3456 source.identifier = info.source_identifier.clone();
3457 }
3458 source.last_update = Some(Utc::now());
3459 source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
3460 Ok("refreshed information cache".to_owned())
3461 }
3462
3463 fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
3464 let source = self.source_clone(source_index);
3465 let cache_path = self.rest_information_cache_path(&source);
3466
3467 if cache_path.exists() {
3468 let cache = serde_json::from_slice::<RestInfoCache>(
3469 &fs::read(&cache_path).context("failed to read REST information cache")?,
3470 )
3471 .context("failed to parse REST information cache")?;
3472 if cache.expires_at > Utc::now() {
3473 return Ok(cache.value);
3474 }
3475 }
3476
3477 self.fetch_rest_information(source_index, true)
3478 }
3479
3480 fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
3481 let source = self.source_clone(source_index);
3482 let url = format!("{}/information", source.arg.trim_end_matches('/'));
3483 let response = self
3484 .client
3485 .get(url)
3486 .send()
3487 .context("REST information request failed")?
3488 .error_for_status()
3489 .context("REST information request returned an error")?;
3490
3491 let max_age = cache_control_max_age(&response);
3492 let json =
3493 serde_json::from_slice::<JsonValue>(&response.bytes().context("failed to read REST information response")?)
3494 .context("failed to parse REST information response")?;
3495 let data = json
3496 .get("Data")
3497 .cloned()
3498 .ok_or_else(|| anyhow!("REST information response missing Data"))?;
3499 let info = serde_json::from_value::<RestInformation>(data)
3500 .context("failed to deserialize REST information payload")?;
3501
3502 if persist {
3503 let cache = RestInfoCache {
3504 expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
3505 value: info.clone(),
3506 };
3507 write_json(self.rest_information_cache_path(&source), &cache)?;
3508 }
3509
3510 Ok(info)
3511 }
3512
3513 fn load_v2_version_data(
3514 &self,
3515 source: &SourceRecord,
3516 package_rowid: i64,
3517 package_hash: &str,
3518 ) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
3519 let connection = Self::open_sqlite_connection(self.preindexed_index_path(source))
3520 .context("failed to reopen preindexed index for V2 version data")?;
3521 let package_hash = package_hash.to_ascii_lowercase();
3522 let package_id = query_optional_value(
3523 &connection,
3524 "SELECT id FROM packages WHERE rowid = ?1",
3525 vec![SqlValue::Integer(package_rowid)],
3526 |row| row_string(row, 0),
3527 )?
3528 .ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
3529 let relative_path = package_version_data_relative_path(&package_id, &package_hash);
3530 let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
3531 let yaml = decompress_mszyml(&file.bytes)?;
3532 let document =
3533 serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
3534 Ok((document.versions, file.path))
3535 }
3536
3537 fn get_cached_source_file(
3538 &self,
3539 bucket: &str,
3540 source: &SourceRecord,
3541 relative_path: &str,
3542 expected_hash: Option<&str>,
3543 ) -> Result<CachedBytes> {
3544 let normalized_relative = relative_path.replace('\\', "/");
3545 let cache_path =
3546 temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
3547
3548 if cache_path.exists() {
3549 let cached = fs::read(&cache_path).context("failed to read cached source file")?;
3550 if hash_matches(expected_hash, &cached) {
3551 return Ok(CachedBytes {
3552 path: cache_path,
3553 bytes: cached,
3554 });
3555 }
3556 }
3557
3558 let url = format!(
3559 "{}/{}",
3560 source.arg.trim_end_matches('/'),
3561 normalized_relative.trim_start_matches('/')
3562 );
3563 let response = self
3564 .client
3565 .get(url)
3566 .send()
3567 .context("failed to fetch source file")?
3568 .error_for_status()
3569 .context("source file request returned an error")?;
3570 let bytes = response.bytes().context("failed to read source file body")?.to_vec();
3571
3572 if let Some(hash) = expected_hash {
3573 verify_hash(hash, &bytes)?;
3574 }
3575
3576 if let Some(parent) = cache_path.parent() {
3577 fs::create_dir_all(parent).context("failed to create cache directory")?;
3578 }
3579 fs::write(&cache_path, &bytes).context("failed to write cache file")?;
3580
3581 Ok(CachedBytes {
3582 path: cache_path,
3583 bytes,
3584 })
3585 }
3586
3587 fn get_or_fetch_rest_manifest(
3588 &mut self,
3589 source: &SourceRecord,
3590 package_id: &str,
3591 version: &str,
3592 channel: &str,
3593 ) -> Result<(Vec<u8>, PathBuf)> {
3594 let cache_path = self.rest_manifest_cache_path(source, package_id, version, channel);
3595 if cache_path.exists() {
3596 return Ok((
3597 fs::read(&cache_path).context("failed to read cached REST manifest")?,
3598 cache_path,
3599 ));
3600 }
3601
3602 let info = self.load_rest_information_by_name(&source.name)?;
3603 let contract = choose_contract(&info.server_supported_versions)
3604 .ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
3605 let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
3606
3607 let mut params = vec![("Version", version.to_owned())];
3608 if !channel.is_empty() {
3609 params.push(("Channel", channel.to_owned()));
3610 }
3611 if info
3612 .required_query_parameters
3613 .iter()
3614 .any(|value| value.eq_ignore_ascii_case("Market"))
3615 {
3616 params.push(("Market", default_market()));
3617 }
3618
3619 let response = self
3620 .client
3621 .get(url)
3622 .header("Version", contract)
3623 .query(¶ms)
3624 .send()
3625 .context("REST manifest request failed")?
3626 .error_for_status()
3627 .context("REST manifest request returned an error")?;
3628 let bytes = response
3629 .bytes()
3630 .context("failed to read REST manifest response")?
3631 .to_vec();
3632
3633 if let Some(parent) = cache_path.parent() {
3634 fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
3635 }
3636 fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
3637
3638 Ok((bytes, cache_path))
3639 }
3640
3641 fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
3642 let index = self
3643 .store
3644 .sources
3645 .iter()
3646 .position(|source| source.name.eq_ignore_ascii_case(name))
3647 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3648 self.load_rest_information(index)
3649 }
3650
3651 fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
3652 if let Some(name) = source_name {
3653 if let Some(index) = self
3654 .store
3655 .sources
3656 .iter()
3657 .position(|source| source.name.eq_ignore_ascii_case(name))
3658 {
3659 return Ok(vec![index]);
3660 }
3661
3662 let index = self
3663 .store
3664 .sources
3665 .iter()
3666 .position(|source| source.identifier.eq_ignore_ascii_case(name))
3667 .ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
3668 return Ok(vec![index]);
3669 }
3670
3671 let mut indexes: Vec<_> = self
3672 .store
3673 .sources
3674 .iter()
3675 .enumerate()
3676 .filter(|(_, source)| !source.explicit)
3677 .collect();
3678 indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
3679 right_source
3680 .priority
3681 .cmp(&left_source.priority)
3682 .then_with(|| left_index.cmp(right_index))
3683 });
3684 Ok(indexes.into_iter().map(|(index, _)| index).collect())
3685 }
3686
3687 fn source_clone(&self, index: usize) -> SourceRecord {
3688 self.store.sources[index].clone()
3689 }
3690
3691 fn refresh_system_winget_sources(&mut self) -> Result<()> {
3692 self.store = match self.source_mode {
3693 EffectiveSourceMode::Private => self.store.clone(),
3694 EffectiveSourceMode::SystemWingetDirect => load_system_winget_source_store()?,
3695 EffectiveSourceMode::SystemWingetMirror => refresh_system_winget_mirror_store(&self.app_root)?,
3696 };
3697 Ok(())
3698 }
3699
3700 fn refresh_system_winget_sources_for_query(&mut self) -> Option<String> {
3704 self.refresh_system_winget_sources_for_query_with_max_age(StdDuration::from_secs(
3705 SYSTEM_WINGET_MIRROR_AUTO_REFRESH_MINUTES * 60,
3706 ))
3707 }
3708
3709 fn refresh_system_winget_sources_for_query_with_max_age(&mut self, max_age: StdDuration) -> Option<String> {
3710 match self.source_mode {
3711 EffectiveSourceMode::Private => None,
3712 EffectiveSourceMode::SystemWingetDirect => match load_system_winget_source_store() {
3713 Ok(store) => {
3714 self.store = store;
3715 None
3716 }
3717 Err(error) => Some(system_winget_source_refresh_warning(&error)),
3718 },
3719 EffectiveSourceMode::SystemWingetMirror => {
3720 if system_winget_mirror_is_fresh(&self.app_root, max_age) {
3721 return None;
3722 }
3723
3724 match refresh_system_winget_mirror_store(&self.app_root) {
3725 Ok(store) => {
3726 self.store = store;
3727 None
3728 }
3729 Err(error) => Some(system_winget_source_refresh_warning(&error)),
3730 }
3731 }
3732 }
3733 }
3734
3735 fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
3736 self.refresh_system_winget_sources()?;
3737 let selected_names: Vec<_> = if source_name.is_none() {
3738 self.store.sources.iter().map(|source| source.name.clone()).collect()
3739 } else {
3740 self.resolve_source_indexes(source_name)?
3741 .into_iter()
3742 .map(|index| self.store.sources[index].name.clone())
3743 .collect()
3744 };
3745
3746 let detail = system_winget_update_sources(source_name)?;
3747 self.refresh_system_winget_sources()?;
3748
3749 Ok(selected_names
3750 .into_iter()
3751 .filter_map(|name| {
3752 self.store
3753 .sources
3754 .iter()
3755 .find(|source| source.name.eq_ignore_ascii_case(&name))
3756 .map(|source| SourceUpdateResult {
3757 name: source.name.clone(),
3758 kind: source.kind,
3759 detail: detail.clone(),
3760 })
3761 })
3762 .collect())
3763 }
3764}
3765
3766#[derive(Debug)]
3767struct CachedBytes {
3768 path: PathBuf,
3769 bytes: Vec<u8>,
3770}
3771
3772#[derive(Debug, Clone)]
3773struct V1SearchRow {
3774 manifest_rowid: i64,
3775 package_rowid: i64,
3776 version: String,
3777 channel: String,
3778 id: String,
3779 name: String,
3780 moniker: Option<String>,
3781 match_criteria: Option<String>,
3782}
3783
3784#[derive(Debug, Clone)]
3785struct V1VersionRow {
3786 version: String,
3787 channel: String,
3788 pathpart_id: i64,
3789 manifest_hash: Option<String>,
3790}
3791
3792#[derive(Debug, Clone)]
3793struct V2SearchRow {
3794 package_rowid: i64,
3795 package_hash: String,
3796 id: String,
3797 name: String,
3798 moniker: Option<String>,
3799 version: String,
3800 match_criteria: Option<String>,
3801}
3802
3803fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
3804 query.query.is_some()
3805 || query.id.is_some()
3806 || query.name.is_some()
3807 || query.moniker.is_some()
3808 || query.tag.is_some()
3809 || query.command.is_some()
3810 || query.source.is_some()
3811}
3812
3813fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
3814 PackageQuery {
3815 query: query.query.clone(),
3816 id: query.id.clone(),
3817 name: query.name.clone(),
3818 moniker: query.moniker.clone(),
3819 tag: query.tag.clone(),
3820 command: query.command.clone(),
3821 source: query.source.clone(),
3822 count: Some(LIST_LOOKUP_MAX_RESULTS),
3823 exact: query.exact,
3824 version: None,
3825 channel: None,
3826 locale: None,
3827 installer_type: None,
3828 installer_architecture: None,
3829 platform: None,
3830 os_version: None,
3831 install_scope: query.install_scope.clone(),
3832 }
3833}
3834
3835fn allow_loose_list_correlation(query: &ListQuery) -> bool {
3836 query.query.is_some() || query.id.is_some() || query.name.is_some()
3837}
3838
3839fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
3840 if is_greater_than_version_marker(&package.installed_version) {
3841 return false;
3842 }
3843 package
3844 .correlated
3845 .as_ref()
3846 .and_then(|candidate| candidate.version.as_deref())
3847 .is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
3848}
3849
3850fn is_greater_than_version_marker(version: &str) -> bool {
3851 version.trim_start().starts_with('>')
3852}
3853
3854fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
3855 package.installed_version.eq_ignore_ascii_case("Unknown")
3856}
3857
3858fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
3859 if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
3862 return false;
3863 }
3864 if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
3867 return false;
3868 }
3869 installed_package_has_upgrade(package)
3870 || (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
3871}
3872
3873fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
3879 let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
3880 let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
3881
3882 for package in packages {
3883 match package.correlated.as_ref() {
3884 Some(candidate) => {
3885 let key = (
3886 candidate.id.to_ascii_lowercase(),
3887 candidate.source_name.to_ascii_lowercase(),
3888 );
3889 let keep_new = match by_id.get(&key) {
3890 Some(existing) => {
3891 match (
3897 package.installed_version_canonical,
3898 existing.installed_version_canonical,
3899 ) {
3900 (true, false) => true,
3901 (false, true) => false,
3902 _ => {
3903 compare_version(&package.installed_version, &existing.installed_version)
3904 == Ordering::Greater
3905 }
3906 }
3907 }
3908 None => true,
3909 };
3910 if keep_new {
3911 by_id.insert(key, package);
3912 }
3913 }
3914 None => uncorrelated.push(package),
3915 }
3916 }
3917
3918 let mut result = uncorrelated;
3919 result.extend(by_id.into_values());
3920 result
3921}
3922
3923fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
3924 let mut source_specific = None;
3925 let mut source_agnostic = None;
3926
3927 for pin in pins {
3928 if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
3929 continue;
3930 }
3931
3932 if pin.source_id.is_empty() {
3933 if source_agnostic.is_none() {
3934 source_agnostic = Some(pin);
3935 }
3936 } else if item
3937 .source_name
3938 .as_deref()
3939 .is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
3940 {
3941 source_specific = Some(pin);
3942 break;
3943 }
3944 }
3945
3946 source_specific.or(source_agnostic)
3947}
3948
3949fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
3950 let Some(available_version) = item.available_version.as_deref() else {
3951 return false;
3952 };
3953 let Some(pin) = find_applicable_pin(item, pins) else {
3954 return false;
3955 };
3956
3957 match pin.pin_type {
3958 PinType::Blocking => true,
3959 PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
3960 }
3961}
3962
3963fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
3964 if pattern.trim().is_empty() || pattern == "*" {
3965 return true;
3966 }
3967
3968 if !pattern.contains('*') {
3969 return version.eq_ignore_ascii_case(pattern);
3970 }
3971
3972 if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
3973 let prefix = &pattern[..pattern.len() - 1];
3974 return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
3975 }
3976
3977 version.eq_ignore_ascii_case(pattern)
3978}
3979
3980fn list_match_from_installed(package: InstalledPackage, sources: &[SourceRecord]) -> ListMatch {
3981 let available_version = package.correlated.as_ref().and_then(|candidate| {
3982 candidate.version.as_ref().and_then(|candidate_version| {
3983 if !is_greater_than_version_marker(&package.installed_version)
3984 && (installed_package_has_unknown_version(&package)
3985 || compare_version(candidate_version, &package.installed_version) == Ordering::Greater)
3986 {
3987 Some(candidate_version.clone())
3988 } else {
3989 None
3990 }
3991 })
3992 });
3993 let mut source_name = package
3994 .correlated
3995 .as_ref()
3996 .map(|candidate| candidate.source_name.clone())
3997 .filter(|value| !value.is_empty());
3998 let mut id = package
3999 .correlated
4000 .as_ref()
4001 .map(|candidate| candidate.id.clone())
4002 .unwrap_or_else(|| package.local_id.clone());
4003
4004 if source_name.is_none()
4005 && let Some(package_id) = package
4006 .winget_package_identifier
4007 .as_deref()
4008 .filter(|value| !value.trim().is_empty())
4009 {
4010 id = package_id.to_owned();
4011 source_name = package
4012 .winget_source_identifier
4013 .as_deref()
4014 .filter(|value| !value.trim().is_empty())
4015 .map(|identifier| source_name_from_identifier(identifier, sources));
4016 }
4017
4018 if source_name.is_none()
4019 && let Some((local_package_id, local_source_name)) =
4020 winget_package_identity_from_local_id(&package.local_id, sources)
4021 {
4022 id = local_package_id;
4023 source_name = Some(local_source_name);
4024 }
4025
4026 ListMatch {
4027 name: package.name,
4028 id,
4029 local_id: package.local_id,
4030 installed_version: package.installed_version,
4031 available_version,
4032 source_name,
4033 publisher: package.publisher,
4034 scope: package.scope,
4035 installer_category: package.installer_category,
4036 install_location: package.install_location,
4037 package_family_names: package.package_family_names,
4038 product_codes: package.product_codes,
4039 upgrade_codes: package.upgrade_codes,
4040 }
4041}
4042
4043fn suppress_duplicate_available_versions(matches: Vec<ListMatch>) -> Vec<ListMatch> {
4044 let mut grouped: HashMap<(String, String), Vec<usize>> = HashMap::new();
4045 for (index, item) in matches.iter().enumerate() {
4046 let Some(source_name) = item.source_name.as_ref().filter(|value| !value.trim().is_empty()) else {
4047 continue;
4048 };
4049 grouped
4050 .entry((source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4051 .or_default()
4052 .push(index);
4053 }
4054
4055 let mut keep = BTreeSet::new();
4056 for indexes in grouped.values().filter(|indexes| indexes.len() > 1) {
4057 let Some(best) = indexes
4058 .iter()
4059 .map(|index| matches[*index].installed_version.as_str())
4060 .max_by(|left, right| compare_version(left, right))
4061 else {
4062 continue;
4063 };
4064 for index in indexes {
4065 if compare_version(&matches[*index].installed_version, best) == Ordering::Equal {
4066 keep.insert(*index);
4067 }
4068 }
4069 }
4070
4071 matches
4072 .into_iter()
4073 .enumerate()
4074 .map(|(index, mut item)| {
4075 if item.available_version.is_some() && !keep.contains(&index) {
4076 let is_duplicate = item
4077 .source_name
4078 .as_ref()
4079 .map(|source_name| {
4080 grouped
4081 .get(&(source_name.to_ascii_lowercase(), item.id.to_ascii_lowercase()))
4082 .is_some_and(|indexes| indexes.len() > 1)
4083 })
4084 .unwrap_or(false);
4085 if is_duplicate {
4086 item.available_version = None;
4087 }
4088 }
4089 item
4090 })
4091 .collect()
4092}
4093
4094fn source_name_from_identifier(identifier: &str, sources: &[SourceRecord]) -> String {
4095 sources
4096 .iter()
4097 .find(|source| source.identifier.eq_ignore_ascii_case(identifier))
4098 .map(|source| source.name.clone())
4099 .unwrap_or_else(|| identifier.to_owned())
4100}
4101
4102fn winget_package_identity_from_local_id(local_id: &str, sources: &[SourceRecord]) -> Option<(String, String)> {
4103 let package_id_start_index = local_id.rfind('\\').map_or(0, |index| index + 1);
4104 for source in sources {
4105 let source_suffix = format!("_{}", source.identifier);
4106 let Some(source_identifier_start_index) = local_id.len().checked_sub(source_suffix.len()) else {
4107 continue;
4108 };
4109 if !local_id[source_identifier_start_index..].eq_ignore_ascii_case(&source_suffix) {
4110 continue;
4111 }
4112 if source_identifier_start_index <= package_id_start_index {
4113 return None;
4114 }
4115 let package_id = local_id[package_id_start_index..source_identifier_start_index].to_owned();
4116 if package_id.trim().is_empty() {
4117 return None;
4118 }
4119 return Some((package_id, source.name.clone()));
4120 }
4121 None
4122}
4123
4124fn list_sort_weight(package: &InstalledPackage) -> usize {
4125 if package.local_id.starts_with("ARP\\") {
4126 0
4127 } else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
4128 1
4129 } else {
4130 2
4131 }
4132}
4133
4134fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
4135 let correlated = package.correlated.as_ref();
4136
4137 if let Some(value) = &query.id {
4138 let local_match = matches_text(&package.local_id, value, query.exact);
4139 let correlated_match = correlated
4140 .map(|candidate| matches_text(&candidate.id, value, query.exact))
4141 .unwrap_or(false);
4142 if !local_match && !correlated_match {
4143 return false;
4144 }
4145 }
4146
4147 if let Some(value) = &query.name
4148 && !matches_text(&package.name, value, query.exact)
4149 {
4150 return false;
4151 }
4152
4153 if let Some(value) = &query.product_code
4154 && !package
4155 .product_codes
4156 .iter()
4157 .any(|code| code.eq_ignore_ascii_case(value))
4158 {
4159 return false;
4160 }
4161
4162 if let Some(value) = &query.version
4163 && !package.installed_version.eq_ignore_ascii_case(value)
4164 {
4165 return false;
4166 }
4167
4168 if let Some(value) = &query.query {
4169 let local_match =
4170 matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
4171 let correlated_match = correlated
4172 .map(|candidate| {
4173 matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
4174 })
4175 .unwrap_or(false);
4176 if !local_match && !correlated_match {
4177 return false;
4178 }
4179 }
4180
4181 if let Some(source) = &query.source
4182 && correlated
4183 .map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
4184 .unwrap_or(true)
4185 {
4186 return false;
4187 }
4188
4189 if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
4190 return false;
4191 }
4192
4193 true
4194}
4195
4196fn correlate_installed_package(
4197 package: &InstalledPackage,
4198 candidates: &[SearchMatch],
4199 allow_loose_name_match: bool,
4200) -> Option<SearchMatch> {
4201 if package.local_id.starts_with("MSIX\\") {
4209 return None;
4210 }
4211
4212 let installed_name = normalize_correlation_name(&package.name);
4213 let installed_name_lower = package.name.to_ascii_lowercase();
4214 let candidate_names = correlation_name_candidates(&package.name);
4215
4216 let mut scored: Vec<(i32, SearchMatch)> = candidates
4217 .iter()
4218 .filter_map(|candidate| {
4219 let candidate_name = normalize_correlation_name(&candidate.name);
4220 let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
4221 1000
4222 } else if candidate_names.iter().any(|name| {
4223 let normalized = normalize_correlation_name(name);
4224 normalized == candidate_name
4225 }) {
4226 900
4227 } else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
4228 700
4229 } else {
4230 0
4231 };
4232
4233 if base_score == 0 {
4234 return None;
4235 }
4236
4237 let candidate_lower = candidate.name.to_ascii_lowercase();
4250 let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
4251 100
4252 } else if installed_name_lower.contains(&candidate_lower) {
4253 50
4254 } else {
4255 0
4256 };
4257
4258 Some((base_score + prefix_bonus, candidate.clone()))
4259 })
4260 .collect();
4261
4262 scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
4263 let mut iter = scored.into_iter();
4264 let (top_score, top_match) = iter.next()?;
4265
4266 if let Some((next_score, _)) = iter.next()
4274 && next_score == top_score
4275 {
4276 return None;
4277 }
4278 Some(top_match)
4279}
4280
4281fn correlation_name_candidates(name: &str) -> Vec<String> {
4282 let mut candidates = Vec::new();
4283 candidates.push(name.trim().to_owned());
4284
4285 let mut trimmed = name.trim().to_owned();
4286 if let Some(index) = trimmed.find(" (") {
4287 trimmed.truncate(index);
4288 }
4289
4290 let mut words = Vec::new();
4291 for token in trimmed.split_whitespace() {
4292 let lower = token
4293 .trim_matches(|ch: char| ch == '(' || ch == ')')
4294 .to_ascii_lowercase();
4295 if !words.is_empty()
4296 && (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
4297 {
4298 break;
4299 }
4300 words.push(token);
4301 }
4302
4303 if !words.is_empty() {
4304 candidates.push(words.join(" "));
4305 }
4306
4307 candidates.sort();
4308 candidates.dedup();
4309 candidates
4310}
4311
4312fn normalize_correlation_name(value: &str) -> String {
4313 value
4314 .chars()
4315 .filter(|ch| ch.is_ascii_alphanumeric())
4316 .flat_map(|ch| ch.to_lowercase())
4317 .collect()
4318}
4319
4320struct V2PackageMetadata {
4321 rowid: i64,
4322 id: String,
4323 name: String,
4324 moniker: Option<String>,
4325 latest_version: String,
4326 package_hash: String,
4327}
4328
4329fn lookup_identity_match_v2(
4335 connection: &Connection,
4336 package: &InstalledPackage,
4337) -> Result<Option<(i64, &'static str)>> {
4338 for pfn in &package.package_family_names {
4339 if let Some(rowid) = query_optional_value(
4340 connection,
4341 "SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
4342 vec![SqlValue::Text(pfn.to_ascii_lowercase())],
4343 |row| row_i64(row, 0),
4344 )? {
4345 return Ok(Some((rowid, "PackageFamilyName")));
4346 }
4347 }
4348 for code in &package.upgrade_codes {
4357 if let Some(rowid) = query_optional_value(
4358 connection,
4359 "SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
4360 vec![SqlValue::Text(code.to_ascii_lowercase())],
4361 |row| row_i64(row, 0),
4362 )? {
4363 return Ok(Some((rowid, "UpgradeCode")));
4364 }
4365 }
4366 for code in &package.product_codes {
4367 if let Some(rowid) = query_optional_value(
4368 connection,
4369 "SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
4370 vec![SqlValue::Text(code.to_ascii_lowercase())],
4371 |row| row_i64(row, 0),
4372 )? {
4373 return Ok(Some((rowid, "ProductCode")));
4374 }
4375 }
4376 Ok(None)
4377}
4378
4379fn installed_db_identity_tables_present(connection: &Connection) -> Result<bool> {
4380 Ok(table_exists(connection, "manifest")?
4381 && table_exists(connection, "ids")?
4382 && table_exists(connection, "names")?
4383 && ((table_exists(connection, "pfns")? && table_exists(connection, "pfns_map")?)
4384 || (table_exists(connection, "productcodes")? && table_exists(connection, "productcodes_map")?)
4385 || (table_exists(connection, "upgradecodes")? && table_exists(connection, "upgradecodes_map")?)))
4386}
4387
4388fn table_exists(connection: &Connection, name: &str) -> Result<bool> {
4389 query_optional_value(
4390 connection,
4391 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
4392 vec![SqlValue::Text(name.to_owned())],
4393 |row| row_i64(row, 0),
4394 )
4395 .map(|value| value.is_some())
4396}
4397
4398fn lookup_installed_db_identity_match(
4399 connection: &Connection,
4400 package: &InstalledPackage,
4401) -> Result<Option<(String, String, &'static str)>> {
4402 for pfn in &package.package_family_names {
4403 if let Some(hit) =
4404 lookup_installed_db_identity_match_value(connection, "pfns", "pfns_map", "pfn", pfn, "PackageFamilyName")?
4405 {
4406 return Ok(Some(hit));
4407 }
4408 }
4409 for code in &package.upgrade_codes {
4410 if let Some(hit) = lookup_installed_db_identity_match_value(
4411 connection,
4412 "upgradecodes",
4413 "upgradecodes_map",
4414 "upgradecode",
4415 code,
4416 "UpgradeCode",
4417 )? {
4418 return Ok(Some(hit));
4419 }
4420 }
4421 for code in &package.product_codes {
4422 if let Some(hit) = lookup_installed_db_identity_match_value(
4423 connection,
4424 "productcodes",
4425 "productcodes_map",
4426 "productcode",
4427 code,
4428 "ProductCode",
4429 )? {
4430 return Ok(Some(hit));
4431 }
4432 }
4433 Ok(None)
4434}
4435
4436fn lookup_installed_db_identity_match_value(
4437 connection: &Connection,
4438 value_table: &'static str,
4439 map_table: &'static str,
4440 value_column: &'static str,
4441 value: &str,
4442 matched_by: &'static str,
4443) -> Result<Option<(String, String, &'static str)>> {
4444 let sql = format!(
4445 "SELECT ids.id, names.name \
4446 FROM {map_table} map \
4447 JOIN {value_table} value ON map.{value_column} = value.rowid \
4448 JOIN manifest ON map.manifest = manifest.rowid \
4449 JOIN ids ON manifest.id = ids.rowid \
4450 JOIN names ON manifest.name = names.rowid \
4451 WHERE value.{value_column} = ?1 \
4452 LIMIT 1"
4453 );
4454 query_optional_value(
4455 connection,
4456 &sql,
4457 vec![SqlValue::Text(value.to_ascii_lowercase())],
4458 |row| Ok((row_string(row, 0)?, row_string(row, 1)?, matched_by)),
4459 )
4460}
4461
4462fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
4466 query_optional_value(
4467 connection,
4468 "SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
4469 vec![SqlValue::Text(package_id.to_owned())],
4470 |row| {
4471 Ok(V2PackageMetadata {
4472 rowid: row_i64(row, 0)?,
4473 id: row_string(row, 1)?,
4474 name: row_string(row, 2)?,
4475 moniker: row_opt_string(row, 3)?,
4476 latest_version: row_string(row, 4)?,
4477 package_hash: row_hex_string(row, 5)?,
4478 })
4479 },
4480 )
4481}
4482
4483fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
4491 if !package.local_id.starts_with("MSIX\\") {
4492 return;
4493 }
4494 if !package.name.starts_with("ms-resource:") {
4495 return;
4496 }
4497 let Some(candidate) = package.correlated.as_ref() else {
4498 return;
4499 };
4500 if !candidate.name.is_empty() {
4501 package.name = candidate.name.clone();
4502 }
4503}
4504
4505fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
4506 query_optional_value(
4507 connection,
4508 "SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
4509 vec![SqlValue::Integer(package_rowid)],
4510 |row| {
4511 Ok(V2PackageMetadata {
4512 rowid: package_rowid,
4513 id: row_string(row, 0)?,
4514 name: row_string(row, 1)?,
4515 moniker: row_opt_string(row, 2)?,
4516 latest_version: row_string(row, 3)?,
4517 package_hash: row_hex_string(row, 4)?,
4518 })
4519 },
4520 )
4521}
4522
4523fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
4524 query_optional_value(
4525 connection,
4526 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
4527 Vec::new(),
4528 |row| row_i64(row, 0),
4529 )
4530 .ok()
4531 .flatten()
4532 .is_some()
4533}
4534
4535fn lookup_unique_normalized_identity(
4542 connection: &Connection,
4543 norm_name: &str,
4544 norm_publisher: &str,
4545) -> Result<Option<i64>> {
4546 let rows: Vec<i64> = query_rows(
4547 connection,
4548 "SELECT DISTINCT n.package FROM norm_names2 n \
4549 WHERE n.norm_name = ?1 \
4550 AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
4551 LIMIT 2",
4552 vec![
4553 SqlValue::Text(norm_name.to_owned()),
4554 SqlValue::Text(norm_publisher.to_owned()),
4555 ],
4556 |row| row_i64(row, 0),
4557 )?;
4558 if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
4559}
4560
4561fn v2_identity_tables_present(connection: &Connection) -> bool {
4562 query_optional_value(
4563 connection,
4564 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
4565 Vec::new(),
4566 |row| row_i64(row, 0),
4567 )
4568 .ok()
4569 .flatten()
4570 .is_some()
4571}
4572
4573fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4580 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4581 return None;
4582 }
4583 for entry in entries {
4584 let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
4585 continue;
4586 };
4587 if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
4588 {
4589 return Some(entry.version.clone());
4590 }
4591 }
4592 None
4593}
4594
4595fn less_than_latest_arp_anchored_version(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
4601 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4602 return None;
4603 }
4604
4605 let latest = entries
4606 .iter()
4607 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4608 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4609 if compare_version(arp_version, &latest.version) != Ordering::Equal {
4610 return None;
4611 }
4612 let latest_min = latest.arp_min_version.as_deref()?;
4613 if compare_version(arp_version, latest_min) != Ordering::Less {
4614 return None;
4615 }
4616
4617 let previous_max = entries
4618 .iter()
4619 .filter(|entry| compare_version(&entry.version, &latest.version) == Ordering::Less)
4620 .filter_map(|entry| entry.arp_max_version.as_deref())
4621 .max_by(|left, right| compare_version(left, right));
4622 if previous_max.is_some_and(|max| compare_version(arp_version, max) != Ordering::Greater) {
4623 return None;
4624 }
4625
4626 Some(format!("< {}", latest.version))
4627}
4628
4629fn greater_than_latest_arp_anchored_version(
4634 entries: &[PackageVersionDataEntry],
4635 arp_version: &str,
4636 catalog_latest_version: &str,
4637) -> Option<String> {
4638 if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
4639 return None;
4640 }
4641
4642 let latest = entries
4643 .iter()
4644 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4645 .max_by(|a, b| compare_version(&a.version, &b.version))?;
4646 let latest_max = latest.arp_max_version.as_deref()?;
4647 if !latest.version.eq_ignore_ascii_case(catalog_latest_version) {
4648 return None;
4649 }
4650 if !has_different_leading_version_number(latest_max, &latest.version) {
4651 return None;
4652 }
4653 if compare_version(arp_version, latest_max) != Ordering::Greater {
4654 return None;
4655 }
4656
4657 Some(format!("> {}", latest.version))
4658}
4659
4660fn has_different_leading_version_number(left: &str, right: &str) -> bool {
4661 let Some(left) = leading_version_number(left) else {
4662 return false;
4663 };
4664 let Some(right) = leading_version_number(right) else {
4665 return false;
4666 };
4667 !left.eq_ignore_ascii_case(right)
4668}
4669
4670fn leading_version_number(value: &str) -> Option<&str> {
4671 let trimmed = value.trim_start();
4672 let length = trimmed
4673 .chars()
4674 .take_while(|ch| ch.is_ascii_digit())
4675 .map(char::len_utf8)
4676 .sum();
4677 (length > 0).then_some(&trimmed[..length])
4678}
4679
4680fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
4689 entries
4690 .iter()
4691 .filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
4692 .max_by(|a, b| compare_version(&a.version, &b.version))
4693 .map(|entry| entry.version.clone())
4694}
4695
4696#[cfg(windows)]
4697fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4698 let mut packages = Vec::new();
4699 let mut seen = BTreeSet::new();
4700
4701 let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
4702 let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
4703
4704 let upgrade_codes = collect_msi_upgrade_codes(machine, user);
4712
4713 if machine {
4714 collect_uninstall_view(
4715 &mut packages,
4716 &mut seen,
4717 &upgrade_codes,
4718 RegKey::predef(HKEY_LOCAL_MACHINE),
4719 "Machine",
4720 "X64",
4721 KEY_READ | KEY_WOW64_64KEY,
4722 )?;
4723 collect_uninstall_view(
4724 &mut packages,
4725 &mut seen,
4726 &upgrade_codes,
4727 RegKey::predef(HKEY_LOCAL_MACHINE),
4728 "Machine",
4729 "X86",
4730 KEY_READ | KEY_WOW64_32KEY,
4731 )?;
4732 collect_appmodel_packages(
4733 &mut packages,
4734 &mut seen,
4735 RegKey::predef(HKEY_LOCAL_MACHINE),
4736 "Machine",
4737 KEY_READ | KEY_WOW64_64KEY,
4738 )?;
4739 }
4740
4741 if user {
4742 collect_uninstall_view(
4743 &mut packages,
4744 &mut seen,
4745 &upgrade_codes,
4746 RegKey::predef(HKEY_CURRENT_USER),
4747 "User",
4748 "X64",
4749 KEY_READ,
4750 )?;
4751 collect_appmodel_packages(
4752 &mut packages,
4753 &mut seen,
4754 RegKey::predef(HKEY_CURRENT_USER),
4755 "User",
4756 KEY_READ,
4757 )?;
4758 }
4759
4760 Ok(packages)
4761}
4762
4763#[cfg(windows)]
4769fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
4770 let mut map = HashMap::new();
4771 if include_machine {
4772 collect_msi_upgrade_codes_from(
4773 RegKey::predef(HKEY_LOCAL_MACHINE),
4774 r"SOFTWARE\Classes\Installer\UpgradeCodes",
4775 KEY_READ | KEY_WOW64_64KEY,
4776 &mut map,
4777 );
4778 }
4779 if include_user {
4780 collect_msi_upgrade_codes_from(
4781 RegKey::predef(HKEY_CURRENT_USER),
4782 r"Software\Microsoft\Installer\UpgradeCodes",
4783 KEY_READ,
4784 &mut map,
4785 );
4786 }
4787 map
4788}
4789
4790#[cfg(windows)]
4791fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
4792 let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
4793 return;
4794 };
4795 for key_name in upgrade_codes.enum_keys().flatten() {
4796 let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
4797 continue;
4798 };
4799 let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
4800 continue;
4801 };
4802 for (value_name, _) in subkey.enum_values().flatten() {
4803 if let Some(product_code) = unflip_packed_guid(&value_name) {
4804 map.entry(product_code).or_insert_with(|| upgrade_code.clone());
4808 }
4809 }
4810 }
4811}
4812
4813#[cfg(windows)]
4818fn unflip_packed_guid(packed: &str) -> Option<String> {
4819 if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
4820 return None;
4821 }
4822 const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
4823 let bytes = packed.as_bytes();
4824 let mut reversed = String::with_capacity(32);
4825 let mut offset = 0;
4826 for size in CHUNKS {
4827 let chunk = &bytes[offset..offset + size];
4828 for &c in chunk.iter().rev() {
4829 reversed.push(c as char);
4830 }
4831 offset += size;
4832 }
4833 let r = reversed.to_ascii_lowercase();
4834 Some(format!(
4835 "{{{}-{}-{}-{}-{}}}",
4836 &r[0..8],
4837 &r[8..12],
4838 &r[12..16],
4839 &r[16..20],
4840 &r[20..32]
4841 ))
4842}
4843
4844#[cfg(not(windows))]
4845fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
4846 Ok(Vec::new())
4847}
4848
4849#[cfg(windows)]
4850fn collect_uninstall_view(
4851 packages: &mut Vec<InstalledPackage>,
4852 seen: &mut BTreeSet<String>,
4853 upgrade_code_map: &HashMap<String, String>,
4854 root: RegKey,
4855 scope: &str,
4856 arch: &str,
4857 flags: u32,
4858) -> Result<()> {
4859 const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
4860
4861 let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
4862 Ok(key) => key,
4863 Err(_) => return Ok(()),
4864 };
4865
4866 for key_name in uninstall.enum_keys().flatten() {
4867 let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
4868 Ok(key) => key,
4869 Err(_) => continue,
4870 };
4871 if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
4872 {
4873 continue;
4874 }
4875
4876 let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
4877 continue;
4878 };
4879
4880 let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
4881 let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
4882 let publisher = read_reg_string(&subkey, "Publisher");
4883 let install_location = read_reg_string(&subkey, "InstallLocation");
4884 let winget_package_identifier = read_reg_string(&subkey, "WinGetPackageIdentifier");
4885 let winget_source_identifier = read_reg_string(&subkey, "WinGetSourceIdentifier");
4886 let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
4887 .into_iter()
4888 .collect::<Vec<_>>();
4889 let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
4890 if !product_codes.iter().any(|code| code.eq_ignore_ascii_case(&key_name)) {
4891 product_codes.push(key_name.to_ascii_lowercase());
4892 }
4893 let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
4894 if upgrade_codes.is_empty() {
4895 for code in &product_codes {
4899 if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
4900 upgrade_codes.push(uc.clone());
4901 break;
4902 }
4903 }
4904 }
4905 let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
4906 .as_deref()
4907 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
4908 {
4909 Some("portable".to_owned())
4915 } else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
4916 Some("msi".to_owned())
4917 } else if key_name.starts_with("MSIX\\") {
4918 Some("msix".to_owned())
4919 } else {
4920 Some("exe".to_owned())
4921 };
4922
4923 let dedupe_key = format!(
4924 "{}|{}|{}|{}",
4925 local_id,
4926 name.to_ascii_lowercase(),
4927 installed_version.to_ascii_lowercase(),
4928 publisher.clone().unwrap_or_default().to_ascii_lowercase()
4929 );
4930 if !seen.insert(dedupe_key) {
4931 continue;
4932 }
4933
4934 packages.push(InstalledPackage {
4935 name,
4936 local_id,
4937 installed_version,
4938 publisher,
4939 scope: Some(scope.to_owned()),
4940 installer_category,
4941 install_location,
4942 winget_package_identifier,
4943 winget_source_identifier,
4944 package_family_names,
4945 product_codes,
4946 upgrade_codes,
4947 correlated: None,
4948 installed_version_canonical: false,
4949 correlated_requires_explicit_upgrade: false,
4950 correlated_lacks_compatible_installer: false,
4951 });
4952 }
4953
4954 Ok(())
4955}
4956
4957#[cfg(windows)]
4958fn collect_appmodel_packages(
4959 packages: &mut Vec<InstalledPackage>,
4960 seen: &mut BTreeSet<String>,
4961 root: RegKey,
4962 scope: &str,
4963 flags: u32,
4964) -> Result<()> {
4965 let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
4966 Ok(key) => key,
4967 Err(_) => return Ok(()),
4968 };
4969
4970 for key_name in appmodel.enum_keys().flatten() {
4971 let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
4972 Ok(key) => key,
4973 Err(_) => continue,
4974 };
4975
4976 let install_location = read_reg_string(&subkey, "PackageRootFolder");
4977 if install_location.as_deref().is_some_and(is_windows_system_path) {
4978 continue;
4979 }
4980
4981 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
4982 continue;
4983 };
4984 if is_msix_split_resource_package(&metadata.resource_id) {
4985 continue;
4986 }
4987
4988 let Some(name) = read_reg_string(&subkey, "DisplayName")
4989 .or_else(|| read_appmodel_display_name(&subkey))
4990 .filter(|value| !value.is_empty())
4991 else {
4992 continue;
4993 };
4994
4995 let local_id = format!(r"MSIX\{key_name}");
4996 let dedupe_key = format!(
4997 "{}|{}|{}",
4998 local_id,
4999 name.to_ascii_lowercase(),
5000 metadata.version.to_ascii_lowercase()
5001 );
5002 if !seen.insert(dedupe_key) {
5003 continue;
5004 }
5005
5006 packages.push(InstalledPackage {
5007 name,
5008 local_id,
5009 installed_version: metadata.version,
5010 publisher: None,
5011 scope: Some(scope.to_owned()),
5012 installer_category: Some("msix".to_owned()),
5013 install_location,
5014 winget_package_identifier: None,
5015 winget_source_identifier: None,
5016 package_family_names: vec![metadata.family_name],
5017 product_codes: Vec::new(),
5018 upgrade_codes: Vec::new(),
5019 correlated: None,
5020 installed_version_canonical: false,
5021 correlated_requires_explicit_upgrade: false,
5022 correlated_lacks_compatible_installer: false,
5023 });
5024 }
5025
5026 Ok(())
5027}
5028
5029#[cfg(windows)]
5030struct ParsedMsixPackageFullName {
5031 version: String,
5032 resource_id: String,
5033 family_name: String,
5034}
5035
5036#[cfg(windows)]
5037fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
5038 let segments = value.split('_').collect::<Vec<_>>();
5039 if segments.len() < 5 {
5040 return None;
5041 }
5042
5043 let name = segments[..segments.len() - 4].join("_");
5044 if name.is_empty() {
5045 return None;
5046 }
5047
5048 let version = segments[segments.len() - 4].trim();
5049 let resource_id = segments[segments.len() - 2].trim();
5050 let publisher_id = segments[segments.len() - 1].trim();
5051 if version.is_empty() || publisher_id.is_empty() {
5052 return None;
5053 }
5054
5055 let family_name = if resource_id.is_empty() {
5056 format!("{name}_{publisher_id}")
5057 } else {
5058 format!("{name}_{resource_id}_{publisher_id}")
5059 };
5060
5061 Some(ParsedMsixPackageFullName {
5062 version: version.to_owned(),
5063 resource_id: resource_id.to_owned(),
5064 family_name,
5065 })
5066}
5067
5068#[cfg(windows)]
5069fn read_appmodel_display_name(package_key: &RegKey) -> Option<String> {
5070 let app_key = package_key.open_subkey(r"App\Capabilities").ok()?;
5071 read_reg_string(&app_key, "ApplicationDescription").or_else(|| read_reg_string(&app_key, "ApplicationName"))
5072}
5073
5074#[cfg(windows)]
5075fn is_msix_split_resource_package(resource_id: &str) -> bool {
5076 resource_id.trim().to_ascii_lowercase().starts_with("split.")
5077}
5078
5079#[cfg(windows)]
5080fn is_windows_system_path(path: &str) -> bool {
5081 path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
5082}
5083
5084#[cfg(windows)]
5085fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
5086 key.get_value::<String, _>(value_name)
5087 .ok()
5088 .map(|value| value.trim().to_owned())
5089 .filter(|value| !value.is_empty())
5090}
5091
5092#[cfg(windows)]
5093fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
5094 key.get_value::<u32, _>(value_name).ok()
5095}
5096
5097fn ensure_app_dirs(app_root: &Path) -> Result<()> {
5098 fs::create_dir_all(app_root).context("failed to create app root directory")?;
5099 if uses_packaged_layout(app_root) {
5100 fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
5101 let user_sources_path = packaged_user_sources_path(app_root)?;
5102 if let Some(parent) = user_sources_path.parent() {
5103 fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
5104 }
5105 } else {
5106 fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
5107 }
5108 Ok(())
5109}
5110
5111fn default_app_root() -> Result<PathBuf> {
5112 if let Some(app_root) = std::env::var_os(APP_ROOT_ENV_VAR) {
5113 return Ok(PathBuf::from(app_root));
5114 }
5115
5116 let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
5117
5118 #[cfg(windows)]
5119 {
5120 if has_current_package_identity() {
5121 return Ok(local_app_data
5122 .join("Packages")
5123 .join(PACKAGED_FAMILY_NAME)
5124 .join("LocalState"));
5125 }
5126 Ok(local_app_data.join("Devolutions").join("Pinget"))
5127 }
5128
5129 #[cfg(not(windows))]
5130 {
5131 Ok(local_app_data.join("pinget"))
5132 }
5133}
5134
5135#[cfg(windows)]
5136fn has_current_package_identity() -> bool {
5137 const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
5140
5141 let mut length: u32 = 0;
5142 let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
5145 rc != APPMODEL_ERROR_NO_PACKAGE
5146}
5147
5148fn store_path(app_root: &Path) -> PathBuf {
5149 app_root.join("sources.json")
5150}
5151
5152fn user_settings_path(app_root: &Path) -> PathBuf {
5153 if uses_packaged_layout(app_root) {
5154 app_root.join("settings.json")
5155 } else {
5156 app_root.join("user-settings.json")
5157 }
5158}
5159
5160fn admin_settings_path(app_root: &Path) -> PathBuf {
5161 app_root.join("admin-settings.json")
5162}
5163
5164#[cfg(test)]
5165fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
5166 source_state_dir_with_keying(app_root, source, false)
5167}
5168
5169fn source_state_dir_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5170 if uses_packaged_layout(app_root) {
5171 return app_root
5172 .join(packaged_source_type(source.kind))
5173 .join(sanitize_path_segment(&source.identifier));
5174 }
5175
5176 if identifier_keyed {
5177 return app_root.join("sources").join(sanitize_path_segment(&source.identifier));
5178 }
5179
5180 app_root.join("sources").join(sanitize_path_segment(&source.name))
5181}
5182
5183fn source_installed_db_path_with_keying(app_root: &Path, source: &SourceRecord, identifier_keyed: bool) -> PathBuf {
5184 if uses_packaged_layout(app_root) {
5185 return app_root
5186 .join(sanitize_path_segment(&source.identifier))
5187 .join("installed.db");
5188 }
5189
5190 source_state_dir_with_keying(app_root, source, identifier_keyed).join("installed.db")
5191}
5192
5193#[cfg(test)]
5194fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
5195 source_state_dir(app_root, source).join("index.db")
5196}
5197
5198fn update_preindexed_http_validators(source: &mut SourceRecord, headers: &HeaderMap) {
5199 if let Some(value) = headers.get(ETAG).and_then(|value| value.to_str().ok()) {
5200 source.etag = Some(value.to_owned());
5201 }
5202
5203 if let Some(value) = headers.get(LAST_MODIFIED).and_then(|value| value.to_str().ok()) {
5204 source.last_modified = Some(value.to_owned());
5205 }
5206}
5207
5208fn remove_sqlite_sidecars(db_path: &Path) {
5212 for suffix in ["-wal", "-shm"] {
5213 let mut sidecar = db_path.as_os_str().to_owned();
5214 sidecar.push(suffix);
5215 let _ = fs::remove_file(PathBuf::from(sidecar));
5216 }
5217}
5218
5219#[cfg(windows)]
5220const REPLACE_FILE_RETRY_ATTEMPTS: u32 = 5;
5221
5222#[cfg(windows)]
5223fn move_replace_existing(source: &Path, target: &Path) -> std::io::Result<()> {
5224 let source_wide = source
5225 .as_os_str()
5226 .encode_wide()
5227 .chain(std::iter::once(0))
5228 .collect::<Vec<_>>();
5229 let target_wide = target
5230 .as_os_str()
5231 .encode_wide()
5232 .chain(std::iter::once(0))
5233 .collect::<Vec<_>>();
5234 let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
5235 let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
5238 if ok == 0 {
5239 return Err(std::io::Error::last_os_error());
5240 }
5241 Ok(())
5242}
5243
5244#[cfg(windows)]
5245fn replace_file(source: &Path, target: &Path) -> Result<()> {
5246 let to_err = |error: std::io::Error| -> anyhow::Error {
5247 anyhow::Error::new(error).context(format!(
5248 "failed to replace {} with {}",
5249 target.display(),
5250 source.display()
5251 ))
5252 };
5253
5254 let first = match move_replace_existing(source, target) {
5256 Ok(()) => return Ok(()),
5257 Err(error) => error,
5258 };
5259
5260 if !source.exists() || !target.exists() {
5263 return Err(to_err(first));
5264 }
5265
5266 let mut last = first;
5271 for attempt in 1..=REPLACE_FILE_RETRY_ATTEMPTS {
5272 std::thread::sleep(std::time::Duration::from_millis(50 * u64::from(attempt)));
5273 match move_replace_existing(source, target) {
5274 Ok(()) => return Ok(()),
5275 Err(error) => last = error,
5276 }
5277 }
5278
5279 let staged = target.with_extension("old.tmp");
5285 if move_replace_existing(target, &staged).is_err() {
5286 return Err(to_err(last));
5287 }
5288 match move_replace_existing(source, target) {
5289 Ok(()) => {
5290 let _ = fs::remove_file(&staged);
5291 Ok(())
5292 }
5293 Err(error) => {
5294 let _ = move_replace_existing(&staged, target);
5295 Err(to_err(error))
5296 }
5297 }
5298}
5299
5300#[cfg(not(windows))]
5301fn replace_file(source: &Path, target: &Path) -> Result<()> {
5302 fs::rename(source, target)
5303 .with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
5304}
5305
5306fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
5307 cache_root(app_root)
5308 .join(bucket)
5309 .join(sanitize_path_segment(identifier))
5310}
5311
5312fn load_store(app_root: &Path) -> Result<SourceStore> {
5313 if uses_packaged_layout(app_root)
5314 && let Some(store) = load_packaged_store(app_root)?
5315 {
5316 return Ok(store);
5317 }
5318
5319 let path = store_path(app_root);
5320 if !path.exists() {
5321 let store = SourceStore::default();
5322 save_store(app_root, &store)?;
5323 return Ok(store);
5324 }
5325
5326 let bytes = fs::read(path).context("failed to read source store")?;
5327 serde_json::from_slice(&bytes).context("failed to parse source store")
5328}
5329
5330fn load_effective_source_store(app_root: &Path, source_mode: SourceMode) -> Result<(EffectiveSourceMode, SourceStore)> {
5331 if uses_system_winget_source_commands(app_root) {
5332 return Ok((
5333 EffectiveSourceMode::SystemWingetDirect,
5334 load_system_winget_source_store()?,
5335 ));
5336 }
5337
5338 match source_mode {
5339 SourceMode::Private => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5340 SourceMode::SystemWingetMirror => {
5341 let store = load_or_refresh_system_winget_mirror_store(app_root, false)?;
5342 Ok((EffectiveSourceMode::SystemWingetMirror, store))
5343 }
5344 SourceMode::Auto => match load_or_refresh_system_winget_mirror_store(app_root, false) {
5345 Ok(store) => Ok((EffectiveSourceMode::SystemWingetMirror, store)),
5346 Err(_) => Ok((EffectiveSourceMode::Private, load_store(app_root)?)),
5347 },
5348 }
5349}
5350
5351fn system_winget_mirror_store_path(app_root: &Path) -> PathBuf {
5352 app_root.join(SYSTEM_WINGET_MIRROR_STORE_FILE_NAME)
5353}
5354
5355fn load_system_winget_mirror_store(app_root: &Path) -> Result<Option<SourceStore>> {
5356 let path = system_winget_mirror_store_path(app_root);
5357 if !path.exists() {
5358 return Ok(None);
5359 }
5360
5361 let bytes = fs::read(path).context("failed to read system WinGet mirror source store")?;
5362 serde_json::from_slice(&bytes)
5363 .context("failed to parse system WinGet mirror source store")
5364 .map(Some)
5365}
5366
5367fn save_system_winget_mirror_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5368 write_json(system_winget_mirror_store_path(app_root), store)
5369}
5370
5371fn system_winget_mirror_is_fresh(app_root: &Path, max_age: StdDuration) -> bool {
5375 if !system_winget_mirror_store_path(app_root).exists() {
5376 return false;
5377 }
5378
5379 fs::metadata(system_winget_mirror_export_stamp_path(app_root))
5380 .and_then(|metadata| metadata.modified())
5381 .ok()
5382 .and_then(|modified| modified.elapsed().ok())
5383 .is_some_and(|age| age < max_age)
5384}
5385
5386fn system_winget_mirror_export_stamp_path(app_root: &Path) -> PathBuf {
5387 app_root.join(SYSTEM_WINGET_MIRROR_EXPORT_STAMP_FILE_NAME)
5388}
5389
5390fn stamp_system_winget_mirror_export(app_root: &Path) {
5393 let _ = fs::write(
5394 system_winget_mirror_export_stamp_path(app_root),
5395 Utc::now().to_rfc3339(),
5396 );
5397}
5398
5399fn system_winget_source_refresh_warning(error: &anyhow::Error) -> String {
5400 format!("Could not refresh the system WinGet sources; using the sources Pinget cached earlier. {error:#}")
5401}
5402
5403fn load_or_refresh_system_winget_mirror_store(app_root: &Path, force_refresh: bool) -> Result<SourceStore> {
5404 if !force_refresh && let Some(store) = load_system_winget_mirror_store(app_root)? {
5405 return Ok(store);
5406 }
5407
5408 refresh_system_winget_mirror_store(app_root)
5409}
5410
5411fn refresh_system_winget_mirror_store(app_root: &Path) -> Result<SourceStore> {
5412 let prior = load_system_winget_mirror_store(app_root)?.unwrap_or_default();
5413 let mut exported = load_system_winget_source_store()?;
5414 merge_source_cache_metadata(&mut exported, &prior);
5415 save_system_winget_mirror_store(app_root, &exported)?;
5416 stamp_system_winget_mirror_export(app_root);
5417 Ok(exported)
5418}
5419
5420fn merge_source_cache_metadata(target: &mut SourceStore, prior: &SourceStore) {
5421 let prior_by_identifier: HashMap<_, _> = prior
5422 .sources
5423 .iter()
5424 .map(|source| (source.identifier.to_ascii_lowercase(), source))
5425 .collect();
5426
5427 for source in &mut target.sources {
5428 let Some(previous) = prior_by_identifier.get(&source.identifier.to_ascii_lowercase()) else {
5429 continue;
5430 };
5431 source.last_update = previous.last_update;
5432 source.source_version = previous.source_version.clone();
5433 source.etag = previous.etag.clone();
5434 source.last_modified = previous.last_modified.clone();
5435 }
5436}
5437
5438fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5439 if uses_system_winget_source_commands(app_root) {
5440 bail!("System WinGet source settings must be modified through winget.");
5441 }
5442
5443 if uses_packaged_layout(app_root) {
5444 return save_packaged_store(app_root, store);
5445 }
5446
5447 write_json(store_path(app_root), store)
5448}
5449
5450fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
5451 let user_sources_path = packaged_user_sources_path(app_root)?;
5452 let metadata_path = app_root.join("sources_metadata");
5453 let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
5454 let metadata_yaml = fs::read_to_string(&metadata_path).ok();
5455 load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
5456}
5457
5458fn load_packaged_store_from_streams(
5459 user_sources_yaml: Option<&str>,
5460 metadata_yaml: Option<&str>,
5461) -> Result<Option<SourceStore>> {
5462 if is_secure_settings_stub(user_sources_yaml) {
5463 return load_system_winget_source_store().map(Some);
5464 }
5465
5466 Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
5467}
5468
5469fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
5470 let user_sources_path = packaged_user_sources_path(app_root)?;
5471 if let Some(parent) = user_sources_path.parent() {
5472 fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
5473 }
5474
5475 fs::write(&user_sources_path, render_packaged_sources_yaml(store))
5476 .context("failed to write packaged user_sources stream")?;
5477 fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
5478 .context("failed to write packaged source metadata")?;
5479 Ok(())
5480}
5481
5482fn uses_system_winget_source_commands(app_root: &Path) -> bool {
5483 if !uses_packaged_layout(app_root) {
5484 return false;
5485 }
5486
5487 #[cfg(windows)]
5488 {
5489 let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
5490 return false;
5491 };
5492
5493 let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
5494 is_secure_settings_stub(user_sources_yaml.as_deref())
5495 }
5496
5497 #[cfg(not(windows))]
5498 {
5499 false
5500 }
5501}
5502
5503fn is_secure_settings_stub(value: Option<&str>) -> bool {
5504 value
5505 .map(str::trim_start)
5506 .is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
5507}
5508
5509fn load_system_winget_source_store() -> Result<SourceStore> {
5510 let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
5511 Ok(SourceStore {
5512 sources: parse_system_winget_source_export(&result.stdout)?,
5513 })
5514}
5515
5516fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
5517 run_checked_winget_source_command(
5518 build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
5519 &format!("add WinGet source '{name}'"),
5520 )?;
5521 Ok(())
5522}
5523
5524fn system_winget_remove_source(name: &str) -> Result<()> {
5525 run_checked_winget_source_command(
5526 vec![
5527 "source".to_owned(),
5528 "remove".to_owned(),
5529 "--name".to_owned(),
5530 name.to_owned(),
5531 "--disable-interactivity".to_owned(),
5532 ],
5533 &format!("remove WinGet source '{name}'"),
5534 )?;
5535 Ok(())
5536}
5537
5538fn system_winget_reset_source(name: &str) -> Result<()> {
5539 run_checked_winget_source_command(
5540 vec![
5541 "source".to_owned(),
5542 "reset".to_owned(),
5543 "--name".to_owned(),
5544 name.to_owned(),
5545 "--force".to_owned(),
5546 "--disable-interactivity".to_owned(),
5547 ],
5548 &format!("reset WinGet source '{name}'"),
5549 )?;
5550 Ok(())
5551}
5552
5553fn system_winget_reset_sources() -> Result<()> {
5554 run_checked_winget_source_command(
5555 vec![
5556 "source".to_owned(),
5557 "reset".to_owned(),
5558 "--force".to_owned(),
5559 "--disable-interactivity".to_owned(),
5560 ],
5561 "reset WinGet sources",
5562 )?;
5563 Ok(())
5564}
5565
5566fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
5567 let mut args = vec!["source".to_owned(), "update".to_owned()];
5568 if let Some(name) = name {
5569 args.push("--name".to_owned());
5570 args.push(name.to_owned());
5571 }
5572
5573 args.push("--disable-interactivity".to_owned());
5574 let result = run_checked_winget_source_command(
5575 args,
5576 if name.is_some() {
5577 "update WinGet source"
5578 } else {
5579 "update WinGet sources"
5580 },
5581 )?;
5582 let detail = result.stdout.trim();
5583 Ok(if detail.is_empty() {
5584 "Done".to_owned()
5585 } else {
5586 detail.to_owned()
5587 })
5588}
5589
5590fn build_winget_source_export_arguments() -> Vec<String> {
5591 vec![
5592 "source".to_owned(),
5593 "export".to_owned(),
5594 "--disable-interactivity".to_owned(),
5595 ]
5596}
5597
5598fn build_winget_source_add_arguments(
5599 name: &str,
5600 arg: &str,
5601 kind: SourceKind,
5602 trust_level: &str,
5603 explicit: bool,
5604) -> Vec<String> {
5605 let mut args = vec![
5606 "source".to_owned(),
5607 "add".to_owned(),
5608 "--name".to_owned(),
5609 name.to_owned(),
5610 "--arg".to_owned(),
5611 arg.to_owned(),
5612 "--type".to_owned(),
5613 packaged_source_type(kind).to_owned(),
5614 "--disable-interactivity".to_owned(),
5615 ];
5616
5617 if !trust_level.trim().is_empty() {
5618 args.push("--trust-level".to_owned());
5619 args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
5620 "trusted".to_owned()
5621 } else {
5622 "none".to_owned()
5623 });
5624 }
5625
5626 if explicit {
5627 args.push("--explicit".to_owned());
5628 }
5629
5630 args
5631}
5632
5633#[derive(Debug)]
5634struct WingetSourceCommandResult {
5635 exit_code: i32,
5636 stdout: String,
5637 stderr: String,
5638}
5639
5640type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
5641
5642static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
5643 RwLock::new(run_winget_source_command_process);
5644
5645fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
5646 let result = run_winget_source_command(&args)?;
5647 if result.exit_code == 0 {
5648 return Ok(result);
5649 }
5650
5651 let detail = [result.stderr.trim(), result.stdout.trim()]
5652 .into_iter()
5653 .filter(|value| !value.is_empty())
5654 .collect::<Vec<_>>()
5655 .join("\n");
5656 if detail.is_empty() {
5657 bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
5658 }
5659
5660 bail!(
5661 "Failed to {action}; winget exited with code {}:\n{}",
5662 result.exit_code,
5663 detail
5664 );
5665}
5666
5667fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
5668 let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
5669 .read()
5670 .map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
5671 runner(args)
5672}
5673
5674fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
5675 use std::process::Command;
5676
5677 let program = resolve_system_winget_program()?;
5678 let output = Command::new(&program)
5679 .args(args)
5680 .output()
5681 .with_context(|| format!("failed to run the WinGet source command with {}", program.display()))?;
5682
5683 Ok(WingetSourceCommandResult {
5684 exit_code: output.status.code().unwrap_or(-1),
5685 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
5686 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
5687 })
5688}
5689
5690fn resolve_system_winget_program() -> Result<PathBuf> {
5691 resolve_system_winget_program_from(
5692 std::env::var_os(SYSTEM_WINGET_PROGRAM_ENV_VAR),
5693 current_executable_directory(),
5694 std::env::var_os("PATH"),
5695 &system_winget_fallback_directories,
5696 &|candidate| candidate.is_file(),
5697 )
5698}
5699
5700fn current_executable_directory() -> Option<PathBuf> {
5701 std::env::current_exe()
5702 .ok()
5703 .and_then(|executable| executable.parent().map(Path::to_path_buf))
5704}
5705
5706fn resolve_system_winget_program_from(
5714 configured: Option<OsString>,
5715 application_directory: Option<PathBuf>,
5716 search_path: Option<OsString>,
5717 fallback_directories: &dyn Fn() -> Vec<PathBuf>,
5718 is_file: &dyn Fn(&Path) -> bool,
5719) -> Result<PathBuf> {
5720 if let Some(configured) = configured.filter(|value| !value.to_string_lossy().trim().is_empty()) {
5721 let configured = PathBuf::from(configured);
5722 if is_file(&configured) {
5723 return Ok(configured);
5724 }
5725
5726 let nested = configured.join(SYSTEM_WINGET_PROGRAM_NAME);
5727 if is_file(&nested) {
5728 return Ok(nested);
5729 }
5730
5731 bail!(
5732 "{SYSTEM_WINGET_PROGRAM_ENV_VAR} is set to {}, where no {SYSTEM_WINGET_PROGRAM_NAME} was found.",
5733 configured.display()
5734 );
5735 }
5736
5737 if let Some(program) = first_system_winget_program(application_directory, is_file) {
5738 return Ok(program);
5739 }
5740
5741 let search_directories = search_path
5742 .as_deref()
5743 .map(|value| std::env::split_paths(value).collect::<Vec<_>>())
5744 .unwrap_or_default();
5745
5746 if let Some(program) = first_system_winget_program(search_directories, is_file) {
5747 return Ok(program);
5748 }
5749
5750 if let Some(program) = first_system_winget_program(fallback_directories(), is_file) {
5753 return Ok(program);
5754 }
5755
5756 bail!(
5757 "{SYSTEM_WINGET_PROGRAM_NAME} was not found on the PATH or in the App Installer install locations. Set {SYSTEM_WINGET_PROGRAM_ENV_VAR} to its full path, or install the App Installer."
5758 )
5759}
5760
5761fn first_system_winget_program(
5762 directories: impl IntoIterator<Item = PathBuf>,
5763 is_file: &dyn Fn(&Path) -> bool,
5764) -> Option<PathBuf> {
5765 directories.into_iter().find_map(|directory| {
5766 let candidate = directory.join(SYSTEM_WINGET_PROGRAM_NAME);
5767 is_file(&candidate).then_some(candidate)
5768 })
5769}
5770
5771#[cfg(windows)]
5772fn system_winget_fallback_directories() -> Vec<PathBuf> {
5773 let mut directories = Vec::new();
5774 if let Some(local_app_data) = dirs::data_local_dir() {
5775 directories.push(local_app_data.join("Microsoft").join("WindowsApps"));
5776 }
5777
5778 directories.extend(app_installer_package_directories());
5779 directories
5780}
5781
5782#[cfg(not(windows))]
5783fn system_winget_fallback_directories() -> Vec<PathBuf> {
5784 Vec::new()
5785}
5786
5787#[cfg(windows)]
5788fn app_installer_package_directories() -> Vec<PathBuf> {
5789 let Ok(packages) = RegKey::predef(HKEY_CURRENT_USER).open_subkey(APPMODEL_PACKAGES_PATH) else {
5790 return Vec::new();
5791 };
5792
5793 let mut found: Vec<(String, PathBuf)> = Vec::new();
5794 for key_name in packages.enum_keys().flatten() {
5795 let Some(metadata) = parse_msix_package_full_name(&key_name) else {
5796 continue;
5797 };
5798 if metadata.family_name != PACKAGED_FAMILY_NAME || is_msix_split_resource_package(&metadata.resource_id) {
5799 continue;
5800 }
5801
5802 let Ok(entry) = packages.open_subkey(&key_name) else {
5803 continue;
5804 };
5805 let Some(package_root) = read_reg_string(&entry, "PackageRootFolder") else {
5806 continue;
5807 };
5808
5809 found.push((metadata.version, PathBuf::from(package_root)));
5810 }
5811
5812 found.sort_by(|left, right| compare_version(&right.0, &left.0));
5813 found.into_iter().map(|(_, directory)| directory).collect()
5814}
5815
5816fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
5817 let trimmed = output.trim();
5818 if trimmed.is_empty() {
5819 return Ok(Vec::new());
5820 }
5821
5822 if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
5823 && let Some(sources) = parse_system_winget_source_export_value(&value)?
5824 {
5825 return Ok(sources);
5826 }
5827
5828 let mut sources = Vec::new();
5829 for line in output.replace("\r\n", "\n").lines().map(str::trim) {
5830 if line.is_empty() {
5831 continue;
5832 }
5833
5834 let value: JsonValue = serde_json::from_str(line)
5835 .with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
5836 let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
5837 bail!("WinGet source export returned a non-source line: {line}");
5838 };
5839 sources.append(&mut parsed);
5840 }
5841
5842 Ok(sources)
5843}
5844
5845fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
5846 match value {
5847 JsonValue::Array(items) => items
5848 .iter()
5849 .map(parse_system_winget_source_record)
5850 .collect::<Result<Vec<_>>>()
5851 .map(Some),
5852 JsonValue::Object(object) => {
5853 if let Some(JsonValue::Array(items)) = object.get("Sources") {
5854 items
5855 .iter()
5856 .map(parse_system_winget_source_record)
5857 .collect::<Result<Vec<_>>>()
5858 .map(Some)
5859 } else if object.contains_key("Name") {
5860 Ok(Some(vec![parse_system_winget_source_record(value)?]))
5861 } else {
5862 Ok(None)
5863 }
5864 }
5865 _ => Ok(None),
5866 }
5867}
5868
5869fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
5870 let object = value
5871 .as_object()
5872 .ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
5873 let name = required_json_string(object, "Name")?;
5874 let source_type = required_json_string(object, "Type")?;
5875 let arg = required_json_string(object, "Arg")?;
5876 let kind = parse_system_winget_source_kind(&source_type)?;
5877 let identifier = optional_json_string(object, "Identifier")
5878 .or_else(|| optional_json_string(object, "Data"))
5879 .unwrap_or_else(|| name.clone());
5880
5881 Ok(SourceRecord {
5882 name,
5883 kind,
5884 arg,
5885 identifier,
5886 trust_level: parse_system_winget_trust_level(object),
5887 explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
5888 priority: optional_json_i32(object, "Priority").unwrap_or_default(),
5889 last_update: None,
5890 source_version: None,
5891 etag: None,
5892 last_modified: None,
5893 })
5894}
5895
5896fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
5897 optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
5898}
5899
5900fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
5901 match object.get(key)? {
5902 JsonValue::String(value) => Some(value.clone()),
5903 JsonValue::Number(value) => Some(value.to_string()),
5904 _ => None,
5905 }
5906}
5907
5908fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
5909 match object.get(key)? {
5910 JsonValue::Bool(value) => Some(*value),
5911 JsonValue::String(value) => value.parse().ok(),
5912 _ => None,
5913 }
5914}
5915
5916fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
5917 match object.get(key)? {
5918 JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
5919 JsonValue::String(value) => value.parse().ok(),
5920 _ => None,
5921 }
5922}
5923
5924fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
5925 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
5926 Ok(SourceKind::PreIndexed)
5927 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
5928 Ok(SourceKind::Rest)
5929 } else {
5930 bail!("Unsupported WinGet source type '{value}'.")
5931 }
5932}
5933
5934fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
5935 let Some(value) = object.get("TrustLevel") else {
5936 return "None".to_owned();
5937 };
5938
5939 match value {
5940 JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
5941 JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
5942 JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
5943 _ => "None".to_owned(),
5944 }
5945}
5946
5947fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
5948 value
5949 .as_str()
5950 .is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
5951}
5952
5953fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
5954 if user_sources_yaml.is_none() && metadata_yaml.is_none() {
5955 return None;
5956 }
5957
5958 let mut sources = SourceStore::default().sources;
5959 for entry in parse_packaged_yaml_entries(user_sources_yaml) {
5960 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5961 continue;
5962 };
5963
5964 if yaml_bool(entry.get("IsTombstone")) {
5965 sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
5966 continue;
5967 }
5968
5969 let Some(mapped) = map_packaged_source_entry(&entry) else {
5970 continue;
5971 };
5972
5973 if let Some(existing) = sources
5974 .iter_mut()
5975 .find(|source| source.name.eq_ignore_ascii_case(&name))
5976 {
5977 *existing = mapped;
5978 } else {
5979 sources.push(mapped);
5980 }
5981 }
5982
5983 for entry in parse_packaged_yaml_entries(metadata_yaml) {
5984 let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
5985 continue;
5986 };
5987
5988 let Some(source) = sources
5989 .iter_mut()
5990 .find(|source| source.name.eq_ignore_ascii_case(&name))
5991 else {
5992 continue;
5993 };
5994
5995 if let Some(last_update) = entry
5996 .get("LastUpdate")
5997 .and_then(|value| value.as_deref())
5998 .and_then(parse_packaged_datetime)
5999 {
6000 source.last_update = Some(last_update);
6001 }
6002
6003 if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
6004 && !source_version.is_empty()
6005 {
6006 source.source_version = Some(source_version);
6007 }
6008
6009 if let Some(etag) = entry.get("ETag").and_then(|value| value.clone())
6010 && !etag.is_empty()
6011 {
6012 source.etag = Some(etag);
6013 }
6014
6015 if let Some(last_modified) = entry.get("LastModified").and_then(|value| value.clone())
6016 && !last_modified.is_empty()
6017 {
6018 source.last_modified = Some(last_modified);
6019 }
6020 }
6021
6022 Some(SourceStore { sources })
6023}
6024
6025fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
6026 let Some(yaml) = yaml else {
6027 return Vec::new();
6028 };
6029
6030 let mut entries = Vec::new();
6031 let mut current: Option<BTreeMap<String, Option<String>>> = None;
6032 for raw_line in yaml.replace("\r\n", "\n").split('\n') {
6033 let line = raw_line.trim_end();
6034 let trimmed = line.trim();
6035 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
6036 continue;
6037 }
6038
6039 if let Some(remainder) = trimmed.strip_prefix("- ") {
6040 if let Some(entry) = current.take() {
6041 entries.push(entry);
6042 }
6043
6044 let mut entry = BTreeMap::new();
6045 add_packaged_yaml_key_value(&mut entry, remainder);
6046 current = Some(entry);
6047 continue;
6048 }
6049
6050 if let Some(entry) = current.as_mut() {
6051 add_packaged_yaml_key_value(entry, trimmed);
6052 }
6053 }
6054
6055 if let Some(entry) = current {
6056 entries.push(entry);
6057 }
6058
6059 entries
6060}
6061
6062fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
6063 let Some((key, value)) = line.split_once(':') else {
6064 return;
6065 };
6066
6067 entry.insert(
6068 key.trim().to_owned(),
6069 Some(unquote_yaml_scalar(value.trim()).to_owned()),
6070 );
6071}
6072
6073fn unquote_yaml_scalar(value: &str) -> &str {
6074 if value.len() >= 2 {
6075 let bytes = value.as_bytes();
6076 let first = bytes[0] as char;
6077 let last = bytes[value.len() - 1] as char;
6078 if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
6079 return &value[1..value.len() - 1];
6080 }
6081 }
6082
6083 value
6084}
6085
6086fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
6087 let name = entry.get("Name").and_then(|value| value.clone())?;
6088 let source_type = entry.get("Type").and_then(|value| value.clone())?;
6089 let arg = entry.get("Arg").and_then(|value| value.clone())?;
6090 let kind = parse_packaged_source_kind(&source_type)?;
6091
6092 Some(SourceRecord {
6093 name: name.clone(),
6094 kind,
6095 arg,
6096 identifier: entry
6097 .get("Identifier")
6098 .and_then(|value| value.clone())
6099 .or_else(|| entry.get("Data").and_then(|value| value.clone()))
6100 .unwrap_or(name),
6101 trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
6102 explicit: yaml_bool(entry.get("Explicit")),
6103 priority: entry
6104 .get("Priority")
6105 .and_then(|value| value.as_deref())
6106 .and_then(|value| value.parse::<i32>().ok())
6107 .unwrap_or_default(),
6108 last_update: None,
6109 source_version: None,
6110 etag: None,
6111 last_modified: None,
6112 })
6113}
6114
6115fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
6116 if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
6117 Some(SourceKind::PreIndexed)
6118 } else if value.eq_ignore_ascii_case("Microsoft.Rest") {
6119 Some(SourceKind::Rest)
6120 } else {
6121 None
6122 }
6123}
6124
6125fn parse_packaged_trust_level(value: Option<&str>) -> String {
6126 match value {
6127 Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
6128 Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
6129 _ => "None".to_owned(),
6130 }
6131}
6132
6133fn yaml_bool(value: Option<&Option<String>>) -> bool {
6134 value
6135 .and_then(|raw| raw.as_deref())
6136 .and_then(|raw| raw.parse::<bool>().ok())
6137 .unwrap_or(false)
6138}
6139
6140fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
6141 if let Ok(unix_seconds) = value.parse::<i64>() {
6142 return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
6143 }
6144
6145 DateTime::parse_from_rfc3339(value)
6146 .ok()
6147 .map(|value| value.with_timezone(&Utc))
6148}
6149
6150fn render_packaged_sources_yaml(store: &SourceStore) -> String {
6151 let mut lines = vec!["Sources:".to_owned()];
6152 for source in &store.sources {
6153 lines.push(format!(" - Name: {}", source.name));
6154 lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
6155 lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
6156 lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
6157 lines.push(format!(
6158 " TrustLevel: {}",
6159 if source.trust_level.eq_ignore_ascii_case("Trusted") {
6160 1
6161 } else {
6162 0
6163 }
6164 ));
6165 lines.push(format!(" Explicit: {}", source.explicit));
6166 lines.push(format!(" Priority: {}", source.priority));
6167 lines.push(" IsTombstone: false".to_owned());
6168 }
6169 lines.push(String::new());
6170 lines.join("\n")
6171}
6172
6173fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
6174 let mut lines = vec!["Sources:".to_owned()];
6175 for source in &store.sources {
6176 if source.last_update.is_none()
6177 && source.source_version.is_none()
6178 && source.etag.is_none()
6179 && source.last_modified.is_none()
6180 {
6181 continue;
6182 }
6183
6184 lines.push(format!(" - Name: {}", source.name));
6185 if let Some(last_update) = source.last_update {
6186 lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
6187 }
6188 if let Some(source_version) = &source.source_version {
6189 lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
6190 }
6191 if let Some(etag) = &source.etag {
6192 lines.push(format!(" ETag: {}", yaml_scalar(etag)));
6193 }
6194 if let Some(last_modified) = &source.last_modified {
6195 lines.push(format!(" LastModified: {}", yaml_scalar(last_modified)));
6196 }
6197 }
6198 lines.push(String::new());
6199 lines.join("\n")
6200}
6201
6202fn yaml_scalar(value: &str) -> String {
6203 if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
6204 format!("\"{}\"", value.replace('"', "\\\""))
6205 } else {
6206 value.to_owned()
6207 }
6208}
6209
6210fn uses_packaged_layout(app_root: &Path) -> bool {
6211 #[cfg(windows)]
6212 {
6213 try_get_packaged_local_state_root(app_root).is_some()
6214 }
6215
6216 #[cfg(not(windows))]
6217 {
6218 let _ = app_root;
6219 false
6220 }
6221}
6222
6223#[cfg(windows)]
6224fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
6225 let local_state = app_root.file_name()?.to_string_lossy();
6226 if !local_state.eq_ignore_ascii_case("LocalState") {
6227 return None;
6228 }
6229
6230 let family = app_root.parent()?.file_name()?.to_string_lossy();
6231 if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
6232 return None;
6233 }
6234
6235 let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
6236 if !packages.eq_ignore_ascii_case("Packages") {
6237 return None;
6238 }
6239
6240 Some(app_root.to_path_buf())
6241}
6242
6243#[cfg(windows)]
6244fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
6245 let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
6246 Ok(packaged_secure_settings_root()?.join("user_sources"))
6247}
6248
6249#[cfg(not(windows))]
6250fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
6251 bail!("packaged WinGet source streams are only available on Windows")
6252}
6253
6254#[cfg(windows)]
6255fn packaged_secure_settings_root() -> Result<PathBuf> {
6256 let program_data = std::env::var_os("PROGRAMDATA")
6257 .map(PathBuf::from)
6258 .ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
6259 Ok(program_data
6260 .join("Microsoft")
6261 .join("WinGet")
6262 .join(current_user_sid()?)
6263 .join("settings")
6264 .join("pkg")
6265 .join(PACKAGED_NAME))
6266}
6267
6268#[cfg(windows)]
6269#[allow(clippy::multiple_unsafe_ops_per_block)]
6270fn current_user_sid() -> Result<String> {
6271 fn close_token(token: HANDLE) {
6272 if !token.is_null() {
6273 unsafe {
6275 CloseHandle(token);
6276 }
6277 }
6278 }
6279
6280 let mut token: HANDLE = std::ptr::null_mut();
6281 let current_process = unsafe { GetCurrentProcess() };
6283 if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
6285 bail!("failed to open current process token")
6286 }
6287
6288 let mut length = 0;
6289 unsafe {
6291 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
6292 }
6293
6294 let element_size = size_of::<TOKEN_USER>();
6295 let element_count = (length as usize).div_ceil(element_size);
6296 let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
6297 if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
6299 close_token(token);
6300 bail!("failed to get current user token information")
6301 }
6302
6303 let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
6305 let mut sid_ptr: *mut u16 = std::ptr::null_mut();
6306 if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
6308 close_token(token);
6309 bail!("failed to convert current user SID")
6310 }
6311
6312 let mut len = 0usize;
6313 while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
6315 len += 1;
6316 }
6317
6318 let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
6320 let sid = OsString::from_wide(sid_slice).to_string_lossy().into_owned();
6321 unsafe {
6323 LocalFree(sid_ptr.cast());
6324 }
6325 close_token(token);
6326 Ok(sid)
6327}
6328
6329fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
6330 app_root.join("Microsoft").join("Windows Package Manager")
6331}
6332
6333fn cache_root(app_root: &Path) -> PathBuf {
6334 if uses_packaged_layout(app_root) {
6335 packaged_file_cache_root(app_root)
6336 } else {
6337 default_cache_root_fallback()
6338 }
6339}
6340
6341fn default_cache_root_fallback() -> PathBuf {
6342 std::env::temp_dir().join("cache")
6343}
6344
6345fn packaged_source_type(kind: SourceKind) -> &'static str {
6346 match kind {
6347 SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
6348 SourceKind::Rest => "Microsoft.Rest",
6349 }
6350}
6351
6352fn sanitize_path_segment(value: &str) -> String {
6353 value
6354 .chars()
6355 .map(|ch| match ch {
6356 '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
6357 _ => ch,
6358 })
6359 .collect()
6360}
6361
6362fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
6363 if !path.exists() {
6364 return Ok(JsonMap::new());
6365 }
6366
6367 let bytes = fs::read(path).context("failed to read JSON file")?;
6368 let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
6369 Ok(match value {
6370 JsonValue::Object(object) => object,
6371 _ => JsonMap::new(),
6372 })
6373}
6374
6375fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
6376 write_json(path, value)
6377}
6378
6379fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
6380 if let Some(parent) = path.parent() {
6381 fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
6382 }
6383
6384 let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
6385 fs::write(path, bytes).context("failed to write JSON file")
6386}
6387
6388fn merge_json_objects(
6389 current: &JsonMap<String, JsonValue>,
6390 update: &JsonMap<String, JsonValue>,
6391) -> JsonMap<String, JsonValue> {
6392 let mut merged = current.clone();
6393 for (key, value) in update {
6394 match (merged.get(key), value) {
6395 (Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
6396 merged.insert(
6397 key.clone(),
6398 JsonValue::Object(merge_json_objects(current_object, update_object)),
6399 );
6400 }
6401 _ => {
6402 merged.insert(key.clone(), value.clone());
6403 }
6404 }
6405 }
6406
6407 merged
6408}
6409
6410fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
6411 match expected {
6412 JsonValue::Object(expected_object) => match current {
6413 JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
6414 current_object
6415 .get(key)
6416 .is_some_and(|current_child| json_contains(current_child, expected_child))
6417 }),
6418 _ => false,
6419 },
6420 JsonValue::Array(expected_array) => match current {
6421 JsonValue::Array(current_array) => {
6422 current_array.len() == expected_array.len()
6423 && current_array
6424 .iter()
6425 .zip(expected_array.iter())
6426 .all(|(current_child, expected_child)| json_contains(current_child, expected_child))
6427 }
6428 _ => false,
6429 },
6430 _ => current == expected,
6431 }
6432}
6433
6434fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
6435 match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
6436 "none" | "default" => Ok("None".to_owned()),
6437 "trusted" => Ok("Trusted".to_owned()),
6438 other => bail!("unsupported source trust level: {other}"),
6439 }
6440}
6441
6442fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
6443 SUPPORTED_ADMIN_SETTINGS
6444 .iter()
6445 .copied()
6446 .find(|setting| setting.eq_ignore_ascii_case(name))
6447 .ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
6448}
6449
6450fn default_source_trust_level() -> String {
6451 "None".to_owned()
6452}
6453
6454fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
6455where
6456 F: FnMut(&SqlRow) -> Result<T>,
6457{
6458 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6459 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6460 let mut result = Vec::new();
6461
6462 while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
6463 result.push(map(&row)?);
6464 }
6465
6466 Ok(result)
6467}
6468
6469fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
6470where
6471 F: FnOnce(&SqlRow) -> Result<T>,
6472{
6473 let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
6474 .with_context(|| format!("failed to execute SQL query: {sql}"))?;
6475
6476 match pollster::block_on(rows.next()).context("failed to read SQL row")? {
6477 Some(row) => Ok(Some(map(&row)?)),
6478 None => Ok(None),
6479 }
6480}
6481
6482fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
6483 pollster::block_on(connection.execute(sql, params_from_iter(params)))
6484 .with_context(|| format!("failed to execute SQL statement: {sql}"))
6485}
6486
6487fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
6488 pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
6489}
6490
6491fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
6492 row.get_value(index)
6493 .with_context(|| format!("failed to read SQL column {index}"))
6494}
6495
6496fn row_string(row: &SqlRow, index: usize) -> Result<String> {
6497 match row_value(row, index)? {
6498 SqlValue::Text(value) => Ok(value),
6499 SqlValue::Integer(value) => Ok(value.to_string()),
6500 SqlValue::Real(value) => Ok(value.to_string()),
6501 SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
6502 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6503 }
6504}
6505
6506fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6507 match row_value(row, index)? {
6508 SqlValue::Null => Ok(None),
6509 SqlValue::Text(value) => Ok(Some(value)),
6510 SqlValue::Integer(value) => Ok(Some(value.to_string())),
6511 SqlValue::Real(value) => Ok(Some(value.to_string())),
6512 SqlValue::Blob(value) => Ok(Some(
6513 String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
6514 )),
6515 }
6516}
6517
6518fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
6519 match row_value(row, index)? {
6520 SqlValue::Integer(value) => Ok(value),
6521 SqlValue::Text(value) => value
6522 .parse::<i64>()
6523 .with_context(|| format!("failed to parse integer from SQL text column {index}")),
6524 SqlValue::Real(value) => f64_to_i64_checked(value, index),
6525 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6526 SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
6527 }
6528}
6529
6530fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
6531 match row_value(row, index)? {
6532 SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
6533 SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
6534 SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
6535 SqlValue::Integer(value) => Ok(format!("{value:x}")),
6536 SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
6537 }
6538}
6539
6540fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
6541 match row_value(row, index)? {
6542 SqlValue::Null => Ok(None),
6543 SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
6544 SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
6545 SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
6546 SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
6547 }
6548}
6549
6550fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
6551 if !value.is_finite() {
6552 bail!("SQL column {index} contained a non-finite floating-point value");
6553 }
6554
6555 if value.fract() != 0.0 {
6556 bail!("SQL column {index} contained a non-integer floating-point value");
6557 }
6558
6559 if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
6560 bail!("SQL column {index} contained a floating-point value outside the i64 range");
6561 }
6562
6563 format!("{value:.0}")
6564 .parse::<i64>()
6565 .with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
6566}
6567
6568fn bytes_to_hex(bytes: &[u8]) -> String {
6569 let mut result = String::with_capacity(bytes.len() * 2);
6570 for byte in bytes {
6571 result.push_str(&format!("{byte:02x}"));
6572 }
6573 result
6574}
6575
6576fn query_v2_matches(
6577 connection: &Connection,
6578 query: &PackageQuery,
6579 semantics: SearchSemantics,
6580) -> Result<(Vec<V2SearchRow>, bool)> {
6581 let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
6582 let limit = source_fetch_results(query, semantics);
6583 let sql = format!(
6584 "SELECT rowid, id, name, moniker, latest_version, hash \
6585 FROM packages WHERE {where_clause} LIMIT {}",
6586 limit + 1
6587 );
6588 let mut rows = query_rows(
6589 connection,
6590 &sql,
6591 params.into_iter().map(SqlValue::Text).collect(),
6592 |row| {
6593 Ok(V2SearchRow {
6594 package_rowid: row_i64(row, 0)?,
6595 id: row_string(row, 1)?,
6596 name: row_string(row, 2)?,
6597 moniker: row_opt_string(row, 3)?,
6598 version: row_string(row, 4)?,
6599 package_hash: row_hex_string(row, 5)?,
6600 match_criteria: None,
6601 })
6602 },
6603 )?;
6604 for row in &mut rows {
6605 row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
6606 }
6607 let truncated = rows.len() > limit;
6608 rows.truncate(limit);
6609 Ok((rows, truncated))
6610}
6611
6612fn query_v1_matches(
6613 connection: &Connection,
6614 query: &PackageQuery,
6615 semantics: SearchSemantics,
6616) -> Result<(Vec<V1SearchRow>, bool)> {
6617 let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
6618 let limit = source_fetch_results(query, semantics);
6619 let sql = format!(
6620 "SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
6621 FROM manifest \
6622 JOIN ids ON manifest.id = ids.rowid \
6623 JOIN names ON manifest.name = names.rowid \
6624 LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
6625 JOIN versions ON manifest.version = versions.rowid \
6626 JOIN channels ON manifest.channel = channels.rowid \
6627 WHERE {where_clause} LIMIT {}",
6628 limit + 1
6629 );
6630 let mut rows = query_rows(
6631 connection,
6632 &sql,
6633 params.into_iter().map(SqlValue::Text).collect(),
6634 |row| {
6635 Ok(V1SearchRow {
6636 manifest_rowid: row_i64(row, 0)?,
6637 package_rowid: row_i64(row, 1)?,
6638 version: row_string(row, 2)?,
6639 channel: row_string(row, 3)?,
6640 id: row_string(row, 4)?,
6641 name: row_string(row, 5)?,
6642 moniker: row_opt_string(row, 6)?,
6643 match_criteria: None,
6644 })
6645 },
6646 )?;
6647 for row in &mut rows {
6648 row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
6649 }
6650 let truncated = rows.len() > limit;
6651 rows.truncate(limit);
6652 Ok((rows, truncated))
6653}
6654
6655fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
6656 let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
6657
6658 for row in rows {
6659 match grouped.get(&row.package_rowid) {
6660 Some(existing)
6661 if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
6662 != Ordering::Less => {}
6663 _ => {
6664 grouped.insert(row.package_rowid, row);
6665 }
6666 }
6667 }
6668
6669 grouped.into_values().collect()
6670}
6671
6672fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
6673 let has_hash = table_has_column(connection, "manifest", "hash")?;
6674 let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
6675 let sql = format!(
6676 "SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
6677 FROM manifest \
6678 JOIN versions ON manifest.version = versions.rowid \
6679 JOIN channels ON manifest.channel = channels.rowid \
6680 WHERE manifest.id = ?1"
6681 );
6682 query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
6683 Ok(V1VersionRow {
6684 version: row_string(row, 0)?,
6685 channel: row_string(row, 1)?,
6686 pathpart_id: row_i64(row, 2)?,
6687 manifest_hash: row_opt_hex_string(row, 3)?,
6688 })
6689 })
6690}
6691
6692fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
6693 let mut parts = Vec::new();
6694 let mut current = Some(pathpart_id);
6695
6696 while let Some(id) = current {
6697 let (parent, pathpart) = query_optional_value(
6698 connection,
6699 "SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
6700 vec![SqlValue::Integer(id)],
6701 |row| {
6702 Ok((
6703 row_opt_string(row, 0)?
6704 .and_then(|value| value.parse::<i64>().ok())
6705 .filter(|value| *value != 0),
6706 row_string(row, 1)?,
6707 ))
6708 },
6709 )?
6710 .ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
6711 parts.push(pathpart);
6712 current = parent;
6713 }
6714
6715 parts.reverse();
6716 Ok(parts.join("/"))
6717}
6718
6719fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
6720 format!(
6721 "packages/{}/{}/versionData.mszyml",
6722 package_id,
6723 package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
6724 )
6725}
6726
6727fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
6728 let message = format!("{error:#}").to_ascii_lowercase();
6729 message.contains("no such table") && message.contains("packages")
6730}
6731
6732fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
6733 for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
6734 row_string(row, 1)
6735 })? {
6736 if value.eq_ignore_ascii_case(column) {
6737 return Ok(true);
6738 }
6739 }
6740
6741 Ok(false)
6742}
6743
6744fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
6745 let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
6746 let id_column = if v2 { "id" } else { "ids.id" };
6747 let name_column = if v2 { "name" } else { "names.name" };
6748 let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
6749 let exact_match = query.exact || semantics == SearchSemantics::Single;
6750 let mut params = Vec::new();
6751
6752 if let Some(value) = &query.id {
6753 return (
6754 single_field_condition(id_column, value, exact_match, &mut params),
6755 params,
6756 );
6757 }
6758 if let Some(value) = &query.name {
6759 return (
6760 single_field_condition(name_column, value, exact_match, &mut params),
6761 params,
6762 );
6763 }
6764 if let Some(value) = &query.moniker {
6765 return (
6766 single_field_condition(moniker_column, value, exact_match, &mut params),
6767 params,
6768 );
6769 }
6770 if let Some(value) = &query.tag {
6771 return (
6772 mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
6773 params,
6774 );
6775 }
6776 if let Some(value) = &query.command {
6777 return (
6778 mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
6779 params,
6780 );
6781 }
6782 if let Some(value) = &query.query {
6783 if exact_match {
6784 let conditions = [
6785 single_field_condition(id_column, value, true, &mut params),
6786 single_field_condition(name_column, value, true, &mut params),
6787 single_field_condition(moniker_column, value, true, &mut params),
6788 ];
6789 return (format!("({})", conditions.join(" OR ")), params);
6790 }
6791
6792 let mut conditions = vec![
6793 single_field_condition(id_column, value, false, &mut params),
6794 single_field_condition(name_column, value, false, &mut params),
6795 single_field_condition(moniker_column, value, false, &mut params),
6796 ];
6797 if semantics == SearchSemantics::Many {
6798 conditions.push(mapped_field_condition(
6799 v2,
6800 "tag",
6801 value,
6802 rowid_column,
6803 false,
6804 &mut params,
6805 ));
6806 conditions.push(mapped_field_condition(
6807 v2,
6808 "command",
6809 value,
6810 rowid_column,
6811 false,
6812 &mut params,
6813 ));
6814 }
6815 return (format!("({})", conditions.join(" OR ")), params);
6816 }
6817
6818 ("1 = 1".to_owned(), Vec::new())
6819}
6820
6821fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
6822 params.push(match_parameter(value, exact));
6823 format!("{column} LIKE ?{}", params.len())
6824}
6825
6826fn mapped_field_condition(
6827 v2: bool,
6828 value_name: &str,
6829 value: &str,
6830 rowid_column: &str,
6831 exact: bool,
6832 params: &mut Vec<String>,
6833) -> String {
6834 let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
6835 (
6836 format!("{value_name}s2"),
6837 format!("{value_name}s2_map"),
6838 value_name.to_owned(),
6839 "package".to_owned(),
6840 )
6841 } else {
6842 (
6843 format!("{value_name}s"),
6844 format!("{value_name}s_map"),
6845 value_name.to_owned(),
6846 "manifest".to_owned(),
6847 )
6848 };
6849 params.push(match_parameter(value, exact));
6850 let parameter = params.len();
6851 format!(
6858 "{rowid_column} IN (SELECT {map_table_name}.{map_owner_column} \
6859 FROM {map_table_name} JOIN {table_name} ON \
6860 {map_table_name}.{value_name} = {table_name}.rowid \
6861 WHERE {table_name}.{map_value_column} LIKE ?{parameter})"
6862 )
6863}
6864
6865fn match_parameter(value: &str, exact: bool) -> String {
6866 if exact { value.to_owned() } else { format!("%{value}%") }
6867}
6868
6869fn build_rest_search_body(
6870 query: &PackageQuery,
6871 info: &RestInformation,
6872 semantics: SearchSemantics,
6873) -> Result<JsonValue> {
6874 let mut root = serde_json::Map::new();
6875 root.insert(
6876 "MaximumResults".to_owned(),
6877 JsonValue::from(source_fetch_results(query, semantics) as u64),
6878 );
6879 let exact_match = query.exact || semantics == SearchSemantics::Single;
6880
6881 if let Some(value) = &query.query {
6882 if semantics == SearchSemantics::Single {
6883 let mut filters = vec![
6884 rest_filter("PackageIdentifier", value, true),
6885 rest_filter("PackageName", value, true),
6886 rest_filter("Moniker", value, true),
6887 ];
6888 append_required_rest_filters(&mut filters, info);
6889 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6890 return Ok(JsonValue::Object(root));
6891 }
6892
6893 root.insert(
6894 "Query".to_owned(),
6895 serde_json::json!({
6896 "KeyWord": value,
6897 "MatchType": if exact_match { "Exact" } else { "Substring" },
6898 }),
6899 );
6900 }
6901
6902 let mut filters = Vec::new();
6903 if let Some(value) = &query.id {
6904 filters.push(rest_filter("PackageIdentifier", value, exact_match));
6905 }
6906 if let Some(value) = &query.name {
6907 filters.push(rest_filter("PackageName", value, exact_match));
6908 }
6909 if let Some(value) = &query.moniker {
6910 filters.push(rest_filter("Moniker", value, exact_match));
6911 }
6912 if let Some(value) = &query.tag {
6913 filters.push(rest_filter("Tag", value, true));
6914 }
6915 if let Some(value) = &query.command {
6916 filters.push(rest_filter("Command", value, true));
6917 }
6918 append_required_rest_filters(&mut filters, info);
6919
6920 if !filters.is_empty() {
6921 root.insert("Filters".to_owned(), JsonValue::Array(filters));
6922 }
6923
6924 Ok(JsonValue::Object(root))
6925}
6926
6927fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
6928 if info
6929 .required_package_match_fields
6930 .iter()
6931 .any(|field| field.eq_ignore_ascii_case("Market"))
6932 {
6933 filters.push(rest_filter("Market", &default_market(), true));
6934 }
6935}
6936
6937fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
6938 serde_json::json!({
6939 "PackageMatchField": field,
6940 "RequestMatch": {
6941 "KeyWord": value,
6942 "MatchType": if exact { "Exact" } else { "Substring" },
6943 }
6944 })
6945}
6946
6947fn max_results(query: &PackageQuery) -> usize {
6948 query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
6949}
6950
6951fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
6952 match semantics {
6953 SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
6954 SearchSemantics::Single => max_results(query),
6955 }
6956}
6957
6958fn infer_preindexed_match_criteria_v2(
6959 connection: &Connection,
6960 row: &V2SearchRow,
6961 query: &PackageQuery,
6962 semantics: SearchSemantics,
6963) -> Result<Option<String>> {
6964 infer_match_criteria(
6965 &row.id,
6966 &row.name,
6967 row.moniker.as_deref(),
6968 query,
6969 semantics,
6970 |value| {
6971 find_mapped_value_v2(
6972 connection,
6973 "tags2",
6974 "tags2_map",
6975 "tag",
6976 row.package_rowid,
6977 value,
6978 query.exact,
6979 )
6980 },
6981 |value| {
6982 find_mapped_value_v2(
6983 connection,
6984 "commands2",
6985 "commands2_map",
6986 "command",
6987 row.package_rowid,
6988 value,
6989 query.exact,
6990 )
6991 },
6992 )
6993}
6994
6995fn infer_preindexed_match_criteria_v1(
6996 connection: &Connection,
6997 row: &V1SearchRow,
6998 query: &PackageQuery,
6999 semantics: SearchSemantics,
7000) -> Result<Option<String>> {
7001 infer_match_criteria(
7002 &row.id,
7003 &row.name,
7004 row.moniker.as_deref(),
7005 query,
7006 semantics,
7007 |value| {
7008 find_mapped_value_v1(
7009 connection,
7010 "tags",
7011 "tags_map",
7012 "tag",
7013 row.manifest_rowid,
7014 value,
7015 query.exact,
7016 )
7017 },
7018 |value| {
7019 find_mapped_value_v1(
7020 connection,
7021 "commands",
7022 "commands_map",
7023 "command",
7024 row.manifest_rowid,
7025 value,
7026 query.exact,
7027 )
7028 },
7029 )
7030}
7031
7032fn infer_match_criteria<FTag, FCommand>(
7033 id: &str,
7034 name: &str,
7035 moniker: Option<&str>,
7036 query: &PackageQuery,
7037 semantics: SearchSemantics,
7038 find_tag: FTag,
7039 find_command: FCommand,
7040) -> Result<Option<String>>
7041where
7042 FTag: FnOnce(&str) -> Result<Option<String>>,
7043 FCommand: FnOnce(&str) -> Result<Option<String>>,
7044{
7045 if let Some(value) = &query.tag {
7046 return Ok(Some(format_match_criteria("Tag", value)));
7047 }
7048 if let Some(value) = &query.command {
7049 return Ok(Some(format_match_criteria("Command", value)));
7050 }
7051 if let Some(value) = &query.moniker {
7052 return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
7053 }
7054 if semantics == SearchSemantics::Single {
7055 return Ok(None);
7056 }
7057 if let Some(value) = &query.query {
7058 if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
7059 return Ok(None);
7060 }
7061 if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
7062 return Ok(Some(format_match_criteria("Moniker", moniker_value)));
7063 }
7064 if let Some(tag) = find_tag(value)? {
7065 return Ok(Some(format_match_criteria("Tag", &tag)));
7066 }
7067 if let Some(command) = find_command(value)? {
7068 return Ok(Some(format_match_criteria("Command", &command)));
7069 }
7070 }
7071 Ok(None)
7072}
7073
7074fn find_mapped_value_v2(
7075 connection: &Connection,
7076 table_name: &str,
7077 map_table_name: &str,
7078 value_name: &str,
7079 package_rowid: i64,
7080 query: &str,
7081 exact: bool,
7082) -> Result<Option<String>> {
7083 let sql = format!(
7084 "SELECT {table_name}.{value_name} FROM {map_table_name} \
7085 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
7086 WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
7087 );
7088 let values = query_rows(
7089 connection,
7090 &sql,
7091 vec![
7092 SqlValue::Integer(package_rowid),
7093 SqlValue::Text(match_parameter(query, exact)),
7094 ],
7095 |row| row_string(row, 0),
7096 )?;
7097 Ok(select_best_text_match(values, query, exact))
7098}
7099
7100fn find_mapped_value_v1(
7101 connection: &Connection,
7102 table_name: &str,
7103 map_table_name: &str,
7104 value_name: &str,
7105 manifest_rowid: i64,
7106 query: &str,
7107 exact: bool,
7108) -> Result<Option<String>> {
7109 let sql = format!(
7110 "SELECT {table_name}.{value_name} FROM {map_table_name} \
7111 JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
7112 WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
7113 );
7114 let values = query_rows(
7115 connection,
7116 &sql,
7117 vec![
7118 SqlValue::Integer(manifest_rowid),
7119 SqlValue::Text(match_parameter(query, exact)),
7120 ],
7121 |row| row_string(row, 0),
7122 )?;
7123 Ok(select_best_text_match(values, query, exact))
7124}
7125
7126fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
7127 if let Some(value) = &query.tag {
7128 return Some(format_match_criteria("Tag", value));
7129 }
7130 if let Some(value) = &query.command {
7131 return Some(format_match_criteria("Command", value));
7132 }
7133 if let Some(value) = &query.moniker {
7134 return Some(format_match_criteria("Moniker", value));
7135 }
7136 if semantics == SearchSemantics::Single {
7137 return None;
7138 }
7139 let value = query.query.as_deref()?;
7140 if json_string(item, "PackageIdentifier")
7141 .as_deref()
7142 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
7143 || json_string(item, "PackageName")
7144 .as_deref()
7145 .is_some_and(|candidate| matches_text(candidate, value, query.exact))
7146 {
7147 return None;
7148 }
7149 json_string(item, "Moniker")
7150 .filter(|candidate| matches_text(candidate, value, query.exact))
7151 .map(|candidate| format_match_criteria("Moniker", &candidate))
7152}
7153
7154fn format_match_criteria(field: &str, value: &str) -> String {
7155 format!("{field}: {value}")
7156}
7157
7158fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
7159 if exact {
7160 candidate.eq_ignore_ascii_case(query)
7161 } else {
7162 candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
7163 }
7164}
7165
7166fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
7167 let mut score = 0;
7168
7169 if let Some(value) = &query.query {
7170 score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
7171 score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
7172 if let Some(moniker) = candidate.moniker.as_deref() {
7173 score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
7174 }
7175 }
7176
7177 if let Some(value) = &query.id {
7178 score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
7179 }
7180 if let Some(value) = &query.name {
7181 score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
7182 }
7183 if let Some(value) = &query.moniker
7184 && let Some(moniker) = candidate.moniker.as_deref()
7185 {
7186 score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
7187 }
7188 if let Some(value) = &query.tag
7189 && let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
7190 {
7191 score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
7192 }
7193 if let Some(value) = &query.command
7194 && let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
7195 {
7196 score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
7197 }
7198
7199 if matches!(candidate.source_kind, SourceKind::PreIndexed) {
7200 score += 5;
7201 }
7202 if search_match_has_unknown_version(candidate) {
7203 score = score.saturating_sub(10);
7204 }
7205
7206 score
7207}
7208
7209fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
7210 candidate
7211 .version
7212 .as_deref()
7213 .is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
7214}
7215
7216fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
7225 let mut document = match documents {
7226 JsonValue::Object(map) => map.clone(),
7227 _ => serde_json::Map::new(),
7228 };
7229
7230 document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
7231 document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
7232 document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
7233 if !manifest.channel.is_empty() {
7234 document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
7235 } else {
7236 document.remove("Channel");
7237 }
7238
7239 let mut single_installer = serde_json::Map::new();
7242 if let Some(arch) = installer.architecture.as_ref() {
7243 single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
7244 }
7245 if let Some(installer_type) = installer.installer_type.as_ref() {
7246 single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
7247 }
7248 if let Some(url) = installer.url.as_ref() {
7249 single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
7250 }
7251 if let Some(sha) = installer.sha256.as_ref() {
7252 single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
7253 }
7254 if let Some(scope) = installer.scope.as_ref() {
7255 single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
7256 }
7257 if let Some(locale) = installer.locale.as_ref() {
7258 single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
7259 }
7260 if let Some(product_code) = installer.product_code.as_ref() {
7261 single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
7262 }
7263 if let Some(release_date) = installer.release_date.as_ref() {
7264 single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
7265 }
7266
7267 document.insert(
7268 "Installers".to_owned(),
7269 JsonValue::Array(vec![JsonValue::Object(single_installer)]),
7270 );
7271 document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
7272
7273 let value = JsonValue::Object(document);
7274 serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
7275}
7276
7277fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
7282 let mut filename = derive_download_filename_stem(manifest, installer);
7283 filename.push_str(".yaml");
7284 filename
7285}
7286
7287fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
7288 let mut stem = sanitize_filename_component(&manifest.name);
7289 if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
7290 stem.push('_');
7291 stem.push_str(&sanitize_filename_component(&manifest.version));
7292 }
7293 if let Some(scope) = installer.scope.as_deref() {
7294 stem.push('_');
7295 stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
7296 }
7297 let architecture = installer.architecture.as_deref().unwrap_or("neutral");
7298 stem.push('_');
7299 stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
7300
7301 let installer_type = installer.installer_type.as_deref().unwrap_or("");
7302 if !installer_type.is_empty() {
7303 stem.push('_');
7304 stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
7305 }
7306 if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
7307 stem.push('_');
7308 stem.push_str(&sanitize_filename_component(locale));
7309 }
7310
7311 stem
7312}
7313
7314fn capitalize_first(value: &str) -> String {
7315 let mut chars = value.chars();
7316 match chars.next() {
7317 Some(first) => {
7318 let mut out = first.to_ascii_uppercase().to_string();
7319 out.push_str(&chars.as_str().to_ascii_lowercase());
7320 out
7321 }
7322 None => String::new(),
7323 }
7324}
7325
7326fn format_architecture(value: &str) -> String {
7327 match value.to_ascii_lowercase().as_str() {
7328 "x64" => "X64".to_owned(),
7329 "x86" => "X86".to_owned(),
7330 "arm" => "Arm".to_owned(),
7331 "arm64" => "Arm64".to_owned(),
7332 "neutral" => "Neutral".to_owned(),
7333 other => capitalize_first(other),
7334 }
7335}
7336
7337fn sanitize_filename_component(value: &str) -> String {
7338 value
7339 .chars()
7340 .map(|ch| match ch {
7341 '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
7342 ch if ch.is_control() => '_',
7343 ch => ch,
7344 })
7345 .collect()
7346}
7347
7348fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
7349 if manifest_path.is_file() {
7350 return Ok(manifest_path.to_path_buf());
7351 }
7352
7353 if !manifest_path.is_dir() {
7354 bail!("Manifest path not found: {}", manifest_path.display());
7355 }
7356
7357 let candidate = fs::read_dir(manifest_path)?
7358 .filter_map(|entry| entry.ok())
7359 .map(|entry| entry.path())
7360 .filter(|path| {
7361 path.extension()
7362 .and_then(|value| value.to_str())
7363 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
7364 })
7365 .min();
7366
7367 candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
7368}
7369
7370fn score_text_match(
7371 candidate: &str,
7372 query: &str,
7373 exact: bool,
7374 exact_score: usize,
7375 prefix_score: usize,
7376 substring_score: usize,
7377) -> usize {
7378 if candidate.eq_ignore_ascii_case(query) {
7379 return exact_score;
7380 }
7381 if exact {
7382 return 0;
7383 }
7384
7385 let candidate_lower = candidate.to_ascii_lowercase();
7386 let query_lower = query.to_ascii_lowercase();
7387 if candidate_lower.starts_with(&query_lower) {
7388 prefix_score
7389 } else if candidate_lower.contains(&query_lower) {
7390 substring_score
7391 } else {
7392 0
7393 }
7394}
7395
7396fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
7397where
7398 I: IntoIterator<Item = String>,
7399{
7400 values
7401 .into_iter()
7402 .filter_map(|candidate| {
7403 let score = score_text_match(&candidate, query, exact, 3, 2, 1);
7404 (score > 0).then_some((score, candidate))
7405 })
7406 .max_by(|(left_score, left_value), (right_score, right_value)| {
7407 left_score
7408 .cmp(right_score)
7409 .then_with(|| right_value.len().cmp(&left_value.len()))
7410 .then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
7411 })
7412 .map(|(_, candidate)| candidate)
7413}
7414
7415fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
7416 let (field, value) = criteria.split_once(": ")?;
7417 Some((field, value))
7418}
7419
7420fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
7421 let versions = item
7422 .get("Versions")
7423 .and_then(JsonValue::as_array)
7424 .ok_or_else(|| anyhow!("REST search result missing Versions"))?;
7425 let mut result = Vec::new();
7426 for version in versions {
7427 let value = json_string(version, "PackageVersion")
7428 .ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
7429 let channel = json_string(version, "Channel").unwrap_or_default();
7430 result.push(VersionKey {
7431 version: value,
7432 channel,
7433 });
7434 }
7435 Ok(result)
7436}
7437
7438fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
7439 let mut merged = YamlMapping::new();
7440 let mut documents = Vec::new();
7441 for document in serde_yaml::Deserializer::from_slice(bytes) {
7442 let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
7443 documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
7444 if let Some(mapping) = value.as_mapping() {
7445 merge_yaml_mapping(&mut merged, mapping);
7446 }
7447 }
7448
7449 let id = yaml_string_from_root(&merged, "PackageIdentifier")
7450 .ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
7451 let version =
7452 yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
7453 let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
7454
7455 let installers = parse_yaml_installers(&merged);
7456 let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
7457 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7458
7459 Ok((
7460 Manifest {
7461 id,
7462 name,
7463 version,
7464 channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
7465 publisher: yaml_localized_string(&merged, "Publisher"),
7466 description: yaml_localized_string(&merged, "Description")
7467 .or_else(|| yaml_localized_string(&merged, "ShortDescription")),
7468 moniker: yaml_string_from_root(&merged, "Moniker"),
7469 package_url: yaml_localized_string(&merged, "PackageUrl"),
7470 publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
7471 publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
7472 license: yaml_localized_string(&merged, "License"),
7473 license_url: yaml_localized_string(&merged, "LicenseUrl"),
7474 privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
7475 author: yaml_localized_string(&merged, "Author"),
7476 copyright: yaml_localized_string(&merged, "Copyright"),
7477 copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
7478 release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
7479 release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
7480 tags: yaml_string_list(&merged, "Tags"),
7481 agreements: yaml_agreement_list(&merged),
7482 package_dependencies: yaml_package_dependencies(&merged),
7483 documentation: yaml_documentation_list(&merged),
7484 icons: yaml_icon_list(&merged),
7485 installers,
7486 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7487 },
7488 collapse_structured_document(&JsonValue::Array(documents)),
7489 ))
7490}
7491
7492fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
7493 parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
7494}
7495
7496fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
7497 let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
7498 let data = root
7499 .get("Data")
7500 .ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
7501 let versions = data
7502 .get("Versions")
7503 .and_then(JsonValue::as_array)
7504 .ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
7505 let selected = versions
7506 .iter()
7507 .find(|item| {
7508 json_string(item, "PackageVersion").as_deref() == Some(version)
7509 && json_string(item, "Channel").unwrap_or_default() == channel
7510 })
7511 .or_else(|| versions.first())
7512 .ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
7513
7514 let default_locale = selected
7515 .get("DefaultLocale")
7516 .ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
7517 let package_default_locale = data.get("DefaultLocale");
7518 let name = json_string(default_locale, "PackageName")
7519 .ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
7520 let installer_switch_defaults = json_installer_switches(selected);
7521 let top_platforms = json_string_list(selected, "Platform");
7522 let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
7523 let top_installer_type = json_string(selected, "InstallerType");
7524 let top_nested_installer_type = json_string(selected, "NestedInstallerType");
7525 let top_nested_installer_files = json_nested_installer_files(selected);
7526
7527 let installers = selected
7528 .get("Installers")
7529 .and_then(JsonValue::as_array)
7530 .map(|items| {
7531 items
7532 .iter()
7533 .map(|item| Installer {
7534 platforms: {
7535 let platforms = json_string_list(item, "Platform");
7536 if platforms.is_empty() {
7537 top_platforms.clone()
7538 } else {
7539 platforms
7540 }
7541 },
7542 minimum_os_version: json_string(item, "MinimumOSVersion")
7543 .or_else(|| top_minimum_os_version.clone()),
7544 architecture: json_string(item, "Architecture"),
7545 installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
7546 nested_installer_type: json_string(item, "NestedInstallerType")
7547 .or_else(|| top_nested_installer_type.clone()),
7548 nested_installer_files: {
7549 let files = json_nested_installer_files(item);
7550 if files.is_empty() {
7551 top_nested_installer_files.clone()
7552 } else {
7553 files
7554 }
7555 },
7556 url: json_string(item, "InstallerUrl"),
7557 sha256: json_string(item, "InstallerSha256"),
7558 product_code: json_string(item, "ProductCode"),
7559 locale: json_string(item, "InstallerLocale"),
7560 scope: json_string(item, "Scope"),
7561 release_date: json_string(item, "ReleaseDate"),
7562 package_family_name: json_string(item, "PackageFamilyName"),
7563 upgrade_code: json_string(item, "UpgradeCode"),
7564 switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
7565 commands: json_string_list(item, "Commands"),
7566 package_dependencies: json_package_dependencies(item),
7567 require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
7568 })
7569 .collect::<Vec<_>>()
7570 })
7571 .unwrap_or_default();
7572 let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
7573 let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
7574 let mut icons = json_icon_list(default_locale);
7575 if icons.is_empty() {
7576 icons = package_default_locale.map(json_icon_list).unwrap_or_default();
7577 }
7578
7579 let manifest = Manifest {
7580 id: package_id.to_owned(),
7581 name,
7582 version: version.to_owned(),
7583 channel: channel.to_owned(),
7584 publisher: json_string(default_locale, "Publisher"),
7585 description: json_string(default_locale, "Description")
7586 .or_else(|| json_string(default_locale, "ShortDescription")),
7587 moniker: json_string(default_locale, "Moniker"),
7588 package_url: json_string(default_locale, "PackageUrl"),
7589 publisher_url: json_string(default_locale, "PublisherUrl"),
7590 publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
7591 license: json_string(default_locale, "License"),
7592 license_url: json_string(default_locale, "LicenseUrl"),
7593 privacy_url: json_string(default_locale, "PrivacyUrl"),
7594 author: json_string(default_locale, "Author"),
7595 copyright: json_string(default_locale, "Copyright"),
7596 copyright_url: json_string(default_locale, "CopyrightUrl"),
7597 release_notes: json_string(default_locale, "ReleaseNotes"),
7598 release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
7599 tags: json_string_list(default_locale, "Tags"),
7600 agreements: json_agreement_list(default_locale),
7601 package_dependencies: json_package_dependencies(selected),
7602 documentation: json_documentation_list(default_locale),
7603 icons,
7604 installers,
7605 require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
7606 };
7607
7608 Ok((
7609 manifest,
7610 build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
7611 ))
7612}
7613
7614fn collapse_structured_document(document: &JsonValue) -> JsonValue {
7615 match document {
7616 JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
7617 merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
7618 }
7619 JsonValue::Array(documents) => merge_manifest_documents(documents),
7620 JsonValue::Object(_) if is_merged_manifest_document(document) => {
7621 merge_manifest_documents(&split_merged_manifest_document(document))
7622 }
7623 _ => document.clone(),
7624 }
7625}
7626
7627fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
7628 documents.iter().map(collapse_structured_document).collect()
7629}
7630
7631fn add_icon_fields_to_structured_document(mut document: JsonValue, manifest: &Manifest) -> JsonValue {
7632 let Some(root) = document.as_object_mut() else {
7633 return document;
7634 };
7635
7636 root.insert(
7637 "IconUrl".to_owned(),
7638 manifest
7639 .icon_url()
7640 .map(|url| JsonValue::String(url.to_owned()))
7641 .unwrap_or(JsonValue::Null),
7642 );
7643 root.insert(
7644 "Icon".to_owned(),
7645 manifest.icon().map(icon_to_json_value).unwrap_or(JsonValue::Null),
7646 );
7647 root.entry("Icons".to_owned())
7648 .or_insert_with(|| serde_json::to_value(&manifest.icons).expect("package icon serialization cannot fail"));
7649
7650 document
7651}
7652
7653fn icon_to_json_value(icon: &PackageIcon) -> JsonValue {
7654 serde_json::to_value(icon).expect("package icon serialization cannot fail")
7655}
7656
7657fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
7658 let version = documents.iter().find(|document| {
7659 document
7660 .get("ManifestType")
7661 .and_then(JsonValue::as_str)
7662 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
7663 });
7664 let default_locale = documents.iter().find(|document| {
7665 document
7666 .get("ManifestType")
7667 .and_then(JsonValue::as_str)
7668 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
7669 });
7670 let installer = documents.iter().find(|document| {
7671 document
7672 .get("ManifestType")
7673 .and_then(JsonValue::as_str)
7674 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
7675 });
7676
7677 let mut singleton = JsonMap::new();
7678 copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
7679 copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
7680 copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
7681
7682 if !singleton.contains_key("PackageLocale")
7683 && let Some(package_locale) = default_locale
7684 .and_then(|document| json_string(document, "PackageLocale"))
7685 .or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
7686 {
7687 singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7688 }
7689
7690 singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
7691 singleton.insert(
7692 "ManifestVersion".to_owned(),
7693 JsonValue::String(
7694 installer
7695 .and_then(|document| json_string(document, "ManifestVersion"))
7696 .or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
7697 .or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
7698 .unwrap_or_else(|| "1.10.0".to_owned()),
7699 ),
7700 );
7701 singleton.remove("DefaultLocale");
7702
7703 JsonValue::Object(singleton)
7704}
7705
7706fn is_merged_manifest_document(document: &JsonValue) -> bool {
7707 document
7708 .get("ManifestType")
7709 .and_then(JsonValue::as_str)
7710 .is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
7711}
7712
7713fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
7714 let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
7715 let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
7716 let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7717 let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
7718
7719 let version_document = serde_json::json!({
7720 "PackageIdentifier": package_identifier,
7721 "PackageVersion": package_version,
7722 "DefaultLocale": package_locale,
7723 "ManifestType": "version",
7724 "ManifestVersion": manifest_version,
7725 });
7726
7727 let mut default_locale_document = project_manifest_document(
7728 merged,
7729 &[
7730 "PackageIdentifier",
7731 "PackageVersion",
7732 "PackageLocale",
7733 "Publisher",
7734 "PublisherUrl",
7735 "PublisherSupportUrl",
7736 "PrivacyUrl",
7737 "Author",
7738 "PackageName",
7739 "PackageUrl",
7740 "License",
7741 "LicenseUrl",
7742 "Copyright",
7743 "CopyrightUrl",
7744 "ShortDescription",
7745 "Description",
7746 "Moniker",
7747 "Tags",
7748 "Agreements",
7749 "ReleaseNotes",
7750 "ReleaseNotesUrl",
7751 "PurchaseUrl",
7752 "InstallationNotes",
7753 "Documentations",
7754 "Icons",
7755 ],
7756 );
7757 default_locale_document.insert(
7758 "PackageIdentifier".to_owned(),
7759 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7760 );
7761 default_locale_document.insert(
7762 "PackageVersion".to_owned(),
7763 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7764 );
7765 default_locale_document.insert(
7766 "PackageLocale".to_owned(),
7767 JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
7768 );
7769 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7770 default_locale_document.insert(
7771 "ManifestVersion".to_owned(),
7772 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7773 );
7774
7775 let mut installer_document = project_manifest_document(
7776 merged,
7777 &[
7778 "PackageIdentifier",
7779 "PackageVersion",
7780 "Channel",
7781 "InstallerLocale",
7782 "Platform",
7783 "MinimumOSVersion",
7784 "InstallerType",
7785 "NestedInstallerType",
7786 "NestedInstallerFiles",
7787 "Scope",
7788 "InstallModes",
7789 "InstallerSwitches",
7790 "InstallerSuccessCodes",
7791 "ExpectedReturnCodes",
7792 "UpgradeBehavior",
7793 "Commands",
7794 "Protocols",
7795 "FileExtensions",
7796 "Dependencies",
7797 "PackageFamilyName",
7798 "ProductCode",
7799 "Capabilities",
7800 "RestrictedCapabilities",
7801 "Markets",
7802 "InstallerAbortsTerminal",
7803 "ReleaseDate",
7804 "InstallLocationRequired",
7805 "RequireExplicitUpgrade",
7806 "DisplayInstallWarnings",
7807 "UnsupportedOSArchitectures",
7808 "UnsupportedArguments",
7809 "AppsAndFeaturesEntries",
7810 "ElevationRequirement",
7811 "InstallationMetadata",
7812 "DownloadCommandProhibited",
7813 "RepairBehavior",
7814 "ArchiveBinariesDependOnPath",
7815 "Authentication",
7816 "Installers",
7817 ],
7818 );
7819 installer_document.insert(
7820 "PackageIdentifier".to_owned(),
7821 JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
7822 );
7823 installer_document.insert(
7824 "PackageVersion".to_owned(),
7825 JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
7826 );
7827 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7828 installer_document.insert(
7829 "ManifestVersion".to_owned(),
7830 JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
7831 );
7832
7833 vec![
7834 version_document,
7835 JsonValue::Object(default_locale_document),
7836 JsonValue::Object(installer_document),
7837 ]
7838}
7839
7840fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
7841 let mut result = JsonMap::new();
7842 if let Some(source) = source.as_object() {
7843 for key in keys {
7844 if let Some(value) = source.get(*key) {
7845 result.insert((*key).to_owned(), value.clone());
7846 }
7847 }
7848 }
7849
7850 result
7851}
7852
7853fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
7854 if let Some(source) = source.and_then(JsonValue::as_object) {
7855 for key in keys {
7856 if let Some(value) = source.get(*key) {
7857 target.insert((*key).to_owned(), value.clone());
7858 }
7859 }
7860 }
7861}
7862
7863fn copy_all_manifest_keys_except(
7864 target: &mut JsonMap<String, JsonValue>,
7865 source: Option<&JsonValue>,
7866 excluded_keys: &[&str],
7867) {
7868 if let Some(source) = source.and_then(JsonValue::as_object) {
7869 for (key, value) in source {
7870 if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
7871 target.insert(key.clone(), value.clone());
7872 }
7873 }
7874 }
7875}
7876
7877fn build_rest_manifest_documents(
7878 data: &JsonValue,
7879 installer_source: &JsonValue,
7880 default_locale: &JsonValue,
7881 package_id: &str,
7882 version: &str,
7883 channel: &str,
7884) -> JsonValue {
7885 let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
7886 let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
7887 let manifest_version = json_string(installer_source, "ManifestVersion")
7888 .or_else(|| json_string(default_locale, "ManifestVersion"))
7889 .or_else(|| json_string(data, "ManifestVersion"))
7890 .unwrap_or_else(|| "1.10.0".to_owned());
7891
7892 let mut version_document = JsonMap::new();
7893 version_document.insert(
7894 "PackageIdentifier".to_owned(),
7895 JsonValue::String(package_identifier.clone()),
7896 );
7897 version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7898 version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
7899 version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
7900 version_document.insert(
7901 "ManifestVersion".to_owned(),
7902 JsonValue::String(manifest_version.clone()),
7903 );
7904
7905 let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
7906 default_locale_document.insert(
7907 "PackageIdentifier".to_owned(),
7908 JsonValue::String(package_identifier.clone()),
7909 );
7910 default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7911 default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
7912 default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
7913 default_locale_document.insert(
7914 "ManifestVersion".to_owned(),
7915 JsonValue::String(manifest_version.clone()),
7916 );
7917
7918 let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
7919 installer_document.remove("DefaultLocale");
7920 installer_document.remove("Locales");
7921 installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
7922 installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
7923 if !channel.is_empty() {
7924 installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
7925 }
7926 installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
7927 installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
7928
7929 let documents = vec![
7930 JsonValue::Object(version_document),
7931 JsonValue::Object(default_locale_document),
7932 JsonValue::Object(installer_document),
7933 ];
7934
7935 merge_manifest_documents(&documents)
7936}
7937
7938fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
7939 let base = installer_defaults(root);
7940 let base_switches = yaml_installer_switches(root);
7941 if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
7942 let installers = items
7943 .iter()
7944 .filter_map(YamlValue::as_mapping)
7945 .map(|item| {
7946 let mut merged = base.clone();
7947 merge_yaml_mapping(&mut merged, item);
7948 let switches = yaml_installer_switches(item).with_fallback(&base_switches);
7949 installer_from_yaml(&merged, switches)
7950 })
7951 .collect::<Vec<_>>();
7952 if !installers.is_empty() {
7953 return installers;
7954 }
7955 }
7956
7957 if base.is_empty() {
7958 Vec::new()
7959 } else {
7960 vec![installer_from_yaml(&base, base_switches)]
7961 }
7962}
7963
7964fn installer_defaults(root: &YamlMapping) -> YamlMapping {
7965 let keys = [
7966 "Architecture",
7967 "InstallerType",
7968 "NestedInstallerType",
7969 "NestedInstallerFiles",
7970 "InstallerUrl",
7971 "InstallerSha256",
7972 "ProductCode",
7973 "InstallerLocale",
7974 "Platform",
7975 "MinimumOSVersion",
7976 "Scope",
7977 "ReleaseDate",
7978 "PackageFamilyName",
7979 "UpgradeCode",
7980 "Commands",
7981 ];
7982 let mut defaults = YamlMapping::new();
7983 for key in keys {
7984 if let Some(value) = root.get(YamlValue::from(key)) {
7985 defaults.insert(YamlValue::from(key), value.clone());
7986 }
7987 }
7988 defaults
7989}
7990
7991fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
7992 Installer {
7993 architecture: yaml_string(root, "Architecture"),
7994 installer_type: yaml_string(root, "InstallerType"),
7995 nested_installer_type: yaml_string(root, "NestedInstallerType"),
7996 nested_installer_files: yaml_nested_installer_files(root),
7997 url: yaml_string(root, "InstallerUrl"),
7998 sha256: yaml_string(root, "InstallerSha256"),
7999 product_code: yaml_string(root, "ProductCode"),
8000 locale: yaml_string(root, "InstallerLocale"),
8001 scope: yaml_string(root, "Scope"),
8002 release_date: yaml_string(root, "ReleaseDate"),
8003 package_family_name: yaml_string(root, "PackageFamilyName"),
8004 upgrade_code: yaml_string(root, "UpgradeCode"),
8005 platforms: yaml_string_list(root, "Platform"),
8006 minimum_os_version: yaml_string(root, "MinimumOSVersion"),
8007 switches,
8008 commands: yaml_string_list(root, "Commands"),
8009 package_dependencies: yaml_package_dependencies(root),
8010 require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
8011 }
8012}
8013
8014fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
8017 let Some(value) = root.get(YamlValue::from(key)) else {
8018 return false;
8019 };
8020 match value {
8021 YamlValue::Bool(b) => *b,
8022 YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
8023 _ => false,
8024 }
8025}
8026
8027fn json_bool(value: &JsonValue, key: &str) -> bool {
8028 match value.get(key) {
8029 Some(JsonValue::Bool(b)) => *b,
8030 Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
8031 _ => false,
8032 }
8033}
8034
8035fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
8036 let mapping = root
8037 .get(YamlValue::from("InstallerSwitches"))
8038 .or_else(|| root.get(YamlValue::from("Switches")))
8039 .and_then(YamlValue::as_mapping);
8040 yaml_installer_switches_from_mapping(mapping)
8041}
8042
8043fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
8044 let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
8045
8046 InstallerSwitches {
8047 silent: string("Silent"),
8048 silent_with_progress: string("SilentWithProgress"),
8049 interactive: string("Interactive"),
8050 custom: string("Custom"),
8051 log: string("Log"),
8052 install_location: string("InstallLocation"),
8053 }
8054}
8055
8056fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
8057 yaml_string(root, key).or_else(|| {
8058 root.get(YamlValue::from("DefaultLocale"))
8059 .and_then(YamlValue::as_mapping)
8060 .and_then(|mapping| yaml_string(mapping, key))
8061 })
8062}
8063
8064fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
8065 yaml_string(root, key)
8066}
8067
8068fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
8069 root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
8070}
8071
8072fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
8073 match value {
8074 YamlValue::Null => None,
8075 YamlValue::Bool(value) => Some(value.to_string()),
8076 YamlValue::Number(value) => Some(value.to_string()),
8077 YamlValue::String(value) => Some(value.clone()),
8078 YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
8079 YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
8080 }
8081}
8082
8083fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
8084 root.get(YamlValue::from(key))
8085 .and_then(YamlValue::as_sequence)
8086 .map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
8087 .unwrap_or_default()
8088}
8089
8090fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
8091 root.get(YamlValue::from("Documentations"))
8092 .and_then(YamlValue::as_sequence)
8093 .map(|items| {
8094 items
8095 .iter()
8096 .filter_map(YamlValue::as_mapping)
8097 .filter_map(|item| {
8098 let url = yaml_string(item, "DocumentUrl")?;
8099 Some(Documentation {
8100 label: yaml_string(item, "DocumentLabel"),
8101 url,
8102 })
8103 })
8104 .collect()
8105 })
8106 .unwrap_or_default()
8107}
8108
8109fn yaml_icon_list(root: &YamlMapping) -> Vec<PackageIcon> {
8110 root.get(YamlValue::from("Icons"))
8111 .and_then(YamlValue::as_sequence)
8112 .map(|items| {
8113 items
8114 .iter()
8115 .filter_map(YamlValue::as_mapping)
8116 .filter_map(|item| {
8117 let icon = PackageIcon {
8118 icon_url: yaml_string(item, "IconUrl"),
8119 icon_file_type: yaml_string(item, "IconFileType"),
8120 icon_resolution: yaml_string(item, "IconResolution"),
8121 icon_theme: yaml_string(item, "IconTheme"),
8122 icon_sha256: yaml_string(item, "IconSha256"),
8123 };
8124 (!icon.is_empty()).then_some(icon)
8125 })
8126 .collect()
8127 })
8128 .unwrap_or_default()
8129}
8130
8131fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
8132 root.get(YamlValue::from("Agreements"))
8133 .and_then(YamlValue::as_sequence)
8134 .map(|items| {
8135 items
8136 .iter()
8137 .filter_map(YamlValue::as_mapping)
8138 .map(|item| PackageAgreement {
8139 label: yaml_string(item, "AgreementLabel"),
8140 text: yaml_string(item, "Agreement"),
8141 url: yaml_string(item, "AgreementUrl"),
8142 })
8143 .collect()
8144 })
8145 .unwrap_or_default()
8146}
8147
8148fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
8149 root.get(YamlValue::from("Dependencies"))
8150 .and_then(YamlValue::as_mapping)
8151 .and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
8152 .and_then(YamlValue::as_sequence)
8153 .map(|items| {
8154 items
8155 .iter()
8156 .filter_map(YamlValue::as_mapping)
8157 .filter_map(|item| yaml_string(item, "PackageIdentifier"))
8158 .collect()
8159 })
8160 .unwrap_or_default()
8161}
8162
8163fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
8164 root.get(YamlValue::from("NestedInstallerFiles"))
8165 .and_then(YamlValue::as_sequence)
8166 .map(|items| {
8167 items
8168 .iter()
8169 .filter_map(YamlValue::as_mapping)
8170 .filter_map(|item| {
8171 let relative = yaml_string(item, "RelativeFilePath")?;
8172 Some(NestedInstallerFile {
8173 relative_file_path: relative,
8174 portable_command_alias: yaml_string(item, "PortableCommandAlias"),
8175 })
8176 })
8177 .collect()
8178 })
8179 .unwrap_or_default()
8180}
8181
8182fn json_string(value: &JsonValue, key: &str) -> Option<String> {
8183 value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
8184}
8185
8186fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
8187 value
8188 .get(key)
8189 .and_then(JsonValue::as_array)
8190 .map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
8191 .unwrap_or_default()
8192}
8193
8194fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
8195 value
8196 .get("Documentations")
8197 .and_then(JsonValue::as_array)
8198 .map(|items| {
8199 items
8200 .iter()
8201 .filter_map(|item| {
8202 let url = json_string(item, "DocumentUrl")?;
8203 Some(Documentation {
8204 label: json_string(item, "DocumentLabel"),
8205 url,
8206 })
8207 })
8208 .collect()
8209 })
8210 .unwrap_or_default()
8211}
8212
8213fn json_icon_list(value: &JsonValue) -> Vec<PackageIcon> {
8214 value
8215 .get("Icons")
8216 .and_then(JsonValue::as_array)
8217 .map(|items| {
8218 items
8219 .iter()
8220 .filter_map(|item| {
8221 let icon = PackageIcon {
8222 icon_url: json_string(item, "IconUrl"),
8223 icon_file_type: json_string(item, "IconFileType"),
8224 icon_resolution: json_string(item, "IconResolution"),
8225 icon_theme: json_string(item, "IconTheme"),
8226 icon_sha256: json_string(item, "IconSha256"),
8227 };
8228 (!icon.is_empty()).then_some(icon)
8229 })
8230 .collect()
8231 })
8232 .unwrap_or_default()
8233}
8234
8235fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
8236 value
8237 .get("Agreements")
8238 .and_then(JsonValue::as_array)
8239 .map(|items| {
8240 items
8241 .iter()
8242 .map(|item| PackageAgreement {
8243 label: json_string(item, "AgreementLabel"),
8244 text: json_string(item, "Agreement"),
8245 url: json_string(item, "AgreementUrl"),
8246 })
8247 .collect()
8248 })
8249 .unwrap_or_default()
8250}
8251
8252fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
8253 value
8254 .get("Dependencies")
8255 .and_then(JsonValue::as_object)
8256 .and_then(|deps| deps.get("PackageDependencies"))
8257 .and_then(JsonValue::as_array)
8258 .map(|items| {
8259 items
8260 .iter()
8261 .filter_map(|item| json_string(item, "PackageIdentifier"))
8262 .collect()
8263 })
8264 .unwrap_or_default()
8265}
8266
8267fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
8268 value
8269 .get("NestedInstallerFiles")
8270 .and_then(JsonValue::as_array)
8271 .map(|items| {
8272 items
8273 .iter()
8274 .filter_map(|item| {
8275 let relative = json_string(item, "RelativeFilePath")?;
8276 Some(NestedInstallerFile {
8277 relative_file_path: relative,
8278 portable_command_alias: json_string(item, "PortableCommandAlias"),
8279 })
8280 })
8281 .collect()
8282 })
8283 .unwrap_or_default()
8284}
8285
8286fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
8287 let switches = value
8288 .get("InstallerSwitches")
8289 .or_else(|| value.get("Switches"))
8290 .and_then(JsonValue::as_object);
8291
8292 let string = |key: &str| {
8293 switches
8294 .and_then(|switches| switches.get(key))
8295 .and_then(JsonValue::as_str)
8296 .map(str::to_owned)
8297 };
8298
8299 InstallerSwitches {
8300 silent: string("Silent"),
8301 silent_with_progress: string("SilentWithProgress"),
8302 interactive: string("Interactive"),
8303 custom: string("Custom"),
8304 log: string("Log"),
8305 install_location: string("InstallLocation"),
8306 }
8307}
8308
8309fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
8310 for (key, value) in source {
8311 target.insert(key.clone(), value.clone());
8312 }
8313}
8314
8315fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
8316 let reader = Cursor::new(bytes.to_vec());
8317 let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
8318 let mut file = archive
8319 .by_name(member_name)
8320 .with_context(|| format!("zip payload missing {member_name}"))?;
8321 let mut output = Vec::new();
8322 file.read_to_end(&mut output)
8323 .with_context(|| format!("failed to read {member_name} from zip payload"))?;
8324 Ok(output)
8325}
8326
8327fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
8328 if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
8329 return Ok(output);
8330 }
8331
8332 if !bytes.starts_with(b"CK")
8333 && let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
8334 {
8335 return decompress_mszyml_payload(&bytes[offset + 2..]);
8336 }
8337
8338 decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
8339}
8340
8341fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
8342 let mut decoder = flate2::read::DeflateDecoder::new(payload);
8343 let mut output = String::new();
8344 decoder.read_to_string(&mut output)?;
8345 Ok(output)
8346}
8347
8348fn cache_control_max_age(response: &Response) -> u64 {
8349 response
8350 .headers()
8351 .get(reqwest::header::CACHE_CONTROL)
8352 .and_then(|value| value.to_str().ok())
8353 .and_then(|header| {
8354 header.split(',').find_map(|part| {
8355 let trimmed = part.trim();
8356 trimmed
8357 .strip_prefix("max-age=")
8358 .and_then(|value| value.parse::<u64>().ok())
8359 })
8360 })
8361 .unwrap_or(60)
8362}
8363
8364fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
8365 REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
8366 let (major, minor) = major_minor(candidate);
8367 server_versions.iter().any(|version| {
8368 let (server_major, server_minor) = major_minor(version);
8369 server_major == major && server_minor == minor
8370 })
8371 })
8372}
8373
8374fn major_minor(version: &str) -> (String, String) {
8375 let mut parts = version.split('.');
8376 (
8377 parts.next().unwrap_or_default().to_owned(),
8378 parts.next().unwrap_or_default().to_owned(),
8379 )
8380}
8381
8382fn default_market() -> String {
8383 std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
8384}
8385
8386fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
8387 let requested_locale = query.locale.as_deref();
8388 let requested_architecture = query.installer_architecture.as_deref();
8389 let requested_type = query.installer_type.as_deref();
8390 let requested_scope = query.install_scope.as_deref();
8391 let requested_platform = query.platform.as_deref();
8392 let requested_os_version = query.os_version.as_deref();
8393 let system_architecture = current_architecture();
8394 let system_platform = current_platform();
8395 let current_os_version = current_os_version();
8396
8397 installers
8398 .iter()
8399 .enumerate()
8400 .filter(|(_, installer)| {
8401 installer_matches_requested(
8402 installer,
8403 requested_type,
8404 requested_scope,
8405 requested_platform,
8406 requested_os_version,
8407 system_platform,
8408 current_os_version.as_deref(),
8409 )
8410 })
8411 .filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
8412 .max_by_key(|(index, installer)| {
8413 (
8414 installer_rank(installer, requested_locale, requested_architecture, system_architecture),
8415 std::cmp::Reverse(*index),
8416 )
8417 })
8418 .map(|(_, installer)| installer)
8419 .cloned()
8420}
8421
8422fn installer_matches_requested(
8423 installer: &Installer,
8424 requested_type: Option<&str>,
8425 requested_scope: Option<&str>,
8426 requested_platform: Option<&str>,
8427 requested_os_version: Option<&str>,
8428 system_platform: Option<&str>,
8429 current_os_version: Option<&str>,
8430) -> bool {
8431 matches_optional_ci(installer.installer_type.as_deref(), requested_type)
8432 && matches_optional_ci(installer.scope.as_deref(), requested_scope)
8433 && installer_matches_platform(installer, requested_platform, system_platform)
8434 && installer_matches_os_version(installer, requested_os_version, current_os_version)
8435}
8436
8437fn installer_matches_platform(
8438 installer: &Installer,
8439 requested_platform: Option<&str>,
8440 system_platform: Option<&str>,
8441) -> bool {
8442 if installer.platforms.is_empty() {
8443 return true;
8444 }
8445
8446 let Some(effective_platform) = requested_platform.or(system_platform) else {
8447 return true;
8448 };
8449
8450 installer
8451 .platforms
8452 .iter()
8453 .any(|platform| platform.eq_ignore_ascii_case(effective_platform))
8454}
8455
8456fn installer_matches_os_version(
8457 installer: &Installer,
8458 requested_os_version: Option<&str>,
8459 current_os_version: Option<&str>,
8460) -> bool {
8461 let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
8462 return true;
8463 };
8464
8465 let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
8466 return true;
8467 };
8468
8469 match (
8470 parse_dotted_version(minimum_os_version),
8471 parse_dotted_version(actual_os_version),
8472 ) {
8473 (Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
8474 _ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
8475 }
8476}
8477
8478fn installer_matches_architecture(
8479 installer: &Installer,
8480 requested_architecture: Option<&str>,
8481 system_architecture: &str,
8482) -> bool {
8483 let Some(architecture) = installer.architecture.as_deref() else {
8484 return true;
8485 };
8486
8487 let architecture = architecture.to_ascii_lowercase();
8488 if let Some(requested) = requested_architecture {
8489 return architecture.eq_ignore_ascii_case(requested);
8490 }
8491
8492 preferred_architectures(system_architecture)
8493 .iter()
8494 .any(|candidate| architecture.eq_ignore_ascii_case(candidate))
8495}
8496
8497fn installer_rank(
8498 installer: &Installer,
8499 requested_locale: Option<&str>,
8500 requested_architecture: Option<&str>,
8501 system_architecture: &str,
8502) -> (i32, i32, i32) {
8503 let architecture_rank = architecture_rank(
8504 installer.architecture.as_deref(),
8505 requested_architecture.unwrap_or(system_architecture),
8506 requested_architecture.is_some(),
8507 system_architecture,
8508 );
8509 let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
8510 let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
8511 (architecture_rank, locale_rank, command_rank)
8512}
8513
8514fn architecture_rank(
8515 installer_architecture: Option<&str>,
8516 preferred_architecture: &str,
8517 strict: bool,
8518 system_architecture: &str,
8519) -> i32 {
8520 let Some(value) = installer_architecture else {
8521 return 0;
8522 };
8523 if value.eq_ignore_ascii_case(preferred_architecture) {
8524 return 5;
8525 }
8526 if value.eq_ignore_ascii_case("neutral") {
8527 return 4;
8528 }
8529 if strict {
8530 return -1;
8531 }
8532
8533 preferred_architectures(system_architecture)
8534 .iter()
8535 .rev()
8536 .position(|candidate| value.eq_ignore_ascii_case(candidate))
8537 .and_then(|index| i32::try_from(index).ok())
8538 .and_then(|index| index.checked_add(1))
8539 .unwrap_or(-1)
8540}
8541
8542fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
8546 if manifest.installers.is_empty() {
8547 return true;
8548 }
8549 let allowed = preferred_architectures(current_architecture());
8550 manifest.installers.iter().any(|installer| {
8551 let arch = installer.architecture.as_deref().unwrap_or("neutral");
8552 allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
8553 })
8554}
8555
8556fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
8557 match system_architecture {
8558 "arm64" => &["arm64", "neutral", "x64", "x86"],
8559 "x64" => &["x64", "neutral", "x86"],
8560 "x86" => &["x86", "neutral"],
8561 _ => &["neutral"],
8562 }
8563}
8564
8565fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
8566 match (installer_locale, requested_locale) {
8567 (Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
8568 (Some(installer), Some(requested))
8569 if installer
8570 .split('-')
8571 .next()
8572 .zip(requested.split('-').next())
8573 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
8574 {
8575 2
8576 }
8577 (None, Some(_)) => 1,
8578 (Some(_), None) => 1,
8579 (None, None) => 0,
8580 _ => -1,
8581 }
8582}
8583
8584fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
8585 match requested {
8586 Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
8587 None => true,
8588 }
8589}
8590
8591#[cfg(windows)]
8592fn current_architecture() -> &'static str {
8593 use std::sync::OnceLock;
8594 static CELL: OnceLock<&'static str> = OnceLock::new();
8595 CELL.get_or_init(|| {
8596 use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
8601 let mut process_machine: u16 = 0;
8602 let mut native_machine: u16 = 0;
8603 let handle = unsafe { GetCurrentProcess() };
8606 let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
8608 if ok != 0 {
8609 match native_machine {
8610 0xAA64 => return "arm64",
8611 0x8664 => return "x64",
8612 0x014C => return "x86",
8613 _ => {}
8614 }
8615 }
8616 compiled_architecture()
8617 })
8618}
8619
8620#[cfg(not(windows))]
8621fn current_architecture() -> &'static str {
8622 compiled_architecture()
8623}
8624
8625fn compiled_architecture() -> &'static str {
8626 match std::env::consts::ARCH {
8627 "x86_64" => "x64",
8628 "x86" => "x86",
8629 "aarch64" => "arm64",
8630 _ => "neutral",
8631 }
8632}
8633
8634fn current_platform() -> Option<&'static str> {
8635 if cfg!(windows) { Some("Windows.Desktop") } else { None }
8636}
8637
8638#[cfg(windows)]
8639fn current_os_version() -> Option<String> {
8640 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
8641 let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
8642 let build: String = key.get_value("CurrentBuildNumber").ok()?;
8643 let ubr: u32 = key.get_value("UBR").unwrap_or(0);
8644 let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
8645 let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
8646 Some(format!("{major}.{minor}.{build}.{ubr}"))
8647}
8648
8649#[cfg(not(windows))]
8650fn current_os_version() -> Option<String> {
8651 None
8652}
8653
8654fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
8655 let mut parsed = Vec::new();
8656 for component in version.split('.') {
8657 if component.is_empty() {
8658 continue;
8659 }
8660 parsed.push(component.parse().ok()?);
8661 }
8662
8663 if parsed.is_empty() { None } else { Some(parsed) }
8664}
8665
8666fn select_v1_version(
8667 versions: &[V1VersionRow],
8668 requested_version: Option<&str>,
8669 requested_channel: Option<&str>,
8670 package_id: &str,
8671 source_name: &str,
8672) -> Result<V1VersionRow> {
8673 let mut ordered = versions.to_vec();
8674 ordered.sort_by(|left, right| {
8675 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8676 });
8677
8678 if let Some(version) = requested_version {
8679 let channel = requested_channel.unwrap_or_default();
8680 return ordered
8681 .iter()
8682 .find(|candidate| {
8683 candidate.version.eq_ignore_ascii_case(version)
8684 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8685 })
8686 .cloned()
8687 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8688 }
8689
8690 if let Some(channel) = requested_channel {
8691 return ordered
8692 .iter()
8693 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8694 .cloned()
8695 .ok_or_else(|| {
8696 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8697 });
8698 }
8699
8700 ordered
8701 .into_iter()
8702 .next()
8703 .ok_or_else(|| anyhow!("package had no versions"))
8704}
8705
8706fn select_v2_version(
8707 versions: &[PackageVersionDataEntry],
8708 requested_version: Option<&str>,
8709 requested_channel: Option<&str>,
8710 package_id: &str,
8711 source_name: &str,
8712) -> Result<PackageVersionDataEntry> {
8713 let mut ordered = versions.to_vec();
8714 ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
8715
8716 if let Some(channel) = requested_channel {
8717 bail!(
8718 "Channel '{channel}' cannot be matched for package '{package_id}' in pre-indexed V2 source '{source_name}' because the local source index does not include channel metadata."
8719 );
8720 }
8721
8722 if let Some(version) = requested_version {
8723 return ordered
8724 .iter()
8725 .find(|candidate| candidate.version.eq_ignore_ascii_case(version))
8726 .cloned()
8727 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8728 }
8729
8730 ordered
8731 .into_iter()
8732 .next()
8733 .ok_or_else(|| anyhow!("package had no V2 versions"))
8734}
8735
8736fn missing_package_version(
8737 package_id: &str,
8738 source_name: &str,
8739 requested_version: &str,
8740 requested_channel: Option<&str>,
8741) -> anyhow::Error {
8742 anyhow!(MissingPackageVersionError {
8743 package_id: package_id.to_owned(),
8744 source_name: source_name.to_owned(),
8745 requested_version: requested_version.to_owned(),
8746 requested_channel: requested_channel.map(str::to_owned),
8747 })
8748}
8749
8750fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
8751 if let Some(refresh_error) = refresh.error.as_deref() {
8752 let missing_error = error.to_string();
8753 return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
8754 }
8755
8756 Err(error)
8757}
8758
8759fn select_rest_version<'a>(
8760 versions: &'a [VersionKey],
8761 requested_version: Option<&str>,
8762 requested_channel: Option<&str>,
8763 package_id: &str,
8764 source_name: &str,
8765) -> Result<&'a VersionKey> {
8766 if let Some(version) = requested_version {
8767 let channel = requested_channel.unwrap_or_default();
8768 return versions
8769 .iter()
8770 .find(|candidate| {
8771 candidate.version.eq_ignore_ascii_case(version)
8772 && (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
8773 })
8774 .ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
8775 }
8776
8777 if let Some(channel) = requested_channel {
8778 return versions
8779 .iter()
8780 .find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
8781 .ok_or_else(|| {
8782 anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
8783 });
8784 }
8785
8786 versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
8787}
8788
8789fn sort_versions_desc(versions: &mut [VersionKey]) {
8790 versions.sort_by(|left, right| {
8791 compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
8792 });
8793}
8794
8795fn compare_version_and_channel(
8796 left_version: &str,
8797 left_channel: &str,
8798 right_version: &str,
8799 right_channel: &str,
8800) -> Ordering {
8801 compare_version(left_version, right_version).then_with(|| {
8802 left_channel
8803 .to_ascii_lowercase()
8804 .cmp(&right_channel.to_ascii_lowercase())
8805 })
8806}
8807
8808fn compare_version(left: &str, right: &str) -> Ordering {
8809 let left_less_than = left.trim().strip_prefix('<').map(str::trim);
8810 let right_less_than = right.trim().strip_prefix('<').map(str::trim);
8811 match (left_less_than, right_less_than) {
8812 (Some(left_value), Some(right_value)) => return compare_version(left_value, right_value),
8813 (Some(left_value), None) if compare_version(left_value, right) == Ordering::Equal => return Ordering::Less,
8814 (None, Some(right_value)) if compare_version(left, right_value) == Ordering::Equal => return Ordering::Greater,
8815 _ => {}
8816 }
8817
8818 let left_parts = tokenize_version(left);
8819 let right_parts = tokenize_version(right);
8820 let max_len = left_parts.len().max(right_parts.len());
8821
8822 for index in 0..max_len {
8823 let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
8824 let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
8825 let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
8826
8827 let ordering = if let Some((left_number, right_number)) = numeric {
8828 left_number.cmp(&right_number)
8829 } else {
8830 left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
8831 };
8832
8833 if ordering != Ordering::Equal {
8834 return ordering;
8835 }
8836 }
8837
8838 Ordering::Equal
8839}
8840
8841fn tokenize_version(value: &str) -> Vec<String> {
8842 let mut parts = Vec::new();
8843 let mut current = String::new();
8844 let mut current_is_digit = None;
8845
8846 for ch in value.chars() {
8847 if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
8848 if !current.is_empty() {
8849 parts.push(std::mem::take(&mut current));
8850 }
8851 current_is_digit = None;
8852 continue;
8853 }
8854
8855 let is_digit = ch.is_ascii_digit();
8856 match current_is_digit {
8857 Some(previous) if previous != is_digit => {
8858 parts.push(std::mem::take(&mut current));
8859 current.push(ch);
8860 current_is_digit = Some(is_digit);
8861 }
8862 Some(_) => current.push(ch),
8863 None => {
8864 current.push(ch);
8865 current_is_digit = Some(is_digit);
8866 }
8867 }
8868 }
8869
8870 if !current.is_empty() {
8871 parts.push(current);
8872 }
8873
8874 parts
8875}
8876
8877fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
8878 if !hash_matches(Some(expected_hash), bytes) {
8879 bail!("downloaded file hash did not match expected SHA256");
8880 }
8881 Ok(())
8882}
8883
8884#[cfg(test)]
8885fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
8886 let Some(expected_hash) = expected_hash else {
8887 return Ok(());
8888 };
8889 if ignore_security_hash {
8890 return Ok(());
8891 }
8892
8893 if !hash_matches(Some(expected_hash), bytes) {
8894 let actual = sha256_hex(bytes);
8895 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
8896 }
8897 Ok(())
8898}
8899
8900fn verify_installer_hash_hex(expected_hash: Option<&str>, actual_hash: &str, ignore_security_hash: bool) -> Result<()> {
8901 let Some(expected_hash) = expected_hash else {
8902 return Ok(());
8903 };
8904 if ignore_security_hash {
8905 return Ok(());
8906 }
8907
8908 if !actual_hash.eq_ignore_ascii_case(expected_hash) {
8909 bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual_hash}");
8910 }
8911 Ok(())
8912}
8913
8914const INSTALLER_DOWNLOAD_ATTEMPTS: usize = 3;
8915const INSTALLER_DOWNLOAD_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(30);
8916const INSTALLER_DOWNLOAD_IDLE_TIMEOUT: StdDuration = StdDuration::from_secs(60);
8917const INSTALLER_DOWNLOAD_TOTAL_TIMEOUT: StdDuration = StdDuration::from_secs(30 * 60);
8918const INSTALLER_DOWNLOAD_METADATA_INTERVAL: u64 = 4 * 1024 * 1024;
8919
8920#[derive(Debug, Clone, Serialize, Deserialize)]
8921struct InstallerDownloadMetadata {
8922 url: String,
8923 etag: Option<String>,
8924 last_modified: Option<String>,
8925 total_length: Option<u64>,
8926 offset: u64,
8927}
8928
8929#[derive(Debug)]
8930struct DownloadAttempt {
8931 total_length: Option<u64>,
8932}
8933
8934fn download_installer_to_file(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8935 let runtime = tokio::runtime::Builder::new_current_thread()
8936 .enable_all()
8937 .build()
8938 .context("failed to create installer download runtime")?;
8939 runtime.block_on(download_installer_to_file_async(user_agent, url, dest))
8940}
8941
8942async fn download_installer_to_file_async(user_agent: &str, url: &str, dest: &Path) -> Result<String> {
8943 let part_path = sidecar_path(dest, "part");
8944 let metadata_path = sidecar_path(dest, "part.json");
8945 let started_at = Instant::now();
8946 let mut last_error = None;
8947
8948 for _attempt in 1..=INSTALLER_DOWNLOAD_ATTEMPTS {
8949 if started_at.elapsed() >= INSTALLER_DOWNLOAD_TOTAL_TIMEOUT {
8950 break;
8951 }
8952
8953 let remaining = INSTALLER_DOWNLOAD_TOTAL_TIMEOUT
8954 .checked_sub(started_at.elapsed())
8955 .unwrap_or_else(|| StdDuration::from_secs(1));
8956 match try_download_installer_to_part(user_agent, url, &part_path, &metadata_path, remaining).await {
8957 Ok(()) => {
8958 let actual_hash = sha256_file_hex(&part_path)?;
8959 replace_file(&part_path, dest)?;
8960 let _ = fs::remove_file(&metadata_path);
8961 return Ok(actual_hash);
8962 }
8963 Err(error) => {
8964 last_error = Some(error);
8965 }
8966 }
8967 }
8968
8969 Err(last_error.unwrap_or_else(|| anyhow!("installer download timed out")))
8970}
8971
8972async fn try_download_installer_to_part(
8973 user_agent: &str,
8974 url: &str,
8975 part_path: &Path,
8976 metadata_path: &Path,
8977 remaining: StdDuration,
8978) -> Result<()> {
8979 let metadata = read_installer_download_metadata(metadata_path, part_path, url)?;
8980 let mut offset = metadata.as_ref().map_or(0, |metadata| metadata.offset);
8981 let mut request = reqwest::Client::builder()
8982 .user_agent(user_agent)
8983 .connect_timeout(INSTALLER_DOWNLOAD_CONNECT_TIMEOUT)
8984 .timeout(remaining)
8985 .build()
8986 .context("failed to build installer download client")?
8987 .get(url)
8988 .header(ACCEPT_ENCODING, "identity");
8989
8990 if offset > 0 {
8991 request = request.header(RANGE, format!("bytes={offset}-"));
8992 if let Some(if_range) = metadata.as_ref().and_then(installer_download_if_range) {
8993 request = request.header(IF_RANGE, if_range);
8994 }
8995 }
8996
8997 let mut response = request.send().await.context("failed to download installer")?;
8998 let headers = response.headers().clone();
8999 let status = response.status();
9000 let attempt = if status == StatusCode::PARTIAL_CONTENT && offset > 0 {
9001 parse_content_range(headers.get(CONTENT_RANGE), offset)?
9002 } else if status == StatusCode::OK {
9003 offset = 0;
9004 DownloadAttempt {
9005 total_length: content_length(&headers),
9006 }
9007 } else if status == StatusCode::RANGE_NOT_SATISFIABLE {
9008 let _ = fs::remove_file(part_path);
9009 let _ = fs::remove_file(metadata_path);
9010 bail!("installer partial download could not be resumed; retrying from the beginning");
9011 } else if status.is_success() {
9012 bail!("unexpected installer download HTTP status {status}");
9013 } else {
9014 bail!("Download failed: HTTP {status}");
9015 };
9016
9017 if offset == 0 {
9018 let _ = fs::remove_file(part_path);
9019 }
9020
9021 let mut file = if offset == 0 {
9022 fs::File::create(part_path).with_context(|| format!("failed to create {}", part_path.display()))?
9023 } else {
9024 OpenOptions::new()
9025 .append(true)
9026 .open(part_path)
9027 .with_context(|| format!("failed to open {}", part_path.display()))?
9028 };
9029
9030 let mut metadata = InstallerDownloadMetadata {
9031 url: url.to_owned(),
9032 etag: strong_etag(&headers),
9033 last_modified: header_string(&headers, LAST_MODIFIED),
9034 total_length: attempt.total_length,
9035 offset,
9036 };
9037 write_installer_download_metadata(metadata_path, &metadata)?;
9038
9039 let mut next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
9040 while let Some(chunk) = tokio::time::timeout(INSTALLER_DOWNLOAD_IDLE_TIMEOUT, response.chunk())
9041 .await
9042 .context("installer download stalled while waiting for data")?
9043 .context("failed to read installer response body")?
9044 {
9045 file.write_all(&chunk)
9046 .with_context(|| format!("failed to write {}", part_path.display()))?;
9047 offset += u64::try_from(chunk.len()).expect("chunk length fits in u64");
9048
9049 if offset >= next_metadata_offset {
9050 file.flush()
9051 .with_context(|| format!("failed to flush {}", part_path.display()))?;
9052 metadata.offset = offset;
9053 write_installer_download_metadata(metadata_path, &metadata)?;
9054 next_metadata_offset = offset.saturating_add(INSTALLER_DOWNLOAD_METADATA_INTERVAL);
9055 }
9056 }
9057
9058 file.flush()
9059 .with_context(|| format!("failed to flush {}", part_path.display()))?;
9060 file.sync_all()
9061 .with_context(|| format!("failed to sync {}", part_path.display()))?;
9062
9063 if let Some(total_length) = attempt.total_length
9064 && offset != total_length
9065 {
9066 bail!("installer download ended early: received {offset} of {total_length} bytes");
9067 }
9068
9069 metadata.offset = offset;
9070 metadata.total_length = attempt.total_length;
9071 write_installer_download_metadata(metadata_path, &metadata)?;
9072 Ok(())
9073}
9074
9075fn read_installer_download_metadata(
9076 metadata_path: &Path,
9077 part_path: &Path,
9078 url: &str,
9079) -> Result<Option<InstallerDownloadMetadata>> {
9080 let Some(mut metadata) = fs::read(metadata_path)
9081 .ok()
9082 .and_then(|bytes| serde_json::from_slice::<InstallerDownloadMetadata>(&bytes).ok())
9083 else {
9084 let _ = fs::remove_file(part_path);
9085 let _ = fs::remove_file(metadata_path);
9086 return Ok(None);
9087 };
9088
9089 if metadata.url != url || metadata.offset == 0 {
9090 let _ = fs::remove_file(part_path);
9091 let _ = fs::remove_file(metadata_path);
9092 return Ok(None);
9093 }
9094
9095 let part_len = fs::metadata(part_path).map(|metadata| metadata.len()).unwrap_or(0);
9096 metadata.offset = metadata.offset.min(part_len);
9097 if metadata.offset == 0 {
9098 let _ = fs::remove_file(part_path);
9099 let _ = fs::remove_file(metadata_path);
9100 return Ok(None);
9101 }
9102
9103 OpenOptions::new()
9104 .write(true)
9105 .open(part_path)
9106 .with_context(|| format!("failed to open {}", part_path.display()))?
9107 .set_len(metadata.offset)
9108 .with_context(|| format!("failed to truncate {}", part_path.display()))?;
9109
9110 Ok(Some(metadata))
9111}
9112
9113fn write_installer_download_metadata(path: &Path, metadata: &InstallerDownloadMetadata) -> Result<()> {
9114 let bytes = serde_json::to_vec(metadata).context("failed to serialize installer download metadata")?;
9115 fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
9116}
9117
9118fn installer_download_if_range(metadata: &InstallerDownloadMetadata) -> Option<String> {
9119 metadata
9120 .etag
9121 .as_ref()
9122 .filter(|etag| !etag.starts_with("W/"))
9123 .cloned()
9124 .or_else(|| metadata.last_modified.clone())
9125}
9126
9127fn parse_content_range(value: Option<&HeaderValue>, expected_start: u64) -> Result<DownloadAttempt> {
9128 let value = value
9129 .and_then(|value| value.to_str().ok())
9130 .ok_or_else(|| anyhow!("partial installer response missing Content-Range"))?;
9131 let range = value
9132 .strip_prefix("bytes ")
9133 .ok_or_else(|| anyhow!("unsupported Content-Range value: {value}"))?;
9134 let (span, total) = range
9135 .split_once('/')
9136 .ok_or_else(|| anyhow!("invalid Content-Range value: {value}"))?;
9137 let (start, _) = span
9138 .split_once('-')
9139 .ok_or_else(|| anyhow!("invalid Content-Range byte span: {value}"))?;
9140 let start = start
9141 .parse::<u64>()
9142 .with_context(|| format!("invalid Content-Range start: {value}"))?;
9143 if start != expected_start {
9144 bail!("partial installer response started at {start}, expected {expected_start}");
9145 }
9146 let total_length = if total == "*" {
9147 None
9148 } else {
9149 Some(
9150 total
9151 .parse::<u64>()
9152 .with_context(|| format!("invalid Content-Range total: {value}"))?,
9153 )
9154 };
9155 Ok(DownloadAttempt { total_length })
9156}
9157
9158fn content_length(headers: &HeaderMap) -> Option<u64> {
9159 headers
9160 .get(CONTENT_LENGTH)
9161 .and_then(|value| value.to_str().ok())
9162 .and_then(|value| value.parse::<u64>().ok())
9163}
9164
9165fn strong_etag(headers: &HeaderMap) -> Option<String> {
9166 header_string(headers, ETAG).filter(|value| !value.starts_with("W/"))
9167}
9168
9169fn header_string(headers: &HeaderMap, name: reqwest::header::HeaderName) -> Option<String> {
9170 headers
9171 .get(name)
9172 .and_then(|value| value.to_str().ok())
9173 .map(str::to_owned)
9174}
9175
9176fn sidecar_path(path: &Path, suffix: &str) -> PathBuf {
9177 let mut value = path.as_os_str().to_os_string();
9178 value.push(".");
9179 value.push(suffix);
9180 PathBuf::from(value)
9181}
9182
9183fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
9184 expected_hash
9185 .map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
9186 .unwrap_or(true)
9187}
9188
9189fn sha256_hex(bytes: &[u8]) -> String {
9190 let digest = Sha256::digest(bytes);
9191 sha256_digest_hex(digest)
9192}
9193
9194fn sha256_file_hex(path: &Path) -> Result<String> {
9195 let mut file = fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
9196 let mut hasher = Sha256::new();
9197 let mut buffer = [0u8; 128 * 1024];
9198 loop {
9199 let read = file
9200 .read(&mut buffer)
9201 .with_context(|| format!("failed to read {}", path.display()))?;
9202 if read == 0 {
9203 break;
9204 }
9205 hasher.update(&buffer[..read]);
9206 }
9207 Ok(sha256_digest_hex(hasher.finalize()))
9208}
9209
9210fn sha256_digest_hex(digest: impl AsRef<[u8]>) -> String {
9211 let digest = digest.as_ref();
9212 let mut output = String::with_capacity(digest.len() * 2);
9213 for byte in digest {
9214 output.push_str(&format!("{byte:02X}"));
9215 }
9216 output
9217}
9218
9219fn pins_db_path(app_root: &Path) -> PathBuf {
9220 if uses_packaged_layout(app_root) {
9221 app_root.join("pinning.db")
9222 } else {
9223 app_root.join("pins.db")
9224 }
9225}
9226
9227fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
9228 let mut has_current_column = false;
9229 let mut has_legacy_column = false;
9230
9231 for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
9232 match column_name.as_str() {
9233 "type" => has_current_column = true,
9234 "pin_type" => has_legacy_column = true,
9235 _ => {}
9236 }
9237 }
9238
9239 Ok(if has_current_column {
9240 Some("type")
9241 } else if has_legacy_column {
9242 Some("pin_type")
9243 } else {
9244 None
9245 })
9246}
9247
9248fn decode_pin_type(pin_type_int: i64) -> PinType {
9249 match pin_type_int {
9250 4 => PinType::Blocking,
9251 3 => PinType::Gating,
9252 _ => PinType::Pinning,
9253 }
9254}
9255
9256#[cfg(windows)]
9257fn dispatch_installer(
9258 installer_path: &Path,
9259 installer_type: &str,
9260 request: &InstallRequest,
9261 manifest: &Manifest,
9262 installer: &Installer,
9263) -> Result<i32> {
9264 match installer_type {
9265 "msi" | "wix" if should_run_msi_direct(request, installer) => run_msi_direct(
9266 installer_path,
9267 &installer_direct_msi_arguments(installer_type, request, manifest, installer_path, installer),
9268 ),
9269 "msi" | "wix" => shell_execute_installer(
9270 Path::new("msiexec.exe"),
9271 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9272 should_elevate_msi_shell_execute(installer),
9273 ),
9274 "msix" | "appx" => {
9275 use std::process::Command;
9276
9277 let mut cmd = Command::new("powershell");
9278 cmd.arg("-NoProfile")
9279 .arg("-Command")
9280 .arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
9281 let status = cmd.status().context("failed to run Add-AppxPackage")?;
9282 Ok(status.code().unwrap_or(-1))
9283 }
9284 "zip" => {
9285 if installer
9291 .nested_installer_type
9292 .as_deref()
9293 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9294 {
9295 return install_portable(installer_path, request, manifest, installer);
9296 }
9297 let target = dirs::data_local_dir()
9298 .unwrap_or_else(|| PathBuf::from("."))
9299 .join("Programs");
9300 fs::create_dir_all(&target)?;
9301 let file = fs::File::open(installer_path)?;
9302 let mut archive = ZipArchive::new(file)?;
9303 archive.extract(&target)?;
9304 Ok(0)
9305 }
9306 "portable" => install_portable(installer_path, request, manifest, installer),
9307 _ => shell_execute_installer(
9310 installer_path,
9311 &installer_shell_execute_arguments(installer_type, request, manifest, installer_path, installer),
9312 false,
9313 ),
9314 }
9315}
9316
9317#[cfg(windows)]
9318fn should_run_msi_direct(request: &InstallRequest, installer: &Installer) -> bool {
9319 request.mode == InstallerMode::Silent && !should_elevate_msi_shell_execute(installer)
9320}
9321
9322#[cfg(windows)]
9323fn should_elevate_msi_shell_execute(installer: &Installer) -> bool {
9324 installer
9325 .scope
9326 .as_deref()
9327 .is_some_and(|scope| scope.eq_ignore_ascii_case("machine"))
9328}
9329
9330#[cfg(windows)]
9331fn shell_execute_installer(installer_path: &Path, args: &[String], run_as: bool) -> Result<i32> {
9332 use std::ffi::OsStr;
9333
9334 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, WAIT_OBJECT_0};
9335 use windows_sys::Win32::System::Threading::{GetExitCodeProcess, INFINITE, WaitForSingleObject};
9336 use windows_sys::Win32::UI::Shell::{SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW};
9337 use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW;
9338
9339 let file = wide_null(installer_path.as_os_str());
9340 let parameters = wide_null(OsStr::new(&join_windows_arguments(args)));
9341 let verb = run_as.then(|| wide_null(OsStr::new("runas")));
9342
9343 let mut exec_info = unsafe { std::mem::zeroed::<SHELLEXECUTEINFOW>() };
9346 exec_info.cbSize =
9347 u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).expect("SHELLEXECUTEINFOW size fits in u32");
9348 exec_info.fMask = SEE_MASK_NOCLOSEPROCESS;
9349 exec_info.lpFile = file.as_ptr();
9350 exec_info.lpParameters = parameters.as_ptr();
9351 if let Some(verb) = verb.as_ref() {
9352 exec_info.lpVerb = verb.as_ptr();
9353 }
9354 exec_info.nShow = SW_SHOW;
9355
9356 if unsafe { ShellExecuteExW(&mut exec_info) } == 0 {
9359 bail!(
9360 "failed to launch installer through ShellExecute (Win32 error {})",
9361 unsafe { GetLastError() }
9363 );
9364 }
9365
9366 if exec_info.hProcess.is_null() {
9367 bail!("ShellExecute did not return an installer process handle");
9368 }
9369
9370 let wait_result = unsafe { WaitForSingleObject(exec_info.hProcess, INFINITE) };
9373 if wait_result != WAIT_OBJECT_0 {
9374 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9376 bail!("failed while waiting for installer process (Win32 wait result {wait_result})");
9377 }
9378
9379 let mut exit_code = 0u32;
9380 let got_exit_code = unsafe { GetExitCodeProcess(exec_info.hProcess, &mut exit_code) };
9382 let _ = unsafe { CloseHandle(exec_info.hProcess) };
9384 if got_exit_code == 0 {
9385 let error = unsafe { GetLastError() };
9387 bail!("failed to retrieve installer exit code (Win32 error {error})");
9388 }
9389
9390 Ok(exit_code.cast_signed())
9391}
9392
9393#[cfg(windows)]
9394fn wide_null(value: &std::ffi::OsStr) -> Vec<u16> {
9395 value.encode_wide().chain(std::iter::once(0)).collect()
9396}
9397
9398#[cfg(windows)]
9399fn run_msi_direct(installer_path: &Path, args: &[String]) -> Result<i32> {
9400 use std::ffi::OsStr;
9401 use std::ptr::{null, null_mut};
9402
9403 use windows_sys::Win32::System::ApplicationInstallationAndServicing::{
9404 INSTALLUILEVEL_NONE, INSTALLUILEVEL_UACONLY, MsiEnableLogW, MsiInstallProductW, MsiSetInternalUI,
9405 };
9406
9407 let parsed = parse_msi_direct_arguments(args)?;
9408 let package_path = wide_null(installer_path.as_os_str());
9409 let properties = wide_null(OsStr::new(&parsed.properties));
9410 let property_ptr = if parsed.properties.is_empty() {
9411 null()
9412 } else {
9413 properties.as_ptr()
9414 };
9415 let log_path = parsed.log_path.as_ref().map(|path| wide_null(OsStr::new(path)));
9416
9417 if let Some(path) = log_path.as_ref() {
9418 let result = unsafe { MsiEnableLogW(parsed.log_mode, path.as_ptr(), parsed.log_attributes) };
9421 if result != 0 {
9422 bail!("failed to enable MSI logging (Win32 error {result})");
9423 }
9424 } else {
9425 let _ = unsafe { MsiEnableLogW(0, null(), 0) };
9427 }
9428
9429 let ui_level = if parsed.ui_level == INSTALLUILEVEL_NONE as u32 {
9430 (INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY) as u32
9431 } else {
9432 parsed.ui_level
9433 };
9434 unsafe { MsiSetInternalUI(ui_level.cast_signed(), null_mut()) };
9437 let result = unsafe { MsiInstallProductW(package_path.as_ptr(), property_ptr) };
9440 Ok(result.cast_signed())
9441}
9442
9443#[cfg(any(windows, test))]
9444#[derive(Debug, PartialEq, Eq)]
9445struct MsiDirectArguments {
9446 ui_level: u32,
9447 properties: String,
9448 log_mode: u32,
9449 log_path: Option<String>,
9450 log_attributes: u32,
9451}
9452
9453#[cfg(any(windows, test))]
9454fn parse_msi_direct_arguments(args: &[String]) -> Result<MsiDirectArguments> {
9455 const INSTALLUILEVEL_DEFAULT: u32 = 1;
9456 const INSTALLUILEVEL_NONE: u32 = 2;
9457 const INSTALLUILEVEL_BASIC: u32 = 3;
9458 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9459 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9460 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9461 const LOGMODE_DEFAULT: u32 = 1 | 2 | 4 | 16 | 128 | 256 | 512;
9462 const LOGMODE_ALL: u32 = LOGMODE_DEFAULT | 8 | 1024 | 2048;
9463 const LOGMODE_VERBOSE: u32 = 4096;
9464 const LOGMODE_EXTRADEBUG: u32 = 8192;
9465 const LOGATTR_APPEND: u32 = 1;
9466 const LOGATTR_FLUSH: u32 = 2;
9467
9468 let mut parsed = MsiDirectArguments {
9469 ui_level: INSTALLUILEVEL_DEFAULT,
9470 properties: String::new(),
9471 log_mode: 0,
9472 log_path: None,
9473 log_attributes: 0,
9474 };
9475
9476 let mut index = 0;
9477 while index < args.len() {
9478 let token = &args[index];
9479 if is_msi_switch(token) {
9480 let option = &token[1..];
9481 if option.eq_ignore_ascii_case("quiet") {
9482 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9483 } else if option.eq_ignore_ascii_case("passive") {
9484 parsed.ui_level = INSTALLUILEVEL_BASIC | INSTALLUILEVEL_PROGRESSONLY | INSTALLUILEVEL_HIDECANCEL;
9485 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=S");
9486 } else if option.eq_ignore_ascii_case("norestart") {
9487 append_msi_property(&mut parsed.properties, "REBOOT=ReallySuppress");
9488 } else if option.eq_ignore_ascii_case("forcerestart") {
9489 append_msi_property(&mut parsed.properties, "REBOOT=Force");
9490 } else if option.eq_ignore_ascii_case("promptrestart") {
9491 append_msi_property(&mut parsed.properties, "REBOOTPROMPT=\"\"");
9492 } else if option.eq_ignore_ascii_case("log") {
9493 index += 1;
9494 let path = args.get(index).ok_or_else(|| anyhow!("MSI /log requires a log path"))?;
9495 parsed.log_mode = LOGMODE_ALL;
9496 parsed.log_path = Some(path.clone());
9497 } else if option.len() == 1 && option.eq_ignore_ascii_case("q") {
9498 parsed.ui_level = INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY;
9499 } else if option.starts_with(['q', 'Q']) {
9500 parsed.ui_level = parse_msi_ui_level(&option[1..])?;
9501 } else if option.starts_with(['l', 'L']) {
9502 index += 1;
9503 let path = args
9504 .get(index)
9505 .ok_or_else(|| anyhow!("MSI logging switch requires a log path"))?;
9506 let (mode, attributes) = parse_msi_log_mode(
9507 &option[1..],
9508 LOGMODE_DEFAULT,
9509 LOGMODE_ALL,
9510 LOGMODE_VERBOSE,
9511 LOGMODE_EXTRADEBUG,
9512 LOGATTR_APPEND,
9513 LOGATTR_FLUSH,
9514 )?;
9515 parsed.log_mode = mode;
9516 parsed.log_attributes = attributes;
9517 parsed.log_path = Some(path.clone());
9518 } else {
9519 bail!("unsupported MSI switch for direct MSI execution: {token}");
9520 }
9521 } else {
9522 append_msi_property(&mut parsed.properties, &format_msi_property(token)?);
9523 }
9524 index += 1;
9525 }
9526
9527 Ok(parsed)
9528}
9529
9530#[cfg(any(windows, test))]
9531fn is_msi_switch(token: &str) -> bool {
9532 token.starts_with('/') || token.starts_with('-')
9533}
9534
9535#[cfg(any(windows, test))]
9536fn append_msi_property(properties: &mut String, property: &str) {
9537 if !properties.is_empty() {
9538 properties.push(' ');
9539 }
9540 properties.push_str(property);
9541}
9542
9543#[cfg(any(windows, test))]
9544fn format_msi_property(token: &str) -> Result<String> {
9545 let Some((name, value)) = token.split_once('=') else {
9546 bail!("invalid MSI property argument: {token}");
9547 };
9548 if name.is_empty() || !name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
9549 bail!("invalid MSI property name: {name}");
9550 }
9551 if value.contains(char::is_whitespace) && !(value.starts_with('"') && value.ends_with('"')) {
9552 Ok(format!("{name}=\"{}\"", value.replace('"', "\"\"")))
9553 } else {
9554 Ok(token.to_owned())
9555 }
9556}
9557
9558#[cfg(any(windows, test))]
9559fn parse_msi_ui_level(modifier: &str) -> Result<u32> {
9560 const INSTALLUILEVEL_NONE: u32 = 2;
9561 const INSTALLUILEVEL_BASIC: u32 = 3;
9562 const INSTALLUILEVEL_REDUCED: u32 = 4;
9563 const INSTALLUILEVEL_FULL: u32 = 5;
9564 const INSTALLUILEVEL_ENDDIALOG: u32 = 0x80;
9565 const INSTALLUILEVEL_PROGRESSONLY: u32 = 0x40;
9566 const INSTALLUILEVEL_HIDECANCEL: u32 = 0x20;
9567 const INSTALLUILEVEL_UACONLY: u32 = 0x100;
9568
9569 let modifier = if modifier.is_empty() { "n" } else { modifier };
9570 let mut chars = modifier.chars();
9571 let Some(first) = chars.next() else {
9572 return Ok(INSTALLUILEVEL_NONE | INSTALLUILEVEL_UACONLY);
9573 };
9574 let mut ui_level = match first.to_ascii_lowercase() {
9575 'f' => INSTALLUILEVEL_FULL,
9576 'r' => INSTALLUILEVEL_REDUCED,
9577 'b' => INSTALLUILEVEL_BASIC,
9578 'n' => INSTALLUILEVEL_NONE,
9579 '+' => INSTALLUILEVEL_NONE | INSTALLUILEVEL_ENDDIALOG,
9580 _ => bail!("invalid MSI /q modifier: {modifier}"),
9581 };
9582
9583 for ch in chars {
9584 match ch {
9585 '+' => ui_level |= INSTALLUILEVEL_ENDDIALOG,
9586 '-' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_PROGRESSONLY,
9587 '!' if ui_level & 0xF == INSTALLUILEVEL_BASIC => ui_level |= INSTALLUILEVEL_HIDECANCEL,
9588 '-' | '!' => bail!("MSI /q modifier {ch} is only valid with basic UI"),
9589 _ => bail!("invalid MSI /q modifier: {modifier}"),
9590 }
9591 }
9592
9593 if ui_level & 0xF == INSTALLUILEVEL_NONE {
9594 ui_level |= INSTALLUILEVEL_UACONLY;
9595 }
9596 Ok(ui_level)
9597}
9598
9599#[cfg(any(windows, test))]
9600#[allow(clippy::too_many_arguments)]
9601fn parse_msi_log_mode(
9602 modifier: &str,
9603 default_mode: u32,
9604 all_mode: u32,
9605 verbose: u32,
9606 extra_debug: u32,
9607 append: u32,
9608 flush: u32,
9609) -> Result<(u32, u32)> {
9610 let mut mode = 0;
9611 let mut attributes = 0;
9612 for ch in modifier.chars() {
9613 match ch.to_ascii_lowercase() {
9614 '*' => mode |= all_mode,
9615 'm' => mode |= 1,
9616 'e' => mode |= 2,
9617 'w' => mode |= 4,
9618 'u' => mode |= 8,
9619 'i' => mode |= 16,
9620 'o' => mode |= 128,
9621 'a' => mode |= 256,
9622 'r' => mode |= 512,
9623 'p' => mode |= 1024,
9624 'c' => mode |= 2048,
9625 'v' => mode |= verbose,
9626 'x' => mode |= extra_debug,
9627 '+' => attributes |= append,
9628 '!' => attributes |= flush,
9629 _ => bail!("invalid MSI /l modifier: {modifier}"),
9630 }
9631 }
9632 if mode == 0 {
9633 mode = default_mode;
9634 }
9635 Ok((mode, attributes))
9636}
9637
9638#[cfg(not(windows))]
9639fn dispatch_installer(
9640 _installer_path: &Path,
9641 _installer_type: &str,
9642 _request: &InstallRequest,
9643 _manifest: &Manifest,
9644 _installer: &Installer,
9645) -> Result<i32> {
9646 bail!("Installing packages is only supported on Windows")
9647}
9648
9649#[cfg(test)]
9650fn installer_command_arguments(
9651 installer_type: &str,
9652 request: &InstallRequest,
9653 _manifest: &Manifest,
9654 installer_path: &Path,
9655 installer: &Installer,
9656) -> Vec<String> {
9657 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9658}
9659
9660#[cfg(any(windows, test))]
9661fn installer_direct_msi_arguments(
9662 installer_type: &str,
9663 request: &InstallRequest,
9664 _manifest: &Manifest,
9665 installer_path: &Path,
9666 installer: &Installer,
9667) -> Vec<String> {
9668 installer_command_arguments_with_options(installer_type, request, installer_path, installer, false)
9669}
9670
9671#[cfg(any(windows, test))]
9672fn installer_shell_execute_arguments(
9673 installer_type: &str,
9674 request: &InstallRequest,
9675 _manifest: &Manifest,
9676 installer_path: &Path,
9677 installer: &Installer,
9678) -> Vec<String> {
9679 installer_command_arguments_with_options(installer_type, request, installer_path, installer, true)
9680}
9681
9682#[cfg(any(windows, test))]
9683fn installer_command_arguments_with_options(
9684 installer_type: &str,
9685 request: &InstallRequest,
9686 installer_path: &Path,
9687 installer: &Installer,
9688 include_msi_install_target: bool,
9689) -> Vec<String> {
9690 if let Some(override_args) = request.override_args.as_deref() {
9691 return split_installer_switches(override_args);
9692 }
9693
9694 let mut args = Vec::new();
9695 match installer_type {
9696 "msi" | "wix" if include_msi_install_target => {
9697 args.push("/i".to_owned());
9698 args.push(installer_path.display().to_string());
9699 }
9700 "msix" | "appx" | "zip" => return Vec::new(),
9701 _ => {}
9702 }
9703
9704 let configured = match request.mode {
9705 InstallerMode::Interactive => installer.switches.interactive.as_deref(),
9706 InstallerMode::SilentWithProgress => installer
9707 .switches
9708 .silent_with_progress
9709 .as_deref()
9710 .or(installer.switches.silent.as_deref()),
9711 InstallerMode::Silent => installer
9712 .switches
9713 .silent
9714 .as_deref()
9715 .or(installer.switches.silent_with_progress.as_deref()),
9716 };
9717
9718 if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
9719 args.extend(split_installer_switches(value));
9720 } else {
9721 args.extend(default_installer_arguments(installer_type, request.mode));
9722 }
9723
9724 append_switches(
9725 &mut args,
9726 resolve_template_switch(
9727 installer.switches.log.as_deref(),
9728 default_log_switch(installer_type),
9729 request.log_path.as_ref().map(|path| path.display().to_string()),
9730 "<LOGPATH>",
9731 ),
9732 );
9733 append_switches(&mut args, installer.switches.custom.clone());
9734 append_switches(&mut args, request.custom.clone());
9735 append_switches(
9736 &mut args,
9737 resolve_template_switch(
9738 installer.switches.install_location.as_deref(),
9739 default_install_location_switch(installer_type),
9740 request.install_location.clone(),
9741 "<INSTALLPATH>",
9742 ),
9743 );
9744 args
9745}
9746
9747#[cfg(any(windows, test))]
9748fn join_windows_arguments(args: &[String]) -> String {
9749 args.iter()
9750 .map(|arg| quote_windows_argument(arg))
9751 .collect::<Vec<_>>()
9752 .join(" ")
9753}
9754
9755#[cfg(any(windows, test))]
9756fn quote_windows_argument(arg: &str) -> String {
9757 if !arg.is_empty() && !arg.chars().any(|ch| ch.is_whitespace() || ch == '"') {
9758 return arg.to_owned();
9759 }
9760
9761 let mut quoted = String::from("\"");
9762 let mut backslashes = 0usize;
9763 for ch in arg.chars() {
9764 match ch {
9765 '\\' => backslashes += 1,
9766 '"' => {
9767 quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
9768 quoted.push('"');
9769 backslashes = 0;
9770 }
9771 _ => {
9772 quoted.push_str(&"\\".repeat(backslashes));
9773 backslashes = 0;
9774 quoted.push(ch);
9775 }
9776 }
9777 }
9778 quoted.push_str(&"\\".repeat(backslashes * 2));
9779 quoted.push('"');
9780 quoted
9781}
9782
9783#[cfg(any(windows, test))]
9784fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
9785 match mode {
9786 InstallerMode::Interactive => Vec::new(),
9787 InstallerMode::SilentWithProgress => match installer_type {
9788 "inno" => vec![
9789 "/SP-".to_owned(),
9790 "/SILENT".to_owned(),
9791 "/SUPPRESSMSGBOXES".to_owned(),
9792 "/NORESTART".to_owned(),
9793 ],
9794 "burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
9795 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9796 _ => vec!["/SILENT".to_owned()],
9797 },
9798 InstallerMode::Silent => match installer_type {
9799 "inno" => vec![
9800 "/SP-".to_owned(),
9801 "/VERYSILENT".to_owned(),
9802 "/SUPPRESSMSGBOXES".to_owned(),
9803 "/NORESTART".to_owned(),
9804 ],
9805 "burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
9806 "nullsoft" | "nsis" => vec!["/S".to_owned()],
9807 _ => vec!["/S".to_owned()],
9808 },
9809 }
9810}
9811
9812#[cfg(any(windows, test))]
9813fn default_log_switch(installer_type: &str) -> Option<&'static str> {
9814 match installer_type {
9815 "burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
9816 "inno" => Some("/LOG=\"<LOGPATH>\""),
9817 _ => None,
9818 }
9819}
9820
9821#[cfg(any(windows, test))]
9822fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
9823 match installer_type {
9824 "burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
9825 "nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
9826 "inno" => Some("/DIR=\"<INSTALLPATH>\""),
9827 _ => None,
9828 }
9829}
9830
9831#[cfg(any(windows, test))]
9832fn resolve_template_switch(
9833 manifest_value: Option<&str>,
9834 fallback: Option<&str>,
9835 replacement: Option<String>,
9836 token: &str,
9837) -> Option<String> {
9838 let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
9839 let replacement = replacement?;
9840 Some(template.replace(token, &replacement))
9841}
9842
9843#[cfg(any(windows, test))]
9844fn append_switches(args: &mut Vec<String>, value: Option<String>) {
9845 if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
9846 args.extend(split_installer_switches(&value));
9847 }
9848}
9849
9850#[cfg(any(windows, test))]
9851fn split_installer_switches(value: &str) -> Vec<String> {
9852 let mut args = Vec::new();
9853 let mut current = String::new();
9854 let mut in_quotes = false;
9855
9856 for ch in value.chars() {
9857 match ch {
9858 '"' => in_quotes = !in_quotes,
9859 c if c.is_whitespace() && !in_quotes => {
9860 if !current.is_empty() {
9861 args.push(std::mem::take(&mut current));
9862 }
9863 }
9864 _ => current.push(ch),
9865 }
9866 }
9867
9868 if !current.is_empty() {
9869 args.push(current);
9870 }
9871
9872 args
9873}
9874
9875#[cfg(windows)]
9876fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
9877 if request.purge && request.preserve {
9878 bail!("--purge and --preserve cannot be used together.");
9879 }
9880
9881 if installed
9882 .installer_category
9883 .as_deref()
9884 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
9885 {
9886 return uninstall_portable(installed, request);
9887 }
9888
9889 if (request.purge || request.preserve) && !request.force {
9890 bail!("--purge and --preserve are currently only supported for portable packages.");
9891 }
9892
9893 if let Some(exit_code) = try_uninstall_arp(installed, request)? {
9894 return Ok(exit_code);
9895 }
9896
9897 if let Some(exit_code) = try_uninstall_msix(installed)? {
9898 return Ok(exit_code);
9899 }
9900
9901 bail!(
9902 "No uninstall command found for installed package '{}' ({})",
9903 installed.name,
9904 installed.local_id
9905 );
9906}
9907
9908#[cfg(windows)]
9909fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
9910 use std::os::windows::process::CommandExt;
9911 use std::process::Command;
9912
9913 let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
9914 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
9915
9916 let arp_paths = [
9917 r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
9918 r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
9919 ];
9920
9921 for root in [&hklm, &hkcu] {
9922 for arp_path in &arp_paths {
9923 if let Ok(key) = root.open_subkey(arp_path) {
9924 for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
9925 if let Ok(subkey) = key.open_subkey(&subkey_name) {
9926 let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
9927 let product_code = subkey.get_value::<String, _>("ProductCode").ok();
9928 if !registry_entry_matches_installed_package(
9929 &subkey_name,
9930 &display_name,
9931 product_code.as_deref(),
9932 installed,
9933 ) {
9934 continue;
9935 }
9936
9937 if let Some(exit_code) =
9938 try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
9939 {
9940 return Ok(Some(exit_code));
9941 }
9942
9943 let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
9944 let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
9945 subkey.get_value("UninstallString").ok()
9946 } else {
9947 quiet_uninstall_cmd
9948 .clone()
9949 .or_else(|| subkey.get_value("UninstallString").ok())
9950 }
9951 .context("No uninstall command found in registry")?;
9952
9953 if is_winget_uninstall_command(&uninstall_cmd)
9954 && let Some(exit_code) = try_uninstall_via_winget(installed, request)?
9955 {
9956 return Ok(Some(exit_code));
9957 }
9958
9959 let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
9960 let uninstall_line = build_uninstall_command_with_mode(
9961 &uninstall_cmd,
9962 request.mode,
9963 quiet_uninstall_cmd.is_some(),
9964 log_path.as_deref(),
9965 );
9966 let mut cmd = Command::new("cmd");
9975 cmd.raw_arg("/S /C");
9976 cmd.raw_arg(format!("\"{uninstall_line}\""));
9977 let status = cmd.status().context("failed to run uninstaller")?;
9978 return Ok(Some(status.code().unwrap_or(-1)));
9979 }
9980 }
9981 }
9982 }
9983 }
9984
9985 Ok(None)
9986}
9987
9988#[cfg(windows)]
9989fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
9990 use std::process::Command;
9991
9992 if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
9993 return Ok(None);
9994 }
9995
9996 let package_full_name = installed.local_id.strip_prefix("MSIX\\");
9997 let mut cmd = Command::new("powershell");
9998 cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
9999 package_full_name,
10000 &installed.package_family_names,
10001 ));
10002 let status = cmd.status().context("failed to run Remove-AppxPackage")?;
10003 Ok(Some(status.code().unwrap_or(-1)))
10004}
10005
10006#[cfg(windows)]
10007fn arp_subkey_name(local_id: &str) -> Option<&str> {
10008 local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
10009}
10010
10011#[cfg(windows)]
10012fn registry_entry_matches_installed_package(
10013 subkey_name: &str,
10014 display_name: &str,
10015 product_code: Option<&str>,
10016 installed: &ListMatch,
10017) -> bool {
10018 if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
10019 && subkey_name.eq_ignore_ascii_case(arp_subkey_name)
10020 {
10021 return true;
10022 }
10023
10024 if installed.product_codes.iter().any(|code| {
10025 code.eq_ignore_ascii_case(subkey_name)
10026 || product_code
10027 .map(|value| code.eq_ignore_ascii_case(value))
10028 .unwrap_or(false)
10029 }) {
10030 return true;
10031 }
10032
10033 display_name.eq_ignore_ascii_case(&installed.name)
10034}
10035
10036#[cfg(all(windows, test))]
10037fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
10038 build_uninstall_command_with_mode(
10039 uninstall_cmd,
10040 if silent {
10041 InstallerMode::Silent
10042 } else {
10043 InstallerMode::Interactive
10044 },
10045 has_quiet_command,
10046 None,
10047 )
10048}
10049
10050#[cfg(windows)]
10051fn build_uninstall_command_with_mode(
10052 uninstall_cmd: &str,
10053 mode: InstallerMode,
10054 has_quiet_command: bool,
10055 log_path: Option<&str>,
10056) -> String {
10057 let uninstall_cmd = log_path
10058 .map(|value| uninstall_cmd.replace("<LOGPATH>", value))
10059 .unwrap_or_else(|| uninstall_cmd.to_owned());
10060 if mode == InstallerMode::Interactive || has_quiet_command {
10061 return uninstall_cmd;
10062 }
10063
10064 let lower = uninstall_cmd.to_ascii_lowercase();
10065 if is_winget_uninstall_command_lower(&lower) {
10066 return uninstall_cmd;
10067 }
10068 if lower.contains("/quiet")
10069 || lower.contains("/passive")
10070 || lower.contains("/verysilent")
10071 || lower.contains("/silent")
10072 || lower.contains(" /s")
10073 {
10074 return uninstall_cmd;
10075 }
10076
10077 format!("{uninstall_cmd} /S")
10078}
10079
10080#[cfg(windows)]
10081fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
10082 let Some(args) = build_winget_uninstall_arguments(installed, request) else {
10083 return Ok(None);
10084 };
10085
10086 let exit_code = run_winget_uninstall(&args)?;
10087 if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
10088 let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
10089 return Ok(Some(exit_code));
10090 };
10091
10092 return Ok(Some(run_winget_uninstall(&args_without_scope)?));
10093 }
10094
10095 Ok(Some(exit_code))
10096}
10097
10098#[cfg(windows)]
10099fn run_winget_uninstall(args: &[String]) -> Result<i32> {
10100 use std::process::Command;
10101
10102 let status = Command::new("winget")
10103 .args(args)
10104 .status()
10105 .context("failed to run delegated winget uninstall")?;
10106 Ok(status.code().unwrap_or(-1))
10107}
10108
10109#[cfg(windows)]
10110fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
10111 build_winget_uninstall_arguments_with_scope(installed, request, true)
10112}
10113
10114#[cfg(windows)]
10115fn build_winget_uninstall_arguments_with_scope(
10116 installed: &ListMatch,
10117 request: &UninstallRequest,
10118 include_scope: bool,
10119) -> Option<Vec<String>> {
10120 if installed.id.eq_ignore_ascii_case(&installed.local_id) {
10121 return None;
10122 }
10123
10124 let mut args = vec![
10125 "uninstall".to_owned(),
10126 "--id".to_owned(),
10127 installed.id.clone(),
10128 "--exact".to_owned(),
10129 "--disable-interactivity".to_owned(),
10130 ];
10131
10132 if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
10133 args.push("--source".to_owned());
10134 args.push(source_name.to_owned());
10135 }
10136
10137 if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
10138 args.push("--scope".to_owned());
10139 args.push(scope.to_owned());
10140 }
10141
10142 if request.mode == InstallerMode::Silent {
10143 args.push("--silent".to_owned());
10144 }
10145
10146 Some(args)
10147}
10148
10149#[cfg(windows)]
10150fn is_winget_uninstall_command(command: &str) -> bool {
10151 is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
10152}
10153
10154#[cfg(windows)]
10155fn is_winget_uninstall_command_lower(command: &str) -> bool {
10156 command.contains("winget uninstall") || command.contains("winget.exe uninstall")
10157}
10158
10159#[cfg(windows)]
10160fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
10161 let full_name_literal = package_full_name
10162 .map(|value| format!("'{}'", value.replace('\'', "''")))
10163 .unwrap_or_else(|| "$null".to_owned());
10164 let family_names_literal = if package_family_names.is_empty() {
10165 "@()".to_owned()
10166 } else {
10167 format!(
10168 "@({})",
10169 package_family_names
10170 .iter()
10171 .map(|value| format!("'{}'", value.replace('\'', "''")))
10172 .collect::<Vec<_>>()
10173 .join(", ")
10174 )
10175 };
10176
10177 format!(
10178 "$fullName = {full_name_literal}; \
10179 $familyNames = {family_names_literal}; \
10180 $targets = Get-AppxPackage | Where-Object {{ \
10181 (($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
10182 ($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
10183 if (-not $targets) {{ exit 1 }}; \
10184 $targets | Remove-AppxPackage"
10185 )
10186}
10187
10188#[cfg(windows)]
10189fn try_run_msi_uninstall(
10190 installed: &ListMatch,
10191 subkey_name: &str,
10192 product_code: Option<&str>,
10193 request: &UninstallRequest,
10194) -> Result<Option<i32>> {
10195 use std::process::Command;
10196
10197 let uninstall_code = installed
10198 .product_codes
10199 .iter()
10200 .map(String::as_str)
10201 .chain(product_code)
10202 .chain(Some(subkey_name))
10203 .find(|value| is_product_code_like(value));
10204
10205 let Some(uninstall_code) = uninstall_code else {
10206 return Ok(None);
10207 };
10208
10209 let mut cmd = Command::new("msiexec");
10210 cmd.arg("/x").arg(uninstall_code);
10211 match request.mode {
10212 InstallerMode::Interactive => {}
10213 InstallerMode::SilentWithProgress => {
10214 cmd.arg("/passive").arg("/norestart");
10215 }
10216 InstallerMode::Silent => {
10217 cmd.arg("/quiet").arg("/norestart");
10218 }
10219 }
10220 if let Some(log_path) = &request.log_path {
10221 cmd.arg("/log").arg(log_path);
10222 }
10223
10224 let status = cmd.status().context("failed to run msiexec uninstall")?;
10225 Ok(Some(status.code().unwrap_or(-1)))
10226}
10227
10228#[cfg(windows)]
10229fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
10230 let known_symlink = read_portable_symlink_full_path(installed);
10236
10237 let Some(location) = installed.install_location.as_deref() else {
10238 if request.force {
10239 try_remove_portable_symlinks(installed, &known_symlink, None);
10240 try_remove_portable_registry_entry(installed);
10241 return Ok(0);
10242 }
10243 bail!(
10244 "Portable package '{}' does not expose an install location.",
10245 installed.name
10246 );
10247 };
10248
10249 if request.preserve {
10250 return Ok(0);
10251 }
10252
10253 let path = Path::new(location);
10254 let removed = if path.is_dir() {
10255 fs::remove_dir_all(path)?;
10256 true
10257 } else if path.is_file() {
10258 fs::remove_file(path)?;
10259 true
10260 } else {
10261 false
10262 };
10263
10264 if removed || request.force {
10265 try_remove_portable_symlinks(installed, &known_symlink, Some(path));
10268 try_remove_portable_registry_entry(installed);
10269 return Ok(0);
10270 }
10271
10272 bail!("Portable package location not found: {location}")
10273}
10274
10275#[cfg(windows)]
10281fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
10282 const ARP_PREFIX: &str = r"ARP\";
10283 if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
10284 let mut parts = rest.splitn(3, '\\');
10285 let scope = parts.next().unwrap_or("");
10286 let _arch = parts.next();
10287 let subkey_name = parts.next().unwrap_or("");
10288 if subkey_name.is_empty() {
10289 return None;
10290 }
10291 let hive = if scope.eq_ignore_ascii_case("User") {
10292 RegKey::predef(HKEY_CURRENT_USER)
10293 } else {
10294 RegKey::predef(HKEY_LOCAL_MACHINE)
10295 };
10296 let subkey = hive
10297 .open_subkey(format!(
10298 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
10299 ))
10300 .ok()?;
10301 return read_reg_string(&subkey, "PortableSymlinkFullPath");
10302 }
10303 None
10304}
10305
10306#[cfg(windows)]
10312fn try_remove_portable_symlinks(
10313 _installed: &ListMatch,
10314 known_symlink: &Option<String>,
10315 install_location: Option<&Path>,
10316) {
10317 if let Some(path) = known_symlink.as_deref() {
10318 let _ = fs::remove_file(path);
10319 }
10320
10321 let Some(install_dir) = install_location else { return };
10326
10327 let Some(local) = dirs::data_local_dir() else { return };
10328 let links_root = local.join("Microsoft").join("WinGet").join("Links");
10329 let Ok(entries) = fs::read_dir(&links_root) else { return };
10330 for entry in entries.flatten() {
10331 let path = entry.path();
10332 let Ok(target) = fs::read_link(&path) else { continue };
10333 if path_starts_with_case_insensitive(&target, install_dir) {
10334 let _ = fs::remove_file(&path);
10335 }
10336 }
10337}
10338
10339#[cfg(windows)]
10346fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
10347 let lower_components = |p: &Path| -> Vec<String> {
10348 p.components()
10349 .map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
10350 .collect()
10351 };
10352 let parent_components = lower_components(parent);
10353 let child_components = lower_components(child);
10354 if parent_components.is_empty() || parent_components.len() > child_components.len() {
10355 return false;
10356 }
10357 parent_components
10358 .iter()
10359 .zip(child_components.iter())
10360 .all(|(a, b)| a == b)
10361}
10362
10363#[cfg(windows)]
10370fn try_remove_portable_registry_entry(installed: &ListMatch) {
10371 let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
10374 let mut parts = rest.splitn(3, '\\');
10376 let scope = parts.next().unwrap_or("");
10377 let _arch = parts.next();
10378 let subkey_name = parts.next().unwrap_or("");
10379 if subkey_name.is_empty() {
10380 return;
10381 }
10382 let is_hkcu = scope.eq_ignore_ascii_case("User");
10383 vec![(
10384 is_hkcu,
10385 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
10386 )]
10387 } else {
10388 let mut found = Vec::new();
10390 for (is_hkcu, hive) in [
10391 (true, RegKey::predef(HKEY_CURRENT_USER)),
10392 (false, RegKey::predef(HKEY_LOCAL_MACHINE)),
10393 ] {
10394 let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
10395 continue;
10396 };
10397 for name in uninstall.enum_keys().flatten() {
10398 let Ok(subkey) = uninstall.open_subkey(&name) else {
10399 continue;
10400 };
10401 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
10402 found.push((
10403 is_hkcu,
10404 format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
10405 ));
10406 }
10407 }
10408 }
10409 found
10410 };
10411
10412 for (is_hkcu, path) in candidates {
10413 let hive = if is_hkcu {
10414 RegKey::predef(HKEY_CURRENT_USER)
10415 } else {
10416 RegKey::predef(HKEY_LOCAL_MACHINE)
10417 };
10418 let _ = hive.delete_subkey_all(&path);
10419 }
10420}
10421
10422#[cfg(windows)]
10423fn is_product_code_like(value: &str) -> bool {
10424 value.starts_with('{') && value.ends_with('}')
10425}
10426
10427#[cfg(not(windows))]
10428fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
10429 bail!("Uninstalling packages is only supported on Windows")
10430}
10431
10432#[cfg(windows)]
10454fn install_portable(
10455 installer_path: &Path,
10456 request: &InstallRequest,
10457 manifest: &Manifest,
10458 installer: &Installer,
10459) -> Result<i32> {
10460 let source_identifier = portable_source_identifier(&request.query);
10461 let existing_entry = read_existing_portable_entry(&manifest.id);
10462 let subkey_name = existing_entry
10465 .as_ref()
10466 .map(|entry| entry.subkey_name.clone())
10467 .unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
10468
10469 let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
10470
10471 let prev_dir_created = existing_entry
10476 .as_ref()
10477 .and_then(|entry| entry.install_directory_created)
10478 == Some(1);
10479 let dir_existed = target_dir.is_dir();
10480 if prev_dir_created && dir_existed {
10481 clean_directory_contents(&target_dir).with_context(|| {
10482 format!(
10483 "failed to clean existing portable install directory: {}",
10484 target_dir.display()
10485 )
10486 })?;
10487 }
10488
10489 fs::create_dir_all(&target_dir)
10490 .with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
10491 let install_directory_created = !dir_existed || prev_dir_created;
10495
10496 let installer_type = installer.installer_type.as_deref().unwrap_or("");
10497 let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
10498 let file = fs::File::open(installer_path)
10499 .with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
10500 let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
10501 archive
10502 .extract(&target_dir)
10503 .with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
10504 installer
10508 .nested_installer_files
10509 .first()
10510 .map(|file| target_dir.join(&file.relative_file_path))
10511 .unwrap_or_else(|| target_dir.clone())
10512 } else {
10513 let basename = installer_path
10517 .file_name()
10518 .ok_or_else(|| anyhow!("portable installer has no filename"))?;
10519 let copied = target_dir.join(basename);
10520 fs::copy(installer_path, &copied)
10521 .with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
10522 copied
10523 };
10524
10525 let alias = determine_portable_alias(installer);
10530 let symlink_full_path = match alias.as_deref() {
10531 Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
10532 _ => None,
10533 };
10534
10535 let added_to_path = if symlink_full_path.is_some() {
10539 try_add_links_dir_to_user_path()
10540 } else {
10541 false
10542 };
10543
10544 write_portable_arp_entry(&PortableArpEntry {
10545 subkey_name: &subkey_name,
10546 install_location: &target_dir,
10547 portable_target_full_path: &portable_target_full_path,
10548 portable_symlink_full_path: symlink_full_path.as_deref(),
10549 install_directory_created,
10550 added_to_path,
10551 source_identifier: &source_identifier,
10552 manifest,
10553 })?;
10554
10555 Ok(0)
10556}
10557
10558#[cfg(windows)]
10559struct PortableArpEntry<'a> {
10560 subkey_name: &'a str,
10561 install_location: &'a Path,
10562 portable_target_full_path: &'a Path,
10563 portable_symlink_full_path: Option<&'a Path>,
10564 install_directory_created: bool,
10565 added_to_path: bool,
10566 source_identifier: &'a str,
10567 manifest: &'a Manifest,
10568}
10569
10570#[cfg(windows)]
10576fn determine_portable_alias(installer: &Installer) -> Option<String> {
10577 installer
10578 .nested_installer_files
10579 .iter()
10580 .find_map(|file| file.portable_command_alias.clone())
10581 .or_else(|| installer.commands.first().cloned())
10582}
10583
10584#[cfg(windows)]
10585fn winget_links_dir() -> PathBuf {
10586 dirs::data_local_dir()
10587 .unwrap_or_else(|| PathBuf::from("."))
10588 .join("Microsoft")
10589 .join("WinGet")
10590 .join("Links")
10591}
10592
10593#[cfg(windows)]
10598fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
10599 use std::os::windows::fs::symlink_file;
10600 let links_dir = winget_links_dir();
10601 if fs::create_dir_all(&links_dir).is_err() {
10602 return None;
10603 }
10604 let link_path = links_dir.join(format!("{alias}.exe"));
10605
10606 if link_path.symlink_metadata().is_ok() {
10609 let _ = fs::remove_file(&link_path);
10610 }
10611
10612 symlink_file(target, &link_path).ok().map(|()| link_path)
10617}
10618
10619#[cfg(windows)]
10625fn try_add_links_dir_to_user_path() -> bool {
10626 let links_dir = winget_links_dir();
10627 let links_str = links_dir.to_string_lossy().to_string();
10628 let normalized_links = links_str.to_ascii_lowercase();
10629
10630 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10631 let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
10632 return false;
10633 };
10634 let existing: String = env_key.get_value("Path").unwrap_or_default();
10635 let already_present = existing
10636 .split(';')
10637 .any(|component| component.trim().to_ascii_lowercase() == normalized_links);
10638 if already_present {
10639 return false;
10640 }
10641
10642 let new_path = if existing.is_empty() {
10643 links_str
10644 } else if existing.ends_with(';') {
10645 format!("{existing}{links_str}")
10646 } else {
10647 format!("{existing};{links_str}")
10648 };
10649
10650 if env_key.set_value("Path", &new_path).is_err() {
10651 return false;
10652 }
10653
10654 broadcast_environment_change();
10655 true
10656}
10657
10658#[cfg(windows)]
10659fn broadcast_environment_change() {
10660 use std::ffi::OsStr;
10661 use std::os::windows::ffi::OsStrExt;
10662
10663 use windows_sys::Win32::UI::WindowsAndMessaging::{
10664 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
10665 };
10666
10667 let param: Vec<u16> = OsStr::new("Environment")
10668 .encode_wide()
10669 .chain(std::iter::once(0))
10670 .collect();
10671 unsafe {
10675 let mut result: usize = 0;
10676 SendMessageTimeoutW(
10677 HWND_BROADCAST,
10678 WM_SETTINGCHANGE,
10679 0usize,
10680 param.as_ptr() as isize,
10681 SMTO_ABORTIFHUNG,
10682 5000,
10683 &mut result,
10684 );
10685 }
10686}
10687
10688#[cfg(windows)]
10693fn portable_source_identifier(query: &PackageQuery) -> String {
10694 match query.source.as_deref() {
10695 Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10696 Some(name) if !name.is_empty() => name.to_owned(),
10697 _ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
10698 }
10699}
10700
10701#[cfg(windows)]
10702fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
10703 format!("{package_id}_{source_identifier}")
10704}
10705
10706#[cfg(windows)]
10707struct ExistingPortableEntry {
10708 install_location: Option<PathBuf>,
10709 install_directory_created: Option<u32>,
10710 subkey_name: String,
10711}
10712
10713#[cfg(windows)]
10714fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
10715 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10716 let uninstall = hkcu
10717 .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
10718 .ok()?;
10719 for name in uninstall.enum_keys().flatten() {
10720 let Ok(subkey) = uninstall.open_subkey(&name) else {
10721 continue;
10722 };
10723 if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
10724 continue;
10725 }
10726 if !read_reg_string(&subkey, "WinGetInstallerType")
10727 .as_deref()
10728 .is_some_and(|value| value.eq_ignore_ascii_case("portable"))
10729 {
10730 continue;
10731 }
10732 return Some(ExistingPortableEntry {
10733 install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
10734 install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
10735 subkey_name: name,
10736 });
10737 }
10738 None
10739}
10740
10741#[cfg(windows)]
10742fn resolve_portable_install_location(
10743 request: &InstallRequest,
10744 existing: &Option<ExistingPortableEntry>,
10745 subkey_name: &str,
10746) -> PathBuf {
10747 resolve_portable_install_location_pure(
10748 request.install_location.as_deref(),
10749 existing
10750 .as_ref()
10751 .and_then(|e| e.install_location.as_deref())
10752 .and_then(Path::to_str),
10753 subkey_name,
10754 &default_user_portable_root(),
10755 )
10756}
10757
10758#[cfg(windows)]
10763fn resolve_portable_install_location_pure(
10764 request_location: Option<&str>,
10765 existing_location: Option<&str>,
10766 subkey_name: &str,
10767 default_user_portable_root: &Path,
10768) -> PathBuf {
10769 if let Some(custom) = request_location
10770 && !custom.is_empty()
10771 {
10772 return PathBuf::from(custom);
10773 }
10774 if let Some(loc) = existing_location
10775 && !loc.is_empty()
10776 {
10777 return PathBuf::from(loc);
10778 }
10779 default_user_portable_root.join(subkey_name)
10780}
10781
10782#[cfg(windows)]
10783fn default_user_portable_root() -> PathBuf {
10784 dirs::data_local_dir()
10785 .unwrap_or_else(|| PathBuf::from("."))
10786 .join("Microsoft")
10787 .join("WinGet")
10788 .join("Packages")
10789}
10790
10791#[cfg(windows)]
10792fn clean_directory_contents(dir: &Path) -> Result<()> {
10793 if !dir.is_dir() {
10794 return Ok(());
10795 }
10796 for entry in fs::read_dir(dir)? {
10797 let entry = entry?;
10798 let path = entry.path();
10799 if path.is_dir() {
10800 fs::remove_dir_all(&path)?;
10801 } else {
10802 fs::remove_file(&path)?;
10803 }
10804 }
10805 Ok(())
10806}
10807
10808#[cfg(windows)]
10812fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
10813 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
10814 let uninstall_path = format!(
10815 r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
10816 entry.subkey_name
10817 );
10818 let (subkey, _) = hkcu
10819 .create_subkey(&uninstall_path)
10820 .context("failed to create portable ARP registry subkey")?;
10821
10822 let manifest = entry.manifest;
10823 let install_location_str = entry.install_location.to_string_lossy().to_string();
10824 subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
10825 subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
10826 subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
10827 subkey.set_value("InstallLocation", &install_location_str)?;
10828 subkey.set_value(
10829 "PortableTargetFullPath",
10830 &entry.portable_target_full_path.to_string_lossy().to_string(),
10831 )?;
10832 if let Some(symlink_path) = entry.portable_symlink_full_path {
10833 subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
10834 }
10835 subkey.set_value(
10836 "InstallDirectoryAddedToPath",
10837 &(if entry.added_to_path { 1u32 } else { 0u32 }),
10838 )?;
10839 subkey.set_value(
10840 "InstallDirectoryCreated",
10841 &(if entry.install_directory_created { 1u32 } else { 0u32 }),
10842 )?;
10843 subkey.set_value(
10844 "DisplayName",
10845 &if manifest.name.is_empty() {
10846 manifest.id.clone()
10847 } else {
10848 manifest.name.clone()
10849 },
10850 )?;
10851 subkey.set_value("DisplayVersion", &manifest.version)?;
10852 if let Some(publisher) = manifest.publisher.as_ref() {
10853 subkey.set_value("Publisher", publisher)?;
10854 }
10855 subkey.set_value(
10856 "UninstallString",
10857 &format!("winget uninstall --product-code {}", entry.subkey_name),
10858 )?;
10859 let today = Utc::now().format("%Y%m%d").to_string();
10860 subkey.set_value("InstallDate", &today)?;
10861 if let Some(url) = manifest.package_url.as_ref() {
10862 subkey.set_value("URLInfoAbout", url)?;
10863 }
10864
10865 Ok(())
10866}
10867
10868#[cfg(test)]
10869mod tests {
10870 use std::io::Write;
10871 use std::net::{SocketAddr, TcpListener, TcpStream};
10872 use std::path::{Path, PathBuf};
10873 use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
10874 use std::thread::{self, JoinHandle};
10875
10876 use flate2::Compression;
10877 use flate2::write::DeflateEncoder;
10878 use zip::write::SimpleFileOptions;
10879
10880 use super::*;
10881
10882 fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
10883 Manifest {
10884 id: id.to_owned(),
10885 name: name.to_owned(),
10886 version: version.to_owned(),
10887 channel: String::new(),
10888 publisher: Some("Acme".to_owned()),
10889 description: Some("desc".to_owned()),
10890 moniker: None,
10891 package_url: None,
10892 publisher_url: None,
10893 publisher_support_url: None,
10894 license: None,
10895 license_url: None,
10896 privacy_url: None,
10897 author: None,
10898 copyright: None,
10899 copyright_url: None,
10900 release_notes: None,
10901 release_notes_url: None,
10902 tags: Vec::new(),
10903 agreements: Vec::new(),
10904 package_dependencies: Vec::new(),
10905 documentation: Vec::new(),
10906 icons: Vec::new(),
10907 installers: Vec::new(),
10908 require_explicit_upgrade: false,
10909 }
10910 }
10911
10912 #[test]
10913 fn derive_download_filename_matches_winget_pattern() {
10914 let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
10915 let installer = Installer {
10916 architecture: Some("arm64".to_owned()),
10917 installer_type: Some("exe".to_owned()),
10918 scope: Some("user".to_owned()),
10919 locale: Some("en-US".to_owned()),
10920 ..Installer::default()
10921 };
10922 assert_eq!(
10923 derive_download_manifest_filename(&manifest, &installer),
10924 "Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
10925 );
10926 }
10927
10928 #[test]
10929 fn derive_download_filename_omits_optional_components() {
10930 let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
10931 let installer = Installer {
10932 architecture: Some("x64".to_owned()),
10933 installer_type: Some("exe".to_owned()),
10934 ..Installer::default()
10935 };
10936 assert_eq!(
10938 derive_download_manifest_filename(&manifest, &installer),
10939 "7-Zip_26.01_X64_exe.yaml"
10940 );
10941 }
10942
10943 #[test]
10944 fn derive_download_filename_skips_unknown_version() {
10945 let manifest = sample_manifest("Test.Package", "Unknown", "Test");
10946 let installer = Installer {
10947 architecture: Some("neutral".to_owned()),
10948 installer_type: Some("msi".to_owned()),
10949 ..Installer::default()
10950 };
10951 assert_eq!(
10952 derive_download_manifest_filename(&manifest, &installer),
10953 "Test_Neutral_msi.yaml"
10954 );
10955 }
10956
10957 #[test]
10958 fn derive_download_filename_sanitizes_illegal_chars() {
10959 let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
10960 let installer = Installer {
10961 architecture: Some("x64".to_owned()),
10962 installer_type: Some("exe".to_owned()),
10963 ..Installer::default()
10964 };
10965 let filename = derive_download_manifest_filename(&manifest, &installer);
10967 assert!(!filename.contains('/') && !filename.contains(':'));
10968 assert!(filename.starts_with("Some_Bad_Name_1.0"));
10969 }
10970
10971 #[test]
10972 fn build_download_manifest_yaml_overrides_identity_fields() {
10973 let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
10974 let documents = serde_json::json!({
10977 "PackageIdentifier": "stale-id",
10978 "PackageVersion": "",
10979 "PackageName": "Old Name",
10980 "Channel": "preview",
10981 "Installers": [
10982 { "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
10983 { "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
10984 ],
10985 });
10986 let installer = Installer {
10987 architecture: Some("x64".to_owned()),
10988 installer_type: Some("exe".to_owned()),
10989 url: Some("https://example.com/a".to_owned()),
10990 ..Installer::default()
10991 };
10992 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
10993 assert!(yaml.contains("PackageIdentifier: App.Id"));
10994 assert!(yaml.contains("PackageVersion: 2.0.0"));
10995 assert!(yaml.contains("PackageName: App Name"));
10996 assert!(yaml.contains("ManifestType: merged"));
10997 assert!(!yaml.contains("Channel: preview"));
11000 assert_eq!(yaml.matches("InstallerUrl").count(), 1);
11002 assert!(yaml.contains("https://example.com/a"));
11003 assert!(!yaml.contains("https://example.com/b"));
11004 }
11005
11006 #[test]
11007 fn build_download_manifest_yaml_preserves_typed_channel() {
11008 let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
11009 manifest.channel = "beta".to_owned();
11010 let documents = serde_json::json!({
11011 "PackageIdentifier": "App.Id",
11012 "PackageVersion": "2.0.0",
11013 "Installers": [],
11014 });
11015 let installer = Installer::default();
11016 let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
11017 assert!(yaml.contains("Channel: beta"));
11018 }
11019
11020 fn temp_app_root(label: &str) -> PathBuf {
11021 static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
11022 std::env::temp_dir().join(format!(
11023 "pinget-rs-tests-{label}-{}-{}",
11024 std::process::id(),
11025 TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
11026 ))
11027 }
11028
11029 #[cfg(windows)]
11030 #[test]
11031 fn path_prefix_check_rejects_string_prefix_collisions() {
11032 assert!(!path_starts_with_case_insensitive(
11036 Path::new(r"C:\Foobar\bin.exe"),
11037 Path::new(r"C:\Foo"),
11038 ));
11039 assert!(!path_starts_with_case_insensitive(
11040 Path::new(r"C:\Foo\bin.exe.bak"),
11041 Path::new(r"C:\Foo\bin.exe"),
11042 ));
11043 }
11044
11045 #[cfg(windows)]
11046 #[test]
11047 fn path_prefix_check_accepts_real_children_and_self() {
11048 assert!(path_starts_with_case_insensitive(
11050 Path::new(r"C:\Foo\sub\bin.exe"),
11051 Path::new(r"C:\Foo"),
11052 ));
11053 assert!(path_starts_with_case_insensitive(
11055 Path::new(r"C:\Foo\bin.exe"),
11056 Path::new(r"C:\Foo\bin.exe"),
11057 ));
11058 }
11059
11060 #[cfg(windows)]
11061 #[test]
11062 fn path_prefix_check_is_case_insensitive() {
11063 assert!(path_starts_with_case_insensitive(
11066 Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
11067 Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
11068 ));
11069 }
11070
11071 #[cfg(windows)]
11072 #[test]
11073 fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
11074 let query = PackageQuery {
11075 source: Some("winget".to_owned()),
11076 ..PackageQuery::default()
11077 };
11078 assert_eq!(
11079 portable_source_identifier(&query),
11080 "Microsoft.Winget.Source_8wekyb3d8bbwe"
11081 );
11082 let query = PackageQuery {
11084 source: Some("WINGET".to_owned()),
11085 ..PackageQuery::default()
11086 };
11087 assert_eq!(
11088 portable_source_identifier(&query),
11089 "Microsoft.Winget.Source_8wekyb3d8bbwe"
11090 );
11091 }
11092
11093 #[cfg(windows)]
11094 #[test]
11095 fn portable_source_identifier_passes_through_custom_sources() {
11096 let query = PackageQuery {
11097 source: Some("internal-feed".to_owned()),
11098 ..PackageQuery::default()
11099 };
11100 assert_eq!(portable_source_identifier(&query), "internal-feed");
11101 }
11102
11103 #[cfg(windows)]
11104 #[test]
11105 fn portable_source_identifier_defaults_for_missing_or_empty_source() {
11106 let query = PackageQuery {
11107 source: None,
11108 ..PackageQuery::default()
11109 };
11110 assert_eq!(
11111 portable_source_identifier(&query),
11112 "Microsoft.Winget.Source_8wekyb3d8bbwe"
11113 );
11114 let query = PackageQuery {
11115 source: Some(String::new()),
11116 ..PackageQuery::default()
11117 };
11118 assert_eq!(
11119 portable_source_identifier(&query),
11120 "Microsoft.Winget.Source_8wekyb3d8bbwe"
11121 );
11122 }
11123
11124 #[cfg(windows)]
11125 #[test]
11126 fn portable_subkey_name_matches_winget_format() {
11127 assert_eq!(
11128 portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
11129 "BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
11130 );
11131 }
11132
11133 #[cfg(windows)]
11134 #[test]
11135 fn determine_portable_alias_prefers_nested_installer_alias() {
11136 let installer = Installer {
11137 nested_installer_files: vec![NestedInstallerFile {
11138 relative_file_path: "ripgrep/rg.exe".to_owned(),
11139 portable_command_alias: Some("rg".to_owned()),
11140 }],
11141 commands: vec!["other-name".to_owned()],
11142 ..Installer::default()
11143 };
11144 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
11145 }
11146
11147 #[cfg(windows)]
11148 #[test]
11149 fn determine_portable_alias_falls_back_to_commands() {
11150 let installer = Installer {
11151 commands: vec!["nuget".to_owned()],
11153 ..Installer::default()
11154 };
11155 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
11156
11157 let installer = Installer {
11159 nested_installer_files: vec![NestedInstallerFile {
11160 relative_file_path: "subdir/bin.exe".to_owned(),
11161 portable_command_alias: None,
11162 }],
11163 commands: vec!["bin".to_owned()],
11164 ..Installer::default()
11165 };
11166 assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
11167 }
11168
11169 #[cfg(windows)]
11170 #[test]
11171 fn determine_portable_alias_returns_none_without_alias_or_commands() {
11172 let installer = Installer::default();
11173 assert!(determine_portable_alias(&installer).is_none());
11174 }
11175
11176 #[cfg(windows)]
11177 #[test]
11178 fn resolve_portable_install_location_prefers_request_override() {
11179 let result = resolve_portable_install_location_pure(
11180 Some(r"D:\Tools\rg"),
11181 Some(r"C:\Software\rg-test"),
11182 "Sub_Source",
11183 Path::new(r"C:\default"),
11184 );
11185 assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
11186 }
11187
11188 #[cfg(windows)]
11189 #[test]
11190 fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
11191 let result = resolve_portable_install_location_pure(
11195 None,
11196 Some(r"C:\Software\rg-test"),
11197 "Sub_Source",
11198 Path::new(r"C:\default"),
11199 );
11200 assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
11201 }
11202
11203 #[cfg(windows)]
11204 #[test]
11205 fn resolve_portable_install_location_falls_back_to_default_root() {
11206 let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
11207 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11208 }
11209
11210 #[cfg(windows)]
11211 #[test]
11212 fn resolve_portable_install_location_treats_empty_strings_as_unset() {
11213 let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
11216 assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
11217 }
11218
11219 #[cfg(windows)]
11220 #[test]
11221 fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
11222 let root = std::env::temp_dir().join(format!(
11223 "pinget-clean-test-{}",
11224 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11225 ));
11226 fs::create_dir_all(&root).expect("create temp dir");
11227 fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
11228 fs::create_dir_all(root.join("nested")).expect("create nested dir");
11229 fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
11230
11231 clean_directory_contents(&root).expect("clean root contents");
11232
11233 assert!(root.is_dir(), "root dir itself must survive");
11234 assert_eq!(
11235 fs::read_dir(&root).expect("read root").count(),
11236 0,
11237 "root must be empty after clean"
11238 );
11239 let _ = fs::remove_dir_all(&root);
11240 }
11241
11242 #[cfg(windows)]
11243 #[test]
11244 fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
11245 let bogus = std::env::temp_dir().join(format!(
11246 "pinget-clean-test-bogus-{}",
11247 Utc::now().timestamp_nanos_opt().unwrap_or_default()
11248 ));
11249 assert!(!bogus.exists());
11250 clean_directory_contents(&bogus).expect("clean nonexistent dir");
11251 assert!(!bogus.exists());
11253 }
11254
11255 #[cfg(windows)]
11256 #[test]
11257 fn path_prefix_check_handles_mixed_separators() {
11258 assert!(path_starts_with_case_insensitive(
11261 Path::new(r"C:\Foo\sub/bin.exe"),
11262 Path::new(r"C:\Foo"),
11263 ));
11264 }
11265
11266 #[test]
11267 fn repository_options_capture_custom_host_settings() {
11268 let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
11269 .with_user_agent("pinget-rs-tests/1.0")
11270 .with_pre_indexed_source_auto_update_interval(None);
11271
11272 assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
11273 assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
11274 assert!(options.pre_indexed_source_auto_update_interval.is_none());
11275 }
11276
11277 #[test]
11278 fn dependency_install_requests_are_silent_and_not_forced() {
11279 let mut parent = InstallRequest::new(PackageQuery {
11280 source: Some("winget".to_owned()),
11281 ..PackageQuery::default()
11282 });
11283 parent.mode = InstallerMode::SilentWithProgress;
11284 parent.force = true;
11285 parent.accept_package_agreements = true;
11286 parent.ignore_security_hash = true;
11287 parent.dependency_source = Some("dependencies".to_owned());
11288
11289 let dependency =
11290 Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
11291
11292 assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
11293 assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
11294 assert_eq!(dependency.mode, InstallerMode::Silent);
11295 assert!(!dependency.force);
11296 assert!(dependency.no_upgrade);
11297 assert!(dependency.accept_package_agreements);
11298 assert!(dependency.ignore_security_hash);
11299 }
11300
11301 #[test]
11302 fn storage_paths_use_configured_app_root() {
11303 let app_root = PathBuf::from(r"C:\temp\pinget-test");
11304 let source = SourceRecord {
11305 name: "winget/test".to_owned(),
11306 kind: SourceKind::PreIndexed,
11307 arg: "https://example.com/cache".to_owned(),
11308 identifier: "Test.Source".to_owned(),
11309 trust_level: "None".to_owned(),
11310 explicit: false,
11311 priority: 0,
11312 last_update: None,
11313 source_version: None,
11314 etag: None,
11315 last_modified: None,
11316 };
11317
11318 assert_eq!(store_path(&app_root), app_root.join("sources.json"));
11319 assert_eq!(
11320 source_state_dir(&app_root, &source),
11321 app_root.join("sources").join("winget_test")
11322 );
11323 assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
11324 }
11325
11326 #[cfg(windows)]
11327 #[test]
11328 fn packaged_layout_paths_resolve_from_packaged_app_root() {
11329 let local_app_data = dirs::data_local_dir().expect("LocalAppData");
11330 let app_root = local_app_data
11331 .join("Packages")
11332 .join(PACKAGED_FAMILY_NAME)
11333 .join("LocalState");
11334 let source = SourceStore::default()
11335 .sources
11336 .into_iter()
11337 .find(|source| source.name == "winget")
11338 .expect("winget source");
11339 assert!(uses_packaged_layout(&app_root));
11340 assert!(
11341 user_settings_path(&app_root)
11342 .display()
11343 .to_string()
11344 .ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
11345 );
11346 assert!(
11347 packaged_file_cache_root(&app_root)
11348 .display()
11349 .to_string()
11350 .ends_with(&format!(
11351 r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
11352 PACKAGED_FAMILY_NAME
11353 ))
11354 );
11355 assert!(
11356 source_state_dir(&app_root, &source)
11357 .display()
11358 .to_string()
11359 .ends_with(&format!(
11360 r"Packages\{}\LocalState\Microsoft.PreIndexed.Package\Microsoft.Winget.Source_8wekyb3d8bbwe",
11361 PACKAGED_FAMILY_NAME
11362 ))
11363 );
11364 assert!(
11365 source_installed_db_path_with_keying(&app_root, &source, false)
11366 .display()
11367 .to_string()
11368 .ends_with(&format!(
11369 r"Packages\{}\LocalState\Microsoft.Winget.Source_8wekyb3d8bbwe\installed.db",
11370 PACKAGED_FAMILY_NAME
11371 ))
11372 );
11373 }
11374
11375 #[test]
11376 fn source_mode_parses_accepted_spellings() {
11377 assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto));
11378 assert_eq!(SourceMode::parse(" Auto "), Some(SourceMode::Auto));
11379 assert_eq!(SourceMode::parse("PRIVATE"), Some(SourceMode::Private));
11380 assert_eq!(
11381 SourceMode::parse("system-winget-mirror"),
11382 Some(SourceMode::SystemWingetMirror)
11383 );
11384 assert_eq!(
11385 SourceMode::parse("SystemWingetMirror"),
11386 Some(SourceMode::SystemWingetMirror)
11387 );
11388 }
11389
11390 #[test]
11391 fn source_mode_rejects_unknown_spellings() {
11392 assert_eq!(SourceMode::parse(""), None);
11393 assert_eq!(SourceMode::parse("mirror"), None);
11394 assert_eq!(SourceMode::parse("system_winget_mirror"), None);
11395 }
11396
11397 #[test]
11398 fn source_mode_resolution_keeps_private_default_for_a_custom_app_root() {
11399 assert_eq!(SourceMode::resolve(None, true), SourceMode::Private);
11400 assert_eq!(SourceMode::resolve(None, false), SourceMode::Auto);
11401 }
11402
11403 #[test]
11404 fn source_mode_resolution_lets_the_environment_override_a_custom_app_root() {
11405 assert_eq!(SourceMode::resolve(Some(SourceMode::Auto), true), SourceMode::Auto);
11406 assert_eq!(
11407 SourceMode::resolve(Some(SourceMode::SystemWingetMirror), true),
11408 SourceMode::SystemWingetMirror
11409 );
11410 assert_eq!(
11411 SourceMode::resolve(Some(SourceMode::Private), false),
11412 SourceMode::Private
11413 );
11414 }
11415
11416 #[cfg(windows)]
11417 #[test]
11418 fn default_app_root_outside_package_avoids_packaged_layout() {
11419 let prior = std::env::var_os(APP_ROOT_ENV_VAR);
11423 unsafe { std::env::remove_var(APP_ROOT_ENV_VAR) };
11425
11426 let result = default_app_root();
11427
11428 if let Some(prior) = prior {
11429 unsafe { std::env::set_var(APP_ROOT_ENV_VAR, prior) };
11431 }
11432
11433 let app_root = result.expect("default app root");
11434 assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
11435 assert!(
11436 app_root.ends_with(Path::new("Devolutions").join("Pinget")),
11437 "unexpected fallback app root: {app_root:?}"
11438 );
11439 }
11440
11441 #[test]
11442 fn packaged_source_stream_overlays_defaults_and_metadata() {
11443 let store = parse_packaged_source_store(
11444 Some(
11445 "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",
11446 ),
11447 Some(
11448 "Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n ETag: \"etag-v1\"\n LastModified: \"Wed, 03 Jun 2026 12:00:00 GMT\"\n",
11449 ),
11450 )
11451 .expect("packaged store");
11452
11453 assert!(!store.sources.iter().any(|source| source.name == "winget"));
11454 let corp = store
11455 .sources
11456 .iter()
11457 .find(|source| source.name == "corp")
11458 .expect("corp source");
11459 assert_eq!(corp.kind, SourceKind::Rest);
11460 assert_eq!(corp.identifier, "Contoso.Rest");
11461 assert_eq!(corp.trust_level, "Trusted");
11462 assert!(corp.explicit);
11463 assert_eq!(corp.priority, 7);
11464 assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
11465 assert_eq!(corp.etag.as_deref(), Some("etag-v1"));
11466 assert_eq!(corp.last_modified.as_deref(), Some("Wed, 03 Jun 2026 12:00:00 GMT"));
11467 assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
11468 }
11469
11470 #[test]
11471 fn system_winget_export_parses_json_lines() {
11472 let output = r#"{"Arg":"https://api.winget.pro/feed","Data":"","Explicit":false,"Identifier":"api.winget.pro","Name":"winget.pro","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11473{"Arg":"https://storeedgefd.dsx.mp.microsoft.com/v9.0","Data":"","Explicit":false,"Identifier":"StoreEdgeFD","Name":"msstore","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11474{"Arg":"https://cdn.winget.microsoft.com/cache","Data":"Microsoft.Winget.Source_8wekyb3d8bbwe","Explicit":false,"Identifier":"Microsoft.Winget.Source_8wekyb3d8bbwe","Name":"winget","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
11475{"Arg":"https://cdn.winget.microsoft.com/fonts","Data":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Microsoft.Winget.Fonts.Source_8wekyb3d8bbwe","Name":"winget-font","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
11476"#;
11477
11478 let sources = parse_system_winget_source_export(output).expect("source export");
11479
11480 assert_eq!(
11481 sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
11482 vec!["winget.pro", "msstore", "winget", "winget-font"]
11483 );
11484 assert_eq!(sources[0].kind, SourceKind::Rest);
11485 assert_eq!(sources[0].arg, "https://api.winget.pro/feed");
11486 assert_eq!(sources[0].identifier, "api.winget.pro");
11487 assert_eq!(sources[0].trust_level, "Trusted");
11488 assert!(!sources[0].explicit);
11489 assert_eq!(sources[2].kind, SourceKind::PreIndexed);
11490 assert_eq!(sources[2].identifier, "Microsoft.Winget.Source_8wekyb3d8bbwe");
11491 assert!(!sources[2].explicit);
11492 }
11493
11494 #[test]
11495 fn system_winget_export_parses_wrapped_sources() {
11496 let output = r#"{
11497 "Sources": [
11498 {
11499 "Name": "corp",
11500 "Type": "Microsoft.Rest",
11501 "Arg": "https://packages.contoso.test/api",
11502 "Identifier": "Contoso.Rest",
11503 "TrustLevel": "Trusted",
11504 "Explicit": true,
11505 "Priority": 9
11506 }
11507 ]
11508}"#;
11509
11510 let sources = parse_system_winget_source_export(output).expect("source export");
11511 let source = sources.first().expect("source");
11512 assert_eq!(sources.len(), 1);
11513 assert_eq!(source.name, "corp");
11514 assert_eq!(source.kind, SourceKind::Rest);
11515 assert_eq!(source.identifier, "Contoso.Rest");
11516 assert_eq!(source.trust_level, "Trusted");
11517 assert!(source.explicit);
11518 assert_eq!(source.priority, 9);
11519 }
11520
11521 #[test]
11522 fn system_winget_source_store_detects_secure_settings_stub() {
11523 assert!(is_secure_settings_stub(Some(
11524 "SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
11525 )));
11526 assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
11527 }
11528
11529 #[test]
11530 fn system_winget_source_store_builds_add_arguments() {
11531 let args = build_winget_source_add_arguments(
11532 "corp",
11533 "https://packages.contoso.test/api",
11534 SourceKind::Rest,
11535 "Trusted",
11536 true,
11537 );
11538
11539 assert_eq!(
11540 args,
11541 vec![
11542 "source",
11543 "add",
11544 "--name",
11545 "corp",
11546 "--arg",
11547 "https://packages.contoso.test/api",
11548 "--type",
11549 "Microsoft.Rest",
11550 "--disable-interactivity",
11551 "--trust-level",
11552 "trusted",
11553 "--explicit",
11554 ]
11555 );
11556 }
11557
11558 fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
11559 Ok(WingetSourceCommandResult {
11560 exit_code: 0,
11561 stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
11562"#
11563 .to_owned(),
11564 stderr: String::new(),
11565 })
11566 }
11567
11568 fn failing_system_winget_source_command(_args: &[String]) -> Result<WingetSourceCommandResult> {
11569 bail!("{SYSTEM_WINGET_PROGRAM_NAME} was not found on the PATH")
11570 }
11571
11572 static SYSTEM_WINGET_RUNNER_LOCK: Mutex<()> = Mutex::new(());
11573
11574 fn lock_system_winget_source_command_runner() -> std::sync::MutexGuard<'static, ()> {
11575 SYSTEM_WINGET_RUNNER_LOCK
11576 .lock()
11577 .unwrap_or_else(|poisoned| poisoned.into_inner())
11578 }
11579
11580 fn swap_system_winget_source_command_runner(runner: WingetSourceCommandRunner) -> WingetSourceCommandRunner {
11581 let mut current = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11582 let previous = *current;
11583 *current = runner;
11584 previous
11585 }
11586
11587 fn search_path_of(directories: &[&Path]) -> OsString {
11588 std::env::join_paths(directories).expect("search path")
11589 }
11590
11591 #[test]
11592 fn configured_system_winget_program_wins_over_the_search_path() {
11593 let configured = PathBuf::from("tools").join(SYSTEM_WINGET_PROGRAM_NAME);
11594 let on_path = PathBuf::from("windows");
11595
11596 let program = resolve_system_winget_program_from(
11597 Some(configured.as_os_str().to_os_string()),
11598 None,
11599 Some(search_path_of(&[on_path.as_path()])),
11600 &Vec::new,
11601 &|candidate| candidate == configured || candidate == on_path.join(SYSTEM_WINGET_PROGRAM_NAME),
11602 )
11603 .expect("program");
11604
11605 assert_eq!(program, configured);
11606 }
11607
11608 #[test]
11609 fn configured_system_winget_directory_resolves_to_the_executable() {
11610 let directory = PathBuf::from("tools");
11611 let program = directory.join(SYSTEM_WINGET_PROGRAM_NAME);
11612
11613 let resolved = resolve_system_winget_program_from(
11614 Some(directory.as_os_str().to_os_string()),
11615 None,
11616 None,
11617 &Vec::new,
11618 &|candidate| candidate == program,
11619 )
11620 .expect("program");
11621
11622 assert_eq!(resolved, program);
11623 }
11624
11625 #[test]
11626 fn configured_system_winget_program_that_is_missing_is_reported() {
11627 let error = resolve_system_winget_program_from(
11628 Some(PathBuf::from("tools").into_os_string()),
11629 None,
11630 None,
11631 &Vec::new,
11632 &|_| false,
11633 )
11634 .expect_err("missing configured program");
11635
11636 assert!(
11637 error.to_string().contains(SYSTEM_WINGET_PROGRAM_ENV_VAR),
11638 "unexpected error: {error}"
11639 );
11640 }
11641
11642 #[test]
11643 fn system_winget_program_is_found_on_the_search_path() {
11644 let first = PathBuf::from("empty");
11645 let second = PathBuf::from("windows");
11646 let program = second.join(SYSTEM_WINGET_PROGRAM_NAME);
11647
11648 let resolved = resolve_system_winget_program_from(
11649 None,
11650 None,
11651 Some(search_path_of(&[first.as_path(), second.as_path()])),
11652 &Vec::new,
11653 &|candidate| candidate == program,
11654 )
11655 .expect("program");
11656
11657 assert_eq!(resolved, program);
11658 }
11659
11660 #[test]
11661 fn system_winget_program_falls_back_to_the_app_installer_locations() {
11662 let on_path = PathBuf::from("windows");
11663 let alias_directory = PathBuf::from("WindowsApps");
11664 let program = alias_directory.join(SYSTEM_WINGET_PROGRAM_NAME);
11665
11666 let resolved = resolve_system_winget_program_from(
11667 None,
11668 None,
11669 Some(search_path_of(&[on_path.as_path()])),
11670 &|| vec![alias_directory.clone()],
11671 &|candidate| candidate == program,
11672 )
11673 .expect("program");
11674
11675 assert_eq!(resolved, program);
11676 }
11677
11678 #[test]
11679 fn unresolvable_system_winget_program_points_at_the_environment_variable() {
11680 let error = resolve_system_winget_program_from(
11681 None,
11682 None,
11683 Some(search_path_of(&[Path::new("windows")])),
11684 &Vec::new,
11685 &|_| false,
11686 )
11687 .expect_err("no program");
11688
11689 assert!(
11690 error.to_string().contains(SYSTEM_WINGET_PROGRAM_ENV_VAR),
11691 "unexpected error: {error}"
11692 );
11693 }
11694
11695 #[test]
11696 fn the_application_directory_keeps_precedence_over_the_search_path() {
11697 let application_directory = PathBuf::from("app");
11698 let on_path = PathBuf::from("windows");
11699 let bundled = application_directory.join(SYSTEM_WINGET_PROGRAM_NAME);
11700 let on_path_program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME);
11701
11702 let resolved = resolve_system_winget_program_from(
11703 None,
11704 Some(application_directory),
11705 Some(search_path_of(&[on_path.as_path()])),
11706 &Vec::new,
11707 &|candidate| candidate == bundled || candidate == on_path_program,
11708 )
11709 .expect("program");
11710
11711 assert_eq!(resolved, bundled);
11712 }
11713
11714 #[test]
11715 fn the_app_installer_locations_are_not_enumerated_when_the_path_resolves_winget() {
11716 let on_path = PathBuf::from("windows");
11717 let program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME);
11718 let enumerated = AtomicBool::new(false);
11719
11720 let resolved = resolve_system_winget_program_from(
11721 None,
11722 None,
11723 Some(search_path_of(&[on_path.as_path()])),
11724 &|| {
11725 enumerated.store(true, AtomicOrdering::SeqCst);
11726 Vec::new()
11727 },
11728 &|candidate| candidate == program,
11729 )
11730 .expect("program");
11731
11732 assert_eq!(resolved, program);
11733 assert!(!enumerated.load(AtomicOrdering::SeqCst));
11734 }
11735
11736 #[test]
11737 fn a_blank_configured_system_winget_program_falls_through_to_the_path() {
11738 let on_path = PathBuf::from("windows");
11739 let program = on_path.join(SYSTEM_WINGET_PROGRAM_NAME);
11740
11741 let resolved = resolve_system_winget_program_from(
11742 Some(OsString::from(" ")),
11743 None,
11744 Some(search_path_of(&[on_path.as_path()])),
11745 &Vec::new,
11746 &|candidate| candidate == program,
11747 )
11748 .expect("program");
11749
11750 assert_eq!(resolved, program);
11751 }
11752
11753 #[test]
11754 fn mirror_freshness_follows_the_export_stamp_not_the_store_file() {
11755 let app_root = temp_app_root("mirror-freshness");
11756 assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900)));
11757
11758 save_system_winget_mirror_store(&app_root, &SourceStore::default()).expect("save mirror");
11760 assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900)));
11761
11762 stamp_system_winget_mirror_export(&app_root);
11763 assert!(system_winget_mirror_is_fresh(&app_root, StdDuration::from_secs(900)));
11764 assert!(!system_winget_mirror_is_fresh(&app_root, StdDuration::ZERO));
11765
11766 let _ = fs::remove_dir_all(&app_root);
11767 }
11768
11769 #[test]
11770 fn a_query_keeps_the_cached_mirror_when_the_system_winget_cannot_be_run() {
11771 let _runner_guard = lock_system_winget_source_command_runner();
11772 let app_root = temp_app_root("mirror-refresh-failure");
11773 let previous = swap_system_winget_source_command_runner(fake_system_winget_source_export);
11774 let opened = Repository::open_with_options(
11775 RepositoryOptions::new(app_root.clone()).with_source_mode(SourceMode::SystemWingetMirror),
11776 );
11777 swap_system_winget_source_command_runner(previous);
11778
11779 let result = (|| -> Result<()> {
11780 let mut repository = opened?;
11781 let cached: Vec<String> = repository
11782 .list_sources()
11783 .into_iter()
11784 .map(|source| source.name)
11785 .collect();
11786 assert_eq!(cached, vec!["contoso".to_owned()]);
11787
11788 let previous = swap_system_winget_source_command_runner(failing_system_winget_source_command);
11789 let warning = repository.refresh_system_winget_sources_for_query_with_max_age(StdDuration::ZERO);
11790 swap_system_winget_source_command_runner(previous);
11791
11792 let warning = warning.expect("refresh warning");
11793 assert!(
11794 warning.contains("Could not refresh the system WinGet sources"),
11795 "{warning}"
11796 );
11797 let kept: Vec<String> = repository
11798 .list_sources()
11799 .into_iter()
11800 .map(|source| source.name)
11801 .collect();
11802 assert_eq!(kept, cached);
11803 Ok(())
11804 })();
11805
11806 let _ = fs::remove_dir_all(&app_root);
11807 result.expect("cached mirror query");
11808 }
11809
11810 #[test]
11811 fn a_query_does_not_re_export_a_fresh_mirror() {
11812 let _runner_guard = lock_system_winget_source_command_runner();
11813 let app_root = temp_app_root("mirror-refresh-fresh");
11814 let previous = swap_system_winget_source_command_runner(fake_system_winget_source_export);
11815 let opened = Repository::open_with_options(
11816 RepositoryOptions::new(app_root.clone()).with_source_mode(SourceMode::SystemWingetMirror),
11817 );
11818 swap_system_winget_source_command_runner(previous);
11819
11820 let result = (|| -> Result<()> {
11821 let mut repository = opened?;
11822 let previous = swap_system_winget_source_command_runner(failing_system_winget_source_command);
11823 let warning = repository.refresh_system_winget_sources_for_query();
11824 swap_system_winget_source_command_runner(previous);
11825
11826 assert!(warning.is_none(), "{warning:?}");
11827 Ok(())
11828 })();
11829
11830 let _ = fs::remove_dir_all(&app_root);
11831 result.expect("fresh mirror query");
11832 }
11833
11834 #[test]
11835 fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
11836 let _runner_guard = lock_system_winget_source_command_runner();
11837 let original_runner = {
11838 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11839 let original = *runner;
11840 *runner = fake_system_winget_source_export;
11841 original
11842 };
11843
11844 let result = load_packaged_store_from_streams(
11845 Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
11846 None,
11847 );
11848
11849 {
11850 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11851 *runner = original_runner;
11852 }
11853
11854 let store = result.expect("packaged store").expect("packaged store");
11855 let source = store.sources.first().expect("source");
11856 assert_eq!(store.sources.len(), 1);
11857 assert_eq!(source.name, "contoso");
11858 assert_eq!(source.kind, SourceKind::Rest);
11859 }
11860
11861 #[test]
11862 fn system_winget_mirror_store_uses_private_cache_and_preserves_metadata() {
11863 let _runner_guard = lock_system_winget_source_command_runner();
11864 let original_runner = {
11865 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11866 let original = *runner;
11867 *runner = fake_system_winget_source_export;
11868 original
11869 };
11870
11871 let app_root = temp_app_root("system-winget-mirror");
11872 let result = load_effective_source_store(&app_root, SourceMode::SystemWingetMirror);
11873
11874 {
11875 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11876 *runner = original_runner;
11877 }
11878
11879 let (mode, mut store) = result.expect("mirror store");
11880 assert_eq!(mode, EffectiveSourceMode::SystemWingetMirror);
11881 assert!(system_winget_mirror_store_path(&app_root).exists());
11882 assert!(
11883 !store_path(&app_root).exists(),
11884 "mirror mode must not write private sources.json"
11885 );
11886
11887 let source = store.sources.first_mut().expect("source");
11888 source.last_update = Some(Utc::now());
11889 source.source_version = Some("cached-contract".to_owned());
11890 source.etag = Some("\"etag\"".to_owned());
11891 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
11892 save_system_winget_mirror_store(&app_root, &store).expect("save mirror");
11893
11894 let original_runner = {
11895 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11896 let original = *runner;
11897 *runner = fake_system_winget_source_export;
11898 original
11899 };
11900 let refreshed = refresh_system_winget_mirror_store(&app_root);
11901 {
11902 let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
11903 *runner = original_runner;
11904 }
11905
11906 let refreshed = refreshed.expect("refresh mirror");
11907 let source = refreshed.sources.first().expect("source");
11908 assert_eq!(source.source_version.as_deref(), Some("cached-contract"));
11909 assert_eq!(source.etag.as_deref(), Some("\"etag\""));
11910 assert_eq!(
11911 source_state_dir_with_keying(&app_root, source, true),
11912 app_root.join("sources").join("api.contoso.test")
11913 );
11914 assert_eq!(
11915 source_state_dir_with_keying(&app_root, source, false),
11916 app_root.join("sources").join("contoso")
11917 );
11918
11919 let _ = fs::remove_dir_all(&app_root);
11920 }
11921
11922 #[test]
11923 fn source_metadata_and_named_reset_round_trip() {
11924 let app_root = temp_app_root("source-reset");
11925 let result = (|| -> Result<()> {
11926 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11927 repository.add_source_with_metadata(
11928 "test",
11929 "https://example.com/test",
11930 SourceKind::Rest,
11931 Some("trusted"),
11932 true,
11933 4,
11934 )?;
11935
11936 repository.edit_source("test", Some(false), None)?;
11937 let edited = repository
11938 .list_sources()
11939 .into_iter()
11940 .find(|source| source.name == "test")
11941 .expect("source");
11942 assert_eq!(edited.trust_level, "Trusted");
11943 assert!(!edited.explicit);
11944 assert_eq!(edited.priority, 4);
11945
11946 repository.reset_source("test")?;
11947 let reset = repository
11948 .list_sources()
11949 .into_iter()
11950 .find(|source| source.name == "test")
11951 .expect("source");
11952 assert_eq!(reset.trust_level, "Trusted");
11953 assert!(!reset.explicit);
11954 assert_eq!(reset.priority, 4);
11955 assert!(reset.last_update.is_none());
11956 assert!(reset.source_version.is_none());
11957 Ok(())
11958 })();
11959
11960 let _ = fs::remove_dir_all(&app_root);
11961 result.expect("source metadata");
11962 }
11963
11964 #[test]
11965 fn explicit_sources_are_skipped_by_default_search_resolution() {
11966 let app_root = temp_app_root("source-resolution");
11967 let result = (|| -> Result<()> {
11968 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11969 repository.add_source_with_metadata(
11970 "explicit-test",
11971 "https://example.com/explicit",
11972 SourceKind::Rest,
11973 Some("trusted"),
11974 true,
11975 10,
11976 )?;
11977
11978 let indexes = repository.resolve_source_indexes(None)?;
11979 let names: Vec<_> = indexes
11980 .into_iter()
11981 .map(|index| repository.list_sources()[index].name.clone())
11982 .collect();
11983 assert!(!names.iter().any(|name| name == "explicit-test"));
11984 Ok(())
11985 })();
11986
11987 let _ = fs::remove_dir_all(&app_root);
11988 result.expect("source resolution");
11989 }
11990
11991 #[test]
11992 fn source_resolution_accepts_source_identifier() {
11993 let app_root = temp_app_root("source-identifier-resolution");
11994 let result = (|| -> Result<()> {
11995 let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
11996 repository.add_source_with_metadata(
11997 "winget.pro",
11998 "https://api.winget.pro/feed",
11999 SourceKind::Rest,
12000 Some("trusted"),
12001 false,
12002 0,
12003 )?;
12004 repository.store.sources[0].identifier = "api.winget.pro".to_owned();
12005
12006 let indexes = repository.resolve_source_indexes(Some("api.winget.pro"))?;
12007 assert_eq!(indexes, vec![0]);
12008 Ok(())
12009 })();
12010
12011 let _ = fs::remove_dir_all(&app_root);
12012 result.expect("source identifier resolution");
12013 }
12014
12015 #[test]
12016 fn show_metadata_retries_winget_manager_alias_only_for_clean_misses() {
12017 let query = PackageQuery {
12018 id: Some("tessl.tessl".to_owned()),
12019 source: Some("winget".to_owned()),
12020 exact: true,
12021 ..PackageQuery::default()
12022 };
12023 let clean_miss = SearchLocatedResult {
12024 matches: Vec::new(),
12025 warnings: Vec::new(),
12026 failures: Vec::new(),
12027 truncated: false,
12028 };
12029 assert!(should_retry_show_across_system_winget_sources(
12030 true,
12031 &query,
12032 &clean_miss
12033 ));
12034
12035 let private_mode = false;
12036 assert!(!should_retry_show_across_system_winget_sources(
12037 private_mode,
12038 &query,
12039 &clean_miss
12040 ));
12041
12042 let explicit_custom_source = PackageQuery {
12043 source: Some("winget.pro".to_owned()),
12044 ..query.clone()
12045 };
12046 assert!(!should_retry_show_across_system_winget_sources(
12047 true,
12048 &explicit_custom_source,
12049 &clean_miss
12050 ));
12051
12052 let failed_source = SearchLocatedResult {
12053 failures: vec![SourceSearchFailure {
12054 source_name: "winget".to_owned(),
12055 message: "network failure".to_owned(),
12056 }],
12057 ..clean_miss
12058 };
12059 assert!(!should_retry_show_across_system_winget_sources(
12060 true,
12061 &query,
12062 &failed_source
12063 ));
12064 }
12065
12066 #[test]
12067 fn user_and_admin_settings_round_trip() {
12068 let app_root = temp_app_root("settings");
12069 let result = (|| -> Result<()> {
12070 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
12071 repository.set_user_settings(
12072 &serde_json::json!({
12073 "visual": {
12074 "progressBar": "retro"
12075 }
12076 }),
12077 false,
12078 )?;
12079 repository.set_user_settings(
12080 &serde_json::json!({
12081 "experimentalFeatures": {
12082 "directMSI": true
12083 }
12084 }),
12085 true,
12086 )?;
12087
12088 let user_settings = repository.get_user_settings()?;
12089 assert_eq!(
12090 user_settings["visual"]["progressBar"],
12091 serde_json::Value::String("retro".to_owned())
12092 );
12093 assert_eq!(
12094 user_settings["experimentalFeatures"]["directMSI"],
12095 serde_json::Value::Bool(true)
12096 );
12097 assert!(repository.test_user_settings(
12098 &serde_json::json!({
12099 "experimentalFeatures": {
12100 "directMSI": true
12101 }
12102 }),
12103 true
12104 )?);
12105
12106 repository.set_admin_setting("LocalManifestFiles", true)?;
12107 repository.set_admin_setting("InstallerHashOverride", true)?;
12108 let admin_settings = repository.get_admin_settings()?;
12109 assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
12110 assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
12111
12112 repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
12113 let reset_one = repository.get_admin_settings()?;
12114 assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
12115
12116 repository.reset_admin_setting(None, true)?;
12117 let reset_all = repository.get_admin_settings()?;
12118 for name in Repository::supported_admin_settings() {
12119 assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
12120 }
12121 Ok(())
12122 })();
12123
12124 let _ = fs::remove_dir_all(&app_root);
12125 result.expect("settings");
12126 }
12127
12128 #[test]
12129 fn show_result_structured_document_is_manifest_oriented() {
12130 let result = ShowResult {
12131 package: SearchMatch {
12132 source_name: "winget".to_owned(),
12133 source_kind: SourceKind::PreIndexed,
12134 id: "Test.Package".to_owned(),
12135 name: "Test Package".to_owned(),
12136 moniker: Some("testpkg".to_owned()),
12137 version: Some("1.2.3".to_owned()),
12138 channel: Some("stable".to_owned()),
12139 match_criteria: Some("Id".to_owned()),
12140 },
12141 manifest: Manifest {
12142 id: "Test.Package".to_owned(),
12143 name: "Test Package".to_owned(),
12144 version: "1.2.3".to_owned(),
12145 channel: "stable".to_owned(),
12146 publisher: Some("Contoso".to_owned()),
12147 description: Some("Structured output".to_owned()),
12148 moniker: Some("testpkg".to_owned()),
12149 package_url: None,
12150 publisher_url: None,
12151 publisher_support_url: None,
12152 license: None,
12153 license_url: None,
12154 privacy_url: None,
12155 author: None,
12156 copyright: None,
12157 copyright_url: None,
12158 release_notes: None,
12159 release_notes_url: None,
12160 tags: vec!["utility".to_owned()],
12161 agreements: Vec::new(),
12162 package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
12163 documentation: vec![Documentation {
12164 label: Some("Docs".to_owned()),
12165 url: "https://example.test/docs".to_owned(),
12166 }],
12167 icons: vec![PackageIcon {
12168 icon_url: Some("https://example.test/default-256.ico".to_owned()),
12169 icon_file_type: Some("ico".to_owned()),
12170 icon_resolution: Some("256x256".to_owned()),
12171 icon_theme: Some("default".to_owned()),
12172 icon_sha256: Some("DEF456".to_owned()),
12173 }],
12174 installers: vec![Installer {
12175 architecture: Some("x64".to_owned()),
12176 installer_type: Some("msix".to_owned()),
12177 url: Some("https://example.test/Test.Package.msix".to_owned()),
12178 sha256: Some("ABC123".to_owned()),
12179 product_code: None,
12180 locale: Some("en-US".to_owned()),
12181 scope: Some("machine".to_owned()),
12182 release_date: None,
12183 package_family_name: None,
12184 upgrade_code: None,
12185 platforms: Vec::new(),
12186 minimum_os_version: None,
12187 switches: InstallerSwitches {
12188 silent: Some("/quiet".to_owned()),
12189 ..InstallerSwitches::default()
12190 },
12191 commands: vec!["testpkg".to_owned()],
12192 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
12193 require_explicit_upgrade: false,
12194 nested_installer_type: None,
12195 nested_installer_files: Vec::new(),
12196 }],
12197 require_explicit_upgrade: false,
12198 },
12199 selected_installer: Some(Installer {
12200 architecture: Some("x64".to_owned()),
12201 installer_type: Some("msix".to_owned()),
12202 url: Some("https://example.test/Test.Package.msix".to_owned()),
12203 sha256: Some("ABC123".to_owned()),
12204 product_code: None,
12205 locale: Some("en-US".to_owned()),
12206 scope: Some("machine".to_owned()),
12207 release_date: None,
12208 package_family_name: None,
12209 upgrade_code: None,
12210 platforms: Vec::new(),
12211 minimum_os_version: None,
12212 switches: InstallerSwitches {
12213 silent: Some("/quiet".to_owned()),
12214 ..InstallerSwitches::default()
12215 },
12216 commands: vec!["testpkg".to_owned()],
12217 package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
12218 require_explicit_upgrade: false,
12219 nested_installer_type: None,
12220 nested_installer_files: Vec::new(),
12221 }),
12222 cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
12223 warnings: vec!["cache warmed".to_owned()],
12224 manifest_documents: JsonValue::Array(vec![
12225 serde_json::json!({
12226 "PackageIdentifier": "Test.Package",
12227 "PackageVersion": "1.2.3",
12228 "DefaultLocale": "en-US",
12229 "ManifestType": "version",
12230 "ManifestVersion": "1.10.0"
12231 }),
12232 serde_json::json!({
12233 "PackageIdentifier": "Test.Package",
12234 "PackageVersion": "1.2.3",
12235 "PackageLocale": "en-US",
12236 "PackageName": "Test Package",
12237 "Publisher": "Example",
12238 "License": "MIT",
12239 "ShortDescription": "Structured output",
12240 "Icons": [
12241 {
12242 "IconUrl": "https://example.test/default-256.ico",
12243 "IconFileType": "ico",
12244 "IconResolution": "256x256",
12245 "IconTheme": "default",
12246 "IconSha256": "DEF456"
12247 }
12248 ],
12249 "ManifestType": "defaultLocale",
12250 "ManifestVersion": "1.10.0"
12251 }),
12252 serde_json::json!({
12253 "PackageIdentifier": "Test.Package",
12254 "PackageVersion": "1.2.3",
12255 "ManifestType": "installer",
12256 "ManifestVersion": "1.10.0",
12257 "Installers": [
12258 {
12259 "Architecture": "x64",
12260 "InstallerType": "msix",
12261 "InstallerUrl": "https://example.test/Test.Package.msix",
12262 "InstallerSha256": "ABC123",
12263 "Commands": ["testpkg"],
12264 "InstallerSwitches": { "Silent": "/quiet" },
12265 "Dependencies": {
12266 "PackageDependencies": [
12267 { "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
12268 ]
12269 }
12270 }
12271 ]
12272 }),
12273 ]),
12274 };
12275
12276 let document = result.structured_document();
12277
12278 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
12279 assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
12280 assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
12281 assert_eq!(
12282 document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
12283 Some("Microsoft.VCRedist.2015+.x64")
12284 );
12285 assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
12286 assert_eq!(
12287 document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
12288 Some("/quiet")
12289 );
12290 assert_eq!(
12291 document["IconUrl"].as_str(),
12292 Some("https://example.test/default-256.ico")
12293 );
12294 assert_eq!(document["Icon"]["IconFileType"].as_str(), Some("ico"));
12295 assert_eq!(
12296 document["Icons"][0]["IconUrl"].as_str(),
12297 Some("https://example.test/default-256.ico")
12298 );
12299 }
12300
12301 #[test]
12302 fn manifest_parses_require_explicit_upgrade_at_top_level() {
12303 let yaml = r#"
12308PackageIdentifier: Test.Package
12309PackageVersion: 1.2.3
12310DefaultLocale: en-US
12311ManifestType: singleton
12312ManifestVersion: 1.10.0
12313PackageLocale: en-US
12314PackageName: Test Package
12315Publisher: Example
12316License: MIT
12317ShortDescription: explicit-upgrade fixture
12318RequireExplicitUpgrade: true
12319Installers:
12320 - Architecture: x64
12321 InstallerType: exe
12322 InstallerUrl: https://example.test/Test.Package.exe
12323 InstallerSha256: ABC123
12324"#;
12325 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12326 assert!(manifest.require_explicit_upgrade);
12327 }
12328
12329 #[test]
12330 fn manifest_parses_require_explicit_upgrade_on_installer() {
12331 let yaml = r#"
12335PackageIdentifier: Test.Package
12336PackageVersion: 1.2.3
12337DefaultLocale: en-US
12338ManifestType: singleton
12339ManifestVersion: 1.10.0
12340PackageLocale: en-US
12341PackageName: Test Package
12342Publisher: Example
12343License: MIT
12344ShortDescription: explicit-upgrade fixture
12345Installers:
12346 - Architecture: x64
12347 InstallerType: exe
12348 InstallerUrl: https://example.test/Test.Package.x64.exe
12349 InstallerSha256: ABC123
12350 - Architecture: arm64
12351 InstallerType: exe
12352 InstallerUrl: https://example.test/Test.Package.arm64.exe
12353 InstallerSha256: DEF456
12354 RequireExplicitUpgrade: true
12355"#;
12356 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12357 assert!(manifest.require_explicit_upgrade);
12358 assert!(!manifest.installers[0].require_explicit_upgrade);
12359 assert!(manifest.installers[1].require_explicit_upgrade);
12360 }
12361
12362 #[test]
12363 fn manifest_without_require_explicit_upgrade_defaults_to_false() {
12364 let yaml = r#"
12365PackageIdentifier: Test.Package
12366PackageVersion: 1.2.3
12367DefaultLocale: en-US
12368ManifestType: singleton
12369ManifestVersion: 1.10.0
12370PackageLocale: en-US
12371PackageName: Test Package
12372Publisher: Example
12373License: MIT
12374ShortDescription: baseline fixture
12375Installers:
12376 - Architecture: x64
12377 InstallerType: exe
12378 InstallerUrl: https://example.test/Test.Package.exe
12379 InstallerSha256: ABC123
12380"#;
12381 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12382 assert!(!manifest.require_explicit_upgrade);
12383 }
12384
12385 #[test]
12386 fn manifest_parses_icons_and_selects_best_icon_from_yaml() {
12387 let yaml = r#"
12388PackageIdentifier: Test.Package
12389PackageVersion: 1.2.3
12390DefaultLocale: en-US
12391ManifestType: singleton
12392ManifestVersion: 1.10.0
12393PackageLocale: en-US
12394PackageName: Test Package
12395Publisher: Example
12396License: MIT
12397ShortDescription: icon fixture
12398Icons:
12399 - IconUrl: https://example.test/dark-512.png
12400 IconFileType: png
12401 IconResolution: 512x512
12402 IconTheme: dark
12403 IconSha256: DARK
12404 - IconUrl: https://example.test/default-64.png
12405 IconFileType: png
12406 IconResolution: 64x64
12407 IconTheme: default
12408 - IconUrl: https://example.test/default-256.ico
12409 IconFileType: ico
12410 IconResolution: 256x256
12411 IconTheme: default
12412 - IconFileType: png
12413 IconResolution: 1024x1024
12414Installers:
12415 - Architecture: x64
12416 InstallerType: exe
12417 InstallerUrl: https://example.test/Test.Package.exe
12418 InstallerSha256: ABC123
12419"#;
12420 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12421
12422 assert_eq!(manifest.icons.len(), 4);
12423 assert_eq!(
12424 manifest.icons[0].icon_url.as_deref(),
12425 Some("https://example.test/dark-512.png")
12426 );
12427 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
12428 let selected = manifest.icon().expect("selected icon");
12429 assert_eq!(selected.icon_file_type.as_deref(), Some("ico"));
12430 assert_eq!(selected.icon_resolution.as_deref(), Some("256x256"));
12431 }
12432
12433 #[test]
12434 fn manifest_without_icons_returns_empty_icon_list_and_no_selection() {
12435 let yaml = r#"
12436PackageIdentifier: Test.Package
12437PackageVersion: 1.2.3
12438DefaultLocale: en-US
12439ManifestType: singleton
12440ManifestVersion: 1.10.0
12441PackageLocale: en-US
12442PackageName: Test Package
12443Publisher: Example
12444License: MIT
12445ShortDescription: no-icon fixture
12446Installers:
12447 - Architecture: x64
12448 InstallerType: exe
12449 InstallerUrl: https://example.test/Test.Package.exe
12450 InstallerSha256: ABC123
12451"#;
12452 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
12453
12454 assert!(manifest.icons.is_empty());
12455 assert!(manifest.icon().is_none());
12456 assert!(manifest.icon_url().is_none());
12457 }
12458
12459 #[test]
12460 fn rest_manifest_parses_icons_from_default_locale() {
12461 let response = serde_json::json!({
12462 "Data": {
12463 "PackageIdentifier": "Test.Package",
12464 "Versions": [
12465 {
12466 "PackageVersion": "1.2.3",
12467 "DefaultLocale": {
12468 "PackageName": "Test Package",
12469 "Publisher": "Example",
12470 "ShortDescription": "REST icon fixture",
12471 "Icons": [
12472 {
12473 "IconUrl": "https://example.test/default-128.png",
12474 "IconFileType": "png",
12475 "IconResolution": "128x128",
12476 "IconTheme": "default"
12477 },
12478 {
12479 "IconUrl": "https://example.test/default-256.ico",
12480 "IconFileType": "ico",
12481 "IconResolution": "256x256"
12482 }
12483 ]
12484 },
12485 "Installers": [
12486 {
12487 "Architecture": "x64",
12488 "InstallerType": "exe",
12489 "InstallerUrl": "https://example.test/Test.Package.exe",
12490 "InstallerSha256": "ABC123"
12491 }
12492 ],
12493 "ManifestVersion": "1.10.0"
12494 }
12495 ],
12496 "ManifestVersion": "1.10.0"
12497 }
12498 });
12499 let bytes = serde_json::to_vec(&response).expect("json");
12500
12501 let (manifest, document) = parse_rest_manifest(&bytes, "Test.Package", "1.2.3", "").expect("parse");
12502
12503 assert_eq!(manifest.icons.len(), 2);
12504 assert_eq!(manifest.icon_url(), Some("https://example.test/default-256.ico"));
12505 assert_eq!(
12506 document["Icons"][0]["IconUrl"].as_str(),
12507 Some("https://example.test/default-128.png")
12508 );
12509 }
12510
12511 #[test]
12512 fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
12513 let mut pkg = InstalledPackage {
12517 name: "Edge".to_owned(),
12518 local_id: r"ARP\Machine\X64\Edge".to_owned(),
12519 installed_version: "100.0".to_owned(),
12520 publisher: None,
12521 scope: Some("Machine".to_owned()),
12522 installer_category: Some("exe".to_owned()),
12523 install_location: None,
12524 package_family_names: Vec::new(),
12525 product_codes: Vec::new(),
12526 upgrade_codes: Vec::new(),
12527 correlated: Some(SearchMatch {
12528 source_name: "winget".to_owned(),
12529 source_kind: SourceKind::PreIndexed,
12530 id: "Microsoft.Edge".to_owned(),
12531 name: "Microsoft Edge".to_owned(),
12532 moniker: None,
12533 version: Some("110.0".to_owned()),
12534 channel: None,
12535 match_criteria: None,
12536 }),
12537 winget_package_identifier: None,
12538 winget_source_identifier: None,
12539 installed_version_canonical: false,
12540 correlated_requires_explicit_upgrade: true,
12541 correlated_lacks_compatible_installer: false,
12542 };
12543 let bulk_query = ListQuery {
12544 upgrade_only: true,
12545 ..ListQuery::default()
12546 };
12547 assert!(
12548 !installed_package_matches_upgrade_filter(&pkg, &bulk_query),
12549 "RequireExplicitUpgrade row must be hidden from bulk upgrade"
12550 );
12551
12552 let filtered_query = ListQuery {
12555 upgrade_only: true,
12556 id: Some("Microsoft.Edge".to_owned()),
12557 ..ListQuery::default()
12558 };
12559 assert!(
12560 installed_package_matches_upgrade_filter(&pkg, &filtered_query),
12561 "RequireExplicitUpgrade row must surface when the user filters for it explicitly"
12562 );
12563
12564 pkg.correlated_requires_explicit_upgrade = false;
12566 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12567 }
12568
12569 #[test]
12570 fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
12571 let mut pkg = InstalledPackage {
12572 name: "Foo".to_owned(),
12573 local_id: r"ARP\Machine\X64\Foo".to_owned(),
12574 installed_version: "1.0".to_owned(),
12575 publisher: None,
12576 scope: Some("Machine".to_owned()),
12577 installer_category: Some("exe".to_owned()),
12578 install_location: None,
12579 package_family_names: Vec::new(),
12580 product_codes: Vec::new(),
12581 upgrade_codes: Vec::new(),
12582 correlated: Some(SearchMatch {
12583 source_name: "winget".to_owned(),
12584 source_kind: SourceKind::PreIndexed,
12585 id: "Test.Foo".to_owned(),
12586 name: "Foo".to_owned(),
12587 moniker: None,
12588 version: Some("2.0".to_owned()),
12589 channel: None,
12590 match_criteria: None,
12591 }),
12592 winget_package_identifier: None,
12593 winget_source_identifier: None,
12594 installed_version_canonical: false,
12595 correlated_requires_explicit_upgrade: false,
12596 correlated_lacks_compatible_installer: true,
12597 };
12598 let bulk_query = ListQuery {
12599 upgrade_only: true,
12600 ..ListQuery::default()
12601 };
12602 assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12603
12604 let filtered_query = ListQuery {
12606 upgrade_only: true,
12607 id: Some("Test.Foo".to_owned()),
12608 ..ListQuery::default()
12609 };
12610 assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
12611
12612 pkg.correlated_lacks_compatible_installer = false;
12614 assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
12615 }
12616
12617 fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
12618 let installers = arches
12619 .iter()
12620 .map(|a| Installer {
12621 architecture: Some((*a).to_owned()),
12622 installer_type: None,
12623 url: None,
12624 sha256: None,
12625 product_code: None,
12626 locale: None,
12627 scope: None,
12628 release_date: None,
12629 package_family_name: None,
12630 upgrade_code: None,
12631 platforms: Vec::new(),
12632 minimum_os_version: None,
12633 switches: InstallerSwitches::default(),
12634 commands: Vec::new(),
12635 package_dependencies: Vec::new(),
12636 require_explicit_upgrade: false,
12637 nested_installer_type: None,
12638 nested_installer_files: Vec::new(),
12639 })
12640 .collect();
12641 Manifest {
12642 id: "Test".to_owned(),
12643 name: "Test".to_owned(),
12644 version: "1.0".to_owned(),
12645 channel: String::new(),
12646 publisher: None,
12647 description: None,
12648 moniker: None,
12649 package_url: None,
12650 publisher_url: None,
12651 publisher_support_url: None,
12652 license: None,
12653 license_url: None,
12654 privacy_url: None,
12655 author: None,
12656 copyright: None,
12657 copyright_url: None,
12658 release_notes: None,
12659 release_notes_url: None,
12660 tags: Vec::new(),
12661 agreements: Vec::new(),
12662 package_dependencies: Vec::new(),
12663 documentation: Vec::new(),
12664 icons: Vec::new(),
12665 installers,
12666 require_explicit_upgrade: false,
12667 }
12668 }
12669
12670 #[test]
12671 fn manifest_compatible_installer_neutral_always_matches() {
12672 assert!(manifest_has_compatible_installer(
12674 &synthetic_manifest_with_installer_arches(&["neutral"])
12675 ));
12676 }
12677
12678 #[test]
12679 fn manifest_compatible_installer_empty_installers_pass_through() {
12680 assert!(manifest_has_compatible_installer(
12683 &synthetic_manifest_with_installer_arches(&[])
12684 ));
12685 }
12686
12687 #[test]
12688 fn manifest_compatible_installer_rejects_alien_arch_only() {
12689 assert!(!manifest_has_compatible_installer(
12691 &synthetic_manifest_with_installer_arches(&["ppc"])
12692 ));
12693 }
12694
12695 #[test]
12696 fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
12697 assert!(manifest_has_compatible_installer(
12699 &synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
12700 ));
12701 }
12702
12703 #[test]
12704 fn lookup_unique_normalized_identity_returns_unique_match() {
12705 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12707 execute_batch_sql(
12708 &connection,
12709 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12710 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12711 INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
12712 INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
12713 )
12714 .expect("seed schema");
12715
12716 let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
12717 .expect("query")
12718 .expect("unique match");
12719 assert_eq!(rowid, 100);
12720 }
12721
12722 #[test]
12723 fn lookup_installed_db_identity_match_returns_product_code_match() {
12724 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12725 execute_batch_sql(
12726 &connection,
12727 "CREATE TABLE ids (rowid INTEGER PRIMARY KEY, id TEXT NOT NULL);\n\
12728 CREATE TABLE names (rowid INTEGER PRIMARY KEY, name TEXT NOT NULL);\n\
12729 CREATE TABLE manifest (rowid INTEGER PRIMARY KEY, id INT64 NOT NULL, name INT64 NOT NULL);\n\
12730 CREATE TABLE productcodes (rowid INTEGER PRIMARY KEY, productcode TEXT NOT NULL);\n\
12731 CREATE TABLE productcodes_map (manifest INT64 NOT NULL, productcode INT64 NOT NULL);\n\
12732 INSERT INTO ids VALUES (1, 'Git.Git');\n\
12733 INSERT INTO names VALUES (1, 'Git');\n\
12734 INSERT INTO manifest VALUES (10, 1, 1);\n\
12735 INSERT INTO productcodes VALUES (20, 'git_is1');\n\
12736 INSERT INTO productcodes_map VALUES (10, 20);",
12737 )
12738 .expect("seed schema");
12739 let package = InstalledPackage {
12740 name: "Git".to_owned(),
12741 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
12742 installed_version: "2.54.0".to_owned(),
12743 publisher: None,
12744 scope: None,
12745 installer_category: None,
12746 install_location: None,
12747 package_family_names: Vec::new(),
12748 product_codes: vec!["git_is1".to_owned()],
12749 upgrade_codes: Vec::new(),
12750 correlated: None,
12751 winget_package_identifier: None,
12752 winget_source_identifier: None,
12753 installed_version_canonical: false,
12754 correlated_requires_explicit_upgrade: false,
12755 correlated_lacks_compatible_installer: false,
12756 };
12757
12758 let (id, name, matched_by) = lookup_installed_db_identity_match(&connection, &package)
12759 .expect("query")
12760 .expect("product code match");
12761
12762 assert_eq!(id, "Git.Git");
12763 assert_eq!(name, "Git");
12764 assert_eq!(matched_by, "ProductCode");
12765 }
12766
12767 #[test]
12768 fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
12769 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12774 execute_batch_sql(
12775 &connection,
12776 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12777 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12778 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12779 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
12780 )
12781 .expect("seed schema");
12782
12783 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12784 assert!(rowid.is_none(), "ambiguous match must not correlate");
12785 }
12786
12787 #[test]
12788 fn lookup_unique_normalized_identity_requires_publisher_intersect() {
12789 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12793 execute_batch_sql(
12794 &connection,
12795 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12796 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12797 INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
12798 INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
12799 )
12800 .expect("seed schema");
12801
12802 let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
12803 assert_eq!(rowid, Some(100));
12804 }
12805
12806 #[test]
12807 fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
12808 let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
12811 execute_batch_sql(
12812 &connection,
12813 "CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
12814 CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
12815 INSERT INTO norm_names2 VALUES ('foo', 100);\n\
12816 INSERT INTO norm_publishers2 VALUES ('bar', 200);",
12817 )
12818 .expect("seed schema");
12819
12820 let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
12821 assert!(
12822 rowid.is_none(),
12823 "name matches package 100 but publisher matches 200 — no intersection"
12824 );
12825 }
12826
12827 #[test]
12828 fn parse_yaml_manifest_bundle_returns_singleton_document() {
12829 let yaml = r#"
12830PackageIdentifier: Test.Package
12831PackageVersion: 1.2.3
12832DefaultLocale: en-US
12833ManifestType: version
12834ManifestVersion: 1.10.0
12835---
12836PackageIdentifier: Test.Package
12837PackageVersion: 1.2.3
12838PackageLocale: en-US
12839PackageName: Test Package
12840Publisher: Example
12841License: MIT
12842ShortDescription: Structured output
12843ManifestType: defaultLocale
12844ManifestVersion: 1.10.0
12845---
12846PackageIdentifier: Test.Package
12847PackageVersion: 1.2.3
12848ManifestType: installer
12849ManifestVersion: 1.10.0
12850Installers:
12851 - Architecture: x64
12852 InstallerType: exe
12853 InstallerUrl: https://example.test/Test.Package.exe
12854 InstallerSha256: ABC123
12855"#;
12856
12857 let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
12858
12859 assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
12860 assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
12861 assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
12862 }
12863
12864 #[test]
12865 fn collapse_structured_documents_returns_plural_show_documents() {
12866 let documents = collapse_structured_documents(&[
12867 JsonValue::Array(vec![
12868 serde_json::json!({
12869 "PackageIdentifier": "Test.Package.One",
12870 "PackageVersion": "1.0.0",
12871 "DefaultLocale": "en-US",
12872 "ManifestType": "version",
12873 "ManifestVersion": "1.10.0"
12874 }),
12875 serde_json::json!({
12876 "PackageIdentifier": "Test.Package.One",
12877 "PackageVersion": "1.0.0",
12878 "PackageLocale": "en-US",
12879 "PackageName": "Test Package One",
12880 "ManifestType": "defaultLocale",
12881 "ManifestVersion": "1.10.0"
12882 }),
12883 serde_json::json!({
12884 "PackageIdentifier": "Test.Package.One",
12885 "PackageVersion": "1.0.0",
12886 "ManifestType": "installer",
12887 "ManifestVersion": "1.10.0",
12888 "Installers": [
12889 {
12890 "Architecture": "x64",
12891 "InstallerType": "exe",
12892 "InstallerUrl": "https://example.test/one.exe",
12893 "InstallerSha256": "ABC123"
12894 }
12895 ]
12896 }),
12897 ]),
12898 serde_json::json!({
12899 "PackageIdentifier": "Test.Package.Two",
12900 "PackageVersion": "2.0.0",
12901 "PackageLocale": "en-US",
12902 "PackageName": "Test Package Two",
12903 "ManifestType": "singleton",
12904 "ManifestVersion": "1.12.0"
12905 }),
12906 ]);
12907
12908 assert_eq!(documents.len(), 2);
12909 assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
12910 assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
12911 assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
12912 assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
12913 assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
12914 }
12915
12916 #[test]
12917 fn unsupported_action_result_marks_noop_and_warning() {
12918 let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
12919
12920 assert!(result.success);
12921 assert!(result.no_op);
12922 assert_eq!(result.exit_code, 0);
12923 assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
12924 }
12925
12926 #[test]
12927 fn create_install_no_op_result_honors_no_upgrade() {
12928 let mut request = InstallRequest::new(PackageQuery {
12929 id: Some("Contoso.App".to_owned()),
12930 ..PackageQuery::default()
12931 });
12932 request.no_upgrade = true;
12933
12934 let manifest = Manifest {
12935 id: "Contoso.App".to_owned(),
12936 name: "Contoso App".to_owned(),
12937 version: "2.0.0".to_owned(),
12938 channel: String::new(),
12939 publisher: None,
12940 description: None,
12941 moniker: None,
12942 package_url: None,
12943 publisher_url: None,
12944 publisher_support_url: None,
12945 license: None,
12946 license_url: None,
12947 privacy_url: None,
12948 author: None,
12949 copyright: None,
12950 copyright_url: None,
12951 release_notes: None,
12952 release_notes_url: None,
12953 tags: Vec::new(),
12954 agreements: Vec::new(),
12955 package_dependencies: Vec::new(),
12956 documentation: Vec::new(),
12957 icons: Vec::new(),
12958 installers: Vec::new(),
12959 require_explicit_upgrade: false,
12960 };
12961 let existing = ListMatch {
12962 name: "Contoso App".to_owned(),
12963 id: "Contoso.App".to_owned(),
12964 local_id: "Contoso.App".to_owned(),
12965 installed_version: "1.0.0".to_owned(),
12966 available_version: Some("2.0.0".to_owned()),
12967 source_name: Some("winget".to_owned()),
12968 publisher: None,
12969 scope: None,
12970 installer_category: None,
12971 install_location: None,
12972 package_family_names: Vec::new(),
12973 product_codes: Vec::new(),
12974 upgrade_codes: Vec::new(),
12975 };
12976
12977 let result =
12978 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
12979
12980 assert!(result.success);
12981 assert!(result.no_op);
12982 assert_eq!(result.version, "1.0.0");
12983 assert_eq!(
12984 result.warnings,
12985 vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
12986 );
12987 }
12988
12989 #[test]
12990 fn create_install_no_op_result_skips_reinstall_when_current() {
12991 let request = InstallRequest::new(PackageQuery {
12992 id: Some("Contoso.App".to_owned()),
12993 ..PackageQuery::default()
12994 });
12995
12996 let manifest = Manifest {
12997 id: "Contoso.App".to_owned(),
12998 name: "Contoso App".to_owned(),
12999 version: "2.0.0".to_owned(),
13000 channel: String::new(),
13001 publisher: None,
13002 description: None,
13003 moniker: None,
13004 package_url: None,
13005 publisher_url: None,
13006 publisher_support_url: None,
13007 license: None,
13008 license_url: None,
13009 privacy_url: None,
13010 author: None,
13011 copyright: None,
13012 copyright_url: None,
13013 release_notes: None,
13014 release_notes_url: None,
13015 tags: Vec::new(),
13016 agreements: Vec::new(),
13017 package_dependencies: Vec::new(),
13018 documentation: Vec::new(),
13019 icons: Vec::new(),
13020 installers: Vec::new(),
13021 require_explicit_upgrade: false,
13022 };
13023 let existing = ListMatch {
13024 name: "Contoso App".to_owned(),
13025 id: "Contoso.App".to_owned(),
13026 local_id: "Contoso.App".to_owned(),
13027 installed_version: "2.0.0".to_owned(),
13028 available_version: Some("2.0.0".to_owned()),
13029 source_name: Some("winget".to_owned()),
13030 publisher: None,
13031 scope: None,
13032 installer_category: None,
13033 install_location: None,
13034 package_family_names: Vec::new(),
13035 product_codes: Vec::new(),
13036 upgrade_codes: Vec::new(),
13037 };
13038
13039 let result =
13040 Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
13041
13042 assert!(result.success);
13043 assert!(result.no_op);
13044 assert_eq!(result.version, "2.0.0");
13045 assert_eq!(
13046 result.warnings,
13047 vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
13048 );
13049 }
13050
13051 #[test]
13052 fn collapse_structured_document_projects_merged_manifest() {
13053 let document = collapse_structured_document(&serde_json::json!({
13054 "PackageIdentifier": "Test.Package",
13055 "PackageVersion": "1.2.3",
13056 "PackageLocale": "en-US",
13057 "PackageName": "Test Package",
13058 "Publisher": "Example",
13059 "InstallerType": "exe",
13060 "Installers": [
13061 {
13062 "Architecture": "x64",
13063 "InstallerUrl": "https://example.test/Test.Package.exe",
13064 "InstallerSha256": "ABC123"
13065 }
13066 ],
13067 "ManifestType": "merged",
13068 "ManifestVersion": "1.10.0"
13069 }));
13070
13071 assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
13072 assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
13073 assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
13074 assert_eq!(document["InstallerType"].as_str(), Some("exe"));
13075 assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
13076 }
13077
13078 #[test]
13079 fn parses_fixture_manifest() {
13080 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
13081 .join("tests")
13082 .join("fixtures")
13083 .join("ManifestV1_28-Singleton.yaml");
13084 let bytes = fs::read(root).expect("fixture bytes");
13085 let manifest = parse_yaml_manifest(&bytes).expect("manifest");
13086 assert_eq!(manifest.id, "microsoft.msixsdk");
13087 assert_eq!(manifest.version, "1.7.32");
13088 assert_eq!(manifest.name, "MSIX SDK");
13089 assert!(!manifest.installers.is_empty());
13090 }
13091
13092 #[test]
13093 fn parses_installer_switches_from_yaml_manifest() {
13094 let yaml = r#"
13095PackageIdentifier: Test.Package
13096PackageVersion: 1.2.3
13097PackageName: Test Package
13098InstallerSwitches:
13099 SilentWithProgress: /SILENT
13100Installers:
13101 - Architecture: x64
13102 InstallerType: inno
13103 InstallerUrl: https://example.test/Test.Package.exe
13104 InstallerSha256: ABC123
13105 InstallerSwitches:
13106 Silent: /VERYSILENT
13107 Interactive: /HELP
13108"#;
13109
13110 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
13111 let installer = &manifest.installers[0];
13112
13113 assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
13114 assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
13115 assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
13116 }
13117
13118 #[test]
13119 fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
13120 let yaml = r#"
13121PackageIdentifier: Test.Package
13122PackageVersion: 1.2.3
13123PackageName: Test Package
13124Platform:
13125 - Windows.Desktop
13126MinimumOSVersion: 10.0.19041.0
13127Installers:
13128 - Architecture: x64
13129 InstallerType: exe
13130 InstallerUrl: https://example.test/Test.Package.exe
13131 InstallerSha256: ABC123
13132 - Architecture: x64
13133 InstallerType: msix
13134 Platform:
13135 - Windows.Universal
13136 MinimumOSVersion: 10.0.22621.0
13137 InstallerUrl: https://example.test/Test.Package.msix
13138 InstallerSha256: DEF456
13139"#;
13140
13141 let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
13142
13143 assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
13144 assert_eq!(
13145 manifest.installers[0].minimum_os_version.as_deref(),
13146 Some("10.0.19041.0")
13147 );
13148 assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
13149 assert_eq!(
13150 manifest.installers[1].minimum_os_version.as_deref(),
13151 Some("10.0.22621.0")
13152 );
13153 }
13154
13155 #[test]
13156 fn installer_switch_arguments_prefer_manifest_switches() {
13157 let installer = Installer {
13158 architecture: None,
13159 installer_type: Some("inno".to_owned()),
13160 url: None,
13161 sha256: None,
13162 product_code: None,
13163 locale: None,
13164 scope: None,
13165 release_date: None,
13166 package_family_name: None,
13167 upgrade_code: None,
13168 platforms: Vec::new(),
13169 minimum_os_version: None,
13170 switches: InstallerSwitches {
13171 silent: Some("/mysilent".to_owned()),
13172 silent_with_progress: Some("/mysilentwithprogress".to_owned()),
13173 interactive: Some("/myinteractive".to_owned()),
13174 ..InstallerSwitches::default()
13175 },
13176 commands: Vec::new(),
13177 package_dependencies: Vec::new(),
13178 require_explicit_upgrade: false,
13179 nested_installer_type: None,
13180 nested_installer_files: Vec::new(),
13181 };
13182 let manifest = Manifest {
13183 id: "Test.Package".to_owned(),
13184 name: "Test Package".to_owned(),
13185 version: "1.0.0".to_owned(),
13186 channel: String::new(),
13187 publisher: None,
13188 description: None,
13189 moniker: None,
13190 package_url: None,
13191 publisher_url: None,
13192 publisher_support_url: None,
13193 license: None,
13194 license_url: None,
13195 privacy_url: None,
13196 author: None,
13197 copyright: None,
13198 copyright_url: None,
13199 release_notes: None,
13200 release_notes_url: None,
13201 tags: Vec::new(),
13202 agreements: Vec::new(),
13203 package_dependencies: Vec::new(),
13204 documentation: Vec::new(),
13205 icons: Vec::new(),
13206 installers: Vec::new(),
13207 require_explicit_upgrade: false,
13208 };
13209
13210 let mut silent_request = InstallRequest::new(PackageQuery::default());
13211 silent_request.mode = InstallerMode::Silent;
13212 let mut progress_request = InstallRequest::new(PackageQuery::default());
13213 progress_request.mode = InstallerMode::SilentWithProgress;
13214 let mut interactive_request = InstallRequest::new(PackageQuery::default());
13215 interactive_request.mode = InstallerMode::Interactive;
13216 assert_eq!(
13217 installer_command_arguments(
13218 "inno",
13219 &silent_request,
13220 &manifest,
13221 Path::new("installer.exe"),
13222 &installer
13223 ),
13224 vec!["/mysilent".to_owned()]
13225 );
13226 assert_eq!(
13227 installer_command_arguments(
13228 "inno",
13229 &progress_request,
13230 &manifest,
13231 Path::new("installer.exe"),
13232 &installer
13233 ),
13234 vec!["/mysilentwithprogress".to_owned()]
13235 );
13236 assert_eq!(
13237 installer_command_arguments(
13238 "inno",
13239 &interactive_request,
13240 &manifest,
13241 Path::new("installer.exe"),
13242 &installer
13243 ),
13244 vec!["/myinteractive".to_owned()]
13245 );
13246 }
13247
13248 #[test]
13249 fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
13250 let installer = Installer {
13251 architecture: None,
13252 installer_type: Some("inno".to_owned()),
13253 url: None,
13254 sha256: None,
13255 product_code: None,
13256 locale: None,
13257 scope: None,
13258 release_date: None,
13259 package_family_name: None,
13260 upgrade_code: None,
13261 platforms: Vec::new(),
13262 minimum_os_version: None,
13263 switches: InstallerSwitches::default(),
13264 commands: Vec::new(),
13265 package_dependencies: Vec::new(),
13266 require_explicit_upgrade: false,
13267 nested_installer_type: None,
13268 nested_installer_files: Vec::new(),
13269 };
13270 let manifest = Manifest {
13271 id: "Test.Package".to_owned(),
13272 name: "Test Package".to_owned(),
13273 version: "1.0.0".to_owned(),
13274 channel: String::new(),
13275 publisher: None,
13276 description: None,
13277 moniker: None,
13278 package_url: None,
13279 publisher_url: None,
13280 publisher_support_url: None,
13281 license: None,
13282 license_url: None,
13283 privacy_url: None,
13284 author: None,
13285 copyright: None,
13286 copyright_url: None,
13287 release_notes: None,
13288 release_notes_url: None,
13289 tags: Vec::new(),
13290 agreements: Vec::new(),
13291 package_dependencies: Vec::new(),
13292 documentation: Vec::new(),
13293 icons: Vec::new(),
13294 installers: Vec::new(),
13295 require_explicit_upgrade: false,
13296 };
13297 let mut progress_request = InstallRequest::new(PackageQuery::default());
13298 progress_request.mode = InstallerMode::SilentWithProgress;
13299 let mut silent_request = InstallRequest::new(PackageQuery::default());
13300 silent_request.mode = InstallerMode::Silent;
13301
13302 assert_eq!(
13303 installer_command_arguments(
13304 "inno",
13305 &progress_request,
13306 &manifest,
13307 Path::new("installer.exe"),
13308 &installer
13309 ),
13310 vec![
13311 "/SP-".to_owned(),
13312 "/SILENT".to_owned(),
13313 "/SUPPRESSMSGBOXES".to_owned(),
13314 "/NORESTART".to_owned()
13315 ]
13316 );
13317 assert_eq!(
13318 installer_command_arguments(
13319 "inno",
13320 &silent_request,
13321 &manifest,
13322 Path::new("installer.exe"),
13323 &installer
13324 ),
13325 vec![
13326 "/SP-".to_owned(),
13327 "/VERYSILENT".to_owned(),
13328 "/SUPPRESSMSGBOXES".to_owned(),
13329 "/NORESTART".to_owned()
13330 ]
13331 );
13332 }
13333
13334 #[test]
13335 fn installer_command_arguments_append_manifest_and_cli_switches() {
13336 let installer = Installer {
13337 architecture: None,
13338 installer_type: Some("msi".to_owned()),
13339 url: None,
13340 sha256: None,
13341 product_code: None,
13342 locale: None,
13343 scope: None,
13344 release_date: None,
13345 package_family_name: None,
13346 upgrade_code: None,
13347 platforms: Vec::new(),
13348 minimum_os_version: None,
13349 switches: InstallerSwitches {
13350 custom: Some("ADDLOCAL=Core".to_owned()),
13351 log: Some("/log \"<LOGPATH>\"".to_owned()),
13352 install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
13353 ..InstallerSwitches::default()
13354 },
13355 commands: Vec::new(),
13356 package_dependencies: Vec::new(),
13357 require_explicit_upgrade: false,
13358 nested_installer_type: None,
13359 nested_installer_files: Vec::new(),
13360 };
13361 let manifest = Manifest {
13362 id: "ShareX.ShareX".to_owned(),
13363 name: "ShareX".to_owned(),
13364 version: "19.0.2".to_owned(),
13365 channel: String::new(),
13366 publisher: None,
13367 description: None,
13368 moniker: None,
13369 package_url: None,
13370 publisher_url: None,
13371 publisher_support_url: None,
13372 license: None,
13373 license_url: None,
13374 privacy_url: None,
13375 author: None,
13376 copyright: None,
13377 copyright_url: None,
13378 release_notes: None,
13379 release_notes_url: None,
13380 tags: Vec::new(),
13381 agreements: Vec::new(),
13382 package_dependencies: Vec::new(),
13383 documentation: Vec::new(),
13384 icons: Vec::new(),
13385 installers: Vec::new(),
13386 require_explicit_upgrade: false,
13387 };
13388 let mut request = InstallRequest::new(PackageQuery::default());
13389 request.mode = InstallerMode::Silent;
13390 request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
13391 request.custom = Some("REBOOT=ReallySuppress".to_owned());
13392 request.install_location = Some(r"C:\Apps\ShareX".to_owned());
13393
13394 assert_eq!(
13395 installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
13396 vec![
13397 "/i".to_owned(),
13398 r"C:\temp\ShareX.msi".to_owned(),
13399 "/quiet".to_owned(),
13400 "/norestart".to_owned(),
13401 "/log".to_owned(),
13402 r"C:\temp\winget.log".to_owned(),
13403 "ADDLOCAL=Core".to_owned(),
13404 "REBOOT=ReallySuppress".to_owned(),
13405 r"TARGETDIR=C:\Apps\ShareX".to_owned(),
13406 ]
13407 );
13408 }
13409
13410 #[test]
13411 fn msi_shell_execute_arguments_include_msiexec_target() {
13412 let installer = Installer {
13413 architecture: None,
13414 installer_type: Some("wix".to_owned()),
13415 url: None,
13416 sha256: None,
13417 product_code: None,
13418 locale: None,
13419 scope: None,
13420 release_date: None,
13421 package_family_name: None,
13422 upgrade_code: None,
13423 platforms: Vec::new(),
13424 minimum_os_version: None,
13425 switches: InstallerSwitches {
13426 log: Some("/log \"<LOGPATH>\"".to_owned()),
13427 install_location: Some("INSTALLDIR=\"<INSTALLPATH>\"".to_owned()),
13428 ..InstallerSwitches::default()
13429 },
13430 commands: Vec::new(),
13431 package_dependencies: Vec::new(),
13432 require_explicit_upgrade: false,
13433 nested_installer_type: None,
13434 nested_installer_files: Vec::new(),
13435 };
13436 let mut request = InstallRequest::new(PackageQuery::default());
13437 request.mode = InstallerMode::Silent;
13438 request.log_path = Some(PathBuf::from(r"C:\temp\node.log"));
13439 request.install_location = Some(r"C:\Program Files\nodejs".to_owned());
13440
13441 assert_eq!(
13442 installer_shell_execute_arguments(
13443 "wix",
13444 &request,
13445 &Manifest {
13446 id: "OpenJS.NodeJS.22".to_owned(),
13447 name: "Node.js".to_owned(),
13448 version: "22.22.3".to_owned(),
13449 channel: String::new(),
13450 publisher: None,
13451 description: None,
13452 moniker: None,
13453 package_url: None,
13454 publisher_url: None,
13455 publisher_support_url: None,
13456 license: None,
13457 license_url: None,
13458 privacy_url: None,
13459 author: None,
13460 copyright: None,
13461 copyright_url: None,
13462 release_notes: None,
13463 release_notes_url: None,
13464 tags: Vec::new(),
13465 agreements: Vec::new(),
13466 package_dependencies: Vec::new(),
13467 documentation: Vec::new(),
13468 icons: Vec::new(),
13469 installers: Vec::new(),
13470 require_explicit_upgrade: false,
13471 },
13472 Path::new(r"C:\temp\node.msi"),
13473 &installer
13474 ),
13475 vec![
13476 "/i".to_owned(),
13477 r"C:\temp\node.msi".to_owned(),
13478 "/quiet".to_owned(),
13479 "/norestart".to_owned(),
13480 "/log".to_owned(),
13481 r"C:\temp\node.log".to_owned(),
13482 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13483 ]
13484 );
13485 }
13486
13487 #[test]
13488 fn msi_direct_arguments_omit_msiexec_target() {
13489 let installer = Installer::default();
13490 let mut request = InstallRequest::new(PackageQuery::default());
13491 request.mode = InstallerMode::Silent;
13492
13493 assert_eq!(
13494 installer_direct_msi_arguments(
13495 "wix",
13496 &request,
13497 &Manifest {
13498 id: "Test.Package".to_owned(),
13499 name: "Test".to_owned(),
13500 version: "1.0.0".to_owned(),
13501 channel: String::new(),
13502 publisher: None,
13503 description: None,
13504 moniker: None,
13505 package_url: None,
13506 publisher_url: None,
13507 publisher_support_url: None,
13508 license: None,
13509 license_url: None,
13510 privacy_url: None,
13511 author: None,
13512 copyright: None,
13513 copyright_url: None,
13514 release_notes: None,
13515 release_notes_url: None,
13516 tags: Vec::new(),
13517 agreements: Vec::new(),
13518 package_dependencies: Vec::new(),
13519 documentation: Vec::new(),
13520 icons: Vec::new(),
13521 installers: Vec::new(),
13522 require_explicit_upgrade: false,
13523 },
13524 Path::new(r"C:\temp\test.msi"),
13525 &installer
13526 ),
13527 vec!["/quiet".to_owned(), "/norestart".to_owned()]
13528 );
13529 }
13530
13531 #[test]
13532 fn msi_direct_arguments_enable_uac_only_for_quiet_ui() {
13533 let args = vec![
13534 "/quiet".to_owned(),
13535 "/norestart".to_owned(),
13536 "/log".to_owned(),
13537 r"C:\temp\node.log".to_owned(),
13538 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13539 ];
13540
13541 let parsed = parse_msi_direct_arguments(&args).expect("parsed");
13542
13543 assert_eq!(parsed.ui_level, 0x102);
13544 assert_eq!(
13545 parsed.properties,
13546 r#"REBOOT=ReallySuppress INSTALLDIR="C:\Program Files\nodejs""#
13547 );
13548 assert_eq!(parsed.log_path.as_deref(), Some(r"C:\temp\node.log"));
13549 }
13550
13551 #[cfg(windows)]
13552 #[test]
13553 fn machine_scope_silent_msi_uses_shell_execute_instead_of_direct_msi() {
13554 let mut request = InstallRequest::new(PackageQuery::default());
13555 request.mode = InstallerMode::Silent;
13556
13557 let installer = Installer {
13558 installer_type: Some("wix".to_owned()),
13559 scope: Some("machine".to_owned()),
13560 ..Installer::default()
13561 };
13562
13563 assert!(!should_run_msi_direct(&request, &installer));
13564 assert!(should_elevate_msi_shell_execute(&installer));
13565 }
13566
13567 #[test]
13568 fn installer_download_if_range_prefers_strong_etag_and_rejects_weak_etag() {
13569 let strong = InstallerDownloadMetadata {
13570 url: "https://example.test/app.exe".to_owned(),
13571 etag: Some(r#""abc""#.to_owned()),
13572 last_modified: Some("Wed, 03 Jun 2026 21:00:00 GMT".to_owned()),
13573 total_length: Some(100),
13574 offset: 50,
13575 };
13576 let weak = InstallerDownloadMetadata {
13577 etag: Some(r#"W/"abc""#.to_owned()),
13578 ..strong.clone()
13579 };
13580
13581 assert_eq!(installer_download_if_range(&strong).as_deref(), Some(r#""abc""#));
13582 assert_eq!(
13583 installer_download_if_range(&weak).as_deref(),
13584 Some("Wed, 03 Jun 2026 21:00:00 GMT")
13585 );
13586 }
13587
13588 #[test]
13589 fn parse_content_range_validates_resume_start_and_total() {
13590 let value = HeaderValue::from_static("bytes 50-99/100");
13591 let parsed = parse_content_range(Some(&value), 50).expect("content range");
13592
13593 assert_eq!(parsed.total_length, Some(100));
13594 assert!(parse_content_range(Some(&value), 49).is_err());
13595 }
13596
13597 #[test]
13598 fn installer_download_sidecar_paths_append_suffixes() {
13599 let path = Path::new(r"C:\temp\installer.msi");
13600
13601 assert_eq!(sidecar_path(path, "part"), PathBuf::from(r"C:\temp\installer.msi.part"));
13602 assert_eq!(
13603 sidecar_path(path, "part.json"),
13604 PathBuf::from(r"C:\temp\installer.msi.part.json")
13605 );
13606 }
13607
13608 #[test]
13609 fn shell_execute_argument_joining_quotes_spaces() {
13610 assert_eq!(
13611 join_windows_arguments(&[
13612 "/quiet".to_owned(),
13613 r"INSTALLDIR=C:\Program Files\nodejs".to_owned(),
13614 r#"VALUE=has "quotes""#.to_owned(),
13615 ]),
13616 r#"/quiet "INSTALLDIR=C:\Program Files\nodejs" "VALUE=has \"quotes\"""#
13617 );
13618 }
13619
13620 #[test]
13621 fn decompresses_mszyml_payload() {
13622 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13623 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13624 encoder.write_all(payload.as_bytes()).expect("write payload");
13625 let compressed = encoder.finish().expect("finish payload");
13626 let mut mszip = b"CK".to_vec();
13627 mszip.extend_from_slice(&compressed);
13628
13629 let decompressed = decompress_mszyml(&mszip).expect("decompress");
13630 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13631 assert_eq!(parsed.versions[0].version, "1.2.3");
13632 }
13633
13634 #[test]
13635 fn decompresses_mszyml_payload_with_prefix() {
13636 let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
13637 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
13638 encoder.write_all(payload.as_bytes()).expect("write payload");
13639 let compressed = encoder.finish().expect("finish payload");
13640 let mut prefixed = vec![0u8; 28];
13641 prefixed.extend_from_slice(b"CK");
13642 prefixed.extend_from_slice(&compressed);
13643
13644 let decompressed = decompress_mszyml(&prefixed).expect("decompress");
13645 let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
13646 assert_eq!(parsed.versions[0].version, "1.2.3");
13647 }
13648
13649 #[test]
13650 fn compares_versions_naturally() {
13651 assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
13652 assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
13653 assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
13654 assert_eq!(compare_version("25.4.1", "< 25.4.1"), Ordering::Greater);
13655 assert_eq!(compare_version("< 25.4.1", "25.4.1"), Ordering::Less);
13656 }
13657
13658 #[test]
13659 fn installer_hash_verification_fails_on_mismatch_without_override() {
13660 let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
13661 assert!(result.is_err());
13662 assert!(
13663 result
13664 .expect_err("hash mismatch")
13665 .to_string()
13666 .contains("Installer hash mismatch")
13667 );
13668 }
13669
13670 #[test]
13671 fn installer_hash_verification_allows_mismatch_with_override() {
13672 verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
13673 verify_installer_hash(None, b"pinget", false).expect("missing hash");
13674 }
13675
13676 #[test]
13677 fn search_query_many_includes_tag_and_command_conditions() {
13678 let query = PackageQuery {
13679 query: Some("terminal".to_owned()),
13680 ..PackageQuery::default()
13681 };
13682
13683 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13684
13685 assert!(where_clause.contains("tags2"));
13686 assert!(where_clause.contains("commands2"));
13687 assert_eq!(params.len(), 5);
13688 }
13689
13690 #[test]
13691 fn tag_and_command_conditions_are_non_correlated() {
13692 let query = PackageQuery {
13699 query: Some("terminal".to_owned()),
13700 ..PackageQuery::default()
13701 };
13702
13703 let (where_clause, _params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13704
13705 assert!(where_clause.contains("packages.rowid IN (SELECT"));
13706 assert!(!where_clause.contains("EXISTS"));
13707 assert!(!where_clause.contains("= packages.rowid"));
13708 }
13709
13710 #[test]
13711 fn show_query_single_omits_tag_and_command_conditions() {
13712 let query = PackageQuery {
13713 query: Some("terminal".to_owned()),
13714 ..PackageQuery::default()
13715 };
13716
13717 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
13718
13719 assert!(!where_clause.contains("tags2"));
13720 assert!(!where_clause.contains("commands2"));
13721 assert_eq!(params.len(), 3);
13722 }
13723
13724 #[test]
13725 fn explicit_tag_filter_uses_exact_match() {
13726 let query = PackageQuery {
13727 tag: Some("terminal".to_owned()),
13728 ..PackageQuery::default()
13729 };
13730
13731 let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
13732
13733 assert!(where_clause.contains("tags2"));
13734 assert_eq!(params, vec!["terminal".to_owned()]);
13735 }
13736
13737 #[test]
13738 fn rest_tag_filter_uses_exact_match() {
13739 let query = PackageQuery {
13740 tag: Some("terminal".to_owned()),
13741 ..PackageQuery::default()
13742 };
13743 let info = RestInformation {
13744 required_package_match_fields: Vec::new(),
13745 unsupported_package_match_fields: Vec::new(),
13746 required_query_parameters: Vec::new(),
13747 ..RestInformation::default()
13748 };
13749
13750 let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
13751 let filters = body["Filters"].as_array().expect("filters");
13752
13753 assert_eq!(filters.len(), 1);
13754 assert_eq!(filters[0]["PackageMatchField"], "Tag");
13755 assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
13756 assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
13757 }
13758
13759 #[test]
13760 fn selects_installer_using_requested_filters() {
13761 let installers = vec![
13762 Installer {
13763 architecture: Some("x86".to_owned()),
13764 installer_type: Some("zip".to_owned()),
13765 url: None,
13766 sha256: None,
13767 product_code: None,
13768 locale: Some("en-US".to_owned()),
13769 scope: Some("user".to_owned()),
13770 release_date: None,
13771 package_family_name: None,
13772 upgrade_code: None,
13773 platforms: Vec::new(),
13774 minimum_os_version: None,
13775 switches: InstallerSwitches::default(),
13776 commands: Vec::new(),
13777 package_dependencies: Vec::new(),
13778 require_explicit_upgrade: false,
13779 nested_installer_type: None,
13780 nested_installer_files: Vec::new(),
13781 },
13782 Installer {
13783 architecture: Some("x64".to_owned()),
13784 installer_type: Some("msix".to_owned()),
13785 url: None,
13786 sha256: None,
13787 product_code: None,
13788 locale: Some("en-US".to_owned()),
13789 scope: Some("user".to_owned()),
13790 release_date: None,
13791 package_family_name: None,
13792 upgrade_code: None,
13793 platforms: Vec::new(),
13794 minimum_os_version: None,
13795 switches: InstallerSwitches::default(),
13796 commands: vec!["demo".to_owned()],
13797 package_dependencies: Vec::new(),
13798 require_explicit_upgrade: false,
13799 nested_installer_type: None,
13800 nested_installer_files: Vec::new(),
13801 },
13802 ];
13803 let query = PackageQuery {
13804 installer_type: Some("msix".to_owned()),
13805 installer_architecture: Some("x64".to_owned()),
13806 install_scope: Some("user".to_owned()),
13807 locale: Some("en-US".to_owned()),
13808 ..PackageQuery::default()
13809 };
13810
13811 let selected = select_installer(&installers, &query).expect("selected installer");
13812 assert_eq!(selected.installer_type.as_deref(), Some("msix"));
13813 assert_eq!(selected.architecture.as_deref(), Some("x64"));
13814 }
13815
13816 #[test]
13817 fn selects_first_installer_when_rank_ties() {
13818 let installers = vec![
13819 Installer {
13820 architecture: Some("x64".to_owned()),
13821 installer_type: Some("exe".to_owned()),
13822 url: None,
13823 sha256: None,
13824 product_code: None,
13825 locale: Some("en-US".to_owned()),
13826 scope: Some("machine".to_owned()),
13827 release_date: None,
13828 package_family_name: None,
13829 upgrade_code: None,
13830 platforms: Vec::new(),
13831 minimum_os_version: None,
13832 switches: InstallerSwitches::default(),
13833 commands: Vec::new(),
13834 package_dependencies: Vec::new(),
13835 require_explicit_upgrade: false,
13836 nested_installer_type: None,
13837 nested_installer_files: Vec::new(),
13838 },
13839 Installer {
13840 architecture: Some("x64".to_owned()),
13841 installer_type: Some("exe".to_owned()),
13842 url: None,
13843 sha256: None,
13844 product_code: None,
13845 locale: Some("en-US".to_owned()),
13846 scope: Some("user".to_owned()),
13847 release_date: None,
13848 package_family_name: None,
13849 upgrade_code: None,
13850 platforms: Vec::new(),
13851 minimum_os_version: None,
13852 switches: InstallerSwitches::default(),
13853 commands: Vec::new(),
13854 package_dependencies: Vec::new(),
13855 require_explicit_upgrade: false,
13856 nested_installer_type: None,
13857 nested_installer_files: Vec::new(),
13858 },
13859 ];
13860
13861 let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
13862 assert_eq!(selected.scope.as_deref(), Some("machine"));
13863 }
13864
13865 #[test]
13866 fn selects_installer_using_requested_platform() {
13867 let installers = vec![
13868 Installer {
13869 architecture: Some("x64".to_owned()),
13870 installer_type: Some("exe".to_owned()),
13871 url: None,
13872 sha256: None,
13873 product_code: None,
13874 locale: Some("en-US".to_owned()),
13875 scope: Some("user".to_owned()),
13876 release_date: None,
13877 package_family_name: None,
13878 upgrade_code: None,
13879 platforms: vec!["Windows.Universal".to_owned()],
13880 minimum_os_version: None,
13881 switches: InstallerSwitches::default(),
13882 commands: Vec::new(),
13883 package_dependencies: Vec::new(),
13884 require_explicit_upgrade: false,
13885 nested_installer_type: None,
13886 nested_installer_files: Vec::new(),
13887 },
13888 Installer {
13889 architecture: Some("x64".to_owned()),
13890 installer_type: Some("exe".to_owned()),
13891 url: None,
13892 sha256: None,
13893 product_code: None,
13894 locale: Some("en-US".to_owned()),
13895 scope: Some("user".to_owned()),
13896 release_date: None,
13897 package_family_name: None,
13898 upgrade_code: None,
13899 platforms: vec!["Windows.Desktop".to_owned()],
13900 minimum_os_version: None,
13901 switches: InstallerSwitches::default(),
13902 commands: Vec::new(),
13903 package_dependencies: Vec::new(),
13904 require_explicit_upgrade: false,
13905 nested_installer_type: None,
13906 nested_installer_files: Vec::new(),
13907 },
13908 ];
13909 let query = PackageQuery {
13910 installer_type: Some("exe".to_owned()),
13911 platform: Some("Windows.Desktop".to_owned()),
13912 ..PackageQuery::default()
13913 };
13914
13915 let selected = select_installer(&installers, &query).expect("selected installer");
13916 assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
13917 }
13918
13919 #[test]
13920 fn selects_installer_using_requested_os_version() {
13921 let installers = vec![
13922 Installer {
13923 architecture: Some("x64".to_owned()),
13924 installer_type: Some("exe".to_owned()),
13925 url: None,
13926 sha256: None,
13927 product_code: None,
13928 locale: Some("en-US".to_owned()),
13929 scope: Some("user".to_owned()),
13930 release_date: None,
13931 package_family_name: None,
13932 upgrade_code: None,
13933 platforms: Vec::new(),
13934 minimum_os_version: Some("10.0.22621.0".to_owned()),
13935 switches: InstallerSwitches::default(),
13936 commands: Vec::new(),
13937 package_dependencies: Vec::new(),
13938 require_explicit_upgrade: false,
13939 nested_installer_type: None,
13940 nested_installer_files: Vec::new(),
13941 },
13942 Installer {
13943 architecture: Some("x64".to_owned()),
13944 installer_type: Some("exe".to_owned()),
13945 url: None,
13946 sha256: None,
13947 product_code: None,
13948 locale: Some("en-US".to_owned()),
13949 scope: Some("user".to_owned()),
13950 release_date: None,
13951 package_family_name: None,
13952 upgrade_code: None,
13953 platforms: Vec::new(),
13954 minimum_os_version: Some("10.0.19041.0".to_owned()),
13955 switches: InstallerSwitches::default(),
13956 commands: Vec::new(),
13957 package_dependencies: Vec::new(),
13958 require_explicit_upgrade: false,
13959 nested_installer_type: None,
13960 nested_installer_files: Vec::new(),
13961 },
13962 ];
13963 let query = PackageQuery {
13964 installer_type: Some("exe".to_owned()),
13965 os_version: Some("10.0.19045.0".to_owned()),
13966 ..PackageQuery::default()
13967 };
13968
13969 let selected = select_installer(&installers, &query).expect("selected installer");
13970 assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
13971 }
13972
13973 #[test]
13974 fn derives_correlation_name_candidates() {
13975 let candidates = correlation_name_candidates("PowerToys (Preview) x64");
13976 assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
13977 assert!(candidates.contains(&"PowerToys".to_owned()));
13978 }
13979
13980 #[test]
13981 fn correlates_installed_package_to_available_match() {
13982 let installed = InstalledPackage {
13983 name: "PowerToys (Preview) x64".to_owned(),
13984 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
13985 installed_version: "0.98.1".to_owned(),
13986 publisher: None,
13987 scope: Some("Machine".to_owned()),
13988 installer_category: Some("exe".to_owned()),
13989 install_location: None,
13990 package_family_names: Vec::new(),
13991 product_codes: Vec::new(),
13992 upgrade_codes: Vec::new(),
13993 correlated: None,
13994 winget_package_identifier: None,
13995 winget_source_identifier: None,
13996 installed_version_canonical: false,
13997 correlated_requires_explicit_upgrade: false,
13998 correlated_lacks_compatible_installer: false,
13999 };
14000 let candidates = vec![SearchMatch {
14001 source_name: "winget".to_owned(),
14002 source_kind: SourceKind::PreIndexed,
14003 id: "Microsoft.PowerToys".to_owned(),
14004 name: "PowerToys".to_owned(),
14005 moniker: None,
14006 version: Some("0.98.1".to_owned()),
14007 channel: None,
14008 match_criteria: None,
14009 }];
14010
14011 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
14012 assert_eq!(correlated.id, "Microsoft.PowerToys");
14013 }
14014
14015 #[test]
14016 fn list_query_uses_available_lookup_for_tag_filters() {
14017 let query = ListQuery {
14018 tag: Some("terminal".to_owned()),
14019 ..ListQuery::default()
14020 };
14021
14022 assert!(list_query_needs_available_lookup(&query));
14023 }
14024
14025 #[test]
14026 fn list_tag_filter_requires_correlated_match() {
14027 let package = InstalledPackage {
14028 name: "PowerToys (Preview) x64".to_owned(),
14029 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
14030 installed_version: "0.98.1".to_owned(),
14031 publisher: None,
14032 scope: Some("Machine".to_owned()),
14033 installer_category: Some("exe".to_owned()),
14034 install_location: None,
14035 package_family_names: Vec::new(),
14036 product_codes: Vec::new(),
14037 upgrade_codes: Vec::new(),
14038 correlated: None,
14039 winget_package_identifier: None,
14040 winget_source_identifier: None,
14041 installed_version_canonical: false,
14042 correlated_requires_explicit_upgrade: false,
14043 correlated_lacks_compatible_installer: false,
14044 };
14045 let query = ListQuery {
14046 tag: Some("powertoys".to_owned()),
14047 ..ListQuery::default()
14048 };
14049
14050 assert!(!list_package_matches(&package, &query));
14051 }
14052
14053 #[test]
14054 fn list_lookup_ignores_display_count() {
14055 let query = ListQuery {
14056 query: Some("git".to_owned()),
14057 count: Some(5),
14058 ..ListQuery::default()
14059 };
14060
14061 let package_query = package_query_from_list_query(&query);
14062 assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
14063 }
14064
14065 #[test]
14066 fn correlation_tiebreaks_on_unnormalized_name_prefix() {
14067 let installed = InstalledPackage {
14072 name: "Notepad++ (ARM 64-bit)".to_owned(),
14073 local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
14074 installed_version: "8.8.9".to_owned(),
14075 publisher: None,
14076 scope: Some("Machine".to_owned()),
14077 installer_category: Some("exe".to_owned()),
14078 install_location: None,
14079 package_family_names: Vec::new(),
14080 product_codes: Vec::new(),
14081 upgrade_codes: Vec::new(),
14082 correlated: None,
14083 winget_package_identifier: None,
14084 winget_source_identifier: None,
14085 installed_version_canonical: false,
14086 correlated_requires_explicit_upgrade: false,
14087 correlated_lacks_compatible_installer: false,
14088 };
14089 let candidates = vec![
14090 SearchMatch {
14091 source_name: "winget".to_owned(),
14092 source_kind: SourceKind::PreIndexed,
14093 id: "ndd.Notepad--".to_owned(),
14094 name: "Notepad--".to_owned(),
14095 moniker: None,
14096 version: Some("1.0.0".to_owned()),
14097 channel: None,
14098 match_criteria: None,
14099 },
14100 SearchMatch {
14101 source_name: "winget".to_owned(),
14102 source_kind: SourceKind::PreIndexed,
14103 id: "Notepad++.Notepad++".to_owned(),
14104 name: "Notepad++".to_owned(),
14105 moniker: None,
14106 version: Some("8.9.5".to_owned()),
14107 channel: None,
14108 match_criteria: None,
14109 },
14110 ];
14111
14112 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
14113 assert_eq!(correlated.id, "Notepad++.Notepad++");
14114 }
14115
14116 #[test]
14117 fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
14118 let installed = InstalledPackage {
14123 name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
14124 local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
14125 installed_version: "40.10.18029".to_owned(),
14126 publisher: None,
14127 scope: Some("Machine".to_owned()),
14128 installer_category: Some("msi".to_owned()),
14129 install_location: None,
14130 package_family_names: Vec::new(),
14131 product_codes: Vec::new(),
14132 upgrade_codes: Vec::new(),
14133 correlated: None,
14134 winget_package_identifier: None,
14135 winget_source_identifier: None,
14136 installed_version_canonical: false,
14137 correlated_requires_explicit_upgrade: false,
14138 correlated_lacks_compatible_installer: false,
14139 };
14140 let candidates = vec![
14141 SearchMatch {
14142 source_name: "winget".to_owned(),
14143 source_kind: SourceKind::PreIndexed,
14144 id: "ZeroBrane.Studio".to_owned(),
14145 name: "Studio".to_owned(),
14146 moniker: None,
14147 version: Some("1.0".to_owned()),
14148 channel: None,
14149 match_criteria: None,
14150 },
14151 SearchMatch {
14152 source_name: "winget".to_owned(),
14153 source_kind: SourceKind::PreIndexed,
14154 id: "BrickLink.Studio".to_owned(),
14155 name: "Studio".to_owned(),
14156 moniker: None,
14157 version: Some("1.0".to_owned()),
14158 channel: None,
14159 match_criteria: None,
14160 },
14161 SearchMatch {
14162 source_name: "winget".to_owned(),
14163 source_kind: SourceKind::PreIndexed,
14164 id: "Microsoft.DotNet.SDK.10".to_owned(),
14165 name: "Microsoft .NET SDK 10.0".to_owned(),
14166 moniker: None,
14167 version: Some("10.0.204".to_owned()),
14168 channel: None,
14169 match_criteria: None,
14170 },
14171 ];
14172
14173 let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
14174 assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
14175 }
14176
14177 #[test]
14178 fn msix_packages_do_not_correlate_via_name() {
14179 let installed = InstalledPackage {
14187 name: "Microsoft Teams".to_owned(),
14188 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
14189 installed_version: "25290.205.4069.4894".to_owned(),
14190 publisher: None,
14191 scope: Some("User".to_owned()),
14192 installer_category: Some("msix".to_owned()),
14193 install_location: None,
14194 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
14195 product_codes: Vec::new(),
14196 upgrade_codes: Vec::new(),
14197 correlated: None,
14198 winget_package_identifier: None,
14199 winget_source_identifier: None,
14200 installed_version_canonical: false,
14201 correlated_requires_explicit_upgrade: false,
14202 correlated_lacks_compatible_installer: false,
14203 };
14204 let candidates = vec![SearchMatch {
14205 source_name: "winget".to_owned(),
14206 source_kind: SourceKind::PreIndexed,
14207 id: "Microsoft.Teams".to_owned(),
14208 name: "Microsoft Teams".to_owned(),
14209 moniker: None,
14210 version: Some("26106.1906.4665.7308".to_owned()),
14211 channel: None,
14212 match_criteria: None,
14213 }];
14214
14215 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
14216 }
14217
14218 #[test]
14219 fn name_fallback_refuses_ambiguous_winners() {
14220 let installed = InstalledPackage {
14228 name: "Git".to_owned(),
14229 local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
14230 installed_version: "2.53.0".to_owned(),
14231 publisher: Some("The Git Development Community".to_owned()),
14232 scope: Some("Machine".to_owned()),
14233 installer_category: Some("exe".to_owned()),
14234 install_location: None,
14235 package_family_names: Vec::new(),
14236 product_codes: Vec::new(),
14237 upgrade_codes: Vec::new(),
14238 correlated: None,
14239 winget_package_identifier: None,
14240 winget_source_identifier: None,
14241 installed_version_canonical: false,
14242 correlated_requires_explicit_upgrade: false,
14243 correlated_lacks_compatible_installer: false,
14244 };
14245 let candidates = vec![
14246 SearchMatch {
14247 source_name: "winget".to_owned(),
14248 source_kind: SourceKind::PreIndexed,
14249 id: "Git.Git".to_owned(),
14250 name: "Git".to_owned(),
14251 moniker: None,
14252 version: Some("2.54.0".to_owned()),
14253 channel: None,
14254 match_criteria: None,
14255 },
14256 SearchMatch {
14257 source_name: "winget".to_owned(),
14258 source_kind: SourceKind::PreIndexed,
14259 id: "Microsoft.Git".to_owned(),
14260 name: "Git".to_owned(),
14261 moniker: None,
14262 version: Some("2.53.0.0.7".to_owned()),
14263 channel: None,
14264 match_criteria: None,
14265 },
14266 ];
14267
14268 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
14269 }
14270
14271 #[test]
14272 fn msix_with_resource_string_name_does_not_correlate() {
14273 let installed = InstalledPackage {
14277 name: "ms-resource:appDisplayName".to_owned(),
14278 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
14279 installed_version: "1.28.239.0".to_owned(),
14280 publisher: None,
14281 scope: Some("User".to_owned()),
14282 installer_category: Some("msix".to_owned()),
14283 install_location: None,
14284 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
14285 product_codes: Vec::new(),
14286 upgrade_codes: Vec::new(),
14287 correlated: None,
14288 winget_package_identifier: None,
14289 winget_source_identifier: None,
14290 installed_version_canonical: false,
14291 correlated_requires_explicit_upgrade: false,
14292 correlated_lacks_compatible_installer: false,
14293 };
14294 let candidates = vec![SearchMatch {
14295 source_name: "winget".to_owned(),
14296 source_kind: SourceKind::PreIndexed,
14297 id: "Microsoft.AppInstaller".to_owned(),
14298 name: "App Installer".to_owned(),
14299 moniker: None,
14300 version: Some("1.28.240.0".to_owned()),
14301 channel: None,
14302 match_criteria: None,
14303 }];
14304
14305 assert!(correlate_installed_package(&installed, &candidates, true).is_none());
14306 }
14307
14308 #[test]
14309 fn strict_list_correlation_avoids_short_substring_matches() {
14310 let installed = InstalledPackage {
14311 name: "GitHub".to_owned(),
14312 local_id: r"ARP\User\X64\GitHub".to_owned(),
14313 installed_version: "1.0.0".to_owned(),
14314 publisher: None,
14315 scope: Some("User".to_owned()),
14316 installer_category: Some("exe".to_owned()),
14317 install_location: None,
14318 package_family_names: Vec::new(),
14319 product_codes: Vec::new(),
14320 upgrade_codes: Vec::new(),
14321 correlated: None,
14322 winget_package_identifier: None,
14323 winget_source_identifier: None,
14324 installed_version_canonical: false,
14325 correlated_requires_explicit_upgrade: false,
14326 correlated_lacks_compatible_installer: false,
14327 };
14328 let candidates = vec![SearchMatch {
14329 source_name: "winget".to_owned(),
14330 source_kind: SourceKind::PreIndexed,
14331 id: "Git.Git.PreRelease".to_owned(),
14332 name: "Git".to_owned(),
14333 moniker: None,
14334 version: Some("2.54.0".to_owned()),
14335 channel: None,
14336 match_criteria: None,
14337 }];
14338
14339 assert!(correlate_installed_package(&installed, &candidates, false).is_none());
14340 }
14341
14342 #[test]
14343 fn detects_available_upgrade_from_correlated_package() {
14344 let package = InstalledPackage {
14345 name: "AzCopy v10".to_owned(),
14346 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
14347 installed_version: "10.32.2".to_owned(),
14348 publisher: None,
14349 scope: Some("Machine".to_owned()),
14350 installer_category: Some("exe".to_owned()),
14351 install_location: None,
14352 package_family_names: Vec::new(),
14353 product_codes: Vec::new(),
14354 upgrade_codes: Vec::new(),
14355 correlated: Some(SearchMatch {
14356 source_name: "winget".to_owned(),
14357 source_kind: SourceKind::PreIndexed,
14358 id: "Microsoft.Azure.AZCopy.10".to_owned(),
14359 name: "AzCopy v10".to_owned(),
14360 moniker: None,
14361 version: Some("10.32.3".to_owned()),
14362 channel: None,
14363 match_criteria: None,
14364 }),
14365 winget_package_identifier: None,
14366 winget_source_identifier: None,
14367 installed_version_canonical: false,
14368 correlated_requires_explicit_upgrade: false,
14369 correlated_lacks_compatible_installer: false,
14370 };
14371
14372 assert!(installed_package_has_upgrade(&package));
14373 }
14374
14375 #[test]
14376 fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
14377 let package = InstalledPackage {
14378 name: "Example Tool".to_owned(),
14379 local_id: r"ARP\User\X64\ExampleTool".to_owned(),
14380 installed_version: "Unknown".to_owned(),
14381 publisher: None,
14382 scope: Some("User".to_owned()),
14383 installer_category: Some("exe".to_owned()),
14384 install_location: None,
14385 package_family_names: Vec::new(),
14386 product_codes: Vec::new(),
14387 upgrade_codes: Vec::new(),
14388 correlated: Some(SearchMatch {
14389 source_name: "winget".to_owned(),
14390 source_kind: SourceKind::PreIndexed,
14391 id: "Contoso.ExampleTool".to_owned(),
14392 name: "Example Tool".to_owned(),
14393 moniker: None,
14394 version: Some("2.0.0".to_owned()),
14395 channel: None,
14396 match_criteria: None,
14397 }),
14398 winget_package_identifier: None,
14399 winget_source_identifier: None,
14400 installed_version_canonical: false,
14401 correlated_requires_explicit_upgrade: false,
14402 correlated_lacks_compatible_installer: false,
14403 };
14404 let query = ListQuery {
14405 upgrade_only: true,
14406 include_unknown: true,
14407 ..Default::default()
14408 };
14409
14410 assert!(installed_package_matches_upgrade_filter(&package, &query));
14411 assert_eq!(
14412 list_match_from_installed(package, &[]).available_version.as_deref(),
14413 Some("2.0.0")
14414 );
14415 }
14416
14417 #[test]
14418 fn create_repair_list_query_includes_installed_selectors() {
14419 let request = RepairRequest {
14420 query: PackageQuery {
14421 query: Some("powertoys".to_owned()),
14422 id: Some("Microsoft.PowerToys".to_owned()),
14423 name: Some("PowerToys".to_owned()),
14424 moniker: Some("powertoys".to_owned()),
14425 source: Some("winget".to_owned()),
14426 exact: true,
14427 version: Some("0.98.1".to_owned()),
14428 install_scope: Some("Machine".to_owned()),
14429 ..PackageQuery::default()
14430 },
14431 manifest_path: None,
14432 product_code: Some("{GUID}".to_owned()),
14433 mode: InstallerMode::Silent,
14434 log_path: Some(PathBuf::from("repair.log")),
14435 accept_package_agreements: true,
14436 force: true,
14437 ignore_security_hash: true,
14438 };
14439
14440 let query = Repository::create_repair_list_query(&request);
14441 assert_eq!(query.query.as_deref(), Some("powertoys"));
14442 assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
14443 assert_eq!(query.name.as_deref(), Some("PowerToys"));
14444 assert_eq!(query.moniker.as_deref(), Some("powertoys"));
14445 assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
14446 assert_eq!(query.version.as_deref(), Some("0.98.1"));
14447 assert_eq!(query.source.as_deref(), Some("winget"));
14448 assert!(query.exact);
14449 assert_eq!(query.install_scope.as_deref(), Some("Machine"));
14450 assert_eq!(query.count, Some(100));
14451 }
14452
14453 #[test]
14454 fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
14455 let request = RepairRequest {
14456 query: PackageQuery {
14457 name: Some("PowerToys".to_owned()),
14458 source: Some("winget".to_owned()),
14459 locale: Some("en-US".to_owned()),
14460 installer_architecture: Some("x64".to_owned()),
14461 install_scope: Some("Machine".to_owned()),
14462 ..PackageQuery::default()
14463 },
14464 manifest_path: None,
14465 product_code: None,
14466 mode: InstallerMode::SilentWithProgress,
14467 log_path: Some(PathBuf::from("repair.log")),
14468 accept_package_agreements: true,
14469 force: false,
14470 ignore_security_hash: true,
14471 };
14472 let installed = ListMatch {
14473 name: "PowerToys".to_owned(),
14474 id: "Microsoft.PowerToys".to_owned(),
14475 local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
14476 installed_version: "0.98.1".to_owned(),
14477 available_version: Some("0.98.2".to_owned()),
14478 source_name: Some("winget".to_owned()),
14479 publisher: None,
14480 scope: Some("Machine".to_owned()),
14481 installer_category: Some("exe".to_owned()),
14482 install_location: None,
14483 package_family_names: Vec::new(),
14484 product_codes: Vec::new(),
14485 upgrade_codes: Vec::new(),
14486 };
14487
14488 let install_request = Repository::create_repair_install_request(&request, Some(&installed));
14489 assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
14490 assert!(install_request.query.query.is_none());
14491 assert!(install_request.query.name.is_none());
14492 assert!(install_request.query.moniker.is_none());
14493 assert_eq!(install_request.query.source.as_deref(), Some("winget"));
14494 assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
14495 assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
14496 assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
14497 assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
14498 assert!(install_request.query.exact);
14499 assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
14500 assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
14501 assert!(install_request.accept_package_agreements);
14502 assert!(install_request.force);
14503 assert!(install_request.ignore_security_hash);
14504 }
14505
14506 #[test]
14507 fn source_scoped_pin_operations_round_trip() {
14508 let app_root = temp_app_root("pins");
14509 let result = (|| -> Result<()> {
14510 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14511 repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
14512 repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
14513 repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
14514
14515 assert_eq!(repository.list_pins(None)?.len(), 3);
14516 assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
14517 assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
14518
14519 assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
14520 assert_eq!(repository.list_pins(None)?.len(), 2);
14521 assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
14522
14523 repository.reset_pins(Some("test"))?;
14524 let remaining = repository.list_pins(None)?;
14525 assert_eq!(remaining.len(), 1);
14526 assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
14527
14528 repository.reset_pins(None)?;
14529 assert!(repository.list_pins(None)?.is_empty());
14530 Ok(())
14531 })();
14532
14533 let _ = fs::remove_dir_all(&app_root);
14534 result.expect("pin round trip");
14535 }
14536
14537 #[test]
14538 fn pin_operations_work_with_packaged_pin_schema() {
14539 let app_root = temp_app_root("pins_packaged_schema");
14540 let result = (|| -> Result<()> {
14541 let db_path = pins_db_path(&app_root);
14542 if let Some(parent) = db_path.parent() {
14543 fs::create_dir_all(parent)?;
14544 }
14545
14546 let conn = Repository::open_sqlite_connection(db_path.clone())?;
14547 execute_batch_sql(
14548 &conn,
14549 "CREATE TABLE pin (
14550 package_id TEXT NOT NULL,
14551 source_id TEXT NOT NULL,
14552 type INTEGER NOT NULL,
14553 version TEXT NOT NULL,
14554 PRIMARY KEY (package_id, source_id)
14555 )",
14556 )?;
14557 conn.cacheflush().context("failed to flush pin database")?;
14558 drop(conn);
14559
14560 let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
14561 repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
14562
14563 let pins = repository.list_pins(None)?;
14564 assert_eq!(pins.len(), 1);
14565 assert_eq!(pins[0].package_id, "Contoso.Tool");
14566 assert_eq!(pins[0].version, "1.2.*");
14567 assert_eq!(pins[0].source_id, "winget");
14568 assert_eq!(pins[0].pin_type, PinType::Gating);
14569
14570 let conn = Repository::open_sqlite_connection(db_path)?;
14571 let stored_pin = query_optional_value(
14572 &conn,
14573 "SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
14574 vec![
14575 SqlValue::Text("Contoso.Tool".to_owned()),
14576 SqlValue::Text("winget".to_owned()),
14577 ],
14578 |row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
14579 )?
14580 .ok_or_else(|| anyhow!("expected stored pin"))?;
14581 assert_eq!(stored_pin.0, 3);
14582 assert_eq!(stored_pin.1, "1.2.*");
14583
14584 Ok(())
14585 })();
14586
14587 let _ = fs::remove_dir_all(&app_root);
14588 result.expect("packaged pin schema round trip");
14589 }
14590
14591 #[test]
14592 fn version_pin_patterns_match_exact_and_prefix_values() {
14593 assert!(version_matches_pin_pattern("1.2.3", "*"));
14594 assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
14595 assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
14596 assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
14597 assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
14598 }
14599
14600 #[test]
14601 fn source_specific_pins_override_source_agnostic_pins() {
14602 let item = ListMatch {
14603 name: "Contoso Tool".to_owned(),
14604 id: "Contoso.Tool".to_owned(),
14605 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14606 installed_version: "1.0.0".to_owned(),
14607 available_version: Some("2.0.0".to_owned()),
14608 source_name: Some("winget".to_owned()),
14609 publisher: None,
14610 scope: None,
14611 installer_category: None,
14612 install_location: None,
14613 package_family_names: Vec::new(),
14614 product_codes: Vec::new(),
14615 upgrade_codes: Vec::new(),
14616 };
14617 let pins = vec![
14618 PinRecord {
14619 package_id: "Contoso.Tool".to_owned(),
14620 version: "*".to_owned(),
14621 source_id: String::new(),
14622 pin_type: PinType::Blocking,
14623 },
14624 PinRecord {
14625 package_id: "Contoso.Tool".to_owned(),
14626 version: "2.0.*".to_owned(),
14627 source_id: "winget".to_owned(),
14628 pin_type: PinType::Pinning,
14629 },
14630 ];
14631
14632 assert!(!is_upgrade_blocked_by_pin(&item, &pins));
14633 }
14634
14635 #[test]
14636 fn blocking_pin_blocks_upgrade() {
14637 let item = ListMatch {
14638 name: "Contoso Tool".to_owned(),
14639 id: "Contoso.Tool".to_owned(),
14640 local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
14641 installed_version: "1.0.0".to_owned(),
14642 available_version: Some("2.0.0".to_owned()),
14643 source_name: Some("winget".to_owned()),
14644 publisher: None,
14645 scope: None,
14646 installer_category: None,
14647 install_location: None,
14648 package_family_names: Vec::new(),
14649 product_codes: Vec::new(),
14650 upgrade_codes: Vec::new(),
14651 };
14652 let pins = vec![PinRecord {
14653 package_id: "Contoso.Tool".to_owned(),
14654 version: "*".to_owned(),
14655 source_id: "winget".to_owned(),
14656 pin_type: PinType::Blocking,
14657 }];
14658
14659 assert!(is_upgrade_blocked_by_pin(&item, &pins));
14660 }
14661
14662 #[cfg(windows)]
14663 #[test]
14664 fn parses_msix_package_full_name_into_version_and_family() {
14665 let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
14666 .expect("package metadata");
14667
14668 assert_eq!(parsed.version, "0.98.1.0");
14669 assert_eq!(parsed.resource_id, "");
14670 assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
14671 assert!(!is_msix_split_resource_package(&parsed.resource_id));
14672 }
14673
14674 #[cfg(windows)]
14675 #[test]
14676 fn classifies_msix_split_resource_package() {
14677 let parsed = parse_msix_package_full_name(
14678 "Microsoft.XboxSpeechToTextOverlay_1.97.17002.0_neutral_split.scale-125_8wekyb3d8bbwe",
14679 )
14680 .expect("package metadata");
14681
14682 assert_eq!(parsed.version, "1.97.17002.0");
14683 assert_eq!(parsed.resource_id, "split.scale-125");
14684 assert_eq!(
14685 parsed.family_name,
14686 "Microsoft.XboxSpeechToTextOverlay_split.scale-125_8wekyb3d8bbwe"
14687 );
14688 assert!(is_msix_split_resource_package(&parsed.resource_id));
14689 }
14690
14691 #[cfg(windows)]
14692 #[test]
14693 fn recognizes_windows_system_paths() {
14694 assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
14695 assert!(!is_windows_system_path(
14696 r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
14697 ));
14698 }
14699
14700 #[test]
14701 fn search_ranking_prefers_exact_name_over_tag_match() {
14702 let query = PackageQuery {
14703 query: Some("PowerToys".to_owned()),
14704 ..Default::default()
14705 };
14706 let exact_name = SearchMatch {
14707 source_name: "winget".to_owned(),
14708 source_kind: SourceKind::PreIndexed,
14709 id: "Microsoft.PowerToys".to_owned(),
14710 name: "PowerToys".to_owned(),
14711 moniker: Some("powertoys".to_owned()),
14712 version: Some("0.98.1".to_owned()),
14713 channel: None,
14714 match_criteria: Some("Moniker: powertoys".to_owned()),
14715 };
14716 let tag_match = SearchMatch {
14717 source_name: "winget".to_owned(),
14718 source_kind: SourceKind::PreIndexed,
14719 id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
14720 name: "QR Codes for Command Palette".to_owned(),
14721 moniker: None,
14722 version: Some("0.4.0.0".to_owned()),
14723 channel: None,
14724 match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
14725 };
14726
14727 assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
14728 }
14729
14730 #[test]
14731 fn select_best_text_match_prefers_exact_tag_value() {
14732 let values = vec![
14733 "microsoft-powertoys".to_owned(),
14734 "powertoys-run".to_owned(),
14735 "powertoys".to_owned(),
14736 ];
14737
14738 assert_eq!(
14739 select_best_text_match(values, "PowerToys", false).as_deref(),
14740 Some("powertoys")
14741 );
14742 }
14743
14744 #[test]
14745 fn search_match_criteria_is_blank_for_name_match() {
14746 let query = PackageQuery {
14747 query: Some("PowerToys".to_owned()),
14748 ..Default::default()
14749 };
14750
14751 let result = infer_match_criteria(
14752 "Microsoft.PowerToys",
14753 "PowerToys",
14754 Some("powertoys"),
14755 &query,
14756 SearchSemantics::Many,
14757 |_| Ok(Some("powertoys".to_owned())),
14758 |_| Ok(None),
14759 )
14760 .expect("match criteria");
14761
14762 assert_eq!(result, None);
14763 }
14764
14765 #[test]
14766 fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
14767 let query = PackageQuery {
14768 query: Some("PowerToys".to_owned()),
14769 ..Default::default()
14770 };
14771 let unknown_version = SearchMatch {
14772 source_name: "msstore".to_owned(),
14773 source_kind: SourceKind::Rest,
14774 id: "XP89DCGQ3K6VLD".to_owned(),
14775 name: "Microsoft PowerToys".to_owned(),
14776 moniker: None,
14777 version: Some("Unknown".to_owned()),
14778 channel: None,
14779 match_criteria: None,
14780 };
14781 let tag_match = SearchMatch {
14782 source_name: "winget".to_owned(),
14783 source_kind: SourceKind::PreIndexed,
14784 id: "Riri.QRCodeforCmdPal".to_owned(),
14785 name: "QR Code for CmdPal".to_owned(),
14786 moniker: None,
14787 version: Some("0.0.1.0".to_owned()),
14788 channel: None,
14789 match_criteria: Some("Tag: powertoys".to_owned()),
14790 };
14791
14792 assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
14793 }
14794
14795 #[test]
14796 fn search_source_fetch_results_ignore_small_display_count() {
14797 let query = PackageQuery {
14798 count: Some(5),
14799 ..Default::default()
14800 };
14801
14802 assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
14803 assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
14804 }
14805
14806 #[test]
14807 fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
14808 let query = PackageQuery {
14809 query: Some("PowerToys".to_owned()),
14810 ..Default::default()
14811 };
14812 let prefix_name = SearchMatch {
14813 source_name: "winget".to_owned(),
14814 source_kind: SourceKind::PreIndexed,
14815 id: "advaith.CurrencyConverterPowerToys".to_owned(),
14816 name: "PowerToys-Run-Currency-Converter".to_owned(),
14817 moniker: None,
14818 version: Some("1.5.4".to_owned()),
14819 channel: None,
14820 match_criteria: None,
14821 };
14822 let exact_tag = SearchMatch {
14823 source_name: "winget".to_owned(),
14824 source_kind: SourceKind::PreIndexed,
14825 id: "Riri.QRCodeforCmdPal".to_owned(),
14826 name: "QR Code for CmdPal".to_owned(),
14827 moniker: None,
14828 version: Some("0.0.1.0".to_owned()),
14829 channel: None,
14830 match_criteria: Some("Tag: powertoys".to_owned()),
14831 };
14832
14833 assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
14834 }
14835
14836 #[test]
14837 fn list_sort_prefers_main_package_then_sparse_app() {
14838 let main = InstalledPackage {
14839 name: "PowerToys (Preview) x64".to_owned(),
14840 local_id: r"ARP\User\X64\PowerToys".to_owned(),
14841 installed_version: "0.98.1".to_owned(),
14842 publisher: None,
14843 scope: Some("User".to_owned()),
14844 installer_category: Some("exe".to_owned()),
14845 install_location: None,
14846 package_family_names: Vec::new(),
14847 product_codes: Vec::new(),
14848 upgrade_codes: Vec::new(),
14849 correlated: None,
14850 winget_package_identifier: None,
14851 winget_source_identifier: None,
14852 installed_version_canonical: false,
14853 correlated_requires_explicit_upgrade: false,
14854 correlated_lacks_compatible_installer: false,
14855 };
14856 let sparse = InstalledPackage {
14857 name: "PowerToys.SparseApp".to_owned(),
14858 local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14859 installed_version: "0.98.1.0".to_owned(),
14860 publisher: None,
14861 scope: Some("User".to_owned()),
14862 installer_category: Some("msix".to_owned()),
14863 install_location: None,
14864 package_family_names: Vec::new(),
14865 product_codes: Vec::new(),
14866 upgrade_codes: Vec::new(),
14867 correlated: None,
14868 winget_package_identifier: None,
14869 winget_source_identifier: None,
14870 installed_version_canonical: false,
14871 correlated_requires_explicit_upgrade: false,
14872 correlated_lacks_compatible_installer: false,
14873 };
14874 let extension = InstalledPackage {
14875 name: "PowerToys FileLocksmith Context Menu".to_owned(),
14876 local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
14877 installed_version: "0.98.1.0".to_owned(),
14878 publisher: None,
14879 scope: Some("User".to_owned()),
14880 installer_category: Some("msix".to_owned()),
14881 install_location: None,
14882 package_family_names: Vec::new(),
14883 product_codes: Vec::new(),
14884 upgrade_codes: Vec::new(),
14885 correlated: None,
14886 winget_package_identifier: None,
14887 winget_source_identifier: None,
14888 installed_version_canonical: false,
14889 correlated_requires_explicit_upgrade: false,
14890 correlated_lacks_compatible_installer: false,
14891 };
14892
14893 assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
14894 assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
14895 }
14896
14897 #[test]
14898 #[cfg(windows)]
14899 fn arp_subkey_name_extracts_registry_subkey() {
14900 assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
14901 assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
14902 }
14903
14904 #[test]
14905 #[cfg(windows)]
14906 fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
14907 let installed = ListMatch {
14908 name: "ShareX".to_owned(),
14909 id: "ShareX.ShareX".to_owned(),
14910 local_id: r"ARP\Machine\X64\ShareX".to_owned(),
14911 installed_version: "19.0.2".to_owned(),
14912 available_version: None,
14913 source_name: Some("winget".to_owned()),
14914 publisher: Some("ShareX Team".to_owned()),
14915 scope: Some("Machine".to_owned()),
14916 installer_category: Some("exe".to_owned()),
14917 install_location: Some(r"C:\Program Files\ShareX".to_owned()),
14918 package_family_names: Vec::new(),
14919 product_codes: Vec::new(),
14920 upgrade_codes: Vec::new(),
14921 };
14922
14923 assert!(registry_entry_matches_installed_package(
14924 "ShareX", "ShareX", None, &installed
14925 ));
14926 assert!(!registry_entry_matches_installed_package(
14927 "ShareX.ShareX",
14928 "ShareX.ShareX",
14929 None,
14930 &installed
14931 ));
14932 }
14933
14934 #[test]
14935 #[cfg(windows)]
14936 fn build_uninstall_command_only_appends_silent_flag_when_needed() {
14937 assert_eq!(
14938 r#""C:\Program Files\ShareX\unins000.exe" /S"#,
14939 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
14940 );
14941 assert_eq!(
14942 r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
14943 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
14944 );
14945 assert_eq!(
14946 r#""C:\Program Files\ShareX\unins000.exe""#,
14947 build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
14948 );
14949 assert_eq!(
14950 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14951 build_uninstall_command(
14952 "winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
14953 true,
14954 false,
14955 )
14956 );
14957 }
14958
14959 #[test]
14960 #[cfg(windows)]
14961 fn build_winget_uninstall_arguments_prefers_correlated_identity() {
14962 let installed = ListMatch {
14963 name: "lazygit".to_owned(),
14964 id: "JesseDuffield.lazygit".to_owned(),
14965 local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
14966 installed_version: "0.61.1".to_owned(),
14967 available_version: None,
14968 source_name: Some("winget".to_owned()),
14969 publisher: Some("Jesse Duffield".to_owned()),
14970 scope: Some("User".to_owned()),
14971 installer_category: Some("exe".to_owned()),
14972 install_location: Some(
14973 r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
14974 ),
14975 package_family_names: Vec::new(),
14976 product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
14977 upgrade_codes: Vec::new(),
14978 };
14979 let mut request = UninstallRequest::new(PackageQuery {
14980 id: Some("JesseDuffield.lazygit".to_owned()),
14981 install_scope: Some("user".to_owned()),
14982 ..PackageQuery::default()
14983 });
14984 request.mode = InstallerMode::Silent;
14985
14986 assert_eq!(
14987 Some(vec![
14988 "uninstall".to_owned(),
14989 "--id".to_owned(),
14990 "JesseDuffield.lazygit".to_owned(),
14991 "--exact".to_owned(),
14992 "--disable-interactivity".to_owned(),
14993 "--source".to_owned(),
14994 "winget".to_owned(),
14995 "--scope".to_owned(),
14996 "user".to_owned(),
14997 "--silent".to_owned(),
14998 ]),
14999 build_winget_uninstall_arguments(&installed, &request)
15000 );
15001
15002 assert_eq!(
15003 Some(vec![
15004 "uninstall".to_owned(),
15005 "--id".to_owned(),
15006 "JesseDuffield.lazygit".to_owned(),
15007 "--exact".to_owned(),
15008 "--disable-interactivity".to_owned(),
15009 "--source".to_owned(),
15010 "winget".to_owned(),
15011 "--silent".to_owned(),
15012 ]),
15013 build_winget_uninstall_arguments_with_scope(&installed, &request, false)
15014 );
15015 }
15016
15017 fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
15018 PackageVersionDataEntry {
15019 version: version.to_owned(),
15020 manifest_relative_path: format!("manifests/x/{version}"),
15021 manifest_hash: "00".repeat(32),
15022 arp_min_version: arp_min.map(str::to_owned),
15023 arp_max_version: arp_max.map(str::to_owned),
15024 }
15025 }
15026
15027 fn v1_version(version: &str, channel: &str) -> V1VersionRow {
15028 V1VersionRow {
15029 version: version.to_owned(),
15030 channel: channel.to_owned(),
15031 pathpart_id: 0,
15032 manifest_hash: None,
15033 }
15034 }
15035
15036 #[derive(Clone)]
15037 struct TestHttpResponse {
15038 status: u16,
15039 body: Vec<u8>,
15040 source_version: Option<String>,
15041 etag: Option<String>,
15042 last_modified: Option<String>,
15043 }
15044
15045 struct TestHttpServer {
15046 addr: SocketAddr,
15047 routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
15048 counts: Arc<Mutex<HashMap<String, usize>>>,
15049 running: Arc<AtomicBool>,
15050 handle: Option<JoinHandle<()>>,
15051 }
15052
15053 impl TestHttpServer {
15054 fn start() -> Self {
15055 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
15056 let addr = listener.local_addr().expect("test HTTP server address");
15057 let routes = Arc::new(Mutex::new(HashMap::new()));
15058 let counts = Arc::new(Mutex::new(HashMap::new()));
15059 let running = Arc::new(AtomicBool::new(true));
15060 let thread_routes = Arc::clone(&routes);
15061 let thread_counts = Arc::clone(&counts);
15062 let thread_running = Arc::clone(&running);
15063 let handle = thread::spawn(move || {
15064 while thread_running.load(AtomicOrdering::SeqCst) {
15065 let Ok((stream, _)) = listener.accept() else {
15066 continue;
15067 };
15068 handle_test_http_request(stream, &thread_routes, &thread_counts);
15069 }
15070 });
15071
15072 Self {
15073 addr,
15074 routes,
15075 counts,
15076 running,
15077 handle: Some(handle),
15078 }
15079 }
15080
15081 fn url(&self) -> String {
15082 format!("http://{}", self.addr)
15083 }
15084
15085 fn set_bytes(&self, path: &str, body: Vec<u8>) {
15086 self.set_response(
15087 path,
15088 TestHttpResponse {
15089 status: 200,
15090 body,
15091 source_version: None,
15092 etag: None,
15093 last_modified: None,
15094 },
15095 );
15096 }
15097
15098 fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
15099 self.set_source_package_with_validators(body, source_version, None, None);
15100 }
15101
15102 fn set_source_package_with_validators(
15103 &self,
15104 body: Vec<u8>,
15105 source_version: &str,
15106 etag: Option<&str>,
15107 last_modified: Option<&str>,
15108 ) {
15109 self.set_response(
15110 "/source.msix",
15111 TestHttpResponse {
15112 status: 200,
15113 body,
15114 source_version: Some(source_version.to_owned()),
15115 etag: etag.map(str::to_owned),
15116 last_modified: last_modified.map(str::to_owned),
15117 },
15118 );
15119 }
15120
15121 fn set_status(&self, path: &str, status: u16) {
15122 self.set_response(
15123 path,
15124 TestHttpResponse {
15125 status,
15126 body: Vec::new(),
15127 source_version: None,
15128 etag: None,
15129 last_modified: None,
15130 },
15131 );
15132 }
15133
15134 fn set_response(&self, path: &str, response: TestHttpResponse) {
15135 self.routes
15136 .lock()
15137 .expect("test HTTP routes")
15138 .insert(path.to_owned(), response);
15139 }
15140
15141 fn request_count(&self, path: &str) -> usize {
15142 self.counts
15143 .lock()
15144 .expect("test HTTP counts")
15145 .get(path)
15146 .copied()
15147 .unwrap_or_default()
15148 }
15149 }
15150
15151 impl Drop for TestHttpServer {
15152 fn drop(&mut self) {
15153 self.running.store(false, AtomicOrdering::SeqCst);
15154 let _ = TcpStream::connect(self.addr);
15155 if let Some(handle) = self.handle.take() {
15156 let _ = handle.join();
15157 }
15158 }
15159 }
15160
15161 fn handle_test_http_request(
15162 mut stream: TcpStream,
15163 routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
15164 counts: &Arc<Mutex<HashMap<String, usize>>>,
15165 ) {
15166 let mut buffer = [0u8; 2048];
15167 let read = stream.read(&mut buffer).unwrap_or(0);
15168 let request = String::from_utf8_lossy(&buffer[..read]);
15169 let path = request
15170 .lines()
15171 .next()
15172 .and_then(|line| line.split_whitespace().nth(1))
15173 .unwrap_or("/");
15174 *counts
15175 .lock()
15176 .expect("test HTTP counts")
15177 .entry(path.to_owned())
15178 .or_default() += 1;
15179 let response = routes.lock().expect("test HTTP routes").get(path).cloned();
15180 let response = response.unwrap_or(TestHttpResponse {
15181 status: 404,
15182 body: Vec::new(),
15183 source_version: None,
15184 etag: None,
15185 last_modified: None,
15186 });
15187 let not_modified = response.status == 200
15188 && (response.etag.as_deref().is_some_and(|etag| {
15189 request_header_value(&request, "If-None-Match")
15190 .is_some_and(|value| value.split(',').any(|candidate| candidate.trim() == etag))
15191 }) || response.last_modified.as_deref().is_some_and(|last_modified| {
15192 request_header_value(&request, "If-Modified-Since")
15193 .is_some_and(|value| value.eq_ignore_ascii_case(last_modified))
15194 }));
15195 let status = if not_modified { 304 } else { response.status };
15196 let body = if not_modified {
15197 &[][..]
15198 } else {
15199 response.body.as_slice()
15200 };
15201 let reason = match status {
15202 200 => "OK",
15203 304 => "Not Modified",
15204 _ => "Not Found",
15205 };
15206 let mut headers = format!(
15207 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
15208 status,
15209 reason,
15210 body.len()
15211 );
15212 if let Some(source_version) = response.source_version {
15213 headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
15214 }
15215 if let Some(etag) = response.etag {
15216 headers.push_str(&format!("ETag: {etag}\r\n"));
15217 }
15218 if let Some(last_modified) = response.last_modified {
15219 headers.push_str(&format!("Last-Modified: {last_modified}\r\n"));
15220 }
15221 headers.push_str("\r\n");
15222 stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
15223 stream.write_all(body).expect("write test HTTP body");
15224 }
15225
15226 fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
15227 request.lines().find_map(|line| {
15228 let (header_name, value) = line.split_once(':')?;
15229 header_name.eq_ignore_ascii_case(name).then(|| value.trim())
15230 })
15231 }
15232
15233 fn test_source_record(source_url: String) -> SourceRecord {
15234 SourceRecord {
15235 name: "test-winget".to_owned(),
15236 kind: SourceKind::PreIndexed,
15237 arg: source_url,
15238 identifier: "Test.Source".to_owned(),
15239 trust_level: "Trusted".to_owned(),
15240 explicit: false,
15241 priority: 0,
15242 last_update: Some(Utc::now()),
15243 source_version: None,
15244 etag: None,
15245 last_modified: None,
15246 }
15247 }
15248
15249 fn open_test_preindexed_repository(
15250 app_root: &Path,
15251 source: &SourceRecord,
15252 interval: Option<Duration>,
15253 ) -> Repository {
15254 fs::create_dir_all(app_root).expect("create app root");
15255 save_store(
15256 app_root,
15257 &SourceStore {
15258 sources: vec![source.clone()],
15259 },
15260 )
15261 .expect("save source store");
15262 Repository::open_with_options(
15263 RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
15264 )
15265 .expect("open repository")
15266 }
15267
15268 fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
15269 let index_path = preindexed_index_path(app_root, source);
15270 if let Some(parent) = index_path.parent() {
15271 fs::create_dir_all(parent).expect("create source state directory");
15272 }
15273 fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
15274 }
15275
15276 #[test]
15277 fn replace_file_failure_keeps_existing_target() {
15278 let root = temp_app_root("replace_file_failure");
15279 fs::create_dir_all(&root).expect("create replace root");
15280 let target = root.join("index.db");
15281 let missing_source = root.join("missing.tmp");
15282 fs::write(&target, b"old-index").expect("write existing target");
15283
15284 let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
15285
15286 assert!(format!("{error:#}").contains("failed to replace"));
15287 assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
15288 let _ = fs::remove_dir_all(root);
15289 }
15290
15291 #[cfg(windows)]
15292 #[test]
15293 fn replace_file_succeeds_while_target_is_open() {
15294 use std::os::windows::fs::OpenOptionsExt;
15295 const SHARE_ALL: u32 = 0x0000_0001 | 0x0000_0002 | 0x0000_0004;
15299
15300 let root = temp_app_root("replace_file_open_target");
15301 fs::create_dir_all(&root).expect("create replace root");
15302 let target = root.join("index.db");
15303 let source = root.join("index.new.tmp");
15304 fs::write(&target, b"old-index").expect("write existing target");
15305 fs::write(&source, b"new-index").expect("write replacement");
15306
15307 let held = OpenOptions::new()
15308 .read(true)
15309 .share_mode(SHARE_ALL)
15310 .open(&target)
15311 .expect("open target with share-delete");
15312
15313 replace_file(&source, &target).expect("stage-aside replace should succeed");
15314
15315 assert_eq!(fs::read(&target).expect("new index in place"), b"new-index");
15316 drop(held);
15317 let _ = fs::remove_dir_all(root);
15318 }
15319
15320 fn make_source_package(versions: &[&str]) -> Vec<u8> {
15321 let cursor = Cursor::new(Vec::new());
15322 let mut archive = zip::ZipWriter::new(cursor);
15323 let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
15324 archive
15325 .start_file("Public/index.db", options)
15326 .expect("start index entry");
15327 archive
15328 .write_all(&make_v1_index_bytes(versions))
15329 .expect("write index entry");
15330 archive.finish().expect("finish source package").into_inner()
15331 }
15332
15333 fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
15334 let root = temp_app_root("preindexed_index_bytes");
15335 fs::create_dir_all(&root).expect("create index temp root");
15336 let db_path = root.join("index.db");
15337 let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
15338 let mut sql = String::from(
15339 "CREATE TABLE ids (id TEXT);
15340 CREATE TABLE names (name TEXT);
15341 CREATE TABLE monikers (moniker TEXT);
15342 CREATE TABLE versions (version TEXT);
15343 CREATE TABLE channels (channel TEXT);
15344 CREATE TABLE pathparts (parent INT64, pathpart TEXT);
15345 CREATE TABLE manifest (
15346 id INT64,
15347 name INT64,
15348 moniker INT64,
15349 version INT64,
15350 channel INT64,
15351 pathpart INT64
15352 );
15353 INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
15354 INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
15355 INSERT INTO channels(rowid, channel) VALUES (1, '');
15356 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
15357 INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
15358 );
15359 for (index, version) in versions.iter().enumerate() {
15360 let rowid = index + 1;
15361 sql.push_str(&format!(
15362 "INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
15363 INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
15364 ));
15365 }
15366 execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
15367 let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
15368 .expect("checkpoint preindexed V1 database");
15369 connection.cacheflush().expect("flush preindexed V1 database");
15370 drop(connection);
15371 let bytes = fs::read(&db_path).expect("read index bytes");
15372 let _ = fs::remove_dir_all(root);
15373 bytes
15374 }
15375
15376 fn test_manifest_yaml(version: &str) -> Vec<u8> {
15377 format!(
15378 "PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
15379 )
15380 .into_bytes()
15381 }
15382
15383 fn show_test_package(version: Option<&str>) -> PackageQuery {
15384 PackageQuery {
15385 id: Some("Test.Package".to_owned()),
15386 source: Some("test-winget".to_owned()),
15387 exact: true,
15388 version: version.map(str::to_owned),
15389 ..PackageQuery::default()
15390 }
15391 }
15392
15393 #[test]
15394 fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
15395 let app_root = temp_app_root("preindexed_explicit_refresh");
15396 let server = TestHttpServer::start();
15397 server.set_status("/source2.msix", 404);
15398 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
15399 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
15400 let source = test_source_record(server.url());
15401 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15402 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15403
15404 let result = repository
15405 .show(&show_test_package(Some("2.0.0")))
15406 .expect("show after refresh");
15407
15408 assert_eq!(result.manifest.version, "2.0.0");
15409 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
15410 let _ = fs::remove_dir_all(app_root);
15411 }
15412
15413 #[test]
15414 fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
15415 let app_root = temp_app_root("preindexed_explicit_still_missing");
15416 let server = TestHttpServer::start();
15417 server.set_status("/source2.msix", 404);
15418 server.set_source_package(make_source_package(&["1.0.0"]), "same");
15419 let source = test_source_record(server.url());
15420 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15421 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15422
15423 let error = repository
15424 .show(&show_test_package(Some("2.0.0")))
15425 .expect_err("missing explicit version must fail");
15426
15427 assert!(format!("{error:#}").contains("2.0.0"));
15428 let _ = fs::remove_dir_all(app_root);
15429 }
15430
15431 #[test]
15432 fn preindexed_stale_index_refreshes_for_latest_requests() {
15433 let app_root = temp_app_root("preindexed_ttl_refresh");
15434 let server = TestHttpServer::start();
15435 server.set_status("/source2.msix", 404);
15436 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15437 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
15438 let mut source = test_source_record(server.url());
15439 source.last_update = Some(Utc::now() - Duration::days(2));
15440 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15441 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15442
15443 let result = repository
15444 .show(&show_test_package(None))
15445 .expect("show latest after TTL refresh");
15446
15447 assert_eq!(result.manifest.version, "2.0.0");
15448 assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
15449 let _ = fs::remove_dir_all(app_root);
15450 }
15451
15452 #[test]
15453 fn preindexed_stale_index_not_modified_keeps_local_index() {
15454 let app_root = temp_app_root("preindexed_ttl_not_modified");
15455 let server = TestHttpServer::start();
15456 server.set_status("/source2.msix", 404);
15457 server.set_source_package_with_validators(
15458 make_source_package(&["1.0.0", "2.0.0"]),
15459 "fresh",
15460 Some("\"same-index\""),
15461 Some("Wed, 03 Jun 2026 12:00:00 GMT"),
15462 );
15463 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15464 let mut source = test_source_record(server.url());
15465 source.last_update = Some(Utc::now() - Duration::days(2));
15466 source.etag = Some("\"same-index\"".to_owned());
15467 source.last_modified = Some("Wed, 03 Jun 2026 12:00:00 GMT".to_owned());
15468 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15469 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15470
15471 let result = repository
15472 .show(&show_test_package(None))
15473 .expect("show latest from not-modified stale index");
15474
15475 let refreshed_source = &repository.list_sources()[0];
15476 assert_eq!(result.manifest.version, "1.0.0");
15477 assert_eq!(server.request_count("/source.msix"), 1);
15478 assert_eq!(refreshed_source.etag.as_deref(), Some("\"same-index\""));
15479 assert_eq!(
15480 refreshed_source.last_modified.as_deref(),
15481 Some("Wed, 03 Jun 2026 12:00:00 GMT")
15482 );
15483 let _ = fs::remove_dir_all(app_root);
15484 }
15485
15486 #[test]
15487 fn preindexed_fresh_index_mtime_skips_refresh_when_metadata_is_stale() {
15488 let app_root = temp_app_root("preindexed_fresh_mtime");
15489 let server = TestHttpServer::start();
15490 server.set_status("/source2.msix", 404);
15491 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15492 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15493 let mut source = test_source_record(server.url());
15494 source.last_update = Some(Utc::now() - Duration::days(2));
15495 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15496 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
15497
15498 let result = repository
15499 .show(&show_test_package(None))
15500 .expect("show latest from fresh index mtime");
15501
15502 assert_eq!(result.manifest.version, "1.0.0");
15503 assert_eq!(server.request_count("/source.msix"), 0);
15504 let _ = fs::remove_dir_all(app_root);
15505 }
15506
15507 #[test]
15508 fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
15509 let app_root = temp_app_root("preindexed_ttl_fallback");
15510 let server = TestHttpServer::start();
15511 server.set_status("/source2.msix", 404);
15512 server.set_status("/source.msix", 404);
15513 server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
15514 let mut source = test_source_record(server.url());
15515 source.last_update = Some(Utc::now() - Duration::days(2));
15516 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15517 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15518
15519 let result = repository
15520 .show(&show_test_package(None))
15521 .expect("show latest from stale cache after refresh failure");
15522
15523 assert_eq!(result.manifest.version, "1.0.0");
15524 let _ = fs::remove_dir_all(app_root);
15525 }
15526
15527 #[test]
15528 fn explicit_source_search_failure_is_not_reported_as_no_match() {
15529 let app_root = temp_app_root("preindexed_source_search_failure");
15530 let server = TestHttpServer::start();
15531 server.set_status("/source2.msix", 404);
15532 server.set_status("/source.msix", 404);
15533 let source = test_source_record(server.url());
15534 let mut repository = open_test_preindexed_repository(&app_root, &source, None);
15535
15536 let error = repository
15537 .show(&show_test_package(None))
15538 .expect_err("source failure must surface");
15539 let message = format!("{error:#}");
15540
15541 assert!(message.contains("failed to search source 'test-winget'"));
15542 assert!(message.contains("source.msix"));
15543 assert!(!message.contains("no package matched the supplied query"));
15544 let _ = fs::remove_dir_all(app_root);
15545 }
15546
15547 #[test]
15548 fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
15549 let app_root = temp_app_root("preindexed_no_double_refresh");
15550 let server = TestHttpServer::start();
15551 server.set_status("/source2.msix", 404);
15552 server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
15553 let mut source = test_source_record(server.url());
15554 source.last_update = Some(Utc::now() - Duration::days(2));
15555 write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
15556 let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::zero()));
15557
15558 let error = repository
15559 .show(&show_test_package(Some("3.0.0")))
15560 .expect_err("still-missing explicit version must fail after one stale refresh");
15561
15562 assert!(format!("{error:#}").contains("3.0.0"));
15563 assert_eq!(server.request_count("/source.msix"), 1);
15564 let _ = fs::remove_dir_all(app_root);
15565 }
15566
15567 #[test]
15568 fn select_v2_version_without_explicit_filters_returns_latest() {
15569 let versions = vec![
15570 version_entry("1.0.0", None, None),
15571 version_entry("1.2.0", None, None),
15572 version_entry("1.1.0", None, None),
15573 ];
15574
15575 let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
15576
15577 assert_eq!(selected.version, "1.2.0");
15578 }
15579
15580 #[test]
15581 fn select_v2_version_with_missing_requested_version_throws() {
15582 let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
15583
15584 let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
15585 .expect_err("missing requested version");
15586
15587 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15588 assert!(error.to_string().contains("9.9.9"));
15589 }
15590
15591 #[test]
15592 fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
15593 let versions = vec![version_entry("1.2.0", None, None)];
15594
15595 let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15596 .expect_err("unsupported channel request");
15597
15598 assert!(error.to_string().contains("beta"));
15599 assert!(error.to_string().contains("channel metadata"));
15600 }
15601
15602 #[test]
15603 fn select_v1_version_channel_only_returns_latest_matching_channel() {
15604 let versions = vec![
15605 v1_version("1.2.0", "stable"),
15606 v1_version("1.3.0", "stable"),
15607 v1_version("2.0.0", "preview"),
15608 ];
15609
15610 let selected =
15611 select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
15612
15613 assert_eq!(selected.version, "1.3.0");
15614 }
15615
15616 #[test]
15617 fn select_rest_version_channel_mismatch_throws() {
15618 let versions = vec![
15619 VersionKey {
15620 version: "1.2.0".to_owned(),
15621 channel: "stable".to_owned(),
15622 },
15623 VersionKey {
15624 version: "1.1.0".to_owned(),
15625 channel: "beta".to_owned(),
15626 },
15627 ];
15628
15629 let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
15630 .expect_err("missing version and channel pair");
15631
15632 assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
15633 }
15634
15635 #[test]
15636 fn map_arp_version_returns_catalog_version_inside_range() {
15637 let entries = vec![
15642 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15643 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15644 version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
15645 ];
15646
15647 assert_eq!(
15648 map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
15649 Some("10.0.108")
15650 );
15651 }
15652
15653 #[test]
15654 fn map_arp_version_returns_none_when_no_range_matches() {
15655 let entries = vec![
15660 version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
15661 version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
15662 ];
15663
15664 assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
15665 }
15666
15667 #[test]
15668 fn map_arp_version_reports_less_than_latest_for_coarse_display_version() {
15669 let entries = vec![
15673 version_entry("25.4.1", Some("25.4.1.10325"), Some("25.4.1.10325")),
15674 version_entry("25.4.0", Some("25.4.0"), Some("25.4.0.8498")),
15675 ];
15676
15677 assert!(map_arp_version_to_catalog(&entries, "25.4.1").is_none());
15678 assert_eq!(
15679 less_than_latest_arp_anchored_version(&entries, "25.4.1").as_deref(),
15680 Some("< 25.4.1")
15681 );
15682 assert!(less_than_latest_arp_anchored_version(&entries, "25.4.0").is_none());
15683 }
15684
15685 #[test]
15686 fn map_arp_version_reports_greater_than_latest_for_serviced_msix_framework_version() {
15687 let entries = vec![
15691 version_entry("1.5.8", Some("5001.373.1736.0"), Some("5001.373.1736.0")),
15692 version_entry("1.5.7", Some("5001.337.1906.0"), Some("5001.337.1906.0")),
15693 ];
15694
15695 assert!(map_arp_version_to_catalog(&entries, "5001.400.42.0").is_none());
15696 assert!(less_than_latest_arp_anchored_version(&entries, "5001.400.42.0").is_none());
15697 assert_eq!(
15698 greater_than_latest_arp_anchored_version(&entries, "5001.400.42.0", "1.5.8").as_deref(),
15699 Some("> 1.5.8")
15700 );
15701 assert!(greater_than_latest_arp_anchored_version(&entries, "5001.337.1906.0", "1.5.8").is_none());
15702
15703 let stale_normal_app_entries = vec![version_entry("2024.1.1", Some("2024.1.1.0"), Some("2024.1.1.0"))];
15704 assert!(
15705 greater_than_latest_arp_anchored_version(&stale_normal_app_entries, "2026.1.3.0", "2026.2.0.0").is_none()
15706 );
15707 }
15708
15709 #[test]
15710 fn map_arp_version_skips_entries_missing_arp_bounds() {
15711 let entries = vec![
15715 version_entry("9.0.300", None, None),
15716 version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
15717 ];
15718
15719 assert_eq!(
15720 map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
15721 Some("9.0.117")
15722 );
15723 assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
15724 }
15725
15726 #[test]
15727 fn map_arp_version_handles_inclusive_range_endpoints() {
15728 let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
15731
15732 assert_eq!(
15733 map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
15734 Some("3.14.5")
15735 );
15736 assert_eq!(
15737 map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
15738 Some("3.14.5")
15739 );
15740 assert_eq!(
15741 map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
15742 Some("3.14.5")
15743 );
15744 }
15745
15746 #[test]
15747 fn map_arp_version_ignores_unknown_marker() {
15748 let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
15752
15753 assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
15754 assert!(map_arp_version_to_catalog(&entries, "").is_none());
15755 }
15756
15757 #[test]
15758 fn latest_arp_anchored_skips_internal_versions() {
15759 let entries = vec![
15765 version_entry("8000.836.2153.0", None, None),
15766 version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
15767 version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
15768 version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
15769 ];
15770
15771 assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
15772 }
15773
15774 #[test]
15775 fn latest_arp_anchored_returns_none_without_bounds() {
15776 let entries = vec![
15780 version_entry("1.28.240.0", None, None),
15781 version_entry("1.27.470.0", None, None),
15782 ];
15783
15784 assert!(latest_arp_anchored_version(&entries).is_none());
15785 }
15786
15787 #[test]
15788 fn list_match_from_installed_prefers_correlated_id_and_source_name() {
15789 let package = InstalledPackage {
15790 name: "AzCopy v10".to_owned(),
15791 local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
15792 installed_version: "10.32.2".to_owned(),
15793 publisher: Some("Microsoft".to_owned()),
15794 scope: Some("Machine".to_owned()),
15795 installer_category: Some("exe".to_owned()),
15796 install_location: None,
15797 package_family_names: Vec::new(),
15798 product_codes: Vec::new(),
15799 upgrade_codes: Vec::new(),
15800 correlated: Some(SearchMatch {
15801 source_name: "winget".to_owned(),
15802 source_kind: SourceKind::PreIndexed,
15803 id: "Microsoft.Azure.AZCopy.10".to_owned(),
15804 name: "AzCopy v10".to_owned(),
15805 moniker: None,
15806 version: Some("10.32.3".to_owned()),
15807 channel: None,
15808 match_criteria: None,
15809 }),
15810 winget_package_identifier: None,
15811 winget_source_identifier: None,
15812 installed_version_canonical: false,
15813 correlated_requires_explicit_upgrade: false,
15814 correlated_lacks_compatible_installer: false,
15815 };
15816
15817 let item = list_match_from_installed(package, &[]);
15818
15819 assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
15820 assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
15821 assert_eq!(item.source_name.as_deref(), Some("winget"));
15822 assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
15823 }
15824
15825 #[test]
15826 fn suppress_duplicate_available_versions_keeps_available_only_on_newest_installed_row() {
15827 let rows = vec![
15828 ListMatch {
15829 name: "Example 1.0".to_owned(),
15830 id: "Example.Tool".to_owned(),
15831 local_id: r"ARP\Machine\X64\old".to_owned(),
15832 installed_version: "1.0.0".to_owned(),
15833 available_version: Some("3.0.0".to_owned()),
15834 source_name: Some("winget".to_owned()),
15835 publisher: None,
15836 scope: None,
15837 installer_category: None,
15838 install_location: None,
15839 package_family_names: Vec::new(),
15840 product_codes: Vec::new(),
15841 upgrade_codes: Vec::new(),
15842 },
15843 ListMatch {
15844 name: "Example 2.0".to_owned(),
15845 id: "Example.Tool".to_owned(),
15846 local_id: r"ARP\Machine\X64\new".to_owned(),
15847 installed_version: "2.0.0".to_owned(),
15848 available_version: Some("3.0.0".to_owned()),
15849 source_name: Some("winget".to_owned()),
15850 publisher: None,
15851 scope: None,
15852 installer_category: None,
15853 install_location: None,
15854 package_family_names: Vec::new(),
15855 product_codes: Vec::new(),
15856 upgrade_codes: Vec::new(),
15857 },
15858 ListMatch {
15859 name: "Other".to_owned(),
15860 id: "Other.Tool".to_owned(),
15861 local_id: r"ARP\Machine\X64\other".to_owned(),
15862 installed_version: "1.0.0".to_owned(),
15863 available_version: Some("2.0.0".to_owned()),
15864 source_name: Some("winget".to_owned()),
15865 publisher: None,
15866 scope: None,
15867 installer_category: None,
15868 install_location: None,
15869 package_family_names: Vec::new(),
15870 product_codes: Vec::new(),
15871 upgrade_codes: Vec::new(),
15872 },
15873 ];
15874
15875 let result = suppress_duplicate_available_versions(rows);
15876
15877 assert_eq!(result[0].available_version, None);
15878 assert_eq!(result[1].available_version.as_deref(), Some("3.0.0"));
15879 assert_eq!(result[2].available_version.as_deref(), Some("2.0.0"));
15880 }
15881
15882 #[test]
15883 fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
15884 let package = InstalledPackage {
15885 name: "Atlassian CLI".to_owned(),
15886 local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15887 installed_version: "1.3.18-stable".to_owned(),
15888 publisher: Some("Atlassian".to_owned()),
15889 scope: Some("User".to_owned()),
15890 installer_category: Some("exe".to_owned()),
15891 install_location: None,
15892 package_family_names: Vec::new(),
15893 product_codes: Vec::new(),
15894 upgrade_codes: Vec::new(),
15895 correlated: Some(SearchMatch {
15896 source_name: "winget".to_owned(),
15897 source_kind: SourceKind::PreIndexed,
15898 id: "Atlassian.AtlassianCLI".to_owned(),
15899 name: "Atlassian CLI".to_owned(),
15900 moniker: None,
15901 version: Some("1.3.18-stable".to_owned()),
15902 channel: None,
15903 match_criteria: Some("ProductCode".to_owned()),
15904 }),
15905 winget_package_identifier: None,
15906 winget_source_identifier: None,
15907 installed_version_canonical: false,
15908 correlated_requires_explicit_upgrade: false,
15909 correlated_lacks_compatible_installer: false,
15910 };
15911
15912 let item = list_match_from_installed(package, &[]);
15913
15914 assert_eq!(item.id, "Atlassian.AtlassianCLI");
15915 assert_eq!(
15916 item.local_id,
15917 r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
15918 );
15919 assert_ne!(item.id, item.local_id);
15920 assert_eq!(item.source_name.as_deref(), Some("winget"));
15921 }
15922
15923 #[test]
15924 fn list_match_from_installed_derives_winget_portable_identity_from_local_id() {
15925 let package = InstalledPackage {
15926 name: "FFmpeg".to_owned(),
15927 local_id: r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15928 installed_version: "8.0".to_owned(),
15929 publisher: Some("Gyan".to_owned()),
15930 scope: Some("User".to_owned()),
15931 installer_category: Some("portable".to_owned()),
15932 install_location: None,
15933 package_family_names: Vec::new(),
15934 product_codes: Vec::new(),
15935 upgrade_codes: Vec::new(),
15936 correlated: None,
15937 winget_package_identifier: None,
15938 winget_source_identifier: None,
15939 installed_version_canonical: false,
15940 correlated_requires_explicit_upgrade: false,
15941 correlated_lacks_compatible_installer: false,
15942 };
15943 let sources = vec![SourceRecord {
15944 name: "winget".to_owned(),
15945 kind: SourceKind::PreIndexed,
15946 arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
15947 identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
15948 trust_level: "Trusted".to_owned(),
15949 explicit: false,
15950 priority: 0,
15951 last_update: None,
15952 source_version: None,
15953 etag: None,
15954 last_modified: None,
15955 }];
15956
15957 let item = list_match_from_installed(package, &sources);
15958
15959 assert_eq!(item.id, "Gyan.FFmpeg");
15960 assert_eq!(
15961 item.local_id,
15962 r"ARP\User\X64\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe"
15963 );
15964 assert_eq!(item.source_name.as_deref(), Some("winget"));
15965 }
15966
15967 #[test]
15968 fn list_match_from_installed_uses_winget_arp_identity_metadata() {
15969 let package = InstalledPackage {
15970 name: "tessl".to_owned(),
15971 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15972 installed_version: "0.77.0".to_owned(),
15973 publisher: Some("tessl.io".to_owned()),
15974 scope: Some("User".to_owned()),
15975 installer_category: Some("portable".to_owned()),
15976 install_location: None,
15977 package_family_names: Vec::new(),
15978 product_codes: Vec::new(),
15979 upgrade_codes: Vec::new(),
15980 correlated: None,
15981 winget_package_identifier: Some("tessl.tessl".to_owned()),
15982 winget_source_identifier: Some("api.winget.pro".to_owned()),
15983 installed_version_canonical: false,
15984 correlated_requires_explicit_upgrade: false,
15985 correlated_lacks_compatible_installer: false,
15986 };
15987
15988 let item = list_match_from_installed(package, &[]);
15989
15990 assert_eq!(item.id, "tessl.tessl");
15991 assert_eq!(item.source_name.as_deref(), Some("api.winget.pro"));
15992 }
15993
15994 #[test]
15995 fn list_match_from_installed_maps_winget_arp_source_identifier_to_configured_name() {
15996 let package = InstalledPackage {
15997 name: "tessl".to_owned(),
15998 local_id: r"ARP\User\X64\tessl.tessl_api.winget.pro".to_owned(),
15999 installed_version: "0.77.0".to_owned(),
16000 publisher: Some("tessl.io".to_owned()),
16001 scope: Some("User".to_owned()),
16002 installer_category: Some("portable".to_owned()),
16003 install_location: None,
16004 package_family_names: Vec::new(),
16005 product_codes: Vec::new(),
16006 upgrade_codes: Vec::new(),
16007 correlated: None,
16008 winget_package_identifier: Some("tessl.tessl".to_owned()),
16009 winget_source_identifier: Some("api.winget.pro".to_owned()),
16010 installed_version_canonical: false,
16011 correlated_requires_explicit_upgrade: false,
16012 correlated_lacks_compatible_installer: false,
16013 };
16014 let sources = vec![SourceRecord {
16015 name: "winget.pro".to_owned(),
16016 kind: SourceKind::Rest,
16017 arg: "https://api.winget.pro/4259fd23-6fcd-46bf-9287-be8833cfbdd5".to_owned(),
16018 identifier: "api.winget.pro".to_owned(),
16019 trust_level: "Trusted".to_owned(),
16020 explicit: false,
16021 priority: 0,
16022 last_update: None,
16023 source_version: None,
16024 etag: None,
16025 last_modified: None,
16026 }];
16027
16028 let item = list_match_from_installed(package, &sources);
16029
16030 assert_eq!(item.id, "tessl.tessl");
16031 assert_eq!(item.source_name.as_deref(), Some("winget.pro"));
16032 }
16033
16034 #[test]
16035 fn list_match_from_installed_keeps_scanning_portable_source_identifiers() {
16036 let package = InstalledPackage {
16037 name: "Example".to_owned(),
16038 local_id: r"ARP\User\X64\Vendor.Example_short".to_owned(),
16039 installed_version: "1.0".to_owned(),
16040 publisher: Some("Vendor".to_owned()),
16041 scope: Some("User".to_owned()),
16042 installer_category: Some("portable".to_owned()),
16043 install_location: None,
16044 package_family_names: Vec::new(),
16045 product_codes: Vec::new(),
16046 upgrade_codes: Vec::new(),
16047 correlated: None,
16048 winget_package_identifier: None,
16049 winget_source_identifier: None,
16050 installed_version_canonical: false,
16051 correlated_requires_explicit_upgrade: false,
16052 correlated_lacks_compatible_installer: false,
16053 };
16054 let sources = vec![
16055 SourceRecord {
16056 name: "long".to_owned(),
16057 kind: SourceKind::PreIndexed,
16058 arg: "https://example.invalid/long".to_owned(),
16059 identifier: "identifier-longer-than-local-id".to_owned(),
16060 trust_level: "Trusted".to_owned(),
16061 explicit: false,
16062 priority: 0,
16063 last_update: None,
16064 source_version: None,
16065 etag: None,
16066 last_modified: None,
16067 },
16068 SourceRecord {
16069 name: "short".to_owned(),
16070 kind: SourceKind::PreIndexed,
16071 arg: "https://example.invalid/short".to_owned(),
16072 identifier: "short".to_owned(),
16073 trust_level: "Trusted".to_owned(),
16074 explicit: false,
16075 priority: 1,
16076 last_update: None,
16077 source_version: None,
16078 etag: None,
16079 last_modified: None,
16080 },
16081 ];
16082
16083 let item = list_match_from_installed(package, &sources);
16084
16085 assert_eq!(item.id, "Vendor.Example");
16086 assert_eq!(item.source_name.as_deref(), Some("short"));
16087 }
16088
16089 #[test]
16090 fn list_response_serialization_keeps_pascal_case_fields() {
16091 let response = ListResponse {
16092 matches: vec![ListMatch {
16093 name: "PowerToys".to_owned(),
16094 id: "Microsoft.PowerToys".to_owned(),
16095 local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
16096 installed_version: "0.98.1".to_owned(),
16097 available_version: Some("0.99.0".to_owned()),
16098 source_name: Some("winget".to_owned()),
16099 publisher: Some("Microsoft".to_owned()),
16100 scope: Some("Machine".to_owned()),
16101 installer_category: Some("msi".to_owned()),
16102 install_location: None,
16103 package_family_names: Vec::new(),
16104 product_codes: Vec::new(),
16105 upgrade_codes: Vec::new(),
16106 }],
16107 warnings: Vec::new(),
16108 truncated: false,
16109 };
16110
16111 let value = serde_json::to_value(&response).expect("serialize list response");
16112 let match_value = &value["Matches"][0];
16113
16114 assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
16115 assert_eq!(match_value["InstalledVersion"], "0.98.1");
16116 assert_eq!(match_value["AvailableVersion"], "0.99.0");
16117 assert_eq!(match_value["SourceName"], "winget");
16118 assert!(match_value.get("local_id").is_none());
16119 assert!(match_value.get("installed_version").is_none());
16120 assert!(match_value.get("available_version").is_none());
16121 assert!(match_value.get("source_name").is_none());
16122 }
16123
16124 #[test]
16125 fn search_response_serialization_preserves_minimum_pascal_case_shape() {
16126 let response = SearchResponse {
16127 matches: vec![SearchMatch {
16128 source_name: "msstore".to_owned(),
16129 source_kind: SourceKind::Rest,
16130 id: "9WZDNCRFJBMP".to_owned(),
16131 name: "Microsoft To Do".to_owned(),
16132 moniker: None,
16133 version: Some("2.123.456.0".to_owned()),
16134 channel: None,
16135 match_criteria: None,
16136 }],
16137 warnings: Vec::new(),
16138 truncated: false,
16139 };
16140
16141 let value = serde_json::to_value(&response).expect("serialize search response");
16142 let match_value = &value["Matches"][0];
16143
16144 assert_eq!(match_value["Name"], "Microsoft To Do");
16145 assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
16146 assert_eq!(match_value["Version"], "2.123.456.0");
16147 assert_eq!(match_value["SourceName"], "msstore");
16148 assert!(match_value.get("source_name").is_none());
16149 }
16150
16151 #[test]
16152 fn versions_result_serialization_keeps_pascal_case_fields() {
16153 let result = VersionsResult {
16154 package: SearchMatch {
16155 source_name: "winget".to_owned(),
16156 source_kind: SourceKind::PreIndexed,
16157 id: "Microsoft.PowerToys".to_owned(),
16158 name: "PowerToys".to_owned(),
16159 moniker: None,
16160 version: Some("0.99.0".to_owned()),
16161 channel: None,
16162 match_criteria: None,
16163 },
16164 versions: vec![
16165 VersionKey {
16166 version: "0.99.0".to_owned(),
16167 channel: "stable".to_owned(),
16168 },
16169 VersionKey {
16170 version: "0.100.0-preview".to_owned(),
16171 channel: "preview".to_owned(),
16172 },
16173 ],
16174 warnings: Vec::new(),
16175 };
16176
16177 let value = serde_json::to_value(&result).expect("serialize versions result");
16178
16179 assert_eq!(value["Versions"][0]["Version"], "0.99.0");
16180 assert_eq!(value["Versions"][0]["Channel"], "stable");
16181 assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
16182 assert_eq!(value["Versions"][1]["Channel"], "preview");
16183 assert!(value.get("versions").is_none());
16184 }
16185
16186 #[test]
16187 fn version_data_parses_arp_bounds_from_winget_payload() {
16188 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";
16192
16193 let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
16194 assert_eq!(document.versions.len(), 2);
16195 assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
16196 assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
16197 assert!(document.versions[1].arp_min_version.is_none());
16198 assert!(document.versions[1].arp_max_version.is_none());
16199 }
16200
16201 #[test]
16202 fn msix_resource_string_name_resolves_to_catalog_name() {
16203 let mut package = InstalledPackage {
16208 name: "ms-resource:appDisplayName".to_owned(),
16209 local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
16210 installed_version: "1.28.239.0".to_owned(),
16211 publisher: None,
16212 scope: Some("User".to_owned()),
16213 installer_category: Some("msix".to_owned()),
16214 install_location: None,
16215 package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
16216 product_codes: Vec::new(),
16217 upgrade_codes: Vec::new(),
16218 correlated: Some(SearchMatch {
16219 source_name: "winget".to_owned(),
16220 source_kind: SourceKind::PreIndexed,
16221 id: "Microsoft.AppInstaller".to_owned(),
16222 name: "App Installer".to_owned(),
16223 moniker: None,
16224 version: Some("1.28.240.0".to_owned()),
16225 channel: None,
16226 match_criteria: Some("PackageFamilyName".to_owned()),
16227 }),
16228 winget_package_identifier: None,
16229 winget_source_identifier: None,
16230 installed_version_canonical: false,
16231 correlated_requires_explicit_upgrade: false,
16232 correlated_lacks_compatible_installer: false,
16233 };
16234 apply_msix_resource_string_name_fix(&mut package);
16235 assert_eq!(package.name, "App Installer");
16236 }
16237
16238 #[test]
16239 fn msix_resource_string_fix_is_a_noop_when_not_msix() {
16240 let mut package = InstalledPackage {
16245 name: "ms-resource:appDisplayName".to_owned(),
16246 local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
16247 installed_version: "1.0".to_owned(),
16248 publisher: None,
16249 scope: Some("Machine".to_owned()),
16250 installer_category: Some("msi".to_owned()),
16251 install_location: None,
16252 package_family_names: Vec::new(),
16253 product_codes: Vec::new(),
16254 upgrade_codes: Vec::new(),
16255 correlated: Some(SearchMatch {
16256 source_name: "winget".to_owned(),
16257 source_kind: SourceKind::PreIndexed,
16258 id: "Some.Package".to_owned(),
16259 name: "Should Not Apply".to_owned(),
16260 moniker: None,
16261 version: Some("1.0".to_owned()),
16262 channel: None,
16263 match_criteria: None,
16264 }),
16265 winget_package_identifier: None,
16266 winget_source_identifier: None,
16267 installed_version_canonical: false,
16268 correlated_requires_explicit_upgrade: false,
16269 correlated_lacks_compatible_installer: false,
16270 };
16271 apply_msix_resource_string_name_fix(&mut package);
16272 assert_eq!(package.name, "ms-resource:appDisplayName");
16273 }
16274
16275 #[test]
16276 fn msix_resource_string_fix_skips_resolved_names() {
16277 let mut package = InstalledPackage {
16282 name: "Microsoft Teams".to_owned(),
16283 local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
16284 installed_version: "25290.205.4069.4894".to_owned(),
16285 publisher: None,
16286 scope: Some("User".to_owned()),
16287 installer_category: Some("msix".to_owned()),
16288 install_location: None,
16289 package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
16290 product_codes: Vec::new(),
16291 upgrade_codes: Vec::new(),
16292 correlated: Some(SearchMatch {
16293 source_name: "winget".to_owned(),
16294 source_kind: SourceKind::PreIndexed,
16295 id: "Microsoft.Teams".to_owned(),
16296 name: "Microsoft Teams Catalog Name".to_owned(),
16297 moniker: None,
16298 version: Some("26106.1906.4665.7308".to_owned()),
16299 channel: None,
16300 match_criteria: Some("PackageFamilyName".to_owned()),
16301 }),
16302 winget_package_identifier: None,
16303 winget_source_identifier: None,
16304 installed_version_canonical: false,
16305 correlated_requires_explicit_upgrade: false,
16306 correlated_lacks_compatible_installer: false,
16307 };
16308 apply_msix_resource_string_name_fix(&mut package);
16309 assert_eq!(package.name, "Microsoft Teams");
16310 }
16311
16312 #[cfg(windows)]
16313 #[test]
16314 fn unflip_packed_guid_reverses_msi_installer_packing() {
16315 assert_eq!(
16322 unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
16323 Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
16324 );
16325 assert_eq!(
16328 unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
16329 Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
16330 );
16331 assert!(unflip_packed_guid("nothex").is_none());
16333 assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
16335 }
16336
16337 fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
16338 InstalledPackage {
16339 name: format!("{id} install"),
16340 local_id: format!(r"ARP\Machine\X64\{id}"),
16341 installed_version: installed_version.to_owned(),
16342 publisher: None,
16343 scope: Some("Machine".to_owned()),
16344 installer_category: Some("msi".to_owned()),
16345 install_location: None,
16346 package_family_names: Vec::new(),
16347 product_codes: Vec::new(),
16348 upgrade_codes: Vec::new(),
16349 correlated: Some(SearchMatch {
16350 source_name: "winget".to_owned(),
16351 source_kind: SourceKind::PreIndexed,
16352 id: id.to_owned(),
16353 name: id.to_owned(),
16354 moniker: None,
16355 version: Some("99.0.0".to_owned()),
16356 channel: None,
16357 match_criteria: None,
16358 }),
16359 winget_package_identifier: None,
16360 winget_source_identifier: None,
16361 installed_version_canonical: canonical,
16362 correlated_requires_explicit_upgrade: false,
16363 correlated_lacks_compatible_installer: false,
16364 }
16365 }
16366
16367 #[test]
16368 fn dedupe_keeps_canonical_row_over_raw_arp_row() {
16369 let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
16376 let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
16377
16378 let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
16379 assert_eq!(result.len(), 1);
16380 assert_eq!(result[0].installed_version, "10.0.108");
16381 assert!(result[0].installed_version_canonical);
16382 }
16383
16384 #[test]
16385 fn dedupe_keeps_highest_among_canonical_rows() {
16386 let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
16387 let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
16388
16389 let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
16390 assert_eq!(result.len(), 1);
16391 assert_eq!(result[0].installed_version, "1.7.9");
16392 }
16393
16394 #[test]
16395 fn dedupe_leaves_uncorrelated_rows_alone() {
16396 let uncorrelated = InstalledPackage {
16400 name: "Foo".to_owned(),
16401 local_id: r"ARP\Machine\X64\Foo".to_owned(),
16402 installed_version: "1.0".to_owned(),
16403 publisher: None,
16404 scope: Some("Machine".to_owned()),
16405 installer_category: Some("exe".to_owned()),
16406 install_location: None,
16407 package_family_names: Vec::new(),
16408 product_codes: Vec::new(),
16409 upgrade_codes: Vec::new(),
16410 correlated: None,
16411 winget_package_identifier: None,
16412 winget_source_identifier: None,
16413 installed_version_canonical: false,
16414 correlated_requires_explicit_upgrade: false,
16415 correlated_lacks_compatible_installer: false,
16416 };
16417 let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
16418 assert_eq!(result.len(), 1);
16419 }
16420}