mod name_normalization;
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt::{Display, Formatter};
use std::fs;
use std::io::{Cursor, Read};
#[cfg(windows)]
use std::mem::{MaybeUninit, size_of};
#[cfg(windows)]
use std::os::windows::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use anyhow::{Context, Result, anyhow, bail};
use chrono::{DateTime, Duration, Utc};
use reqwest::blocking::{Client, Response};
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as JsonValue};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use sha2::{Digest, Sha256};
use turso::{Builder as SqlBuilder, Connection, Row as SqlRow, Value as SqlValue, params_from_iter};
#[cfg(windows)]
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
#[cfg(windows)]
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
#[cfg(windows)]
use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser};
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW};
#[cfg(windows)]
use windows_sys::Win32::Storage::Packaging::Appx::GetCurrentPackageFullName;
#[cfg(windows)]
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
#[cfg(windows)]
use winreg::{RegKey, enums::*};
use zip::ZipArchive;
const DEFAULT_MARKET: &str = "US";
const DEFAULT_MAX_RESULTS: usize = 50;
const LIST_LOOKUP_MAX_RESULTS: usize = 500;
const PREINDEXED_CANDIDATES: &[&str] = &["source2.msix", "source.msix"];
const DEFAULT_USER_AGENT: &str = "pinget-rs/0.1";
const DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES: i64 = 5;
const PREINDEXED_REFRESH_RETRY_MINUTES: i64 = 5;
#[cfg(windows)]
const PACKAGED_FAMILY_NAME: &str = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
#[cfg(windows)]
const PACKAGED_NAME: &str = "Microsoft.DesktopAppInstaller";
const INSTALLED_STATE_UNSUPPORTED_WARNING: &str =
"Installed package discovery is not supported on this platform; returning no installed packages.";
const INSTALL_UNSUPPORTED_WARNING: &str =
"Installing packages is not supported on this platform; no changes were made.";
const REPAIR_UNSUPPORTED_WARNING: &str = "Repairing packages is not supported on this platform; no changes were made.";
const REPAIR_REINSTALL_WARNING: &str =
"Pinget repair currently re-runs the package install flow for the selected package.";
const UNINSTALL_UNSUPPORTED_WARNING: &str =
"Uninstalling packages is not supported on this platform; no changes were made.";
#[cfg(windows)]
const WINGET_PACKAGE_NOT_FOUND_EXIT_CODE: i32 = -1978335212;
const SUPPORTED_ADMIN_SETTINGS: &[&str] = &[
"LocalManifestFiles",
"BypassCertificatePinningForMicrosoftStore",
"InstallerHashOverride",
"LocalArchiveMalwareScanOverride",
"ProxyCommandLineOptions",
];
const REST_SUPPORTED_CONTRACTS: &[&str] = &[
"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",
];
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SourceKind {
#[serde(rename = "PreIndexed", alias = "preIndexed")]
PreIndexed,
#[serde(rename = "Rest", alias = "rest")]
Rest,
}
impl Display for SourceKind {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
SourceKind::PreIndexed => f.write_str("preindexed"),
SourceKind::Rest => f.write_str("rest"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceRecord {
pub name: String,
pub kind: SourceKind,
pub arg: String,
pub identifier: String,
#[serde(default = "default_source_trust_level")]
pub trust_level: String,
#[serde(default)]
pub explicit: bool,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub last_update: Option<DateTime<Utc>>,
#[serde(default)]
pub source_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SourceStore {
sources: Vec<SourceRecord>,
}
impl Default for SourceStore {
fn default() -> Self {
Self {
sources: vec![
SourceRecord {
name: "winget".to_owned(),
kind: SourceKind::PreIndexed,
arg: "https://cdn.winget.microsoft.com/cache".to_owned(),
identifier: "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
trust_level: "Trusted".to_owned(),
explicit: false,
priority: 0,
last_update: None,
source_version: None,
},
SourceRecord {
name: "msstore".to_owned(),
kind: SourceKind::Rest,
arg: "https://storeedgefd.dsx.mp.microsoft.com/v9.0".to_owned(),
identifier: "StoreEdgeFD".to_owned(),
trust_level: "Trusted".to_owned(),
explicit: false,
priority: 0,
last_update: None,
source_version: None,
},
],
}
}
}
#[derive(Debug, Clone)]
pub struct RepositoryOptions {
pub app_root: PathBuf,
pub user_agent: String,
pub pre_indexed_source_auto_update_interval: Option<Duration>,
}
impl RepositoryOptions {
pub fn new(app_root: impl Into<PathBuf>) -> Self {
Self {
app_root: app_root.into(),
user_agent: DEFAULT_USER_AGENT.to_owned(),
pre_indexed_source_auto_update_interval: Some(Duration::minutes(DEFAULT_PREINDEXED_AUTO_UPDATE_MINUTES)),
}
}
pub fn for_current_user() -> Result<Self> {
Ok(Self::new(default_app_root()?))
}
#[must_use]
pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
#[must_use]
pub fn with_pre_indexed_source_auto_update_interval(mut self, interval: Option<Duration>) -> Self {
self.pre_indexed_source_auto_update_interval = interval;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct PackageQuery {
pub query: Option<String>,
pub id: Option<String>,
pub name: Option<String>,
pub moniker: Option<String>,
pub tag: Option<String>,
pub command: Option<String>,
pub source: Option<String>,
pub count: Option<usize>,
pub exact: bool,
pub version: Option<String>,
pub channel: Option<String>,
pub locale: Option<String>,
pub installer_type: Option<String>,
pub installer_architecture: Option<String>,
pub platform: Option<String>,
pub os_version: Option<String>,
pub install_scope: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListQuery {
pub query: Option<String>,
pub id: Option<String>,
pub name: Option<String>,
pub moniker: Option<String>,
pub tag: Option<String>,
pub command: Option<String>,
pub product_code: Option<String>,
pub version: Option<String>,
pub source: Option<String>,
pub count: Option<usize>,
pub exact: bool,
pub install_scope: Option<String>,
pub upgrade_only: bool,
pub include_unknown: bool,
pub include_pinned: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct SearchMatch {
pub source_name: String,
pub source_kind: SourceKind,
pub id: String,
pub name: String,
pub moniker: Option<String>,
pub version: Option<String>,
pub channel: Option<String>,
pub match_criteria: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct SearchResponse {
pub matches: Vec<SearchMatch>,
pub warnings: Vec<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct ListMatch {
pub name: String,
pub id: String,
pub local_id: String,
pub installed_version: String,
pub available_version: Option<String>,
pub source_name: Option<String>,
pub publisher: Option<String>,
pub scope: Option<String>,
pub installer_category: Option<String>,
pub install_location: Option<String>,
pub package_family_names: Vec<String>,
pub product_codes: Vec<String>,
pub upgrade_codes: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct ListResponse {
pub matches: Vec<ListMatch>,
pub warnings: Vec<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct VersionKey {
pub version: String,
pub channel: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Manifest {
pub id: String,
pub name: String,
pub version: String,
pub channel: String,
pub publisher: Option<String>,
pub description: Option<String>,
pub moniker: Option<String>,
pub package_url: Option<String>,
pub publisher_url: Option<String>,
pub publisher_support_url: Option<String>,
pub license: Option<String>,
pub license_url: Option<String>,
pub privacy_url: Option<String>,
pub author: Option<String>,
pub copyright: Option<String>,
pub copyright_url: Option<String>,
pub release_notes: Option<String>,
pub release_notes_url: Option<String>,
pub tags: Vec<String>,
pub agreements: Vec<PackageAgreement>,
pub package_dependencies: Vec<String>,
pub documentation: Vec<Documentation>,
pub installers: Vec<Installer>,
pub require_explicit_upgrade: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Documentation {
pub label: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PackageAgreement {
pub label: Option<String>,
pub text: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct Installer {
pub architecture: Option<String>,
pub installer_type: Option<String>,
pub nested_installer_type: Option<String>,
pub nested_installer_files: Vec<NestedInstallerFile>,
pub url: Option<String>,
pub sha256: Option<String>,
pub product_code: Option<String>,
pub locale: Option<String>,
pub scope: Option<String>,
pub release_date: Option<String>,
pub package_family_name: Option<String>,
pub upgrade_code: Option<String>,
pub platforms: Vec<String>,
pub minimum_os_version: Option<String>,
pub switches: InstallerSwitches,
pub commands: Vec<String>,
pub package_dependencies: Vec<String>,
pub require_explicit_upgrade: bool,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct NestedInstallerFile {
pub relative_file_path: String,
pub portable_command_alias: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct InstallerSwitches {
pub silent: Option<String>,
pub silent_with_progress: Option<String>,
pub interactive: Option<String>,
pub custom: Option<String>,
pub log: Option<String>,
pub install_location: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallerMode {
Interactive,
SilentWithProgress,
Silent,
}
#[derive(Debug, Clone)]
pub struct InstallRequest {
pub query: PackageQuery,
pub manifest_path: Option<PathBuf>,
pub mode: InstallerMode,
pub log_path: Option<PathBuf>,
pub custom: Option<String>,
pub override_args: Option<String>,
pub install_location: Option<String>,
pub skip_dependencies: bool,
pub dependencies_only: bool,
pub accept_package_agreements: bool,
pub force: bool,
pub rename: Option<String>,
pub uninstall_previous: bool,
pub ignore_security_hash: bool,
pub dependency_source: Option<String>,
pub no_upgrade: bool,
}
impl InstallRequest {
pub fn new(query: PackageQuery) -> Self {
Self {
query,
manifest_path: None,
mode: InstallerMode::SilentWithProgress,
log_path: None,
custom: None,
override_args: None,
install_location: None,
skip_dependencies: false,
dependencies_only: false,
accept_package_agreements: false,
force: false,
rename: None,
uninstall_previous: false,
ignore_security_hash: false,
dependency_source: None,
no_upgrade: false,
}
}
}
#[derive(Debug, Clone)]
pub struct UninstallRequest {
pub query: PackageQuery,
pub manifest_path: Option<PathBuf>,
pub product_code: Option<String>,
pub mode: InstallerMode,
pub all_versions: bool,
pub force: bool,
pub purge: bool,
pub preserve: bool,
pub log_path: Option<PathBuf>,
}
impl UninstallRequest {
pub fn new(query: PackageQuery) -> Self {
Self {
query,
manifest_path: None,
product_code: None,
mode: InstallerMode::SilentWithProgress,
all_versions: false,
force: false,
purge: false,
preserve: false,
log_path: None,
}
}
}
#[derive(Debug, Clone)]
pub struct RepairRequest {
pub query: PackageQuery,
pub manifest_path: Option<PathBuf>,
pub product_code: Option<String>,
pub mode: InstallerMode,
pub log_path: Option<PathBuf>,
pub accept_package_agreements: bool,
pub force: bool,
pub ignore_security_hash: bool,
}
impl RepairRequest {
pub fn new(query: PackageQuery) -> Self {
Self {
query,
manifest_path: None,
product_code: None,
mode: InstallerMode::SilentWithProgress,
log_path: None,
accept_package_agreements: false,
force: false,
ignore_security_hash: false,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ShowResult {
pub package: SearchMatch,
pub manifest: Manifest,
pub selected_installer: Option<Installer>,
pub cached_files: Vec<PathBuf>,
pub warnings: Vec<String>,
#[serde(skip_serializing)]
manifest_documents: JsonValue,
}
impl ShowResult {
pub fn structured_document(&self) -> JsonValue {
collapse_structured_document(&self.manifest_documents)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DownloadOutput {
pub manifest: Manifest,
pub installer_path: PathBuf,
pub manifest_path: PathBuf,
}
struct FetchedInstaller {
manifest: Manifest,
manifest_documents: JsonValue,
installer: Installer,
installer_path: PathBuf,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct VersionsResult {
pub package: SearchMatch,
pub versions: Vec<VersionKey>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct CacheWarmResult {
pub package: SearchMatch,
pub cached_files: Vec<PathBuf>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct SourceUpdateResult {
pub name: String,
pub kind: SourceKind,
pub detail: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct PinRecord {
pub package_id: String,
pub version: String,
pub source_id: String,
pub pin_type: PinType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum PinType {
Pinning,
Blocking,
Gating,
}
impl Display for PinType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
PinType::Pinning => f.write_str("Pinning"),
PinType::Blocking => f.write_str("Blocking"),
PinType::Gating => f.write_str("Gating"),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct InstallResult {
pub package_id: String,
pub version: String,
pub installer_path: PathBuf,
pub installer_type: String,
pub exit_code: i32,
pub success: bool,
pub no_op: bool,
pub warnings: Vec<String>,
}
impl InstallerSwitches {
fn with_fallback(&self, fallback: &Self) -> Self {
Self {
silent: self.silent.clone().or_else(|| fallback.silent.clone()),
silent_with_progress: self
.silent_with_progress
.clone()
.or_else(|| fallback.silent_with_progress.clone()),
interactive: self.interactive.clone().or_else(|| fallback.interactive.clone()),
custom: self.custom.clone().or_else(|| fallback.custom.clone()),
log: self.log.clone().or_else(|| fallback.log.clone()),
install_location: self
.install_location
.clone()
.or_else(|| fallback.install_location.clone()),
}
}
}
fn installed_package_discovery_supported() -> bool {
cfg!(windows)
}
fn package_actions_supported() -> bool {
cfg!(windows)
}
fn unsupported_action_result(
package_id: impl Into<String>,
version: impl Into<String>,
installer_type: impl Into<String>,
warning: &str,
) -> InstallResult {
InstallResult {
package_id: package_id.into(),
version: version.into(),
installer_path: PathBuf::new(),
installer_type: installer_type.into(),
exit_code: 0,
success: true,
no_op: true,
warnings: vec![warning.to_owned()],
}
}
#[derive(Debug, Clone)]
struct InstalledPackage {
name: String,
local_id: String,
installed_version: String,
publisher: Option<String>,
scope: Option<String>,
installer_category: Option<String>,
install_location: Option<String>,
package_family_names: Vec<String>,
product_codes: Vec<String>,
upgrade_codes: Vec<String>,
correlated: Option<SearchMatch>,
installed_version_canonical: bool,
correlated_requires_explicit_upgrade: bool,
correlated_lacks_compatible_installer: bool,
}
#[derive(Debug, Clone)]
struct LocatedMatch {
display: SearchMatch,
source_index: usize,
locator: MatchLocator,
}
#[derive(Debug)]
struct SearchSourceMatches {
matches: Vec<LocatedMatch>,
truncated: bool,
}
#[derive(Debug, Clone)]
enum MatchLocator {
PreIndexedV1 {
package_rowid: i64,
},
PreIndexedV2 {
package_rowid: i64,
package_hash: String,
},
Rest {
package_id: String,
versions: Vec<VersionKey>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchSemantics {
Many,
Single,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RestInfoCache {
expires_at: DateTime<Utc>,
value: RestInformation,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RestInformation {
#[serde(rename = "SourceIdentifier")]
source_identifier: String,
#[serde(rename = "ServerSupportedVersions", default)]
server_supported_versions: Vec<String>,
#[serde(rename = "RequiredPackageMatchFields", default)]
required_package_match_fields: Vec<String>,
#[serde(rename = "UnsupportedPackageMatchFields", default)]
unsupported_package_match_fields: Vec<String>,
#[serde(rename = "RequiredQueryParameters", default)]
required_query_parameters: Vec<String>,
#[serde(rename = "UnsupportedQueryParameters", default)]
unsupported_query_parameters: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct PackageVersionDataDocument {
#[serde(rename = "vD", default)]
versions: Vec<PackageVersionDataEntry>,
}
#[derive(Debug, Clone, Deserialize)]
struct PackageVersionDataEntry {
#[serde(rename = "v")]
version: String,
#[serde(rename = "rP")]
manifest_relative_path: String,
#[serde(rename = "s256H")]
manifest_hash: String,
#[serde(rename = "aMiV", default)]
arp_min_version: Option<String>,
#[serde(rename = "aMaV", default)]
arp_max_version: Option<String>,
}
pub struct Repository {
app_root: PathBuf,
client: Client,
store: SourceStore,
use_system_winget_sources: bool,
pre_indexed_source_auto_update_interval: Option<Duration>,
preindexed_refresh_attempts: HashMap<String, PreindexedRefreshAttempt>,
}
#[derive(Debug, Clone)]
struct PreindexedRefreshAttempt {
attempted_at: DateTime<Utc>,
succeeded: bool,
}
#[derive(Debug, Clone, Default)]
struct PreindexedRefreshOutcome {
attempted: bool,
reused_recent_attempt: bool,
error: Option<String>,
}
struct PreindexedOpenConnection {
connection: Connection,
refresh: PreindexedRefreshOutcome,
}
#[derive(Debug)]
struct MissingPackageVersionError {
package_id: String,
source_name: String,
requested_version: String,
requested_channel: Option<String>,
}
impl Display for MissingPackageVersionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let channel = self
.requested_channel
.as_deref()
.filter(|value| !value.is_empty())
.map(|value| format!(" with channel '{value}'"))
.unwrap_or_default();
write!(
f,
"Version '{}'{} was not found for package '{}' in source '{}'.",
self.requested_version, channel, self.package_id, self.source_name
)
}
}
impl std::error::Error for MissingPackageVersionError {}
fn preindexed_refresh_locks() -> &'static RwLock<HashMap<String, Arc<Mutex<()>>>> {
static LOCKS: OnceLock<RwLock<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
LOCKS.get_or_init(|| RwLock::new(HashMap::new()))
}
impl Repository {
pub fn open() -> Result<Self> {
Self::open_with_options(RepositoryOptions::for_current_user()?)
}
pub fn open_with_options(options: RepositoryOptions) -> Result<Self> {
ensure_app_dirs(&options.app_root)?;
let use_system_winget_sources = uses_system_winget_source_commands(&options.app_root);
let store = load_store(&options.app_root)?;
let client = Client::builder()
.user_agent(&options.user_agent)
.build()
.context("failed to build HTTP client")?;
Ok(Self {
app_root: options.app_root,
client,
store,
use_system_winget_sources,
pre_indexed_source_auto_update_interval: options.pre_indexed_source_auto_update_interval,
preindexed_refresh_attempts: HashMap::new(),
})
}
pub fn app_root(&self) -> &Path {
&self.app_root
}
pub fn list_sources(&self) -> Vec<SourceRecord> {
self.store.sources.clone()
}
pub fn add_source(&mut self, name: &str, arg: &str, kind: SourceKind) -> Result<()> {
self.add_source_with_metadata(name, arg, kind, None, false, 0)
}
pub fn add_source_with_metadata(
&mut self,
name: &str,
arg: &str,
kind: SourceKind,
trust_level: Option<&str>,
explicit: bool,
priority: i32,
) -> Result<()> {
if self.use_system_winget_sources {
system_winget_add_source(name, arg, kind, &normalize_source_trust_level(trust_level)?, explicit)?;
self.refresh_system_winget_sources()?;
return Ok(());
}
if self.store.sources.iter().any(|s| s.name.eq_ignore_ascii_case(name)) {
bail!("A source with name '{name}' already exists.");
}
if self.store.sources.iter().any(|s| s.arg == arg) {
bail!("A source with argument '{arg}' already exists.");
}
let identifier = name.to_owned();
self.store.sources.push(SourceRecord {
name: name.to_owned(),
kind,
arg: arg.to_owned(),
identifier,
trust_level: normalize_source_trust_level(trust_level)?,
explicit,
priority,
last_update: None,
source_version: None,
});
self.save_store()?;
Ok(())
}
pub fn edit_source(&mut self, name: &str, explicit: Option<bool>, trust_level: Option<&str>) -> Result<()> {
if self.use_system_winget_sources {
bail!("Editing system WinGet sources is not supported by winget source commands.");
}
let source = self
.store
.sources
.iter_mut()
.find(|source| source.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow!("Source '{name}' not found."))?;
if let Some(explicit) = explicit {
source.explicit = explicit;
}
if trust_level.is_some() {
source.trust_level = normalize_source_trust_level(trust_level)?;
}
self.save_store()?;
Ok(())
}
pub fn remove_source(&mut self, name: &str) -> Result<()> {
if self.use_system_winget_sources {
system_winget_remove_source(name)?;
self.refresh_system_winget_sources()?;
return Ok(());
}
let idx = self
.store
.sources
.iter()
.position(|s| s.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow!("Source '{name}' not found."))?;
let source = self.store.sources.remove(idx);
let state_dir = self.source_state_dir(&source);
if state_dir.exists() {
let _ = fs::remove_dir_all(&state_dir);
}
self.save_store()?;
Ok(())
}
pub fn reset_source(&mut self, name: &str) -> Result<()> {
if self.use_system_winget_sources {
system_winget_reset_source(name)?;
self.refresh_system_winget_sources()?;
return Ok(());
}
let idx = self
.store
.sources
.iter()
.position(|source| source.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow!("Source '{name}' not found."))?;
let source = self.store.sources[idx].clone();
let state_dir = self.source_state_dir(&source);
if state_dir.exists() {
let _ = fs::remove_dir_all(&state_dir);
}
if let Some(default_source) = SourceStore::default()
.sources
.into_iter()
.find(|source| source.name.eq_ignore_ascii_case(name))
{
self.store.sources[idx] = default_source;
} else {
self.store.sources[idx].last_update = None;
self.store.sources[idx].source_version = None;
}
self.save_store()?;
Ok(())
}
pub fn reset_sources(&mut self) -> Result<()> {
if self.use_system_winget_sources {
system_winget_reset_sources()?;
self.refresh_system_winget_sources()?;
return Ok(());
}
for source in &self.store.sources {
let state_dir = self.source_state_dir(source);
if state_dir.exists() {
let _ = fs::remove_dir_all(&state_dir);
}
}
self.store = SourceStore::default();
self.save_store()?;
Ok(())
}
pub fn supported_admin_settings() -> &'static [&'static str] {
SUPPORTED_ADMIN_SETTINGS
}
pub fn get_user_settings(&self) -> Result<JsonValue> {
Ok(JsonValue::Object(load_json_object(&user_settings_path(
&self.app_root,
))?))
}
pub fn set_user_settings(&self, user_settings: &JsonValue, merge: bool) -> Result<JsonValue> {
let update = match user_settings {
JsonValue::Object(object) => object.clone(),
_ => bail!("user settings must be a JSON object"),
};
let effective = if merge {
merge_json_objects(&load_json_object(&user_settings_path(&self.app_root))?, &update)
} else {
update
};
save_json_object(user_settings_path(&self.app_root), &effective)?;
Ok(JsonValue::Object(effective))
}
pub fn test_user_settings(&self, expected: &JsonValue, ignore_not_set: bool) -> Result<bool> {
let current = self.get_user_settings()?;
Ok(if ignore_not_set {
json_contains(¤t, expected)
} else {
current == *expected
})
}
pub fn get_admin_settings(&self) -> Result<JsonValue> {
let mut settings = load_json_object(&admin_settings_path(&self.app_root))?;
for name in SUPPORTED_ADMIN_SETTINGS {
settings.entry((*name).to_owned()).or_insert(JsonValue::Bool(false));
}
Ok(JsonValue::Object(settings))
}
pub fn set_admin_setting(&self, name: &str, enabled: bool) -> Result<()> {
let normalized = normalize_admin_setting_name(name)?;
let mut settings = match self.get_admin_settings()? {
JsonValue::Object(settings) => settings,
_ => JsonMap::new(),
};
settings.insert(normalized.to_owned(), JsonValue::Bool(enabled));
save_json_object(admin_settings_path(&self.app_root), &settings)
}
pub fn reset_admin_setting(&self, name: Option<&str>, reset_all: bool) -> Result<()> {
if !reset_all && name.is_none() {
bail!("resetting admin settings requires a setting name or reset-all");
}
let mut settings = match self.get_admin_settings()? {
JsonValue::Object(settings) => settings,
_ => JsonMap::new(),
};
if reset_all {
for setting_name in SUPPORTED_ADMIN_SETTINGS {
settings.insert((*setting_name).to_owned(), JsonValue::Bool(false));
}
} else {
settings.insert(
normalize_admin_setting_name(name.expect("checked above"))?.to_owned(),
JsonValue::Bool(false),
);
}
save_json_object(admin_settings_path(&self.app_root), &settings)
}
pub fn ensure_settings_files(&self) -> Result<()> {
let user_settings = user_settings_path(&self.app_root);
if !user_settings.exists() {
save_json_object(user_settings, &JsonMap::new())?;
}
let admin_settings = admin_settings_path(&self.app_root);
if !admin_settings.exists() {
let mut values = JsonMap::new();
for name in SUPPORTED_ADMIN_SETTINGS {
values.insert((*name).to_owned(), JsonValue::Bool(false));
}
save_json_object(admin_settings, &values)?;
}
Ok(())
}
pub fn update_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
if self.use_system_winget_sources {
return self.update_system_winget_sources(source_name);
}
let indexes = self.resolve_source_indexes(source_name)?;
let mut results = Vec::new();
for index in indexes {
let detail = match self.store.sources[index].kind {
SourceKind::PreIndexed => self.update_preindexed(index)?,
SourceKind::Rest => self.update_rest(index)?,
};
results.push(SourceUpdateResult {
name: self.store.sources[index].name.clone(),
kind: self.store.sources[index].kind,
detail,
});
}
self.save_store()?;
Ok(results)
}
pub fn search(&mut self, query: &PackageQuery) -> Result<SearchResponse> {
let (matches, warnings, truncated) = self.search_located(query, SearchSemantics::Many)?;
Ok(SearchResponse {
matches: matches.into_iter().map(|item| item.display).collect(),
warnings,
truncated,
})
}
pub fn search_manifests(&mut self, query: &PackageQuery) -> Result<Vec<JsonValue>> {
let (matches, _, _) = self.search_located(query, SearchSemantics::Many)?;
let mut structured_documents = Vec::with_capacity(matches.len());
for located in matches {
let (_, manifest_documents, _) = self.manifest_for_match(&located, query)?;
structured_documents.push(manifest_documents);
}
Ok(collapse_structured_documents(&structured_documents))
}
pub fn list(&mut self, query: &ListQuery) -> Result<ListResponse> {
if (query.include_unknown || query.include_pinned) && !query.upgrade_only {
bail!("--include-unknown and --include-pinned require --upgrade-available");
}
if query.source.is_some()
&& query.query.is_none()
&& query.id.is_none()
&& query.name.is_none()
&& query.moniker.is_none()
&& query.tag.is_none()
&& query.command.is_none()
{
bail!("list --source currently requires a query or explicit filter");
}
let has_filter = list_query_needs_available_lookup(query);
let needs_available = true;
let mut warnings = Vec::new();
if !installed_package_discovery_supported() {
warnings.push(INSTALLED_STATE_UNSUPPORTED_WARNING.to_owned());
}
let mut installed = collect_installed_packages(query.install_scope.as_deref())?;
if needs_available {
warnings.extend(self.correlate_installed_via_index(&mut installed, query.source.as_deref())?);
warnings.extend(self.correlate_installed_by_normalized_identity(&mut installed, query.source.as_deref())?);
}
if needs_available && has_filter {
let available_query = package_query_from_list_query(query);
let (matches, source_warnings, _) = self.search_located(&available_query, SearchSemantics::Many)?;
warnings.extend(source_warnings);
let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
for package in &mut installed {
if package.correlated.is_some() {
continue;
}
package.correlated =
correlate_installed_package(package, &candidates, allow_loose_list_correlation(query));
}
} else if needs_available {
warnings.extend(self.correlate_all_installed(&mut installed)?);
}
if needs_available {
warnings.extend(self.enrich_correlated_via_index(&mut installed, query.source.as_deref())?);
}
if query.upgrade_only {
installed = dedupe_correlated_for_upgrade(installed);
}
let mut matches = installed
.into_iter()
.filter(|package| {
list_package_matches(package, query)
&& (!query.upgrade_only || installed_package_matches_upgrade_filter(package, query))
})
.collect::<Vec<_>>();
matches.sort_by(|left, right| {
list_sort_weight(left)
.cmp(&list_sort_weight(right))
.then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
.then_with(|| left.local_id.cmp(&right.local_id))
});
let pins = if query.upgrade_only {
self.list_pins(query.source.as_deref())?
} else {
Vec::new()
};
let mut list_matches = matches
.into_iter()
.map(list_match_from_installed)
.filter(|item| !query.upgrade_only || query.include_pinned || !is_upgrade_blocked_by_pin(item, &pins))
.collect::<Vec<_>>();
let truncated = if let Some(limit) = query.count {
let was_truncated = list_matches.len() > limit;
list_matches.truncate(limit);
was_truncated
} else {
false
};
Ok(ListResponse {
matches: list_matches,
warnings,
truncated,
})
}
fn correlate_all_installed(&mut self, installed: &mut [InstalledPackage]) -> Result<Vec<String>> {
if installed.iter().all(|p| p.correlated.is_some()) {
return Ok(Vec::new());
}
let all_query = PackageQuery {
query: None,
id: None,
name: None,
moniker: None,
tag: None,
command: None,
source: None,
count: Some(100_000), exact: false,
version: None,
channel: None,
locale: None,
installer_type: None,
installer_architecture: None,
platform: None,
os_version: None,
install_scope: None,
};
let (matches, warnings, _) = self.search_located(&all_query, SearchSemantics::Many)?;
let candidates: Vec<SearchMatch> = matches.into_iter().map(|c| c.display).collect();
for package in installed.iter_mut() {
if package.correlated.is_some() {
continue;
}
package.correlated = correlate_installed_package(package, &candidates, true);
}
Ok(warnings)
}
fn correlate_installed_via_index(
&mut self,
installed: &mut [InstalledPackage],
requested_source: Option<&str>,
) -> Result<Vec<String>> {
let source_indices: Vec<usize> = self
.store
.sources
.iter()
.enumerate()
.filter(|(_, source)| {
source.kind == SourceKind::PreIndexed
&& match requested_source {
Some(name) => source.name.eq_ignore_ascii_case(name),
None => true,
}
})
.map(|(index, _)| index)
.collect();
for source_index in source_indices {
let resolved: Vec<(usize, V2PackageMetadata, &'static str)> = {
let connection = match self.open_preindexed_connection(source_index) {
Ok(c) => c,
Err(_) => continue,
};
if !v2_identity_tables_present(&connection) {
continue;
}
let mut acc = Vec::new();
for (idx, package) in installed.iter().enumerate() {
if package.correlated.is_some() {
continue;
}
let Some((rowid, by)) = lookup_identity_match_v2(&connection, package)? else {
continue;
};
let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
continue;
};
acc.push((idx, meta, by));
}
acc
};
let source = self.source_clone(source_index);
for (idx, meta, by) in resolved {
let (canonical_installed, anchored_latest) =
match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
Ok((entries, _)) => (
map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
latest_arp_anchored_version(&entries),
),
Err(_) => (None, None),
};
let available_version = match (&canonical_installed, anchored_latest) {
(Some(_), Some(anchored)) => anchored,
_ => meta.latest_version,
};
let installed_pkg = &mut installed[idx];
installed_pkg.correlated = Some(SearchMatch {
source_name: source.name.clone(),
source_kind: source.kind,
id: meta.id,
name: meta.name,
moniker: meta.moniker,
version: Some(available_version),
channel: None,
match_criteria: Some(by.to_owned()),
});
if let Some(version) = canonical_installed {
installed_pkg.installed_version = version;
installed_pkg.installed_version_canonical = true;
}
}
}
Ok(Vec::new())
}
fn correlate_installed_by_normalized_identity(
&mut self,
installed: &mut [InstalledPackage],
requested_source: Option<&str>,
) -> Result<Vec<String>> {
let source_indices: Vec<usize> = self
.store
.sources
.iter()
.enumerate()
.filter(|(_, source)| {
source.kind == SourceKind::PreIndexed
&& match requested_source {
Some(name) => source.name.eq_ignore_ascii_case(name),
None => true,
}
})
.map(|(index, _)| index)
.collect();
for source_index in source_indices {
let resolved: Vec<(usize, V2PackageMetadata)> = {
let connection = match self.open_preindexed_connection(source_index) {
Ok(c) => c,
Err(_) => continue,
};
if !v2_normalized_identity_tables_present(&connection) {
continue;
}
let mut acc = Vec::new();
for (idx, pkg) in installed.iter().enumerate() {
if pkg.correlated.is_some() {
continue;
}
if pkg.local_id.starts_with("MSIX\\") {
continue;
}
let Some(publisher) = pkg.publisher.as_deref() else {
continue;
};
let normalized_name = name_normalization::normalize_name(&pkg.name).name;
if normalized_name.is_empty() {
continue;
}
let normalized_publisher = name_normalization::normalize_publisher(publisher);
if normalized_publisher.is_empty() {
continue;
}
let Some(rowid) =
lookup_unique_normalized_identity(&connection, &normalized_name, &normalized_publisher)?
else {
continue;
};
let Some(meta) = fetch_v2_package_metadata(&connection, rowid)? else {
continue;
};
acc.push((idx, meta));
}
acc
};
let source = self.source_clone(source_index);
for (idx, meta) in resolved {
let (canonical_installed, anchored_latest) =
match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
Ok((entries, _)) => (
map_arp_version_to_catalog(&entries, &installed[idx].installed_version),
latest_arp_anchored_version(&entries),
),
Err(_) => (None, None),
};
let available_version = match (&canonical_installed, anchored_latest) {
(Some(_), Some(anchored)) => anchored,
_ => meta.latest_version,
};
let installed_pkg = &mut installed[idx];
installed_pkg.correlated = Some(SearchMatch {
source_name: source.name.clone(),
source_kind: source.kind,
id: meta.id,
name: meta.name,
moniker: meta.moniker,
version: Some(available_version),
channel: None,
match_criteria: Some("NormalizedNameAndPublisher".to_owned()),
});
if let Some(version) = canonical_installed {
installed_pkg.installed_version = version;
installed_pkg.installed_version_canonical = true;
}
}
}
Ok(Vec::new())
}
fn enrich_correlated_via_index(
&mut self,
installed: &mut [InstalledPackage],
requested_source: Option<&str>,
) -> Result<Vec<String>> {
for package in installed.iter_mut() {
apply_msix_resource_string_name_fix(package);
}
let source_indices: Vec<usize> = self
.store
.sources
.iter()
.enumerate()
.filter(|(_, source)| {
source.kind == SourceKind::PreIndexed
&& match requested_source {
Some(name) => source.name.eq_ignore_ascii_case(name),
None => true,
}
})
.map(|(index, _)| index)
.collect();
for source_index in source_indices {
let needs: Vec<(usize, V2PackageMetadata)> = {
let connection = match self.open_preindexed_connection(source_index) {
Ok(c) => c,
Err(_) => continue,
};
if !v2_identity_tables_present(&connection) {
continue;
}
let mut acc = Vec::new();
for (idx, pkg) in installed.iter().enumerate() {
let Some(candidate) = pkg.correlated.as_ref() else {
continue;
};
if !candidate
.source_name
.eq_ignore_ascii_case(&self.store.sources[source_index].name)
{
continue;
}
let Some(meta) = lookup_v2_metadata_by_id(&connection, &candidate.id)? else {
continue;
};
acc.push((idx, meta));
}
acc
};
let source = self.source_clone(source_index);
for (idx, meta) in needs {
let entries = match self.load_v2_version_data(&source, meta.rowid, &meta.package_hash) {
Ok((entries, _)) => entries,
Err(_) => continue,
};
if !installed[idx].installed_version_canonical {
let canonical_installed = map_arp_version_to_catalog(&entries, &installed[idx].installed_version);
let anchored_latest = latest_arp_anchored_version(&entries);
if let Some(canonical) = canonical_installed {
installed[idx].installed_version = canonical;
installed[idx].installed_version_canonical = true;
if let (Some(correlated), Some(anchored)) =
(installed[idx].correlated.as_mut(), anchored_latest)
{
correlated.version = Some(anchored);
}
}
}
if let Some(latest) = entries.first() {
let bytes_result = self.get_cached_source_file(
"V2_M",
&source,
&latest.manifest_relative_path,
Some(latest.manifest_hash.as_str()),
);
if let Ok(bytes) = bytes_result
&& let Ok(manifest) = parse_yaml_manifest(&bytes.bytes)
{
installed[idx].correlated_requires_explicit_upgrade = manifest.require_explicit_upgrade;
installed[idx].correlated_lacks_compatible_installer =
!manifest_has_compatible_installer(&manifest);
}
}
}
}
Ok(Vec::new())
}
pub fn search_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
let (located, warnings) = self.find_single_match_with_semantics(query, SearchSemantics::Many)?;
let versions = self.versions_for_match(&located, query)?;
Ok(VersionsResult {
package: located.display,
versions,
warnings,
})
}
pub fn show_versions(&mut self, query: &PackageQuery) -> Result<VersionsResult> {
let (located, warnings) = self.find_single_match(query)?;
let versions = self.versions_for_match(&located, query)?;
Ok(VersionsResult {
package: located.display,
versions,
warnings,
})
}
pub fn show(&mut self, query: &PackageQuery) -> Result<ShowResult> {
let (located, warnings) = self.find_single_match(query)?;
let (manifest, manifest_documents, cached_files) = self.manifest_for_match(&located, query)?;
let selected_installer = select_installer(&manifest.installers, query);
Ok(ShowResult {
package: located.display,
manifest,
selected_installer,
cached_files,
warnings,
manifest_documents,
})
}
pub fn warm_cache(&mut self, query: &PackageQuery) -> Result<CacheWarmResult> {
let (located, warnings) = self.find_single_match(query)?;
let (_, _, cached_files) = self.manifest_for_match(&located, query)?;
Ok(CacheWarmResult {
package: located.display,
cached_files,
warnings,
})
}
pub fn list_pins(&self, source_id: Option<&str>) -> Result<Vec<PinRecord>> {
let db_path = pins_db_path(&self.app_root);
if !db_path.exists() {
return Ok(Vec::new());
}
let conn = Self::open_sqlite_connection(db_path)?;
let pin_type_column = match resolve_pin_type_column(&conn)? {
Some(column) => column,
None => return Ok(Vec::new()),
};
let sql = if source_id.is_some() {
format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin WHERE source_id = ?1")
} else {
format!("SELECT package_id, version, source_id, {pin_type_column} FROM pin")
};
let params = if let Some(source_id) = source_id {
vec![SqlValue::Text(source_id.to_owned())]
} else {
Vec::new()
};
query_rows(&conn, &sql, params, |row| {
let pin_type_int = row_i64(row, 3)?;
Ok(PinRecord {
package_id: row_string(row, 0)?,
version: row_string(row, 1)?,
source_id: row_string(row, 2)?,
pin_type: decode_pin_type(pin_type_int),
})
})
}
pub fn add_pin(&self, package_id: &str, version: &str, source_id: &str, pin_type: PinType) -> Result<()> {
let db_path = pins_db_path(&self.app_root);
let conn = Self::open_sqlite_connection(db_path)?;
execute_batch_sql(
&conn,
"CREATE TABLE IF NOT EXISTS pin (
package_id TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '*',
source_id TEXT NOT NULL DEFAULT '',
type INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (package_id, source_id)
)",
)?;
let pin_type_column = resolve_pin_type_column(&conn)?.unwrap_or("type");
let type_int: i64 = match pin_type {
PinType::Pinning => 2,
PinType::Blocking => 4,
PinType::Gating => 3,
};
execute_sql(
&conn,
&format!(
"INSERT OR REPLACE INTO pin (package_id, version, source_id, {pin_type_column}) VALUES (?1, ?2, ?3, ?4)"
),
vec![
SqlValue::Text(package_id.to_owned()),
SqlValue::Text(version.to_owned()),
SqlValue::Text(source_id.to_owned()),
SqlValue::Integer(type_int),
],
)?;
conn.cacheflush().context("failed to flush pin database")?;
Ok(())
}
pub fn remove_pin(&self, package_id: &str, source_id: Option<&str>) -> Result<bool> {
let db_path = pins_db_path(&self.app_root);
if !db_path.exists() {
return Ok(false);
}
let conn = Self::open_sqlite_connection(db_path)?;
let count = if let Some(source_id) = source_id {
execute_sql(
&conn,
"DELETE FROM pin WHERE package_id = ?1 AND source_id = ?2",
vec![
SqlValue::Text(package_id.to_owned()),
SqlValue::Text(source_id.to_owned()),
],
)?
} else {
execute_sql(
&conn,
"DELETE FROM pin WHERE package_id = ?1",
vec![SqlValue::Text(package_id.to_owned())],
)?
};
conn.cacheflush().context("failed to flush pin database")?;
Ok(count > 0)
}
pub fn reset_pins(&self, source_id: Option<&str>) -> Result<()> {
let db_path = pins_db_path(&self.app_root);
if !db_path.exists() {
return Ok(());
}
if let Some(source_id) = source_id {
let conn = Self::open_sqlite_connection(db_path)?;
execute_sql(
&conn,
"DELETE FROM pin WHERE source_id = ?1",
vec![SqlValue::Text(source_id.to_owned())],
)?;
conn.cacheflush().context("failed to flush pin database")?;
} else {
fs::remove_file(&db_path)?;
}
Ok(())
}
fn save_store(&self) -> Result<()> {
save_store(&self.app_root, &self.store)
}
fn source_state_dir(&self, source: &SourceRecord) -> PathBuf {
source_state_dir(&self.app_root, source)
}
pub fn download_installer(&mut self, query: &PackageQuery, download_dir: &Path) -> Result<DownloadOutput> {
self.download_installer_for_request(&InstallRequest::new(query.clone()), download_dir)
}
pub fn download_installer_for_request(
&mut self,
request: &InstallRequest,
download_dir: &Path,
) -> Result<DownloadOutput> {
let fetched = self.fetch_installer_for_request(request, download_dir)?;
let manifest_yaml =
build_download_manifest_yaml(&fetched.manifest, &fetched.manifest_documents, &fetched.installer)
.context("failed to render manifest YAML")?;
let manifest_filename = derive_download_manifest_filename(&fetched.manifest, &fetched.installer);
let manifest_path = download_dir.join(&manifest_filename);
fs::write(&manifest_path, manifest_yaml.as_bytes())
.with_context(|| format!("failed to write manifest to {}", manifest_path.display()))?;
Ok(DownloadOutput {
manifest: fetched.manifest,
installer_path: fetched.installer_path,
manifest_path,
})
}
fn fetch_installer_for_request(
&mut self,
request: &InstallRequest,
download_dir: &Path,
) -> Result<FetchedInstaller> {
let (manifest, manifest_documents) = self.resolve_manifest_and_documents_for_install(request)?;
let installer = select_installer(&manifest.installers, &request.query)
.ok_or_else(|| anyhow!("No applicable installer found for the current system"))?;
let url = installer
.url
.as_deref()
.ok_or_else(|| anyhow!("Installer has no URL"))?;
fs::create_dir_all(download_dir)?;
let filename = request.rename.as_deref().unwrap_or_else(|| {
url.rsplit('/')
.next()
.unwrap_or("installer")
.split('?')
.next()
.unwrap_or("installer")
});
let dest = download_dir.join(filename);
let response = self.client.get(url).send().context("failed to download installer")?;
if !response.status().is_success() {
bail!("Download failed: HTTP {}", response.status());
}
let bytes = response.bytes()?;
fs::write(&dest, &bytes)?;
if let Err(error) = verify_installer_hash(installer.sha256.as_deref(), &bytes, request.ignore_security_hash) {
let _ = fs::remove_file(&dest);
return Err(error);
}
Ok(FetchedInstaller {
manifest,
manifest_documents,
installer,
installer_path: dest,
})
}
pub fn install(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
let mut request = InstallRequest::new(query.clone());
request.mode = if silent {
InstallerMode::Silent
} else {
InstallerMode::SilentWithProgress
};
self.install_request(&request)
}
pub fn install_with_mode(&mut self, query: &PackageQuery, mode: InstallerMode) -> Result<InstallResult> {
let mut request = InstallRequest::new(query.clone());
request.mode = mode;
self.install_request(&request)
}
pub fn install_request(&mut self, request: &InstallRequest) -> Result<InstallResult> {
let manifest = self.resolve_manifest_for_install(request)?;
if !package_actions_supported() {
let installer_type = select_installer(&manifest.installers, &request.query)
.and_then(|installer| installer.installer_type)
.unwrap_or_else(|| "install".to_owned())
.to_lowercase();
return Ok(unsupported_action_result(
manifest.id.clone(),
manifest.version.clone(),
installer_type,
INSTALL_UNSUPPORTED_WARNING,
));
}
let existing_match = self.find_installed_package_for_install(request, &manifest)?;
if let Some(no_op_result) = Self::create_install_no_op_result(request, &manifest, existing_match.as_ref()) {
return Ok(no_op_result);
}
Self::ensure_package_agreements_accepted(&manifest, request)?;
self.install_dependencies(&manifest, request, &mut HashSet::new())?;
if request.dependencies_only {
return Ok(InstallResult {
package_id: manifest.id.clone(),
version: manifest.version.clone(),
installer_path: PathBuf::new(),
installer_type: "dependencies".to_owned(),
exit_code: 0,
success: true,
no_op: false,
warnings: Vec::new(),
});
}
let installer = select_installer(&manifest.installers, &request.query)
.ok_or_else(|| anyhow!("No applicable installer found"))?;
if request.uninstall_previous {
let mut uninstall_request = UninstallRequest::new(PackageQuery {
id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
query: request.query.query.clone().or_else(|| Some(manifest.id.clone())),
source: request.query.source.clone(),
exact: true,
version: request.query.version.clone(),
..PackageQuery::default()
});
uninstall_request.product_code = installer.product_code.clone();
uninstall_request.mode = InstallerMode::Silent;
uninstall_request.all_versions = true;
uninstall_request.force = true;
let _ = self.uninstall_request(&uninstall_request);
}
let temp_dir = std::env::temp_dir().join("pinget-install");
let installer_path = self.fetch_installer_for_request(request, &temp_dir)?.installer_path;
let installer_type = installer.installer_type.as_deref().unwrap_or("exe").to_lowercase();
let exit_code = dispatch_installer(&installer_path, &installer_type, request, &manifest, &installer)?;
Ok(InstallResult {
package_id: manifest.id.clone(),
version: manifest.version.clone(),
installer_path,
installer_type,
exit_code,
success: exit_code == 0,
no_op: false,
warnings: Vec::new(),
})
}
fn find_installed_package_for_install(
&mut self,
request: &InstallRequest,
manifest: &Manifest,
) -> Result<Option<ListMatch>> {
let installed_matches = self.list(&ListQuery {
query: request.query.query.clone(),
id: request.query.id.clone().or_else(|| Some(manifest.id.clone())),
name: request.query.name.clone(),
moniker: request.query.moniker.clone(),
tag: None,
command: None,
product_code: None,
version: None,
source: request.query.source.clone(),
count: Some(100),
exact: request.query.exact || !manifest.id.trim().is_empty(),
install_scope: request.query.install_scope.clone(),
upgrade_only: false,
include_unknown: false,
include_pinned: false,
})?;
Ok(installed_matches.matches.into_iter().find(|candidate| {
candidate.id.eq_ignore_ascii_case(&manifest.id) || candidate.local_id.eq_ignore_ascii_case(&manifest.id)
}))
}
fn create_install_no_op_result(
request: &InstallRequest,
manifest: &Manifest,
existing_match: Option<&ListMatch>,
) -> Option<InstallResult> {
let existing_match = existing_match?;
if request.no_upgrade {
return Some(InstallResult {
package_id: manifest.id.clone(),
version: existing_match.installed_version.clone(),
installer_path: PathBuf::new(),
installer_type: "install".to_owned(),
exit_code: 0,
success: true,
no_op: true,
warnings: vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()],
});
}
if !request.force
&& !existing_match.installed_version.trim().is_empty()
&& compare_version(&existing_match.installed_version, &manifest.version) != Ordering::Less
{
return Some(InstallResult {
package_id: manifest.id.clone(),
version: existing_match.installed_version.clone(),
installer_path: PathBuf::new(),
installer_type: "install".to_owned(),
exit_code: 0,
success: true,
no_op: true,
warnings: vec![
"Package is already installed and up to date; rerun with --force to reinstall.".to_owned(),
],
});
}
None
}
pub fn repair(&mut self, request: &RepairRequest) -> Result<InstallResult> {
if request.manifest_path.is_none() && !package_actions_supported() {
let package_id = request
.query
.id
.clone()
.or_else(|| request.query.name.clone())
.or_else(|| request.query.moniker.clone())
.or_else(|| request.query.query.clone())
.or_else(|| request.product_code.clone())
.unwrap_or_else(|| "repair".to_owned());
return Ok(unsupported_action_result(
package_id,
request.query.version.clone().unwrap_or_default(),
"repair".to_owned(),
REPAIR_UNSUPPORTED_WARNING,
));
}
let mut warnings = vec![REPAIR_REINSTALL_WARNING.to_owned()];
let installed_match = if request.manifest_path.is_some() {
None
} else {
let installed = self.list(&Self::create_repair_list_query(request))?;
warnings.extend(installed.warnings);
if installed.matches.is_empty() {
bail!("No installed package matched the supplied repair query.");
}
if installed.matches.len() > 1 {
bail!("Multiple installed packages matched the supplied repair query.");
}
installed.matches.into_iter().next()
};
let mut result =
self.install_request(&Self::create_repair_install_request(request, installed_match.as_ref()))?;
warnings.extend(result.warnings);
result.warnings = warnings;
Ok(result)
}
pub fn uninstall(&mut self, query: &PackageQuery, silent: bool) -> Result<InstallResult> {
let mut request = UninstallRequest::new(query.clone());
request.mode = if silent {
InstallerMode::Silent
} else {
InstallerMode::SilentWithProgress
};
self.uninstall_request(&request)
}
pub fn uninstall_request(&mut self, request: &UninstallRequest) -> Result<InstallResult> {
if !package_actions_supported() {
let (package_id, version) = if let Some(path) = &request.manifest_path {
let manifest = Self::load_manifest_from_path(path)?;
(manifest.id, request.query.version.clone().unwrap_or(manifest.version))
} else {
(
request
.query
.id
.clone()
.or_else(|| request.query.query.clone())
.or_else(|| request.query.name.clone())
.or_else(|| request.query.moniker.clone())
.or_else(|| request.product_code.clone())
.unwrap_or_else(|| "unknown".to_owned()),
request.query.version.clone().unwrap_or_default(),
)
};
return Ok(unsupported_action_result(
package_id,
version,
"uninstall",
UNINSTALL_UNSUPPORTED_WARNING,
));
}
let matches = self.resolve_uninstall_matches(request)?;
let mut exit_code = 0;
for installed in &matches {
exit_code = uninstall_package(installed, request)?;
if exit_code != 0 && !request.all_versions {
break;
}
}
let installed = matches
.first()
.ok_or_else(|| anyhow!("No installed package found matching the query"))?;
Ok(InstallResult {
package_id: installed.id.clone(),
version: installed.installed_version.clone(),
installer_path: PathBuf::new(),
installer_type: "uninstall".to_owned(),
exit_code,
success: exit_code == 0,
no_op: false,
warnings: Vec::new(),
})
}
fn resolve_manifest_for_install(&mut self, request: &InstallRequest) -> Result<Manifest> {
self.resolve_manifest_and_documents_for_install(request)
.map(|(manifest, _)| manifest)
}
fn resolve_manifest_and_documents_for_install(
&mut self,
request: &InstallRequest,
) -> Result<(Manifest, JsonValue)> {
if let Some(path) = &request.manifest_path {
return Self::load_manifest_from_path_with_documents(path);
}
let (located, _warnings) = self.find_single_match(&request.query)?;
let (manifest, documents, _cached_files) = self.manifest_for_match(&located, &request.query)?;
Ok((manifest, documents))
}
fn ensure_package_agreements_accepted(manifest: &Manifest, request: &InstallRequest) -> Result<()> {
if !request.accept_package_agreements && !manifest.agreements.is_empty() {
bail!("Package agreements are present; rerun with --accept-package-agreements to continue.");
}
Ok(())
}
fn install_dependencies(
&mut self,
manifest: &Manifest,
request: &InstallRequest,
visited: &mut HashSet<String>,
) -> Result<()> {
if request.skip_dependencies {
return Ok(());
}
let dependency_ids = manifest
.package_dependencies
.iter()
.chain(
manifest
.installers
.iter()
.flat_map(|installer| installer.package_dependencies.iter()),
)
.filter(|value| !value.trim().is_empty())
.cloned()
.collect::<BTreeSet<_>>();
for dependency_id in dependency_ids {
if !visited.insert(dependency_id.clone()) {
continue;
}
let dependency_request = Self::create_dependency_install_request(dependency_id, request);
self.install_request(&dependency_request)?;
}
Ok(())
}
fn create_dependency_install_request(dependency_id: String, request: &InstallRequest) -> InstallRequest {
let mut dependency_request = InstallRequest::new(PackageQuery {
id: Some(dependency_id),
source: request
.dependency_source
.clone()
.or_else(|| request.query.source.clone()),
exact: true,
..PackageQuery::default()
});
dependency_request.mode = match request.mode {
InstallerMode::Interactive => InstallerMode::Interactive,
InstallerMode::SilentWithProgress | InstallerMode::Silent => InstallerMode::Silent,
};
dependency_request.accept_package_agreements = request.accept_package_agreements;
dependency_request.no_upgrade = true;
dependency_request.ignore_security_hash = request.ignore_security_hash;
dependency_request.dependency_source = request.dependency_source.clone();
dependency_request
}
fn create_repair_list_query(request: &RepairRequest) -> ListQuery {
ListQuery {
query: request.query.query.clone(),
id: request.query.id.clone(),
name: request.query.name.clone(),
moniker: request.query.moniker.clone(),
product_code: request.product_code.clone(),
version: request.query.version.clone(),
source: request.query.source.clone(),
count: Some(100),
exact: request.query.exact,
install_scope: request.query.install_scope.clone(),
..ListQuery::default()
}
}
fn create_repair_install_request(request: &RepairRequest, installed_match: Option<&ListMatch>) -> InstallRequest {
let mut query = request.query.clone();
query.query = if installed_match.is_none() {
request.query.query.clone()
} else {
None
};
query.id = installed_match
.map(|item| item.id.clone())
.or_else(|| request.query.id.clone());
query.name = if installed_match.is_none() {
request.query.name.clone()
} else {
None
};
query.moniker = if installed_match.is_none() {
request.query.moniker.clone()
} else {
None
};
query.source = request
.query
.source
.clone()
.or_else(|| installed_match.and_then(|item| item.source_name.clone()));
query.exact = true;
query.version = request
.query
.version
.clone()
.or_else(|| installed_match.map(|item| item.installed_version.clone()));
InstallRequest {
query,
manifest_path: request.manifest_path.clone(),
mode: request.mode,
log_path: request.log_path.clone(),
custom: None,
override_args: None,
install_location: None,
skip_dependencies: false,
dependencies_only: false,
accept_package_agreements: request.accept_package_agreements,
force: true,
rename: None,
uninstall_previous: false,
ignore_security_hash: request.ignore_security_hash,
dependency_source: None,
no_upgrade: false,
}
}
fn resolve_uninstall_matches(&mut self, request: &UninstallRequest) -> Result<Vec<ListMatch>> {
let mut effective_query = request.query.clone();
let mut effective_product_code = request.product_code.clone();
if let Some(path) = &request.manifest_path {
let manifest = Self::load_manifest_from_path(path)?;
effective_query = PackageQuery {
query: Some(manifest.id.clone()),
id: Some(manifest.id.clone()),
name: Some(manifest.name.clone()),
exact: true,
version: effective_query.version.clone().or(Some(manifest.version)),
..PackageQuery::default()
};
effective_product_code = effective_product_code.or_else(|| {
manifest
.installers
.iter()
.find_map(|installer| installer.product_code.clone())
});
}
let list_query = ListQuery {
query: effective_query.query.clone(),
id: effective_query.id.clone(),
name: effective_query.name.clone(),
moniker: effective_query.moniker.clone(),
tag: None,
command: None,
product_code: effective_product_code,
version: effective_query.version.clone(),
source: effective_query.source.clone(),
count: if request.all_versions { None } else { Some(100) },
exact: effective_query.exact,
install_scope: effective_query.install_scope,
upgrade_only: false,
include_unknown: false,
include_pinned: false,
};
let list_result = self.list(&list_query)?;
if list_result.matches.is_empty() {
bail!("No installed package found matching the query");
}
if !request.all_versions && list_result.matches.len() > 1 && !request.force {
bail!("Multiple installed packages matched the query; refine the query or use --all-versions.");
}
Ok(if request.all_versions {
list_result.matches
} else {
vec![list_result.matches[0].clone()]
})
}
fn load_manifest_from_path(manifest_path: &Path) -> Result<Manifest> {
Self::load_manifest_from_path_with_documents(manifest_path).map(|(manifest, _)| manifest)
}
fn load_manifest_from_path_with_documents(manifest_path: &Path) -> Result<(Manifest, JsonValue)> {
let resolved = resolve_manifest_path(manifest_path)?;
let bytes =
fs::read(&resolved).with_context(|| format!("failed to read manifest from {}", resolved.display()))?;
parse_yaml_manifest_bundle(&bytes)
}
fn find_single_match(&mut self, query: &PackageQuery) -> Result<(LocatedMatch, Vec<String>)> {
self.find_single_match_with_semantics(query, SearchSemantics::Single)
}
fn find_single_match_with_semantics(
&mut self,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<(LocatedMatch, Vec<String>)> {
let (matches, warnings, _) = self.search_located(query, semantics)?;
if matches.is_empty() {
bail!("no package matched the supplied query");
}
if matches.len() > 1 {
let choices = matches
.iter()
.take(10)
.map(|item| {
format!(
"{} [{}] ({})",
item.display.name, item.display.id, item.display.source_name
)
})
.collect::<Vec<_>>()
.join(", ");
bail!("multiple packages matched: {choices}");
}
Ok((matches.into_iter().next().expect("one match"), warnings))
}
fn search_located(
&mut self,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<(Vec<LocatedMatch>, Vec<String>, bool)> {
let indexes = self.resolve_source_indexes(query.source.as_deref())?;
let mut matches = Vec::new();
let mut warnings = Vec::new();
let mut truncated = false;
for index in indexes {
match self.search_source(index, query, semantics) {
Ok(mut source_matches) => {
truncated |= source_matches.truncated;
matches.append(&mut source_matches.matches);
}
Err(_error) => warnings.push(format!(
"Failed when searching source; results will not be included: {}",
self.store.sources[index].name
)),
}
}
if semantics == SearchSemantics::Many {
matches.sort_by(|left, right| {
search_match_sort_score(&right.display, query).cmp(&search_match_sort_score(&left.display, query))
});
let limit = max_results(query);
if matches.len() > limit {
truncated = true;
matches.truncate(limit);
}
}
Ok((matches, warnings, truncated))
}
fn search_source(
&mut self,
source_index: usize,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<SearchSourceMatches> {
match self.store.sources[source_index].kind {
SourceKind::PreIndexed => self.search_preindexed(source_index, query, semantics),
SourceKind::Rest => self.search_rest(source_index, query, semantics),
}
}
fn versions_for_match(&mut self, located: &LocatedMatch, _query: &PackageQuery) -> Result<Vec<VersionKey>> {
match &located.locator {
MatchLocator::PreIndexedV1 { package_rowid } => {
let source = self.source_clone(located.source_index);
let connection = self.open_preindexed_connection(located.source_index)?;
let versions = query_v1_versions(&connection, *package_rowid)?;
let mut keys = versions
.into_iter()
.map(|row| VersionKey {
version: row.version,
channel: row.channel,
})
.collect::<Vec<_>>();
sort_versions_desc(&mut keys);
if keys.is_empty() {
bail!("no versions found for {} in {}", located.display.id, source.name);
}
Ok(keys)
}
MatchLocator::PreIndexedV2 {
package_rowid,
package_hash,
} => {
let source = self.source_clone(located.source_index);
let (entries, _) = self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
let mut keys = entries
.into_iter()
.map(|entry| VersionKey {
version: entry.version,
channel: String::new(),
})
.collect::<Vec<_>>();
sort_versions_desc(&mut keys);
Ok(keys)
}
MatchLocator::Rest { versions, .. } => {
let mut keys = versions.clone();
sort_versions_desc(&mut keys);
Ok(keys)
}
}
}
fn manifest_for_match(
&mut self,
located: &LocatedMatch,
query: &PackageQuery,
) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
self.manifest_for_match_with_refresh(located, query, true)
}
fn manifest_for_match_with_refresh(
&mut self,
located: &LocatedMatch,
query: &PackageQuery,
allow_explicit_version_refresh: bool,
) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
match &located.locator {
MatchLocator::PreIndexedV1 { package_rowid } => {
let source = self.source_clone(located.source_index);
let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
let versions = query_v1_versions(&opened.connection, *package_rowid)?;
let selected = match select_v1_version(
&versions,
query.version.as_deref(),
query.channel.as_deref(),
&located.display.id,
&source.name,
) {
Ok(selected) => selected,
Err(error)
if allow_explicit_version_refresh
&& query.version.is_some()
&& error.downcast_ref::<MissingPackageVersionError>().is_some() =>
{
let open_refresh = opened.refresh.clone();
if open_refresh.attempted && !open_refresh.reused_recent_attempt {
return missing_error_after_open_refresh(error, &open_refresh);
}
if self.has_recent_successful_preindexed_refresh_attempt(
&self.preindexed_refresh_lock_key(&source),
) {
return Err(error);
}
drop(opened);
return self.refresh_preindexed_and_retry_manifest(
located.source_index,
&located.display,
query,
error,
);
}
Err(error) => return Err(error),
};
let relative_path = resolve_v1_relative_path(&opened.connection, selected.pathpart_id)?;
let bytes =
self.get_cached_source_file("V1_M", &source, &relative_path, selected.manifest_hash.as_deref())?;
let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&bytes.bytes)?;
manifest.version = selected.version.clone();
manifest.channel = selected.channel;
Ok((manifest, manifest_documents, vec![bytes.path]))
}
MatchLocator::PreIndexedV2 {
package_rowid,
package_hash,
} => {
let source = self.source_clone(located.source_index);
let opened = self.open_preindexed_connection_with_refresh(located.source_index)?;
let (entries, version_data_file) =
self.load_v2_version_data(&source, *package_rowid, package_hash.as_str())?;
let selected = match select_v2_version(
&entries,
query.version.as_deref(),
query.channel.as_deref(),
&located.display.id,
&source.name,
) {
Ok(selected) => selected,
Err(error)
if allow_explicit_version_refresh
&& query.version.is_some()
&& error.downcast_ref::<MissingPackageVersionError>().is_some() =>
{
let open_refresh = opened.refresh.clone();
if open_refresh.attempted && !open_refresh.reused_recent_attempt {
return missing_error_after_open_refresh(error, &open_refresh);
}
if self.has_recent_successful_preindexed_refresh_attempt(
&self.preindexed_refresh_lock_key(&source),
) {
return Err(error);
}
drop(opened);
return self.refresh_preindexed_and_retry_manifest(
located.source_index,
&located.display,
query,
error,
);
}
Err(error) => return Err(error),
};
let manifest_bytes = self.get_cached_source_file(
"V2_M",
&source,
&selected.manifest_relative_path,
Some(selected.manifest_hash.as_str()),
)?;
let (mut manifest, manifest_documents) = parse_yaml_manifest_bundle(&manifest_bytes.bytes)?;
manifest.version = selected.version;
Ok((
manifest,
manifest_documents,
vec![version_data_file, manifest_bytes.path],
))
}
MatchLocator::Rest { package_id, versions } => {
let source = self.source_clone(located.source_index);
let selected = select_rest_version(
versions,
query.version.as_deref(),
query.channel.as_deref(),
&located.display.id,
&source.name,
)?;
let (bytes, cache_path) =
self.get_or_fetch_rest_manifest(&source, package_id, &selected.version, &selected.channel)?;
let (manifest, manifest_documents) =
parse_rest_manifest(&bytes, package_id, &selected.version, &selected.channel)?;
Ok((manifest, manifest_documents, vec![cache_path]))
}
}
}
fn refresh_preindexed_and_retry_manifest(
&mut self,
source_index: usize,
previous_display: &SearchMatch,
query: &PackageQuery,
missing_error: anyhow::Error,
) -> Result<(Manifest, JsonValue, Vec<PathBuf>)> {
self.refresh_preindexed_source(source_index, true)
.with_context(|| format!("{missing_error} The source refresh also failed"))?;
let refreshed = self.find_refreshed_preindexed_match(source_index, previous_display, query)?;
self.manifest_for_match_with_refresh(&refreshed, query, false)
}
fn find_refreshed_preindexed_match(
&mut self,
source_index: usize,
previous_display: &SearchMatch,
query: &PackageQuery,
) -> Result<LocatedMatch> {
let source_name = self.store.sources[source_index].name.clone();
let mut source_query = query.clone();
source_query.source = Some(source_name.clone());
let matches = self
.search_preindexed(source_index, &source_query, SearchSemantics::Single)?
.matches;
if let Some(found) = matches
.iter()
.find(|candidate| candidate.display.id.eq_ignore_ascii_case(&previous_display.id))
{
return Ok(found.clone());
}
if matches.len() == 1 {
return Ok(matches.into_iter().next().expect("single match"));
}
bail!(
"Package '{}' was not found in source '{}' after refreshing the pre-indexed source.",
previous_display.id,
source_name
)
}
fn search_preindexed(
&mut self,
source_index: usize,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<SearchSourceMatches> {
let connection = self.open_preindexed_connection(source_index)?;
let source = self.source_clone(source_index);
match query_v2_matches(&connection, query, semantics) {
Ok((rows, truncated)) => Ok(SearchSourceMatches {
truncated,
matches: rows
.into_iter()
.map(|row| LocatedMatch {
display: SearchMatch {
source_name: source.name.clone(),
source_kind: source.kind,
id: row.id.clone(),
name: row.name.clone(),
moniker: row.moniker.clone(),
version: Some(row.version.clone()),
channel: None,
match_criteria: row.match_criteria.clone(),
},
source_index,
locator: MatchLocator::PreIndexedV2 {
package_rowid: row.package_rowid,
package_hash: row.package_hash,
},
})
.collect(),
}),
Err(error) if can_fallback_to_v1(&error) => {
let (rows, truncated) = query_v1_matches(&connection, query, semantics)?;
let grouped = group_v1_rows(rows);
Ok(SearchSourceMatches {
truncated,
matches: grouped
.into_iter()
.map(|row| LocatedMatch {
display: SearchMatch {
source_name: source.name.clone(),
source_kind: source.kind,
id: row.id.clone(),
name: row.name.clone(),
moniker: row.moniker.clone(),
version: Some(row.version.clone()),
channel: Some(row.channel.clone()),
match_criteria: row.match_criteria.clone(),
},
source_index,
locator: MatchLocator::PreIndexedV1 {
package_rowid: row.package_rowid,
},
})
.collect(),
})
}
Err(error) => Err(error),
}
}
fn search_rest(
&mut self,
source_index: usize,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<SearchSourceMatches> {
let source = self.source_clone(source_index);
let info = self.load_rest_information(source_index)?;
let contract = choose_contract(&info.server_supported_versions)
.ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
let url = format!("{}/manifestSearch", source.arg.trim_end_matches('/'));
let body = build_rest_search_body(query, &info, semantics)?;
let response = self
.client
.post(url)
.header("Version", contract)
.json(&body)
.send()
.context("REST search request failed")?
.error_for_status()
.context("REST search request returned an error")?;
let json = response
.json::<JsonValue>()
.context("failed to parse REST search response")?;
let data = json
.get("Data")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
let max_results = source_fetch_results(query, semantics);
let mut results = Vec::new();
for item in data {
let package_id = json_string(&item, "PackageIdentifier")
.ok_or_else(|| anyhow!("REST search result missing PackageIdentifier"))?;
let package_name =
json_string(&item, "PackageName").ok_or_else(|| anyhow!("REST search result missing PackageName"))?;
let mut versions = parse_rest_versions(&item)?;
sort_versions_desc(&mut versions);
let latest = versions
.first()
.cloned()
.ok_or_else(|| anyhow!("REST search result had no versions"))?;
results.push(LocatedMatch {
display: SearchMatch {
source_name: source.name.clone(),
source_kind: source.kind,
id: package_id.clone(),
name: package_name,
moniker: json_string(&item, "Moniker"),
version: Some(latest.version.clone()),
channel: if latest.channel.is_empty() {
None
} else {
Some(latest.channel.clone())
},
match_criteria: rest_match_criteria(&item, query, semantics),
},
source_index,
locator: MatchLocator::Rest { package_id, versions },
});
}
Ok(SearchSourceMatches {
truncated: results.len() >= max_results,
matches: results,
})
}
fn open_preindexed_connection(&mut self, source_index: usize) -> Result<Connection> {
Ok(self.open_preindexed_connection_with_refresh(source_index)?.connection)
}
fn open_preindexed_connection_with_refresh(&mut self, source_index: usize) -> Result<PreindexedOpenConnection> {
let source = self.source_clone(source_index);
let index_path = preindexed_index_path(&self.app_root, &source);
let mut refresh = PreindexedRefreshOutcome::default();
if !index_path.exists() {
let _ = self.refresh_preindexed_source(source_index, true)?;
refresh.attempted = true;
} else if self.is_preindexed_index_stale(&source, &index_path) {
let lock_key = self.preindexed_refresh_lock_key(&source);
if self.has_recent_preindexed_refresh_attempt(&lock_key) {
refresh.attempted = true;
refresh.reused_recent_attempt = true;
} else {
refresh.attempted = true;
if let Err(error) = self.refresh_preindexed_source(source_index, false) {
refresh.error = Some(format!("{error:#}"));
}
}
}
Ok(PreindexedOpenConnection {
connection: Self::open_sqlite_connection(index_path).context("failed to open preindexed index")?,
refresh,
})
}
fn is_preindexed_index_stale(&self, source: &SourceRecord, index_path: &Path) -> bool {
let Some(interval) = self.pre_indexed_source_auto_update_interval else {
return false;
};
let last_update = source.last_update.or_else(|| {
fs::metadata(index_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(DateTime::<Utc>::from)
});
match last_update {
Some(last_update) => Utc::now() - last_update >= interval,
None => true,
}
}
fn preindexed_refresh_lock_key(&self, source: &SourceRecord) -> String {
format!("{}|{}|{}", self.app_root.display(), source.identifier, source.name)
}
fn has_recent_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
})
}
fn has_recent_successful_preindexed_refresh_attempt(&self, lock_key: &str) -> bool {
self.preindexed_refresh_attempts.get(lock_key).is_some_and(|attempt| {
attempt.succeeded && Utc::now() - attempt.attempted_at < Duration::minutes(PREINDEXED_REFRESH_RETRY_MINUTES)
})
}
fn record_preindexed_refresh_attempt(&mut self, lock_key: String, succeeded: bool) {
self.preindexed_refresh_attempts.insert(
lock_key,
PreindexedRefreshAttempt {
attempted_at: Utc::now(),
succeeded,
},
);
}
fn preindexed_refresh_lock(lock_key: &str) -> Result<Arc<Mutex<()>>> {
{
let locks = preindexed_refresh_locks()
.read()
.map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
if let Some(existing) = locks.get(lock_key) {
return Ok(Arc::clone(existing));
}
}
let mut locks = preindexed_refresh_locks()
.write()
.map_err(|_| anyhow!("pre-indexed refresh lock registry was poisoned"))?;
Ok(Arc::clone(
locks
.entry(lock_key.to_owned())
.or_insert_with(|| Arc::new(Mutex::new(()))),
))
}
fn refresh_preindexed_source(&mut self, source_index: usize, force: bool) -> Result<String> {
let source = self.source_clone(source_index);
let index_path = preindexed_index_path(&self.app_root, &source);
let lock_key = self.preindexed_refresh_lock_key(&source);
let refresh_lock = Self::preindexed_refresh_lock(&lock_key)?;
let _guard = refresh_lock
.lock()
.map_err(|_| anyhow!("pre-indexed source refresh lock was poisoned"))?;
if !force && index_path.exists() && !self.is_preindexed_index_stale(&source, &index_path) {
return Ok("source already refreshed".to_owned());
}
let result = self.update_preindexed(source_index);
self.record_preindexed_refresh_attempt(lock_key, result.is_ok());
let detail = result?;
if !self.use_system_winget_sources {
self.save_store()?;
}
Ok(detail)
}
fn open_sqlite_connection(path: PathBuf) -> Result<Connection> {
let path_text = path.to_string_lossy().into_owned();
let database = pollster::block_on(SqlBuilder::new_local(&path_text).build())
.with_context(|| format!("failed to open SQLite database at {path_text}"))?;
database
.connect()
.with_context(|| format!("failed to connect to SQLite database at {path_text}"))
}
fn update_preindexed(&mut self, source_index: usize) -> Result<String> {
let source = &mut self.store.sources[source_index];
let state_dir = source_state_dir(&self.app_root, source);
fs::create_dir_all(&state_dir).context("failed to create source state directory")?;
let mut last_error = None;
for candidate in PREINDEXED_CANDIDATES {
let url = format!("{}/{}", source.arg.trim_end_matches('/'), candidate);
match self.client.get(&url).send() {
Ok(response) if response.status().is_success() => {
let header_version = response
.headers()
.get("x-ms-meta-sourceversion")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let bytes = response.bytes().context("failed to read preindexed package bytes")?;
let payload = bytes.to_vec();
let index_bytes = extract_zip_member(&payload, "Public/index.db")
.context("preindexed package did not contain Public/index.db")?;
fs::write(preindexed_package_path(&self.app_root, source), &payload)
.context("failed to persist source package")?;
let index_path = preindexed_index_path(&self.app_root, source);
let temp_index_path = state_dir.join(format!(
"index.{}.tmp",
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
fs::write(&temp_index_path, index_bytes).context("failed to persist temporary source index")?;
replace_file(&temp_index_path, &index_path).context("failed to persist source index")?;
source.last_update = Some(Utc::now());
source.source_version = header_version;
return Ok(format!("downloaded {}", candidate));
}
Ok(response) => {
last_error = Some(anyhow!("candidate {} returned HTTP {}", candidate, response.status()));
}
Err(error) => {
last_error = Some(anyhow!("candidate {} failed: {error}", candidate));
}
}
}
Err(last_error.unwrap_or_else(|| anyhow!("no preindexed source candidate succeeded")))
}
fn update_rest(&mut self, source_index: usize) -> Result<String> {
let info = self.fetch_rest_information(source_index, true)?;
let source = &mut self.store.sources[source_index];
if !info.source_identifier.is_empty() {
source.identifier = info.source_identifier.clone();
}
source.last_update = Some(Utc::now());
source.source_version = choose_contract(&info.server_supported_versions).map(str::to_owned);
Ok("refreshed information cache".to_owned())
}
fn load_rest_information(&mut self, source_index: usize) -> Result<RestInformation> {
let source = self.source_clone(source_index);
let cache_path = rest_information_cache_path(&self.app_root, &source);
if cache_path.exists() {
let cache = serde_json::from_slice::<RestInfoCache>(
&fs::read(&cache_path).context("failed to read REST information cache")?,
)
.context("failed to parse REST information cache")?;
if cache.expires_at > Utc::now() {
return Ok(cache.value);
}
}
self.fetch_rest_information(source_index, true)
}
fn fetch_rest_information(&mut self, source_index: usize, persist: bool) -> Result<RestInformation> {
let source = self.source_clone(source_index);
let url = format!("{}/information", source.arg.trim_end_matches('/'));
let response = self
.client
.get(url)
.send()
.context("REST information request failed")?
.error_for_status()
.context("REST information request returned an error")?;
let max_age = cache_control_max_age(&response);
let json = response
.json::<JsonValue>()
.context("failed to parse REST information response")?;
let data = json
.get("Data")
.cloned()
.ok_or_else(|| anyhow!("REST information response missing Data"))?;
let info = serde_json::from_value::<RestInformation>(data)
.context("failed to deserialize REST information payload")?;
if persist {
let cache = RestInfoCache {
expires_at: Utc::now() + Duration::seconds(i64::try_from(max_age).unwrap_or(i64::MAX)),
value: info.clone(),
};
write_json(rest_information_cache_path(&self.app_root, &source), &cache)?;
}
Ok(info)
}
fn load_v2_version_data(
&self,
source: &SourceRecord,
package_rowid: i64,
package_hash: &str,
) -> Result<(Vec<PackageVersionDataEntry>, PathBuf)> {
let connection = Self::open_sqlite_connection(preindexed_index_path(&self.app_root, source))
.context("failed to reopen preindexed index for V2 version data")?;
let package_hash = package_hash.to_ascii_lowercase();
let package_id = query_optional_value(
&connection,
"SELECT id FROM packages WHERE rowid = ?1",
vec![SqlValue::Integer(package_rowid)],
|row| row_string(row, 0),
)?
.ok_or_else(|| anyhow!("failed to resolve package id for V2 version data"))?;
let relative_path = package_version_data_relative_path(&package_id, &package_hash);
let file = self.get_cached_source_file("V2_PVD", source, &relative_path, Some(&package_hash))?;
let yaml = decompress_mszyml(&file.bytes)?;
let document =
serde_yaml::from_str::<PackageVersionDataDocument>(&yaml).context("failed to parse versionData.mszyml")?;
Ok((document.versions, file.path))
}
fn get_cached_source_file(
&self,
bucket: &str,
source: &SourceRecord,
relative_path: &str,
expected_hash: Option<&str>,
) -> Result<CachedBytes> {
let normalized_relative = relative_path.replace('\\', "/");
let cache_path =
temp_cache_path(&self.app_root, bucket, &source.identifier).join(normalized_relative.replace('/', "\\"));
if cache_path.exists() {
let cached = fs::read(&cache_path).context("failed to read cached source file")?;
if hash_matches(expected_hash, &cached) {
return Ok(CachedBytes {
path: cache_path,
bytes: cached,
});
}
}
let url = format!(
"{}/{}",
source.arg.trim_end_matches('/'),
normalized_relative.trim_start_matches('/')
);
let response = self
.client
.get(url)
.send()
.context("failed to fetch source file")?
.error_for_status()
.context("source file request returned an error")?;
let bytes = response.bytes().context("failed to read source file body")?.to_vec();
if let Some(hash) = expected_hash {
verify_hash(hash, &bytes)?;
}
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent).context("failed to create cache directory")?;
}
fs::write(&cache_path, &bytes).context("failed to write cache file")?;
Ok(CachedBytes {
path: cache_path,
bytes,
})
}
fn get_or_fetch_rest_manifest(
&mut self,
source: &SourceRecord,
package_id: &str,
version: &str,
channel: &str,
) -> Result<(Vec<u8>, PathBuf)> {
let cache_path = rest_manifest_cache_path_with_root(&self.app_root, source, package_id, version, channel);
if cache_path.exists() {
return Ok((
fs::read(&cache_path).context("failed to read cached REST manifest")?,
cache_path,
));
}
let info = self.load_rest_information_by_name(&source.name)?;
let contract = choose_contract(&info.server_supported_versions)
.ok_or_else(|| anyhow!("no compatible REST contract for {}", source.name))?;
let url = format!("{}/packageManifests/{}", source.arg.trim_end_matches('/'), package_id);
let mut params = vec![("Version", version.to_owned())];
if !channel.is_empty() {
params.push(("Channel", channel.to_owned()));
}
if info
.required_query_parameters
.iter()
.any(|value| value.eq_ignore_ascii_case("Market"))
{
params.push(("Market", default_market()));
}
let response = self
.client
.get(url)
.header("Version", contract)
.query(¶ms)
.send()
.context("REST manifest request failed")?
.error_for_status()
.context("REST manifest request returned an error")?;
let bytes = response
.bytes()
.context("failed to read REST manifest response")?
.to_vec();
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent).context("failed to create REST manifest cache directory")?;
}
fs::write(&cache_path, &bytes).context("failed to write REST manifest cache")?;
Ok((bytes, cache_path))
}
fn load_rest_information_by_name(&mut self, name: &str) -> Result<RestInformation> {
let index = self
.store
.sources
.iter()
.position(|source| source.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
self.load_rest_information(index)
}
fn resolve_source_indexes(&self, source_name: Option<&str>) -> Result<Vec<usize>> {
if let Some(name) = source_name {
let index = self
.store
.sources
.iter()
.position(|source| source.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow!("source '{name}' was not configured"))?;
return Ok(vec![index]);
}
let mut indexes: Vec<_> = self
.store
.sources
.iter()
.enumerate()
.filter(|(_, source)| !source.explicit)
.collect();
indexes.sort_by(|(left_index, left_source), (right_index, right_source)| {
right_source
.priority
.cmp(&left_source.priority)
.then_with(|| left_index.cmp(right_index))
});
Ok(indexes.into_iter().map(|(index, _)| index).collect())
}
fn source_clone(&self, index: usize) -> SourceRecord {
self.store.sources[index].clone()
}
fn refresh_system_winget_sources(&mut self) -> Result<()> {
if self.use_system_winget_sources {
self.store = load_system_winget_source_store()?;
}
Ok(())
}
fn update_system_winget_sources(&mut self, source_name: Option<&str>) -> Result<Vec<SourceUpdateResult>> {
self.refresh_system_winget_sources()?;
let selected_names: Vec<_> = if source_name.is_none() {
self.store.sources.iter().map(|source| source.name.clone()).collect()
} else {
self.resolve_source_indexes(source_name)?
.into_iter()
.map(|index| self.store.sources[index].name.clone())
.collect()
};
let detail = system_winget_update_sources(source_name)?;
self.refresh_system_winget_sources()?;
Ok(selected_names
.into_iter()
.filter_map(|name| {
self.store
.sources
.iter()
.find(|source| source.name.eq_ignore_ascii_case(&name))
.map(|source| SourceUpdateResult {
name: source.name.clone(),
kind: source.kind,
detail: detail.clone(),
})
})
.collect())
}
}
#[derive(Debug)]
struct CachedBytes {
path: PathBuf,
bytes: Vec<u8>,
}
#[derive(Debug, Clone)]
struct V1SearchRow {
manifest_rowid: i64,
package_rowid: i64,
version: String,
channel: String,
id: String,
name: String,
moniker: Option<String>,
match_criteria: Option<String>,
}
#[derive(Debug, Clone)]
struct V1VersionRow {
version: String,
channel: String,
pathpart_id: i64,
manifest_hash: Option<String>,
}
#[derive(Debug, Clone)]
struct V2SearchRow {
package_rowid: i64,
package_hash: String,
id: String,
name: String,
moniker: Option<String>,
version: String,
match_criteria: Option<String>,
}
fn list_query_needs_available_lookup(query: &ListQuery) -> bool {
query.query.is_some()
|| query.id.is_some()
|| query.name.is_some()
|| query.moniker.is_some()
|| query.tag.is_some()
|| query.command.is_some()
|| query.source.is_some()
}
fn package_query_from_list_query(query: &ListQuery) -> PackageQuery {
PackageQuery {
query: query.query.clone(),
id: query.id.clone(),
name: query.name.clone(),
moniker: query.moniker.clone(),
tag: query.tag.clone(),
command: query.command.clone(),
source: query.source.clone(),
count: Some(LIST_LOOKUP_MAX_RESULTS),
exact: query.exact,
version: None,
channel: None,
locale: None,
installer_type: None,
installer_architecture: None,
platform: None,
os_version: None,
install_scope: query.install_scope.clone(),
}
}
fn allow_loose_list_correlation(query: &ListQuery) -> bool {
query.query.is_some() || query.id.is_some() || query.name.is_some()
}
fn installed_package_has_upgrade(package: &InstalledPackage) -> bool {
package
.correlated
.as_ref()
.and_then(|candidate| candidate.version.as_deref())
.is_some_and(|version| compare_version(version, &package.installed_version) == Ordering::Greater)
}
fn installed_package_has_unknown_version(package: &InstalledPackage) -> bool {
package.installed_version.eq_ignore_ascii_case("Unknown")
}
fn installed_package_matches_upgrade_filter(package: &InstalledPackage, query: &ListQuery) -> bool {
if package.correlated_requires_explicit_upgrade && !list_query_needs_available_lookup(query) {
return false;
}
if package.correlated_lacks_compatible_installer && !list_query_needs_available_lookup(query) {
return false;
}
installed_package_has_upgrade(package)
|| (query.include_unknown && installed_package_has_unknown_version(package) && package.correlated.is_some())
}
fn dedupe_correlated_for_upgrade(packages: Vec<InstalledPackage>) -> Vec<InstalledPackage> {
let mut by_id: HashMap<(String, String), InstalledPackage> = HashMap::new();
let mut uncorrelated: Vec<InstalledPackage> = Vec::new();
for package in packages {
match package.correlated.as_ref() {
Some(candidate) => {
let key = (
candidate.id.to_ascii_lowercase(),
candidate.source_name.to_ascii_lowercase(),
);
let keep_new = match by_id.get(&key) {
Some(existing) => {
match (
package.installed_version_canonical,
existing.installed_version_canonical,
) {
(true, false) => true,
(false, true) => false,
_ => {
compare_version(&package.installed_version, &existing.installed_version)
== Ordering::Greater
}
}
}
None => true,
};
if keep_new {
by_id.insert(key, package);
}
}
None => uncorrelated.push(package),
}
}
let mut result = uncorrelated;
result.extend(by_id.into_values());
result
}
fn find_applicable_pin<'a>(item: &ListMatch, pins: &'a [PinRecord]) -> Option<&'a PinRecord> {
let mut source_specific = None;
let mut source_agnostic = None;
for pin in pins {
if !pin.package_id.eq_ignore_ascii_case(&item.id) && !pin.package_id.eq_ignore_ascii_case(&item.local_id) {
continue;
}
if pin.source_id.is_empty() {
if source_agnostic.is_none() {
source_agnostic = Some(pin);
}
} else if item
.source_name
.as_deref()
.is_some_and(|source| pin.source_id.eq_ignore_ascii_case(source))
{
source_specific = Some(pin);
break;
}
}
source_specific.or(source_agnostic)
}
fn is_upgrade_blocked_by_pin(item: &ListMatch, pins: &[PinRecord]) -> bool {
let Some(available_version) = item.available_version.as_deref() else {
return false;
};
let Some(pin) = find_applicable_pin(item, pins) else {
return false;
};
match pin.pin_type {
PinType::Blocking => true,
PinType::Gating | PinType::Pinning => !version_matches_pin_pattern(available_version, &pin.version),
}
}
fn version_matches_pin_pattern(version: &str, pattern: &str) -> bool {
if pattern.trim().is_empty() || pattern == "*" {
return true;
}
if !pattern.contains('*') {
return version.eq_ignore_ascii_case(pattern);
}
if pattern.ends_with('*') && pattern.matches('*').count() == 1 {
let prefix = &pattern[..pattern.len() - 1];
return version.to_ascii_lowercase().starts_with(&prefix.to_ascii_lowercase());
}
version.eq_ignore_ascii_case(pattern)
}
fn list_match_from_installed(package: InstalledPackage) -> ListMatch {
let available_version = package.correlated.as_ref().and_then(|candidate| {
candidate.version.as_ref().and_then(|candidate_version| {
if installed_package_has_unknown_version(&package)
|| compare_version(candidate_version, &package.installed_version) == Ordering::Greater
{
Some(candidate_version.clone())
} else {
None
}
})
});
let source_name = package
.correlated
.as_ref()
.map(|candidate| candidate.source_name.clone())
.filter(|value| !value.is_empty());
let id = package
.correlated
.as_ref()
.map(|candidate| candidate.id.clone())
.unwrap_or_else(|| package.local_id.clone());
ListMatch {
name: package.name,
id,
local_id: package.local_id,
installed_version: package.installed_version,
available_version,
source_name,
publisher: package.publisher,
scope: package.scope,
installer_category: package.installer_category,
install_location: package.install_location,
package_family_names: package.package_family_names,
product_codes: package.product_codes,
upgrade_codes: package.upgrade_codes,
}
}
fn list_sort_weight(package: &InstalledPackage) -> usize {
if package.local_id.starts_with("ARP\\") {
0
} else if package.name.contains(".SparseApp") || package.local_id.contains(".SparseApp_") {
1
} else {
2
}
}
fn list_package_matches(package: &InstalledPackage, query: &ListQuery) -> bool {
let correlated = package.correlated.as_ref();
if let Some(value) = &query.id {
let local_match = matches_text(&package.local_id, value, query.exact);
let correlated_match = correlated
.map(|candidate| matches_text(&candidate.id, value, query.exact))
.unwrap_or(false);
if !local_match && !correlated_match {
return false;
}
}
if let Some(value) = &query.name
&& !matches_text(&package.name, value, query.exact)
{
return false;
}
if let Some(value) = &query.product_code
&& !package
.product_codes
.iter()
.any(|code| code.eq_ignore_ascii_case(value))
{
return false;
}
if let Some(value) = &query.version
&& !package.installed_version.eq_ignore_ascii_case(value)
{
return false;
}
if let Some(value) = &query.query {
let local_match =
matches_text(&package.name, value, query.exact) || matches_text(&package.local_id, value, query.exact);
let correlated_match = correlated
.map(|candidate| {
matches_text(&candidate.id, value, query.exact) || matches_text(&candidate.name, value, query.exact)
})
.unwrap_or(false);
if !local_match && !correlated_match {
return false;
}
}
if let Some(source) = &query.source
&& correlated
.map(|candidate| !candidate.source_name.eq_ignore_ascii_case(source))
.unwrap_or(true)
{
return false;
}
if (query.moniker.is_some() || query.tag.is_some() || query.command.is_some()) && correlated.is_none() {
return false;
}
true
}
fn correlate_installed_package(
package: &InstalledPackage,
candidates: &[SearchMatch],
allow_loose_name_match: bool,
) -> Option<SearchMatch> {
if package.local_id.starts_with("MSIX\\") {
return None;
}
let installed_name = normalize_correlation_name(&package.name);
let installed_name_lower = package.name.to_ascii_lowercase();
let candidate_names = correlation_name_candidates(&package.name);
let mut scored: Vec<(i32, SearchMatch)> = candidates
.iter()
.filter_map(|candidate| {
let candidate_name = normalize_correlation_name(&candidate.name);
let base_score = if candidate.id.eq_ignore_ascii_case(&package.local_id) {
1000
} else if candidate_names.iter().any(|name| {
let normalized = normalize_correlation_name(name);
normalized == candidate_name
}) {
900
} else if allow_loose_name_match && candidate_name.len() >= 6 && installed_name.contains(&candidate_name) {
700
} else {
0
};
if base_score == 0 {
return None;
}
let candidate_lower = candidate.name.to_ascii_lowercase();
let prefix_bonus = if installed_name_lower.starts_with(&candidate_lower) {
100
} else if installed_name_lower.contains(&candidate_lower) {
50
} else {
0
};
Some((base_score + prefix_bonus, candidate.clone()))
})
.collect();
scored.sort_by_key(|entry| std::cmp::Reverse(entry.0));
let mut iter = scored.into_iter();
let (top_score, top_match) = iter.next()?;
if let Some((next_score, _)) = iter.next()
&& next_score == top_score
{
return None;
}
Some(top_match)
}
fn correlation_name_candidates(name: &str) -> Vec<String> {
let mut candidates = Vec::new();
candidates.push(name.trim().to_owned());
let mut trimmed = name.trim().to_owned();
if let Some(index) = trimmed.find(" (") {
trimmed.truncate(index);
}
let mut words = Vec::new();
for token in trimmed.split_whitespace() {
let lower = token
.trim_matches(|ch: char| ch == '(' || ch == ')')
.to_ascii_lowercase();
if !words.is_empty()
&& (lower == "x64" || lower == "x86" || lower == "arm64" || token.chars().any(|ch| ch.is_ascii_digit()))
{
break;
}
words.push(token);
}
if !words.is_empty() {
candidates.push(words.join(" "));
}
candidates.sort();
candidates.dedup();
candidates
}
fn normalize_correlation_name(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.flat_map(|ch| ch.to_lowercase())
.collect()
}
struct V2PackageMetadata {
rowid: i64,
id: String,
name: String,
moniker: Option<String>,
latest_version: String,
package_hash: String,
}
fn lookup_identity_match_v2(
connection: &Connection,
package: &InstalledPackage,
) -> Result<Option<(i64, &'static str)>> {
for pfn in &package.package_family_names {
if let Some(rowid) = query_optional_value(
connection,
"SELECT package FROM pfns2 WHERE pfn = ?1 LIMIT 1",
vec![SqlValue::Text(pfn.to_ascii_lowercase())],
|row| row_i64(row, 0),
)? {
return Ok(Some((rowid, "PackageFamilyName")));
}
}
for code in &package.upgrade_codes {
if let Some(rowid) = query_optional_value(
connection,
"SELECT package FROM upgradecodes2 WHERE upgradecode = ?1 LIMIT 1",
vec![SqlValue::Text(code.to_ascii_lowercase())],
|row| row_i64(row, 0),
)? {
return Ok(Some((rowid, "UpgradeCode")));
}
}
for code in &package.product_codes {
if let Some(rowid) = query_optional_value(
connection,
"SELECT package FROM productcodes2 WHERE productcode = ?1 LIMIT 1",
vec![SqlValue::Text(code.to_ascii_lowercase())],
|row| row_i64(row, 0),
)? {
return Ok(Some((rowid, "ProductCode")));
}
}
Ok(None)
}
fn lookup_v2_metadata_by_id(connection: &Connection, package_id: &str) -> Result<Option<V2PackageMetadata>> {
query_optional_value(
connection,
"SELECT rowid, id, name, moniker, latest_version, hash FROM packages WHERE id = ?1 COLLATE NOCASE LIMIT 1",
vec![SqlValue::Text(package_id.to_owned())],
|row| {
Ok(V2PackageMetadata {
rowid: row_i64(row, 0)?,
id: row_string(row, 1)?,
name: row_string(row, 2)?,
moniker: row_opt_string(row, 3)?,
latest_version: row_string(row, 4)?,
package_hash: row_hex_string(row, 5)?,
})
},
)
}
fn apply_msix_resource_string_name_fix(package: &mut InstalledPackage) {
if !package.local_id.starts_with("MSIX\\") {
return;
}
if !package.name.starts_with("ms-resource:") {
return;
}
let Some(candidate) = package.correlated.as_ref() else {
return;
};
if !candidate.name.is_empty() {
package.name = candidate.name.clone();
}
}
fn fetch_v2_package_metadata(connection: &Connection, package_rowid: i64) -> Result<Option<V2PackageMetadata>> {
query_optional_value(
connection,
"SELECT id, name, moniker, latest_version, hash FROM packages WHERE rowid = ?1",
vec![SqlValue::Integer(package_rowid)],
|row| {
Ok(V2PackageMetadata {
rowid: package_rowid,
id: row_string(row, 0)?,
name: row_string(row, 1)?,
moniker: row_opt_string(row, 2)?,
latest_version: row_string(row, 3)?,
package_hash: row_hex_string(row, 4)?,
})
},
)
}
fn v2_normalized_identity_tables_present(connection: &Connection) -> bool {
query_optional_value(
connection,
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'norm_names2' LIMIT 1",
Vec::new(),
|row| row_i64(row, 0),
)
.ok()
.flatten()
.is_some()
}
fn lookup_unique_normalized_identity(
connection: &Connection,
norm_name: &str,
norm_publisher: &str,
) -> Result<Option<i64>> {
let rows: Vec<i64> = query_rows(
connection,
"SELECT DISTINCT n.package FROM norm_names2 n \
WHERE n.norm_name = ?1 \
AND EXISTS (SELECT 1 FROM norm_publishers2 p WHERE p.package = n.package AND p.norm_publisher = ?2) \
LIMIT 2",
vec![
SqlValue::Text(norm_name.to_owned()),
SqlValue::Text(norm_publisher.to_owned()),
],
|row| row_i64(row, 0),
)?;
if rows.len() == 1 { Ok(Some(rows[0])) } else { Ok(None) }
}
fn v2_identity_tables_present(connection: &Connection) -> bool {
query_optional_value(
connection,
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'productcodes2' LIMIT 1",
Vec::new(),
|row| row_i64(row, 0),
)
.ok()
.flatten()
.is_some()
}
fn map_arp_version_to_catalog(entries: &[PackageVersionDataEntry], arp_version: &str) -> Option<String> {
if arp_version.is_empty() || arp_version.eq_ignore_ascii_case("Unknown") {
return None;
}
for entry in entries {
let (Some(min), Some(max)) = (entry.arp_min_version.as_deref(), entry.arp_max_version.as_deref()) else {
continue;
};
if compare_version(arp_version, min) != Ordering::Less && compare_version(arp_version, max) != Ordering::Greater
{
return Some(entry.version.clone());
}
}
None
}
fn latest_arp_anchored_version(entries: &[PackageVersionDataEntry]) -> Option<String> {
entries
.iter()
.filter(|e| e.arp_min_version.is_some() && e.arp_max_version.is_some())
.max_by(|a, b| compare_version(&a.version, &b.version))
.map(|entry| entry.version.clone())
}
#[cfg(windows)]
fn collect_installed_packages(scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
let mut packages = Vec::new();
let mut seen = BTreeSet::new();
let machine = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("user"));
let user = !matches!(scope, Some(value) if value.eq_ignore_ascii_case("machine"));
let upgrade_codes = collect_msi_upgrade_codes(machine, user);
if machine {
collect_uninstall_view(
&mut packages,
&mut seen,
&upgrade_codes,
RegKey::predef(HKEY_LOCAL_MACHINE),
"Machine",
"X64",
KEY_READ | KEY_WOW64_64KEY,
)?;
collect_uninstall_view(
&mut packages,
&mut seen,
&upgrade_codes,
RegKey::predef(HKEY_LOCAL_MACHINE),
"Machine",
"X86",
KEY_READ | KEY_WOW64_32KEY,
)?;
collect_appmodel_packages(
&mut packages,
&mut seen,
RegKey::predef(HKEY_LOCAL_MACHINE),
"Machine",
KEY_READ | KEY_WOW64_64KEY,
)?;
}
if user {
collect_uninstall_view(
&mut packages,
&mut seen,
&upgrade_codes,
RegKey::predef(HKEY_CURRENT_USER),
"User",
"X64",
KEY_READ,
)?;
collect_appmodel_packages(
&mut packages,
&mut seen,
RegKey::predef(HKEY_CURRENT_USER),
"User",
KEY_READ,
)?;
}
Ok(packages)
}
#[cfg(windows)]
fn collect_msi_upgrade_codes(include_machine: bool, include_user: bool) -> HashMap<String, String> {
let mut map = HashMap::new();
if include_machine {
collect_msi_upgrade_codes_from(
RegKey::predef(HKEY_LOCAL_MACHINE),
r"SOFTWARE\Classes\Installer\UpgradeCodes",
KEY_READ | KEY_WOW64_64KEY,
&mut map,
);
}
if include_user {
collect_msi_upgrade_codes_from(
RegKey::predef(HKEY_CURRENT_USER),
r"Software\Microsoft\Installer\UpgradeCodes",
KEY_READ,
&mut map,
);
}
map
}
#[cfg(windows)]
fn collect_msi_upgrade_codes_from(root: RegKey, path: &str, flags: u32, map: &mut HashMap<String, String>) {
let Ok(upgrade_codes) = root.open_subkey_with_flags(path, flags) else {
return;
};
for key_name in upgrade_codes.enum_keys().flatten() {
let Some(upgrade_code) = unflip_packed_guid(&key_name) else {
continue;
};
let Ok(subkey) = upgrade_codes.open_subkey_with_flags(&key_name, flags) else {
continue;
};
for (value_name, _) in subkey.enum_values().flatten() {
if let Some(product_code) = unflip_packed_guid(&value_name) {
map.entry(product_code).or_insert_with(|| upgrade_code.clone());
}
}
}
}
#[cfg(windows)]
fn unflip_packed_guid(packed: &str) -> Option<String> {
if packed.len() != 32 || !packed.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
const CHUNKS: [usize; 11] = [8, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2];
let bytes = packed.as_bytes();
let mut reversed = String::with_capacity(32);
let mut offset = 0;
for size in CHUNKS {
let chunk = &bytes[offset..offset + size];
for &c in chunk.iter().rev() {
reversed.push(c as char);
}
offset += size;
}
let r = reversed.to_ascii_lowercase();
Some(format!(
"{{{}-{}-{}-{}-{}}}",
&r[0..8],
&r[8..12],
&r[12..16],
&r[16..20],
&r[20..32]
))
}
#[cfg(not(windows))]
fn collect_installed_packages(_scope: Option<&str>) -> Result<Vec<InstalledPackage>> {
Ok(Vec::new())
}
#[cfg(windows)]
fn collect_uninstall_view(
packages: &mut Vec<InstalledPackage>,
seen: &mut BTreeSet<String>,
upgrade_code_map: &HashMap<String, String>,
root: RegKey,
scope: &str,
arch: &str,
flags: u32,
) -> Result<()> {
const UNINSTALL_PATH: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
let uninstall = match root.open_subkey_with_flags(UNINSTALL_PATH, flags) {
Ok(key) => key,
Err(_) => return Ok(()),
};
for key_name in uninstall.enum_keys().flatten() {
let subkey = match uninstall.open_subkey_with_flags(&key_name, flags) {
Ok(key) => key,
Err(_) => continue,
};
if read_reg_dword(&subkey, "SystemComponent") == Some(1) || read_reg_string(&subkey, "ParentKeyName").is_some()
{
continue;
}
let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
continue;
};
let local_id = format!(r"ARP\{scope}\{arch}\{key_name}");
let installed_version = read_reg_string(&subkey, "DisplayVersion").unwrap_or_else(|| "Unknown".to_owned());
let publisher = read_reg_string(&subkey, "Publisher");
let install_location = read_reg_string(&subkey, "InstallLocation");
let package_family_names = read_reg_string(&subkey, "PackageFamilyName")
.into_iter()
.collect::<Vec<_>>();
let mut product_codes = read_reg_string(&subkey, "ProductCode").into_iter().collect::<Vec<_>>();
if product_codes.is_empty() && looks_like_product_code(&key_name) {
product_codes.push(key_name.to_ascii_lowercase());
}
let mut upgrade_codes = read_reg_string(&subkey, "UpgradeCode").into_iter().collect::<Vec<_>>();
if upgrade_codes.is_empty() {
for code in &product_codes {
if let Some(uc) = upgrade_code_map.get(&code.to_ascii_lowercase()) {
upgrade_codes.push(uc.clone());
break;
}
}
}
let installer_category = if read_reg_string(&subkey, "WinGetInstallerType")
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case("portable"))
{
Some("portable".to_owned())
} else if local_id.starts_with("ARP\\") && read_reg_dword(&subkey, "WindowsInstaller") == Some(1) {
Some("msi".to_owned())
} else if key_name.starts_with("MSIX\\") {
Some("msix".to_owned())
} else {
Some("exe".to_owned())
};
let dedupe_key = format!(
"{}|{}|{}|{}",
local_id,
name.to_ascii_lowercase(),
installed_version.to_ascii_lowercase(),
publisher.clone().unwrap_or_default().to_ascii_lowercase()
);
if !seen.insert(dedupe_key) {
continue;
}
packages.push(InstalledPackage {
name,
local_id,
installed_version,
publisher,
scope: Some(scope.to_owned()),
installer_category,
install_location,
package_family_names,
product_codes,
upgrade_codes,
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
});
}
Ok(())
}
#[cfg(windows)]
fn collect_appmodel_packages(
packages: &mut Vec<InstalledPackage>,
seen: &mut BTreeSet<String>,
root: RegKey,
scope: &str,
flags: u32,
) -> Result<()> {
const APPMODEL_PACKAGES_PATH: &str =
r"Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
let appmodel = match root.open_subkey_with_flags(APPMODEL_PACKAGES_PATH, flags) {
Ok(key) => key,
Err(_) => return Ok(()),
};
for key_name in appmodel.enum_keys().flatten() {
let subkey = match appmodel.open_subkey_with_flags(&key_name, flags) {
Ok(key) => key,
Err(_) => continue,
};
let Some(name) = read_reg_string(&subkey, "DisplayName").filter(|value| !value.is_empty()) else {
continue;
};
let install_location = read_reg_string(&subkey, "PackageRootFolder");
if install_location.as_deref().is_some_and(is_windows_system_path) {
continue;
}
let Some(metadata) = parse_msix_package_full_name(&key_name) else {
continue;
};
let local_id = format!(r"MSIX\{key_name}");
let dedupe_key = format!(
"{}|{}|{}",
local_id,
name.to_ascii_lowercase(),
metadata.version.to_ascii_lowercase()
);
if !seen.insert(dedupe_key) {
continue;
}
packages.push(InstalledPackage {
name,
local_id,
installed_version: metadata.version,
publisher: None,
scope: Some(scope.to_owned()),
installer_category: Some("msix".to_owned()),
install_location,
package_family_names: vec![metadata.family_name],
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
});
}
Ok(())
}
#[cfg(windows)]
struct ParsedMsixPackageFullName {
version: String,
family_name: String,
}
#[cfg(windows)]
fn parse_msix_package_full_name(value: &str) -> Option<ParsedMsixPackageFullName> {
let segments = value.split('_').collect::<Vec<_>>();
if segments.len() < 5 {
return None;
}
let name = segments[..segments.len() - 4].join("_");
if name.is_empty() {
return None;
}
let version = segments[segments.len() - 4].trim();
let resource_id = segments[segments.len() - 2].trim();
let publisher_id = segments[segments.len() - 1].trim();
if version.is_empty() || publisher_id.is_empty() {
return None;
}
let family_name = if resource_id.is_empty() {
format!("{name}_{publisher_id}")
} else {
format!("{name}_{resource_id}_{publisher_id}")
};
Some(ParsedMsixPackageFullName {
version: version.to_owned(),
family_name,
})
}
#[cfg(windows)]
fn is_windows_system_path(path: &str) -> bool {
path.trim().to_ascii_lowercase().starts_with(r"c:\windows\")
}
#[cfg(windows)]
fn read_reg_string(key: &RegKey, value_name: &str) -> Option<String> {
key.get_value::<String, _>(value_name)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
#[cfg(windows)]
fn read_reg_dword(key: &RegKey, value_name: &str) -> Option<u32> {
key.get_value::<u32, _>(value_name).ok()
}
#[cfg(windows)]
fn looks_like_product_code(value: &str) -> bool {
value.starts_with('{') && value.ends_with('}')
}
fn ensure_app_dirs(app_root: &Path) -> Result<()> {
fs::create_dir_all(app_root).context("failed to create app root directory")?;
if uses_packaged_layout(app_root) {
fs::create_dir_all(packaged_file_cache_root(app_root)).context("failed to create packaged file cache root")?;
let user_sources_path = packaged_user_sources_path(app_root)?;
if let Some(parent) = user_sources_path.parent() {
fs::create_dir_all(parent).context("failed to create packaged secure settings directory")?;
}
} else {
fs::create_dir_all(app_root.join("sources")).context("failed to create app source directory")?;
}
Ok(())
}
fn default_app_root() -> Result<PathBuf> {
if let Some(app_root) = std::env::var_os("PINGET_APPROOT") {
return Ok(PathBuf::from(app_root));
}
let local_app_data = dirs::data_local_dir().ok_or_else(|| anyhow!("unable to determine LocalAppData path"))?;
#[cfg(windows)]
{
if has_current_package_identity() {
return Ok(local_app_data
.join("Packages")
.join(PACKAGED_FAMILY_NAME)
.join("LocalState"));
}
Ok(local_app_data.join("Devolutions").join("Pinget"))
}
#[cfg(not(windows))]
{
Ok(local_app_data.join("pinget"))
}
}
#[cfg(windows)]
fn has_current_package_identity() -> bool {
const APPMODEL_ERROR_NO_PACKAGE: u32 = 15700;
let mut length: u32 = 0;
let rc = unsafe { GetCurrentPackageFullName(&mut length, std::ptr::null_mut()) };
rc != APPMODEL_ERROR_NO_PACKAGE
}
fn store_path(app_root: &Path) -> PathBuf {
app_root.join("sources.json")
}
fn user_settings_path(app_root: &Path) -> PathBuf {
if uses_packaged_layout(app_root) {
app_root.join("settings.json")
} else {
app_root.join("user-settings.json")
}
}
fn admin_settings_path(app_root: &Path) -> PathBuf {
app_root.join("admin-settings.json")
}
fn source_state_dir(app_root: &Path, source: &SourceRecord) -> PathBuf {
if uses_packaged_layout(app_root) {
return app_root
.join(packaged_source_type(source.kind))
.join(sanitize_path_segment(&source.identifier));
}
app_root.join("sources").join(sanitize_path_segment(&source.name))
}
fn preindexed_package_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
source_state_dir(app_root, source).join("source.msix")
}
fn preindexed_index_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
source_state_dir(app_root, source).join("index.db")
}
#[cfg(windows)]
fn replace_file(source: &Path, target: &Path) -> Result<()> {
let source_wide = source
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let target_wide = target
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH;
let ok = unsafe { MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), flags) };
if ok == 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("failed to replace {} with {}", target.display(), source.display()));
}
Ok(())
}
#[cfg(not(windows))]
fn replace_file(source: &Path, target: &Path) -> Result<()> {
fs::rename(source, target)
.with_context(|| format!("failed to replace {} with {}", target.display(), source.display()))
}
fn rest_information_cache_path(app_root: &Path, source: &SourceRecord) -> PathBuf {
source_state_dir(app_root, source).join("rest-information.json")
}
fn rest_manifest_cache_path_with_root(
app_root: &Path,
source: &SourceRecord,
package_id: &str,
version: &str,
channel: &str,
) -> PathBuf {
let key = format!("{}|{}|{}|{}", source.identifier, package_id, version, channel);
let digest = sha256_hex(key.as_bytes());
cache_root(app_root)
.join("REST_M")
.join(sanitize_path_segment(&source.identifier))
.join(format!("{digest}.json"))
}
fn temp_cache_path(app_root: &Path, bucket: &str, identifier: &str) -> PathBuf {
cache_root(app_root)
.join(bucket)
.join(sanitize_path_segment(identifier))
}
fn load_store(app_root: &Path) -> Result<SourceStore> {
if uses_packaged_layout(app_root)
&& let Some(store) = load_packaged_store(app_root)?
{
return Ok(store);
}
let path = store_path(app_root);
if !path.exists() {
let store = SourceStore::default();
save_store(app_root, &store)?;
return Ok(store);
}
let bytes = fs::read(path).context("failed to read source store")?;
serde_json::from_slice(&bytes).context("failed to parse source store")
}
fn save_store(app_root: &Path, store: &SourceStore) -> Result<()> {
if uses_system_winget_source_commands(app_root) {
bail!("System WinGet source settings must be modified through winget.");
}
if uses_packaged_layout(app_root) {
return save_packaged_store(app_root, store);
}
write_json(store_path(app_root), store)
}
fn load_packaged_store(app_root: &Path) -> Result<Option<SourceStore>> {
let user_sources_path = packaged_user_sources_path(app_root)?;
let metadata_path = app_root.join("sources_metadata");
let user_sources_yaml = fs::read_to_string(&user_sources_path).ok();
let metadata_yaml = fs::read_to_string(&metadata_path).ok();
load_packaged_store_from_streams(user_sources_yaml.as_deref(), metadata_yaml.as_deref())
}
fn load_packaged_store_from_streams(
user_sources_yaml: Option<&str>,
metadata_yaml: Option<&str>,
) -> Result<Option<SourceStore>> {
if is_secure_settings_stub(user_sources_yaml) {
return load_system_winget_source_store().map(Some);
}
Ok(parse_packaged_source_store(user_sources_yaml, metadata_yaml))
}
fn save_packaged_store(app_root: &Path, store: &SourceStore) -> Result<()> {
let user_sources_path = packaged_user_sources_path(app_root)?;
if let Some(parent) = user_sources_path.parent() {
fs::create_dir_all(parent).context("failed to create packaged source stream directory")?;
}
fs::write(&user_sources_path, render_packaged_sources_yaml(store))
.context("failed to write packaged user_sources stream")?;
fs::write(app_root.join("sources_metadata"), render_packaged_metadata_yaml(store))
.context("failed to write packaged source metadata")?;
Ok(())
}
fn uses_system_winget_source_commands(app_root: &Path) -> bool {
if !uses_packaged_layout(app_root) {
return false;
}
#[cfg(windows)]
{
let Ok(user_sources_path) = packaged_user_sources_path(app_root) else {
return false;
};
let user_sources_yaml = fs::read_to_string(user_sources_path).ok();
is_secure_settings_stub(user_sources_yaml.as_deref())
}
#[cfg(not(windows))]
{
false
}
}
fn is_secure_settings_stub(value: Option<&str>) -> bool {
value
.map(str::trim_start)
.is_some_and(|value| value.starts_with("SHA256:") || value.starts_with("sha256:"))
}
fn load_system_winget_source_store() -> Result<SourceStore> {
let result = run_checked_winget_source_command(build_winget_source_export_arguments(), "export WinGet sources")?;
Ok(SourceStore {
sources: parse_system_winget_source_export(&result.stdout)?,
})
}
fn system_winget_add_source(name: &str, arg: &str, kind: SourceKind, trust_level: &str, explicit: bool) -> Result<()> {
run_checked_winget_source_command(
build_winget_source_add_arguments(name, arg, kind, trust_level, explicit),
&format!("add WinGet source '{name}'"),
)?;
Ok(())
}
fn system_winget_remove_source(name: &str) -> Result<()> {
run_checked_winget_source_command(
vec![
"source".to_owned(),
"remove".to_owned(),
"--name".to_owned(),
name.to_owned(),
"--disable-interactivity".to_owned(),
],
&format!("remove WinGet source '{name}'"),
)?;
Ok(())
}
fn system_winget_reset_source(name: &str) -> Result<()> {
run_checked_winget_source_command(
vec![
"source".to_owned(),
"reset".to_owned(),
"--name".to_owned(),
name.to_owned(),
"--force".to_owned(),
"--disable-interactivity".to_owned(),
],
&format!("reset WinGet source '{name}'"),
)?;
Ok(())
}
fn system_winget_reset_sources() -> Result<()> {
run_checked_winget_source_command(
vec![
"source".to_owned(),
"reset".to_owned(),
"--force".to_owned(),
"--disable-interactivity".to_owned(),
],
"reset WinGet sources",
)?;
Ok(())
}
fn system_winget_update_sources(name: Option<&str>) -> Result<String> {
let mut args = vec!["source".to_owned(), "update".to_owned()];
if let Some(name) = name {
args.push("--name".to_owned());
args.push(name.to_owned());
}
args.push("--disable-interactivity".to_owned());
let result = run_checked_winget_source_command(
args,
if name.is_some() {
"update WinGet source"
} else {
"update WinGet sources"
},
)?;
let detail = result.stdout.trim();
Ok(if detail.is_empty() {
"Done".to_owned()
} else {
detail.to_owned()
})
}
fn build_winget_source_export_arguments() -> Vec<String> {
vec![
"source".to_owned(),
"export".to_owned(),
"--disable-interactivity".to_owned(),
]
}
fn build_winget_source_add_arguments(
name: &str,
arg: &str,
kind: SourceKind,
trust_level: &str,
explicit: bool,
) -> Vec<String> {
let mut args = vec![
"source".to_owned(),
"add".to_owned(),
"--name".to_owned(),
name.to_owned(),
"--arg".to_owned(),
arg.to_owned(),
"--type".to_owned(),
packaged_source_type(kind).to_owned(),
"--disable-interactivity".to_owned(),
];
if !trust_level.trim().is_empty() {
args.push("--trust-level".to_owned());
args.push(if trust_level.eq_ignore_ascii_case("Trusted") {
"trusted".to_owned()
} else {
"none".to_owned()
});
}
if explicit {
args.push("--explicit".to_owned());
}
args
}
#[derive(Debug)]
struct WingetSourceCommandResult {
exit_code: i32,
stdout: String,
stderr: String,
}
type WingetSourceCommandRunner = fn(&[String]) -> Result<WingetSourceCommandResult>;
static SYSTEM_WINGET_SOURCE_COMMAND_RUNNER: RwLock<WingetSourceCommandRunner> =
RwLock::new(run_winget_source_command_process);
fn run_checked_winget_source_command(args: Vec<String>, action: &str) -> Result<WingetSourceCommandResult> {
let result = run_winget_source_command(&args)?;
if result.exit_code == 0 {
return Ok(result);
}
let detail = [result.stderr.trim(), result.stdout.trim()]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n");
if detail.is_empty() {
bail!("Failed to {action}; winget exited with code {}.", result.exit_code);
}
bail!(
"Failed to {action}; winget exited with code {}:\n{}",
result.exit_code,
detail
);
}
fn run_winget_source_command(args: &[String]) -> Result<WingetSourceCommandResult> {
let runner = *SYSTEM_WINGET_SOURCE_COMMAND_RUNNER
.read()
.map_err(|_| anyhow!("failed to read WinGet source command runner"))?;
runner(args)
}
fn run_winget_source_command_process(args: &[String]) -> Result<WingetSourceCommandResult> {
use std::process::Command;
let output = Command::new("winget")
.args(args)
.output()
.context("failed to run winget source command")?;
Ok(WingetSourceCommandResult {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
fn parse_system_winget_source_export(output: &str) -> Result<Vec<SourceRecord>> {
let trimmed = output.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed)
&& let Some(sources) = parse_system_winget_source_export_value(&value)?
{
return Ok(order_sources(sources));
}
let mut sources = Vec::new();
for line in output.replace("\r\n", "\n").lines().map(str::trim) {
if line.is_empty() {
continue;
}
let value: JsonValue = serde_json::from_str(line)
.with_context(|| format!("WinGet source export returned a non-source line: {line}"))?;
let Some(mut parsed) = parse_system_winget_source_export_value(&value)? else {
bail!("WinGet source export returned a non-source line: {line}");
};
sources.append(&mut parsed);
}
Ok(order_sources(sources))
}
fn parse_system_winget_source_export_value(value: &JsonValue) -> Result<Option<Vec<SourceRecord>>> {
match value {
JsonValue::Array(items) => items
.iter()
.map(parse_system_winget_source_record)
.collect::<Result<Vec<_>>>()
.map(Some),
JsonValue::Object(object) => {
if let Some(JsonValue::Array(items)) = object.get("Sources") {
items
.iter()
.map(parse_system_winget_source_record)
.collect::<Result<Vec<_>>>()
.map(Some)
} else if object.contains_key("Name") {
Ok(Some(vec![parse_system_winget_source_record(value)?]))
} else {
Ok(None)
}
}
_ => Ok(None),
}
}
fn parse_system_winget_source_record(value: &JsonValue) -> Result<SourceRecord> {
let object = value
.as_object()
.ok_or_else(|| anyhow!("WinGet source export entry was not an object"))?;
let name = required_json_string(object, "Name")?;
let source_type = required_json_string(object, "Type")?;
let arg = required_json_string(object, "Arg")?;
let kind = parse_system_winget_source_kind(&source_type)?;
let identifier = optional_json_string(object, "Identifier")
.or_else(|| optional_json_string(object, "Data"))
.unwrap_or_else(|| name.clone());
Ok(SourceRecord {
name,
kind,
arg,
identifier,
trust_level: parse_system_winget_trust_level(object),
explicit: optional_json_bool(object, "Explicit").unwrap_or(false),
priority: optional_json_i32(object, "Priority").unwrap_or_default(),
last_update: None,
source_version: None,
})
}
fn required_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Result<String> {
optional_json_string(object, key).ok_or_else(|| anyhow!("WinGet source export did not include '{key}'."))
}
fn optional_json_string(object: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
match object.get(key)? {
JsonValue::String(value) => Some(value.clone()),
JsonValue::Number(value) => Some(value.to_string()),
_ => None,
}
}
fn optional_json_bool(object: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
match object.get(key)? {
JsonValue::Bool(value) => Some(*value),
JsonValue::String(value) => value.parse().ok(),
_ => None,
}
}
fn optional_json_i32(object: &JsonMap<String, JsonValue>, key: &str) -> Option<i32> {
match object.get(key)? {
JsonValue::Number(value) => value.as_i64().and_then(|value| i32::try_from(value).ok()),
JsonValue::String(value) => value.parse().ok(),
_ => None,
}
}
fn parse_system_winget_source_kind(value: &str) -> Result<SourceKind> {
if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
Ok(SourceKind::PreIndexed)
} else if value.eq_ignore_ascii_case("Microsoft.Rest") {
Ok(SourceKind::Rest)
} else {
bail!("Unsupported WinGet source type '{value}'.")
}
}
fn parse_system_winget_trust_level(object: &JsonMap<String, JsonValue>) -> String {
let Some(value) = object.get("TrustLevel") else {
return "None".to_owned();
};
match value {
JsonValue::Array(items) if items.iter().any(json_trust_value_is_trusted) => "Trusted".to_owned(),
JsonValue::String(_) if json_trust_value_is_trusted(value) => "Trusted".to_owned(),
JsonValue::Number(value) if value.as_i64().unwrap_or_default() > 0 => "Trusted".to_owned(),
_ => "None".to_owned(),
}
}
fn json_trust_value_is_trusted(value: &JsonValue) -> bool {
value
.as_str()
.is_some_and(|value| value.eq_ignore_ascii_case("Trusted"))
}
fn order_sources(mut sources: Vec<SourceRecord>) -> Vec<SourceRecord> {
sources.sort_by(|left, right| {
left.explicit
.cmp(&right.explicit)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| left.name.to_ascii_lowercase().cmp(&right.name.to_ascii_lowercase()))
});
sources
}
fn parse_packaged_source_store(user_sources_yaml: Option<&str>, metadata_yaml: Option<&str>) -> Option<SourceStore> {
if user_sources_yaml.is_none() && metadata_yaml.is_none() {
return None;
}
let mut sources = SourceStore::default().sources;
for entry in parse_packaged_yaml_entries(user_sources_yaml) {
let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
continue;
};
if yaml_bool(entry.get("IsTombstone")) {
sources.retain(|source| !source.name.eq_ignore_ascii_case(&name));
continue;
}
let Some(mapped) = map_packaged_source_entry(&entry) else {
continue;
};
if let Some(existing) = sources
.iter_mut()
.find(|source| source.name.eq_ignore_ascii_case(&name))
{
*existing = mapped;
} else {
sources.push(mapped);
}
}
for entry in parse_packaged_yaml_entries(metadata_yaml) {
let Some(name) = entry.get("Name").and_then(|value| value.clone()) else {
continue;
};
let Some(source) = sources
.iter_mut()
.find(|source| source.name.eq_ignore_ascii_case(&name))
else {
continue;
};
if let Some(last_update) = entry
.get("LastUpdate")
.and_then(|value| value.as_deref())
.and_then(parse_packaged_datetime)
{
source.last_update = Some(last_update);
}
if let Some(source_version) = entry.get("SourceVersion").and_then(|value| value.clone())
&& !source_version.is_empty()
{
source.source_version = Some(source_version);
}
}
Some(SourceStore { sources })
}
fn parse_packaged_yaml_entries(yaml: Option<&str>) -> Vec<BTreeMap<String, Option<String>>> {
let Some(yaml) = yaml else {
return Vec::new();
};
let mut entries = Vec::new();
let mut current: Option<BTreeMap<String, Option<String>>> = None;
for raw_line in yaml.replace("\r\n", "\n").split('\n') {
let line = raw_line.trim_end();
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.eq_ignore_ascii_case("Sources:") {
continue;
}
if let Some(remainder) = trimmed.strip_prefix("- ") {
if let Some(entry) = current.take() {
entries.push(entry);
}
let mut entry = BTreeMap::new();
add_packaged_yaml_key_value(&mut entry, remainder);
current = Some(entry);
continue;
}
if let Some(entry) = current.as_mut() {
add_packaged_yaml_key_value(entry, trimmed);
}
}
if let Some(entry) = current {
entries.push(entry);
}
entries
}
fn add_packaged_yaml_key_value(entry: &mut BTreeMap<String, Option<String>>, line: &str) {
let Some((key, value)) = line.split_once(':') else {
return;
};
entry.insert(
key.trim().to_owned(),
Some(unquote_yaml_scalar(value.trim()).to_owned()),
);
}
fn unquote_yaml_scalar(value: &str) -> &str {
if value.len() >= 2 {
let bytes = value.as_bytes();
let first = bytes[0] as char;
let last = bytes[value.len() - 1] as char;
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return &value[1..value.len() - 1];
}
}
value
}
fn map_packaged_source_entry(entry: &BTreeMap<String, Option<String>>) -> Option<SourceRecord> {
let name = entry.get("Name").and_then(|value| value.clone())?;
let source_type = entry.get("Type").and_then(|value| value.clone())?;
let arg = entry.get("Arg").and_then(|value| value.clone())?;
let kind = parse_packaged_source_kind(&source_type)?;
Some(SourceRecord {
name: name.clone(),
kind,
arg,
identifier: entry
.get("Identifier")
.and_then(|value| value.clone())
.or_else(|| entry.get("Data").and_then(|value| value.clone()))
.unwrap_or(name),
trust_level: parse_packaged_trust_level(entry.get("TrustLevel").and_then(|value| value.as_deref())),
explicit: yaml_bool(entry.get("Explicit")),
priority: entry
.get("Priority")
.and_then(|value| value.as_deref())
.and_then(|value| value.parse::<i32>().ok())
.unwrap_or_default(),
last_update: None,
source_version: None,
})
}
fn parse_packaged_source_kind(value: &str) -> Option<SourceKind> {
if value.eq_ignore_ascii_case("Microsoft.PreIndexed.Package") {
Some(SourceKind::PreIndexed)
} else if value.eq_ignore_ascii_case("Microsoft.Rest") {
Some(SourceKind::Rest)
} else {
None
}
}
fn parse_packaged_trust_level(value: Option<&str>) -> String {
match value {
Some(raw) if raw.eq_ignore_ascii_case("Trusted") => "Trusted".to_owned(),
Some(raw) if raw.parse::<i64>().ok().unwrap_or_default() > 0 => "Trusted".to_owned(),
_ => "None".to_owned(),
}
}
fn yaml_bool(value: Option<&Option<String>>) -> bool {
value
.and_then(|raw| raw.as_deref())
.and_then(|raw| raw.parse::<bool>().ok())
.unwrap_or(false)
}
fn parse_packaged_datetime(value: &str) -> Option<DateTime<Utc>> {
if let Ok(unix_seconds) = value.parse::<i64>() {
return DateTime::<Utc>::from_timestamp(unix_seconds, 0);
}
DateTime::parse_from_rfc3339(value)
.ok()
.map(|value| value.with_timezone(&Utc))
}
fn render_packaged_sources_yaml(store: &SourceStore) -> String {
let mut lines = vec!["Sources:".to_owned()];
for source in &store.sources {
lines.push(format!(" - Name: {}", source.name));
lines.push(format!(" Type: {}", packaged_source_type(source.kind)));
lines.push(format!(" Arg: {}", yaml_scalar(&source.arg)));
lines.push(format!(" Data: {}", yaml_scalar(&source.identifier)));
lines.push(format!(
" TrustLevel: {}",
if source.trust_level.eq_ignore_ascii_case("Trusted") {
1
} else {
0
}
));
lines.push(format!(" Explicit: {}", source.explicit));
lines.push(format!(" Priority: {}", source.priority));
lines.push(" IsTombstone: false".to_owned());
}
lines.push(String::new());
lines.join("\n")
}
fn render_packaged_metadata_yaml(store: &SourceStore) -> String {
let mut lines = vec!["Sources:".to_owned()];
for source in &store.sources {
if source.last_update.is_none() && source.source_version.is_none() {
continue;
}
lines.push(format!(" - Name: {}", source.name));
if let Some(last_update) = source.last_update {
lines.push(format!(" LastUpdate: {}", last_update.timestamp()));
}
if let Some(source_version) = &source.source_version {
lines.push(format!(" SourceVersion: {}", yaml_scalar(source_version)));
}
}
lines.push(String::new());
lines.join("\n")
}
fn yaml_scalar(value: &str) -> String {
if value.chars().any(|ch| ch.is_whitespace() || ch == ':') {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_owned()
}
}
fn uses_packaged_layout(app_root: &Path) -> bool {
#[cfg(windows)]
{
try_get_packaged_local_state_root(app_root).is_some()
}
#[cfg(not(windows))]
{
let _ = app_root;
false
}
}
#[cfg(windows)]
fn try_get_packaged_local_state_root(app_root: &Path) -> Option<PathBuf> {
let local_state = app_root.file_name()?.to_string_lossy();
if !local_state.eq_ignore_ascii_case("LocalState") {
return None;
}
let family = app_root.parent()?.file_name()?.to_string_lossy();
if !family.eq_ignore_ascii_case(PACKAGED_FAMILY_NAME) {
return None;
}
let packages = app_root.parent()?.parent()?.file_name()?.to_string_lossy();
if !packages.eq_ignore_ascii_case("Packages") {
return None;
}
Some(app_root.to_path_buf())
}
#[cfg(windows)]
fn packaged_user_sources_path(app_root: &Path) -> Result<PathBuf> {
let _ = try_get_packaged_local_state_root(app_root).ok_or_else(|| anyhow!("not a packaged WinGet app root"))?;
Ok(packaged_secure_settings_root()?.join("user_sources"))
}
#[cfg(not(windows))]
fn packaged_user_sources_path(_app_root: &Path) -> Result<PathBuf> {
bail!("packaged WinGet source streams are only available on Windows")
}
#[cfg(windows)]
fn packaged_secure_settings_root() -> Result<PathBuf> {
let program_data = std::env::var_os("PROGRAMDATA")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("unable to determine ProgramData path"))?;
Ok(program_data
.join("Microsoft")
.join("WinGet")
.join(current_user_sid()?)
.join("settings")
.join("pkg")
.join(PACKAGED_NAME))
}
#[cfg(windows)]
#[allow(clippy::multiple_unsafe_ops_per_block)]
fn current_user_sid() -> Result<String> {
fn close_token(token: HANDLE) {
if !token.is_null() {
unsafe {
CloseHandle(token);
}
}
}
let mut token: HANDLE = std::ptr::null_mut();
let current_process = unsafe { GetCurrentProcess() };
if unsafe { OpenProcessToken(current_process, TOKEN_QUERY, &mut token) } == 0 {
bail!("failed to open current process token")
}
let mut length = 0;
unsafe {
GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut length);
}
let element_size = size_of::<TOKEN_USER>();
let element_count = (length as usize).div_ceil(element_size);
let mut buffer = vec![MaybeUninit::<TOKEN_USER>::uninit(); element_count];
if unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), length, &mut length) } == 0 {
close_token(token);
bail!("failed to get current user token information")
}
let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
let mut sid_ptr: *mut u16 = std::ptr::null_mut();
if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut sid_ptr) } == 0 {
close_token(token);
bail!("failed to convert current user SID")
}
let mut len = 0usize;
while unsafe { *sid_ptr.wrapping_add(len) } != 0 {
len += 1;
}
let sid_slice = unsafe { std::slice::from_raw_parts(sid_ptr, len) };
let sid = std::ffi::OsString::from_wide(sid_slice).to_string_lossy().into_owned();
unsafe {
LocalFree(sid_ptr.cast());
}
close_token(token);
Ok(sid)
}
fn packaged_file_cache_root(app_root: &Path) -> PathBuf {
app_root.join("Microsoft").join("Windows Package Manager")
}
fn cache_root(app_root: &Path) -> PathBuf {
if uses_packaged_layout(app_root) {
packaged_file_cache_root(app_root)
} else {
default_cache_root_fallback()
}
}
fn default_cache_root_fallback() -> PathBuf {
std::env::temp_dir().join("cache")
}
fn packaged_source_type(kind: SourceKind) -> &'static str {
match kind {
SourceKind::PreIndexed => "Microsoft.PreIndexed.Package",
SourceKind::Rest => "Microsoft.Rest",
}
}
fn sanitize_path_segment(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => ch,
})
.collect()
}
fn load_json_object(path: &Path) -> Result<JsonMap<String, JsonValue>> {
if !path.exists() {
return Ok(JsonMap::new());
}
let bytes = fs::read(path).context("failed to read JSON file")?;
let value: JsonValue = serde_json::from_slice(&bytes).context("failed to parse JSON file")?;
Ok(match value {
JsonValue::Object(object) => object,
_ => JsonMap::new(),
})
}
fn save_json_object(path: PathBuf, value: &JsonMap<String, JsonValue>) -> Result<()> {
write_json(path, value)
}
fn write_json<T: Serialize>(path: PathBuf, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).context("failed to create JSON parent directory")?;
}
let bytes = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
fs::write(path, bytes).context("failed to write JSON file")
}
fn merge_json_objects(
current: &JsonMap<String, JsonValue>,
update: &JsonMap<String, JsonValue>,
) -> JsonMap<String, JsonValue> {
let mut merged = current.clone();
for (key, value) in update {
match (merged.get(key), value) {
(Some(JsonValue::Object(current_object)), JsonValue::Object(update_object)) => {
merged.insert(
key.clone(),
JsonValue::Object(merge_json_objects(current_object, update_object)),
);
}
_ => {
merged.insert(key.clone(), value.clone());
}
}
}
merged
}
fn json_contains(current: &JsonValue, expected: &JsonValue) -> bool {
match expected {
JsonValue::Object(expected_object) => match current {
JsonValue::Object(current_object) => expected_object.iter().all(|(key, expected_child)| {
current_object
.get(key)
.is_some_and(|current_child| json_contains(current_child, expected_child))
}),
_ => false,
},
JsonValue::Array(expected_array) => match current {
JsonValue::Array(current_array) => {
current_array.len() == expected_array.len()
&& current_array
.iter()
.zip(expected_array.iter())
.all(|(current_child, expected_child)| json_contains(current_child, expected_child))
}
_ => false,
},
_ => current == expected,
}
}
fn normalize_source_trust_level(trust_level: Option<&str>) -> Result<String> {
match trust_level.unwrap_or("none").trim().to_ascii_lowercase().as_str() {
"none" | "default" => Ok("None".to_owned()),
"trusted" => Ok("Trusted".to_owned()),
other => bail!("unsupported source trust level: {other}"),
}
}
fn normalize_admin_setting_name(name: &str) -> Result<&'static str> {
SUPPORTED_ADMIN_SETTINGS
.iter()
.copied()
.find(|setting| setting.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow!("unsupported admin setting: {name}"))
}
fn default_source_trust_level() -> String {
"None".to_owned()
}
fn query_rows<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, mut map: F) -> Result<Vec<T>>
where
F: FnMut(&SqlRow) -> Result<T>,
{
let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
.with_context(|| format!("failed to execute SQL query: {sql}"))?;
let mut result = Vec::new();
while let Some(row) = pollster::block_on(rows.next()).context("failed to read SQL row")? {
result.push(map(&row)?);
}
Ok(result)
}
fn query_optional_value<T, F>(connection: &Connection, sql: &str, params: Vec<SqlValue>, map: F) -> Result<Option<T>>
where
F: FnOnce(&SqlRow) -> Result<T>,
{
let mut rows = pollster::block_on(connection.query(sql, params_from_iter(params)))
.with_context(|| format!("failed to execute SQL query: {sql}"))?;
match pollster::block_on(rows.next()).context("failed to read SQL row")? {
Some(row) => Ok(Some(map(&row)?)),
None => Ok(None),
}
}
fn execute_sql(connection: &Connection, sql: &str, params: Vec<SqlValue>) -> Result<u64> {
pollster::block_on(connection.execute(sql, params_from_iter(params)))
.with_context(|| format!("failed to execute SQL statement: {sql}"))
}
fn execute_batch_sql(connection: &Connection, sql: &str) -> Result<()> {
pollster::block_on(connection.execute_batch(sql)).with_context(|| format!("failed to execute SQL batch: {sql}"))
}
fn row_value(row: &SqlRow, index: usize) -> Result<SqlValue> {
row.get_value(index)
.with_context(|| format!("failed to read SQL column {index}"))
}
fn row_string(row: &SqlRow, index: usize) -> Result<String> {
match row_value(row, index)? {
SqlValue::Text(value) => Ok(value),
SqlValue::Integer(value) => Ok(value.to_string()),
SqlValue::Real(value) => Ok(value.to_string()),
SqlValue::Blob(value) => String::from_utf8(value).context("SQL blob column was not valid UTF-8"),
SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
}
}
fn row_opt_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
match row_value(row, index)? {
SqlValue::Null => Ok(None),
SqlValue::Text(value) => Ok(Some(value)),
SqlValue::Integer(value) => Ok(Some(value.to_string())),
SqlValue::Real(value) => Ok(Some(value.to_string())),
SqlValue::Blob(value) => Ok(Some(
String::from_utf8(value).context("SQL blob column was not valid UTF-8")?,
)),
}
}
fn row_i64(row: &SqlRow, index: usize) -> Result<i64> {
match row_value(row, index)? {
SqlValue::Integer(value) => Ok(value),
SqlValue::Text(value) => value
.parse::<i64>()
.with_context(|| format!("failed to parse integer from SQL text column {index}")),
SqlValue::Real(value) => f64_to_i64_checked(value, index),
SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
SqlValue::Blob(_) => bail!("SQL column {index} was unexpectedly a blob"),
}
}
fn row_hex_string(row: &SqlRow, index: usize) -> Result<String> {
match row_value(row, index)? {
SqlValue::Blob(value) => Ok(bytes_to_hex(&value)),
SqlValue::Text(value) => Ok(value.to_ascii_lowercase()),
SqlValue::Null => bail!("SQL column {index} was unexpectedly NULL"),
SqlValue::Integer(value) => Ok(format!("{value:x}")),
SqlValue::Real(value) => Ok(format!("{:x}", f64_to_i64_checked(value, index)?)),
}
}
fn row_opt_hex_string(row: &SqlRow, index: usize) -> Result<Option<String>> {
match row_value(row, index)? {
SqlValue::Null => Ok(None),
SqlValue::Blob(value) => Ok(Some(bytes_to_hex(&value))),
SqlValue::Text(value) => Ok(Some(value.to_ascii_lowercase())),
SqlValue::Integer(value) => Ok(Some(format!("{value:x}"))),
SqlValue::Real(value) => Ok(Some(format!("{:x}", f64_to_i64_checked(value, index)?))),
}
}
fn f64_to_i64_checked(value: f64, index: usize) -> Result<i64> {
if !value.is_finite() {
bail!("SQL column {index} contained a non-finite floating-point value");
}
if value.fract() != 0.0 {
bail!("SQL column {index} contained a non-integer floating-point value");
}
if !(-9_223_372_036_854_775_808.0..=9_223_372_036_854_775_807.0).contains(&value) {
bail!("SQL column {index} contained a floating-point value outside the i64 range");
}
format!("{value:.0}")
.parse::<i64>()
.with_context(|| format!("failed to parse integer from SQL floating-point column {index}"))
}
fn bytes_to_hex(bytes: &[u8]) -> String {
let mut result = String::with_capacity(bytes.len() * 2);
for byte in bytes {
result.push_str(&format!("{byte:02x}"));
}
result
}
fn query_v2_matches(
connection: &Connection,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<(Vec<V2SearchRow>, bool)> {
let (where_clause, params) = build_preindexed_where_clause(query, true, semantics);
let limit = source_fetch_results(query, semantics);
let sql = format!(
"SELECT rowid, id, name, moniker, latest_version, hash \
FROM packages WHERE {where_clause} LIMIT {}",
limit + 1
);
let mut rows = query_rows(
connection,
&sql,
params.into_iter().map(SqlValue::Text).collect(),
|row| {
Ok(V2SearchRow {
package_rowid: row_i64(row, 0)?,
id: row_string(row, 1)?,
name: row_string(row, 2)?,
moniker: row_opt_string(row, 3)?,
version: row_string(row, 4)?,
package_hash: row_hex_string(row, 5)?,
match_criteria: None,
})
},
)?;
for row in &mut rows {
row.match_criteria = infer_preindexed_match_criteria_v2(connection, row, query, semantics)?;
}
let truncated = rows.len() > limit;
rows.truncate(limit);
Ok((rows, truncated))
}
fn query_v1_matches(
connection: &Connection,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<(Vec<V1SearchRow>, bool)> {
let (where_clause, params) = build_preindexed_where_clause(query, false, semantics);
let limit = source_fetch_results(query, semantics);
let sql = format!(
"SELECT manifest.rowid, manifest.id, versions.version, channels.channel, ids.id, names.name, monikers.moniker \
FROM manifest \
JOIN ids ON manifest.id = ids.rowid \
JOIN names ON manifest.name = names.rowid \
LEFT JOIN monikers ON manifest.moniker = monikers.rowid \
JOIN versions ON manifest.version = versions.rowid \
JOIN channels ON manifest.channel = channels.rowid \
WHERE {where_clause} LIMIT {}",
limit + 1
);
let mut rows = query_rows(
connection,
&sql,
params.into_iter().map(SqlValue::Text).collect(),
|row| {
Ok(V1SearchRow {
manifest_rowid: row_i64(row, 0)?,
package_rowid: row_i64(row, 1)?,
version: row_string(row, 2)?,
channel: row_string(row, 3)?,
id: row_string(row, 4)?,
name: row_string(row, 5)?,
moniker: row_opt_string(row, 6)?,
match_criteria: None,
})
},
)?;
for row in &mut rows {
row.match_criteria = infer_preindexed_match_criteria_v1(connection, row, query, semantics)?;
}
let truncated = rows.len() > limit;
rows.truncate(limit);
Ok((rows, truncated))
}
fn group_v1_rows(rows: Vec<V1SearchRow>) -> Vec<V1SearchRow> {
let mut grouped = BTreeMap::<i64, V1SearchRow>::new();
for row in rows {
match grouped.get(&row.package_rowid) {
Some(existing)
if compare_version_and_channel(&existing.version, &existing.channel, &row.version, &row.channel)
!= Ordering::Less => {}
_ => {
grouped.insert(row.package_rowid, row);
}
}
}
grouped.into_values().collect()
}
fn query_v1_versions(connection: &Connection, package_rowid: i64) -> Result<Vec<V1VersionRow>> {
let has_hash = table_has_column(connection, "manifest", "hash")?;
let hash_sql = if has_hash { "manifest.hash" } else { "NULL" };
let sql = format!(
"SELECT versions.version, channels.channel, manifest.pathpart, {hash_sql} \
FROM manifest \
JOIN versions ON manifest.version = versions.rowid \
JOIN channels ON manifest.channel = channels.rowid \
WHERE manifest.id = ?1"
);
query_rows(connection, &sql, vec![SqlValue::Integer(package_rowid)], |row| {
Ok(V1VersionRow {
version: row_string(row, 0)?,
channel: row_string(row, 1)?,
pathpart_id: row_i64(row, 2)?,
manifest_hash: row_opt_hex_string(row, 3)?,
})
})
}
fn resolve_v1_relative_path(connection: &Connection, pathpart_id: i64) -> Result<String> {
let mut parts = Vec::new();
let mut current = Some(pathpart_id);
while let Some(id) = current {
let (parent, pathpart) = query_optional_value(
connection,
"SELECT parent, pathpart FROM pathparts WHERE rowid = ?1",
vec![SqlValue::Integer(id)],
|row| {
Ok((
row_opt_string(row, 0)?
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value != 0),
row_string(row, 1)?,
))
},
)?
.ok_or_else(|| anyhow!("failed to resolve pathpart {id}"))?;
parts.push(pathpart);
current = parent;
}
parts.reverse();
Ok(parts.join("/"))
}
fn package_version_data_relative_path(package_id: &str, package_hash: &str) -> String {
format!(
"packages/{}/{}/versionData.mszyml",
package_id,
package_hash[..package_hash.len().min(8)].to_ascii_lowercase()
)
}
fn can_fallback_to_v1(error: &anyhow::Error) -> bool {
let message = format!("{error:#}").to_ascii_lowercase();
message.contains("no such table") && message.contains("packages")
}
fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
for value in query_rows(connection, &format!("PRAGMA table_info({table})"), Vec::new(), |row| {
row_string(row, 1)
})? {
if value.eq_ignore_ascii_case(column) {
return Ok(true);
}
}
Ok(false)
}
fn build_preindexed_where_clause(query: &PackageQuery, v2: bool, semantics: SearchSemantics) -> (String, Vec<String>) {
let rowid_column = if v2 { "packages.rowid" } else { "manifest.rowid" };
let id_column = if v2 { "id" } else { "ids.id" };
let name_column = if v2 { "name" } else { "names.name" };
let moniker_column = if v2 { "moniker" } else { "monikers.moniker" };
let exact_match = query.exact || semantics == SearchSemantics::Single;
let mut params = Vec::new();
if let Some(value) = &query.id {
return (
single_field_condition(id_column, value, exact_match, &mut params),
params,
);
}
if let Some(value) = &query.name {
return (
single_field_condition(name_column, value, exact_match, &mut params),
params,
);
}
if let Some(value) = &query.moniker {
return (
single_field_condition(moniker_column, value, exact_match, &mut params),
params,
);
}
if let Some(value) = &query.tag {
return (
mapped_field_condition(v2, "tag", value, rowid_column, true, &mut params),
params,
);
}
if let Some(value) = &query.command {
return (
mapped_field_condition(v2, "command", value, rowid_column, true, &mut params),
params,
);
}
if let Some(value) = &query.query {
if exact_match {
let conditions = [
single_field_condition(id_column, value, true, &mut params),
single_field_condition(name_column, value, true, &mut params),
single_field_condition(moniker_column, value, true, &mut params),
];
return (format!("({})", conditions.join(" OR ")), params);
}
let mut conditions = vec![
single_field_condition(id_column, value, false, &mut params),
single_field_condition(name_column, value, false, &mut params),
single_field_condition(moniker_column, value, false, &mut params),
];
if semantics == SearchSemantics::Many {
conditions.push(mapped_field_condition(
v2,
"tag",
value,
rowid_column,
false,
&mut params,
));
conditions.push(mapped_field_condition(
v2,
"command",
value,
rowid_column,
false,
&mut params,
));
}
return (format!("({})", conditions.join(" OR ")), params);
}
("1 = 1".to_owned(), Vec::new())
}
fn single_field_condition(column: &str, value: &str, exact: bool, params: &mut Vec<String>) -> String {
params.push(match_parameter(value, exact));
format!("{column} LIKE ?{}", params.len())
}
fn mapped_field_condition(
v2: bool,
value_name: &str,
value: &str,
rowid_column: &str,
exact: bool,
params: &mut Vec<String>,
) -> String {
let (table_name, map_table_name, map_value_column, map_owner_column) = if v2 {
(
format!("{value_name}s2"),
format!("{value_name}s2_map"),
value_name.to_owned(),
"package".to_owned(),
)
} else {
(
format!("{value_name}s"),
format!("{value_name}s_map"),
value_name.to_owned(),
"manifest".to_owned(),
)
};
params.push(match_parameter(value, exact));
let parameter = params.len();
format!(
"EXISTS (SELECT 1 FROM {map_table_name} JOIN {table_name} ON \
{map_table_name}.{value_name} = {table_name}.rowid \
WHERE {map_table_name}.{map_owner_column} = {rowid_column} \
AND {table_name}.{map_value_column} LIKE ?{parameter})"
)
}
fn match_parameter(value: &str, exact: bool) -> String {
if exact { value.to_owned() } else { format!("%{value}%") }
}
fn build_rest_search_body(
query: &PackageQuery,
info: &RestInformation,
semantics: SearchSemantics,
) -> Result<JsonValue> {
let mut root = serde_json::Map::new();
root.insert(
"MaximumResults".to_owned(),
JsonValue::from(source_fetch_results(query, semantics) as u64),
);
let exact_match = query.exact || semantics == SearchSemantics::Single;
if let Some(value) = &query.query {
if semantics == SearchSemantics::Single {
let mut filters = vec![
rest_filter("PackageIdentifier", value, true),
rest_filter("PackageName", value, true),
rest_filter("Moniker", value, true),
];
append_required_rest_filters(&mut filters, info);
root.insert("Filters".to_owned(), JsonValue::Array(filters));
return Ok(JsonValue::Object(root));
}
root.insert(
"Query".to_owned(),
serde_json::json!({
"KeyWord": value,
"MatchType": if exact_match { "Exact" } else { "Substring" },
}),
);
}
let mut filters = Vec::new();
if let Some(value) = &query.id {
filters.push(rest_filter("PackageIdentifier", value, exact_match));
}
if let Some(value) = &query.name {
filters.push(rest_filter("PackageName", value, exact_match));
}
if let Some(value) = &query.moniker {
filters.push(rest_filter("Moniker", value, exact_match));
}
if let Some(value) = &query.tag {
filters.push(rest_filter("Tag", value, true));
}
if let Some(value) = &query.command {
filters.push(rest_filter("Command", value, true));
}
append_required_rest_filters(&mut filters, info);
if !filters.is_empty() {
root.insert("Filters".to_owned(), JsonValue::Array(filters));
}
Ok(JsonValue::Object(root))
}
fn append_required_rest_filters(filters: &mut Vec<JsonValue>, info: &RestInformation) {
if info
.required_package_match_fields
.iter()
.any(|field| field.eq_ignore_ascii_case("Market"))
{
filters.push(rest_filter("Market", &default_market(), true));
}
}
fn rest_filter(field: &str, value: &str, exact: bool) -> JsonValue {
serde_json::json!({
"PackageMatchField": field,
"RequestMatch": {
"KeyWord": value,
"MatchType": if exact { "Exact" } else { "Substring" },
}
})
}
fn max_results(query: &PackageQuery) -> usize {
query.count.unwrap_or(DEFAULT_MAX_RESULTS).max(1)
}
fn source_fetch_results(query: &PackageQuery, semantics: SearchSemantics) -> usize {
match semantics {
SearchSemantics::Many => max_results(query).max(DEFAULT_MAX_RESULTS),
SearchSemantics::Single => max_results(query),
}
}
fn infer_preindexed_match_criteria_v2(
connection: &Connection,
row: &V2SearchRow,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<Option<String>> {
infer_match_criteria(
&row.id,
&row.name,
row.moniker.as_deref(),
query,
semantics,
|value| {
find_mapped_value_v2(
connection,
"tags2",
"tags2_map",
"tag",
row.package_rowid,
value,
query.exact,
)
},
|value| {
find_mapped_value_v2(
connection,
"commands2",
"commands2_map",
"command",
row.package_rowid,
value,
query.exact,
)
},
)
}
fn infer_preindexed_match_criteria_v1(
connection: &Connection,
row: &V1SearchRow,
query: &PackageQuery,
semantics: SearchSemantics,
) -> Result<Option<String>> {
infer_match_criteria(
&row.id,
&row.name,
row.moniker.as_deref(),
query,
semantics,
|value| {
find_mapped_value_v1(
connection,
"tags",
"tags_map",
"tag",
row.manifest_rowid,
value,
query.exact,
)
},
|value| {
find_mapped_value_v1(
connection,
"commands",
"commands_map",
"command",
row.manifest_rowid,
value,
query.exact,
)
},
)
}
fn infer_match_criteria<FTag, FCommand>(
id: &str,
name: &str,
moniker: Option<&str>,
query: &PackageQuery,
semantics: SearchSemantics,
find_tag: FTag,
find_command: FCommand,
) -> Result<Option<String>>
where
FTag: FnOnce(&str) -> Result<Option<String>>,
FCommand: FnOnce(&str) -> Result<Option<String>>,
{
if let Some(value) = &query.tag {
return Ok(Some(format_match_criteria("Tag", value)));
}
if let Some(value) = &query.command {
return Ok(Some(format_match_criteria("Command", value)));
}
if let Some(value) = &query.moniker {
return Ok(Some(format_match_criteria("Moniker", moniker.unwrap_or(value))));
}
if semantics == SearchSemantics::Single {
return Ok(None);
}
if let Some(value) = &query.query {
if matches_text(id, value, query.exact) || matches_text(name, value, query.exact) {
return Ok(None);
}
if let Some(moniker_value) = moniker.filter(|candidate| matches_text(candidate, value, query.exact)) {
return Ok(Some(format_match_criteria("Moniker", moniker_value)));
}
if let Some(tag) = find_tag(value)? {
return Ok(Some(format_match_criteria("Tag", &tag)));
}
if let Some(command) = find_command(value)? {
return Ok(Some(format_match_criteria("Command", &command)));
}
}
Ok(None)
}
fn find_mapped_value_v2(
connection: &Connection,
table_name: &str,
map_table_name: &str,
value_name: &str,
package_rowid: i64,
query: &str,
exact: bool,
) -> Result<Option<String>> {
let sql = format!(
"SELECT {table_name}.{value_name} FROM {map_table_name} \
JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
WHERE {map_table_name}.package = ?1 AND {table_name}.{value_name} LIKE ?2"
);
let values = query_rows(
connection,
&sql,
vec![
SqlValue::Integer(package_rowid),
SqlValue::Text(match_parameter(query, exact)),
],
|row| row_string(row, 0),
)?;
Ok(select_best_text_match(values, query, exact))
}
fn find_mapped_value_v1(
connection: &Connection,
table_name: &str,
map_table_name: &str,
value_name: &str,
manifest_rowid: i64,
query: &str,
exact: bool,
) -> Result<Option<String>> {
let sql = format!(
"SELECT {table_name}.{value_name} FROM {map_table_name} \
JOIN {table_name} ON {map_table_name}.{value_name} = {table_name}.rowid \
WHERE {map_table_name}.manifest = ?1 AND {table_name}.{value_name} LIKE ?2"
);
let values = query_rows(
connection,
&sql,
vec![
SqlValue::Integer(manifest_rowid),
SqlValue::Text(match_parameter(query, exact)),
],
|row| row_string(row, 0),
)?;
Ok(select_best_text_match(values, query, exact))
}
fn rest_match_criteria(item: &JsonValue, query: &PackageQuery, semantics: SearchSemantics) -> Option<String> {
if let Some(value) = &query.tag {
return Some(format_match_criteria("Tag", value));
}
if let Some(value) = &query.command {
return Some(format_match_criteria("Command", value));
}
if let Some(value) = &query.moniker {
return Some(format_match_criteria("Moniker", value));
}
if semantics == SearchSemantics::Single {
return None;
}
let value = query.query.as_deref()?;
if json_string(item, "PackageIdentifier")
.as_deref()
.is_some_and(|candidate| matches_text(candidate, value, query.exact))
|| json_string(item, "PackageName")
.as_deref()
.is_some_and(|candidate| matches_text(candidate, value, query.exact))
{
return None;
}
json_string(item, "Moniker")
.filter(|candidate| matches_text(candidate, value, query.exact))
.map(|candidate| format_match_criteria("Moniker", &candidate))
}
fn format_match_criteria(field: &str, value: &str) -> String {
format!("{field}: {value}")
}
fn matches_text(candidate: &str, query: &str, exact: bool) -> bool {
if exact {
candidate.eq_ignore_ascii_case(query)
} else {
candidate.to_ascii_lowercase().contains(&query.to_ascii_lowercase())
}
}
fn search_match_sort_score(candidate: &SearchMatch, query: &PackageQuery) -> usize {
let mut score = 0;
if let Some(value) = &query.query {
score = score.max(score_text_match(&candidate.name, value, query.exact, 140, 50, 30));
score = score.max(score_text_match(&candidate.id, value, query.exact, 135, 45, 25));
if let Some(moniker) = candidate.moniker.as_deref() {
score = score.max(score_text_match(moniker, value, query.exact, 130, 55, 35));
}
}
if let Some(value) = &query.id {
score = score.max(score_text_match(&candidate.id, value, query.exact, 220, 200, 180));
}
if let Some(value) = &query.name {
score = score.max(score_text_match(&candidate.name, value, query.exact, 210, 190, 170));
}
if let Some(value) = &query.moniker
&& let Some(moniker) = candidate.moniker.as_deref()
{
score = score.max(score_text_match(moniker, value, query.exact, 205, 185, 165));
}
if let Some(value) = &query.tag
&& let Some(("Tag", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
{
score = score.max(score_text_match(matched_value, value, query.exact, 160, 150, 140));
}
if let Some(value) = &query.command
&& let Some(("Command", matched_value)) = candidate.match_criteria.as_deref().and_then(parse_match_criteria)
{
score = score.max(score_text_match(matched_value, value, query.exact, 155, 145, 135));
}
if matches!(candidate.source_kind, SourceKind::PreIndexed) {
score += 5;
}
if search_match_has_unknown_version(candidate) {
score = score.saturating_sub(10);
}
score
}
fn search_match_has_unknown_version(candidate: &SearchMatch) -> bool {
candidate
.version
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case("Unknown"))
}
fn build_download_manifest_yaml(manifest: &Manifest, documents: &JsonValue, installer: &Installer) -> Result<String> {
let mut document = match documents {
JsonValue::Object(map) => map.clone(),
_ => serde_json::Map::new(),
};
document.insert("PackageIdentifier".to_owned(), JsonValue::String(manifest.id.clone()));
document.insert("PackageVersion".to_owned(), JsonValue::String(manifest.version.clone()));
document.insert("PackageName".to_owned(), JsonValue::String(manifest.name.clone()));
if !manifest.channel.is_empty() {
document.insert("Channel".to_owned(), JsonValue::String(manifest.channel.clone()));
} else {
document.remove("Channel");
}
let mut single_installer = serde_json::Map::new();
if let Some(arch) = installer.architecture.as_ref() {
single_installer.insert("Architecture".to_owned(), JsonValue::String(arch.clone()));
}
if let Some(installer_type) = installer.installer_type.as_ref() {
single_installer.insert("InstallerType".to_owned(), JsonValue::String(installer_type.clone()));
}
if let Some(url) = installer.url.as_ref() {
single_installer.insert("InstallerUrl".to_owned(), JsonValue::String(url.clone()));
}
if let Some(sha) = installer.sha256.as_ref() {
single_installer.insert("InstallerSha256".to_owned(), JsonValue::String(sha.clone()));
}
if let Some(scope) = installer.scope.as_ref() {
single_installer.insert("Scope".to_owned(), JsonValue::String(scope.clone()));
}
if let Some(locale) = installer.locale.as_ref() {
single_installer.insert("InstallerLocale".to_owned(), JsonValue::String(locale.clone()));
}
if let Some(product_code) = installer.product_code.as_ref() {
single_installer.insert("ProductCode".to_owned(), JsonValue::String(product_code.clone()));
}
if let Some(release_date) = installer.release_date.as_ref() {
single_installer.insert("ReleaseDate".to_owned(), JsonValue::String(release_date.clone()));
}
document.insert(
"Installers".to_owned(),
JsonValue::Array(vec![JsonValue::Object(single_installer)]),
);
document.insert("ManifestType".to_owned(), JsonValue::String("merged".to_owned()));
let value = JsonValue::Object(document);
serde_yaml::to_string(&value).context("failed to serialize manifest YAML")
}
fn derive_download_manifest_filename(manifest: &Manifest, installer: &Installer) -> String {
let mut filename = derive_download_filename_stem(manifest, installer);
filename.push_str(".yaml");
filename
}
fn derive_download_filename_stem(manifest: &Manifest, installer: &Installer) -> String {
let mut stem = sanitize_filename_component(&manifest.name);
if !manifest.version.is_empty() && !manifest.version.eq_ignore_ascii_case("Unknown") {
stem.push('_');
stem.push_str(&sanitize_filename_component(&manifest.version));
}
if let Some(scope) = installer.scope.as_deref() {
stem.push('_');
stem.push_str(&sanitize_filename_component(&capitalize_first(scope)));
}
let architecture = installer.architecture.as_deref().unwrap_or("neutral");
stem.push('_');
stem.push_str(&sanitize_filename_component(&format_architecture(architecture)));
let installer_type = installer.installer_type.as_deref().unwrap_or("");
if !installer_type.is_empty() {
stem.push('_');
stem.push_str(&sanitize_filename_component(&installer_type.to_ascii_lowercase()));
}
if let Some(locale) = installer.locale.as_deref().filter(|value| !value.is_empty()) {
stem.push('_');
stem.push_str(&sanitize_filename_component(locale));
}
stem
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => {
let mut out = first.to_ascii_uppercase().to_string();
out.push_str(&chars.as_str().to_ascii_lowercase());
out
}
None => String::new(),
}
}
fn format_architecture(value: &str) -> String {
match value.to_ascii_lowercase().as_str() {
"x64" => "X64".to_owned(),
"x86" => "X86".to_owned(),
"arm" => "Arm".to_owned(),
"arm64" => "Arm64".to_owned(),
"neutral" => "Neutral".to_owned(),
other => capitalize_first(other),
}
}
fn sanitize_filename_component(value: &str) -> String {
value
.chars()
.map(|ch| match ch {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
ch if ch.is_control() => '_',
ch => ch,
})
.collect()
}
fn resolve_manifest_path(manifest_path: &Path) -> Result<PathBuf> {
if manifest_path.is_file() {
return Ok(manifest_path.to_path_buf());
}
if !manifest_path.is_dir() {
bail!("Manifest path not found: {}", manifest_path.display());
}
let candidate = fs::read_dir(manifest_path)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
})
.min();
candidate.ok_or_else(|| anyhow!("No manifest file found under: {}", manifest_path.display()))
}
fn score_text_match(
candidate: &str,
query: &str,
exact: bool,
exact_score: usize,
prefix_score: usize,
substring_score: usize,
) -> usize {
if candidate.eq_ignore_ascii_case(query) {
return exact_score;
}
if exact {
return 0;
}
let candidate_lower = candidate.to_ascii_lowercase();
let query_lower = query.to_ascii_lowercase();
if candidate_lower.starts_with(&query_lower) {
prefix_score
} else if candidate_lower.contains(&query_lower) {
substring_score
} else {
0
}
}
fn select_best_text_match<I>(values: I, query: &str, exact: bool) -> Option<String>
where
I: IntoIterator<Item = String>,
{
values
.into_iter()
.filter_map(|candidate| {
let score = score_text_match(&candidate, query, exact, 3, 2, 1);
(score > 0).then_some((score, candidate))
})
.max_by(|(left_score, left_value), (right_score, right_value)| {
left_score
.cmp(right_score)
.then_with(|| right_value.len().cmp(&left_value.len()))
.then_with(|| right_value.to_ascii_lowercase().cmp(&left_value.to_ascii_lowercase()))
})
.map(|(_, candidate)| candidate)
}
fn parse_match_criteria(criteria: &str) -> Option<(&str, &str)> {
let (field, value) = criteria.split_once(": ")?;
Some((field, value))
}
fn parse_rest_versions(item: &JsonValue) -> Result<Vec<VersionKey>> {
let versions = item
.get("Versions")
.and_then(JsonValue::as_array)
.ok_or_else(|| anyhow!("REST search result missing Versions"))?;
let mut result = Vec::new();
for version in versions {
let value = json_string(version, "PackageVersion")
.ok_or_else(|| anyhow!("REST version entry missing PackageVersion"))?;
let channel = json_string(version, "Channel").unwrap_or_default();
result.push(VersionKey {
version: value,
channel,
});
}
Ok(result)
}
fn parse_yaml_manifest_bundle(bytes: &[u8]) -> Result<(Manifest, JsonValue)> {
let mut merged = YamlMapping::new();
let mut documents = Vec::new();
for document in serde_yaml::Deserializer::from_slice(bytes) {
let value = YamlValue::deserialize(document).context("failed to deserialize YAML document")?;
documents.push(serde_json::to_value(&value).context("failed to convert YAML manifest document")?);
if let Some(mapping) = value.as_mapping() {
merge_yaml_mapping(&mut merged, mapping);
}
}
let id = yaml_string_from_root(&merged, "PackageIdentifier")
.ok_or_else(|| anyhow!("manifest missing PackageIdentifier"))?;
let version =
yaml_string_from_root(&merged, "PackageVersion").ok_or_else(|| anyhow!("manifest missing PackageVersion"))?;
let name = yaml_localized_string(&merged, "PackageName").ok_or_else(|| anyhow!("manifest missing PackageName"))?;
let installers = parse_yaml_installers(&merged);
let top_level_require_explicit = yaml_scalar_bool(&merged, "RequireExplicitUpgrade");
let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
Ok((
Manifest {
id,
name,
version,
channel: yaml_string_from_root(&merged, "Channel").unwrap_or_default(),
publisher: yaml_localized_string(&merged, "Publisher"),
description: yaml_localized_string(&merged, "Description")
.or_else(|| yaml_localized_string(&merged, "ShortDescription")),
moniker: yaml_string_from_root(&merged, "Moniker"),
package_url: yaml_localized_string(&merged, "PackageUrl"),
publisher_url: yaml_localized_string(&merged, "PublisherUrl"),
publisher_support_url: yaml_localized_string(&merged, "PublisherSupportUrl"),
license: yaml_localized_string(&merged, "License"),
license_url: yaml_localized_string(&merged, "LicenseUrl"),
privacy_url: yaml_localized_string(&merged, "PrivacyUrl"),
author: yaml_localized_string(&merged, "Author"),
copyright: yaml_localized_string(&merged, "Copyright"),
copyright_url: yaml_localized_string(&merged, "CopyrightUrl"),
release_notes: yaml_localized_string(&merged, "ReleaseNotes"),
release_notes_url: yaml_localized_string(&merged, "ReleaseNotesUrl"),
tags: yaml_string_list(&merged, "Tags"),
agreements: yaml_agreement_list(&merged),
package_dependencies: yaml_package_dependencies(&merged),
documentation: yaml_documentation_list(&merged),
installers,
require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
},
collapse_structured_document(&JsonValue::Array(documents)),
))
}
fn parse_yaml_manifest(bytes: &[u8]) -> Result<Manifest> {
parse_yaml_manifest_bundle(bytes).map(|(manifest, _)| manifest)
}
fn parse_rest_manifest(bytes: &[u8], package_id: &str, version: &str, channel: &str) -> Result<(Manifest, JsonValue)> {
let root = serde_json::from_slice::<JsonValue>(bytes).context("failed to deserialize REST manifest JSON")?;
let data = root
.get("Data")
.ok_or_else(|| anyhow!("REST manifest response missing Data"))?;
let versions = data
.get("Versions")
.and_then(JsonValue::as_array)
.ok_or_else(|| anyhow!("REST manifest response missing Versions"))?;
let selected = versions
.iter()
.find(|item| {
json_string(item, "PackageVersion").as_deref() == Some(version)
&& json_string(item, "Channel").unwrap_or_default() == channel
})
.or_else(|| versions.first())
.ok_or_else(|| anyhow!("REST manifest response did not contain a version payload"))?;
let default_locale = selected
.get("DefaultLocale")
.ok_or_else(|| anyhow!("REST manifest response missing DefaultLocale"))?;
let name = json_string(default_locale, "PackageName")
.ok_or_else(|| anyhow!("REST manifest response missing PackageName"))?;
let installer_switch_defaults = json_installer_switches(selected);
let top_platforms = json_string_list(selected, "Platform");
let top_minimum_os_version = json_string(selected, "MinimumOSVersion");
let top_installer_type = json_string(selected, "InstallerType");
let top_nested_installer_type = json_string(selected, "NestedInstallerType");
let top_nested_installer_files = json_nested_installer_files(selected);
let installers = selected
.get("Installers")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.map(|item| Installer {
platforms: {
let platforms = json_string_list(item, "Platform");
if platforms.is_empty() {
top_platforms.clone()
} else {
platforms
}
},
minimum_os_version: json_string(item, "MinimumOSVersion")
.or_else(|| top_minimum_os_version.clone()),
architecture: json_string(item, "Architecture"),
installer_type: json_string(item, "InstallerType").or_else(|| top_installer_type.clone()),
nested_installer_type: json_string(item, "NestedInstallerType")
.or_else(|| top_nested_installer_type.clone()),
nested_installer_files: {
let files = json_nested_installer_files(item);
if files.is_empty() {
top_nested_installer_files.clone()
} else {
files
}
},
url: json_string(item, "InstallerUrl"),
sha256: json_string(item, "InstallerSha256"),
product_code: json_string(item, "ProductCode"),
locale: json_string(item, "InstallerLocale"),
scope: json_string(item, "Scope"),
release_date: json_string(item, "ReleaseDate"),
package_family_name: json_string(item, "PackageFamilyName"),
upgrade_code: json_string(item, "UpgradeCode"),
switches: json_installer_switches(item).with_fallback(&installer_switch_defaults),
commands: json_string_list(item, "Commands"),
package_dependencies: json_package_dependencies(item),
require_explicit_upgrade: json_bool(item, "RequireExplicitUpgrade"),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let top_level_require_explicit = json_bool(selected, "RequireExplicitUpgrade");
let any_installer_require_explicit = installers.iter().any(|i| i.require_explicit_upgrade);
let manifest = Manifest {
id: package_id.to_owned(),
name,
version: version.to_owned(),
channel: channel.to_owned(),
publisher: json_string(default_locale, "Publisher"),
description: json_string(default_locale, "Description")
.or_else(|| json_string(default_locale, "ShortDescription")),
moniker: json_string(default_locale, "Moniker"),
package_url: json_string(default_locale, "PackageUrl"),
publisher_url: json_string(default_locale, "PublisherUrl"),
publisher_support_url: json_string(default_locale, "PublisherSupportUrl"),
license: json_string(default_locale, "License"),
license_url: json_string(default_locale, "LicenseUrl"),
privacy_url: json_string(default_locale, "PrivacyUrl"),
author: json_string(default_locale, "Author"),
copyright: json_string(default_locale, "Copyright"),
copyright_url: json_string(default_locale, "CopyrightUrl"),
release_notes: json_string(default_locale, "ReleaseNotes"),
release_notes_url: json_string(default_locale, "ReleaseNotesUrl"),
tags: json_string_list(default_locale, "Tags"),
agreements: json_agreement_list(default_locale),
package_dependencies: json_package_dependencies(selected),
documentation: json_documentation_list(default_locale),
installers,
require_explicit_upgrade: top_level_require_explicit || any_installer_require_explicit,
};
Ok((
manifest,
build_rest_manifest_documents(data, selected, default_locale, package_id, version, channel),
))
}
fn collapse_structured_document(document: &JsonValue) -> JsonValue {
match document {
JsonValue::Array(documents) if documents.len() == 1 && is_merged_manifest_document(&documents[0]) => {
merge_manifest_documents(&split_merged_manifest_document(&documents[0]))
}
JsonValue::Array(documents) => merge_manifest_documents(documents),
JsonValue::Object(_) if is_merged_manifest_document(document) => {
merge_manifest_documents(&split_merged_manifest_document(document))
}
_ => document.clone(),
}
}
fn collapse_structured_documents(documents: &[JsonValue]) -> Vec<JsonValue> {
documents.iter().map(collapse_structured_document).collect()
}
fn merge_manifest_documents(documents: &[JsonValue]) -> JsonValue {
let version = documents.iter().find(|document| {
document
.get("ManifestType")
.and_then(JsonValue::as_str)
.is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("version"))
});
let default_locale = documents.iter().find(|document| {
document
.get("ManifestType")
.and_then(JsonValue::as_str)
.is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("defaultLocale"))
});
let installer = documents.iter().find(|document| {
document
.get("ManifestType")
.and_then(JsonValue::as_str)
.is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("installer"))
});
let mut singleton = JsonMap::new();
copy_manifest_keys(&mut singleton, version, &["PackageIdentifier", "PackageVersion"]);
copy_all_manifest_keys_except(&mut singleton, default_locale, &["ManifestType", "ManifestVersion"]);
copy_all_manifest_keys_except(&mut singleton, installer, &["ManifestType", "ManifestVersion"]);
if !singleton.contains_key("PackageLocale")
&& let Some(package_locale) = default_locale
.and_then(|document| json_string(document, "PackageLocale"))
.or_else(|| version.and_then(|document| json_string(document, "DefaultLocale")))
{
singleton.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
}
singleton.insert("ManifestType".to_owned(), JsonValue::String("singleton".to_owned()));
singleton.insert(
"ManifestVersion".to_owned(),
JsonValue::String(
installer
.and_then(|document| json_string(document, "ManifestVersion"))
.or_else(|| default_locale.and_then(|document| json_string(document, "ManifestVersion")))
.or_else(|| version.and_then(|document| json_string(document, "ManifestVersion")))
.unwrap_or_else(|| "1.10.0".to_owned()),
),
);
singleton.remove("DefaultLocale");
JsonValue::Object(singleton)
}
fn is_merged_manifest_document(document: &JsonValue) -> bool {
document
.get("ManifestType")
.and_then(JsonValue::as_str)
.is_some_and(|manifest_type| manifest_type.eq_ignore_ascii_case("merged"))
}
fn split_merged_manifest_document(merged: &JsonValue) -> Vec<JsonValue> {
let package_identifier = json_string(merged, "PackageIdentifier").unwrap_or_default();
let package_version = json_string(merged, "PackageVersion").unwrap_or_default();
let package_locale = json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
let manifest_version = json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned());
let version_document = serde_json::json!({
"PackageIdentifier": package_identifier,
"PackageVersion": package_version,
"DefaultLocale": package_locale,
"ManifestType": "version",
"ManifestVersion": manifest_version,
});
let mut default_locale_document = project_manifest_document(
merged,
&[
"PackageIdentifier",
"PackageVersion",
"PackageLocale",
"Publisher",
"PublisherUrl",
"PublisherSupportUrl",
"PrivacyUrl",
"Author",
"PackageName",
"PackageUrl",
"License",
"LicenseUrl",
"Copyright",
"CopyrightUrl",
"ShortDescription",
"Description",
"Moniker",
"Tags",
"Agreements",
"ReleaseNotes",
"ReleaseNotesUrl",
"PurchaseUrl",
"InstallationNotes",
"Documentations",
"Icons",
],
);
default_locale_document.insert(
"PackageIdentifier".to_owned(),
JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
);
default_locale_document.insert(
"PackageVersion".to_owned(),
JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
);
default_locale_document.insert(
"PackageLocale".to_owned(),
JsonValue::String(json_string(merged, "PackageLocale").unwrap_or_else(|| "en-US".to_owned())),
);
default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
default_locale_document.insert(
"ManifestVersion".to_owned(),
JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
);
let mut installer_document = project_manifest_document(
merged,
&[
"PackageIdentifier",
"PackageVersion",
"Channel",
"InstallerLocale",
"Platform",
"MinimumOSVersion",
"InstallerType",
"NestedInstallerType",
"NestedInstallerFiles",
"Scope",
"InstallModes",
"InstallerSwitches",
"InstallerSuccessCodes",
"ExpectedReturnCodes",
"UpgradeBehavior",
"Commands",
"Protocols",
"FileExtensions",
"Dependencies",
"PackageFamilyName",
"ProductCode",
"Capabilities",
"RestrictedCapabilities",
"Markets",
"InstallerAbortsTerminal",
"ReleaseDate",
"InstallLocationRequired",
"RequireExplicitUpgrade",
"DisplayInstallWarnings",
"UnsupportedOSArchitectures",
"UnsupportedArguments",
"AppsAndFeaturesEntries",
"ElevationRequirement",
"InstallationMetadata",
"DownloadCommandProhibited",
"RepairBehavior",
"ArchiveBinariesDependOnPath",
"Authentication",
"Installers",
],
);
installer_document.insert(
"PackageIdentifier".to_owned(),
JsonValue::String(json_string(merged, "PackageIdentifier").unwrap_or_default()),
);
installer_document.insert(
"PackageVersion".to_owned(),
JsonValue::String(json_string(merged, "PackageVersion").unwrap_or_default()),
);
installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
installer_document.insert(
"ManifestVersion".to_owned(),
JsonValue::String(json_string(merged, "ManifestVersion").unwrap_or_else(|| "1.10.0".to_owned())),
);
vec![
version_document,
JsonValue::Object(default_locale_document),
JsonValue::Object(installer_document),
]
}
fn project_manifest_document(source: &JsonValue, keys: &[&str]) -> JsonMap<String, JsonValue> {
let mut result = JsonMap::new();
if let Some(source) = source.as_object() {
for key in keys {
if let Some(value) = source.get(*key) {
result.insert((*key).to_owned(), value.clone());
}
}
}
result
}
fn copy_manifest_keys(target: &mut JsonMap<String, JsonValue>, source: Option<&JsonValue>, keys: &[&str]) {
if let Some(source) = source.and_then(JsonValue::as_object) {
for key in keys {
if let Some(value) = source.get(*key) {
target.insert((*key).to_owned(), value.clone());
}
}
}
}
fn copy_all_manifest_keys_except(
target: &mut JsonMap<String, JsonValue>,
source: Option<&JsonValue>,
excluded_keys: &[&str],
) {
if let Some(source) = source.and_then(JsonValue::as_object) {
for (key, value) in source {
if !excluded_keys.iter().any(|excluded| key.eq_ignore_ascii_case(excluded)) {
target.insert(key.clone(), value.clone());
}
}
}
}
fn build_rest_manifest_documents(
data: &JsonValue,
installer_source: &JsonValue,
default_locale: &JsonValue,
package_id: &str,
version: &str,
channel: &str,
) -> JsonValue {
let package_identifier = json_string(data, "PackageIdentifier").unwrap_or_else(|| package_id.to_owned());
let package_locale = json_string(default_locale, "PackageLocale").unwrap_or_else(|| "en-US".to_owned());
let manifest_version = json_string(installer_source, "ManifestVersion")
.or_else(|| json_string(default_locale, "ManifestVersion"))
.or_else(|| json_string(data, "ManifestVersion"))
.unwrap_or_else(|| "1.10.0".to_owned());
let mut version_document = JsonMap::new();
version_document.insert(
"PackageIdentifier".to_owned(),
JsonValue::String(package_identifier.clone()),
);
version_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
version_document.insert("DefaultLocale".to_owned(), JsonValue::String(package_locale.clone()));
version_document.insert("ManifestType".to_owned(), JsonValue::String("version".to_owned()));
version_document.insert(
"ManifestVersion".to_owned(),
JsonValue::String(manifest_version.clone()),
);
let mut default_locale_document = default_locale.as_object().cloned().unwrap_or_else(JsonMap::new);
default_locale_document.insert(
"PackageIdentifier".to_owned(),
JsonValue::String(package_identifier.clone()),
);
default_locale_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
default_locale_document.insert("PackageLocale".to_owned(), JsonValue::String(package_locale));
default_locale_document.insert("ManifestType".to_owned(), JsonValue::String("defaultLocale".to_owned()));
default_locale_document.insert(
"ManifestVersion".to_owned(),
JsonValue::String(manifest_version.clone()),
);
let mut installer_document = installer_source.as_object().cloned().unwrap_or_else(JsonMap::new);
installer_document.remove("DefaultLocale");
installer_document.remove("Locales");
installer_document.insert("PackageIdentifier".to_owned(), JsonValue::String(package_identifier));
installer_document.insert("PackageVersion".to_owned(), JsonValue::String(version.to_owned()));
if !channel.is_empty() {
installer_document.insert("Channel".to_owned(), JsonValue::String(channel.to_owned()));
}
installer_document.insert("ManifestType".to_owned(), JsonValue::String("installer".to_owned()));
installer_document.insert("ManifestVersion".to_owned(), JsonValue::String(manifest_version));
let documents = vec![
JsonValue::Object(version_document),
JsonValue::Object(default_locale_document),
JsonValue::Object(installer_document),
];
merge_manifest_documents(&documents)
}
fn parse_yaml_installers(root: &YamlMapping) -> Vec<Installer> {
let base = installer_defaults(root);
let base_switches = yaml_installer_switches(root);
if let Some(items) = root.get(YamlValue::from("Installers")).and_then(YamlValue::as_sequence) {
let installers = items
.iter()
.filter_map(YamlValue::as_mapping)
.map(|item| {
let mut merged = base.clone();
merge_yaml_mapping(&mut merged, item);
let switches = yaml_installer_switches(item).with_fallback(&base_switches);
installer_from_yaml(&merged, switches)
})
.collect::<Vec<_>>();
if !installers.is_empty() {
return installers;
}
}
if base.is_empty() {
Vec::new()
} else {
vec![installer_from_yaml(&base, base_switches)]
}
}
fn installer_defaults(root: &YamlMapping) -> YamlMapping {
let keys = [
"Architecture",
"InstallerType",
"NestedInstallerType",
"NestedInstallerFiles",
"InstallerUrl",
"InstallerSha256",
"ProductCode",
"InstallerLocale",
"Platform",
"MinimumOSVersion",
"Scope",
"ReleaseDate",
"PackageFamilyName",
"UpgradeCode",
"Commands",
];
let mut defaults = YamlMapping::new();
for key in keys {
if let Some(value) = root.get(YamlValue::from(key)) {
defaults.insert(YamlValue::from(key), value.clone());
}
}
defaults
}
fn installer_from_yaml(root: &YamlMapping, switches: InstallerSwitches) -> Installer {
Installer {
architecture: yaml_string(root, "Architecture"),
installer_type: yaml_string(root, "InstallerType"),
nested_installer_type: yaml_string(root, "NestedInstallerType"),
nested_installer_files: yaml_nested_installer_files(root),
url: yaml_string(root, "InstallerUrl"),
sha256: yaml_string(root, "InstallerSha256"),
product_code: yaml_string(root, "ProductCode"),
locale: yaml_string(root, "InstallerLocale"),
scope: yaml_string(root, "Scope"),
release_date: yaml_string(root, "ReleaseDate"),
package_family_name: yaml_string(root, "PackageFamilyName"),
upgrade_code: yaml_string(root, "UpgradeCode"),
platforms: yaml_string_list(root, "Platform"),
minimum_os_version: yaml_string(root, "MinimumOSVersion"),
switches,
commands: yaml_string_list(root, "Commands"),
package_dependencies: yaml_package_dependencies(root),
require_explicit_upgrade: yaml_scalar_bool(root, "RequireExplicitUpgrade"),
}
}
fn yaml_scalar_bool(root: &YamlMapping, key: &str) -> bool {
let Some(value) = root.get(YamlValue::from(key)) else {
return false;
};
match value {
YamlValue::Bool(b) => *b,
YamlValue::String(s) => s.eq_ignore_ascii_case("true"),
_ => false,
}
}
fn json_bool(value: &JsonValue, key: &str) -> bool {
match value.get(key) {
Some(JsonValue::Bool(b)) => *b,
Some(JsonValue::String(s)) => s.eq_ignore_ascii_case("true"),
_ => false,
}
}
fn yaml_installer_switches(root: &YamlMapping) -> InstallerSwitches {
let mapping = root
.get(YamlValue::from("InstallerSwitches"))
.or_else(|| root.get(YamlValue::from("Switches")))
.and_then(YamlValue::as_mapping);
yaml_installer_switches_from_mapping(mapping)
}
fn yaml_installer_switches_from_mapping(mapping: Option<&YamlMapping>) -> InstallerSwitches {
let string = |key: &str| mapping.and_then(|mapping| mapping.get(YamlValue::from(key)).and_then(yaml_scalar_string));
InstallerSwitches {
silent: string("Silent"),
silent_with_progress: string("SilentWithProgress"),
interactive: string("Interactive"),
custom: string("Custom"),
log: string("Log"),
install_location: string("InstallLocation"),
}
}
fn yaml_localized_string(root: &YamlMapping, key: &str) -> Option<String> {
yaml_string(root, key).or_else(|| {
root.get(YamlValue::from("DefaultLocale"))
.and_then(YamlValue::as_mapping)
.and_then(|mapping| yaml_string(mapping, key))
})
}
fn yaml_string_from_root(root: &YamlMapping, key: &str) -> Option<String> {
yaml_string(root, key)
}
fn yaml_string(root: &YamlMapping, key: &str) -> Option<String> {
root.get(YamlValue::from(key)).and_then(yaml_scalar_string)
}
fn yaml_scalar_string(value: &YamlValue) -> Option<String> {
match value {
YamlValue::Null => None,
YamlValue::Bool(value) => Some(value.to_string()),
YamlValue::Number(value) => Some(value.to_string()),
YamlValue::String(value) => Some(value.clone()),
YamlValue::Tagged(tagged) => yaml_scalar_string(&tagged.value),
YamlValue::Sequence(_) | YamlValue::Mapping(_) => None,
}
}
fn yaml_string_list(root: &YamlMapping, key: &str) -> Vec<String> {
root.get(YamlValue::from(key))
.and_then(YamlValue::as_sequence)
.map(|items| items.iter().filter_map(YamlValue::as_str).map(str::to_owned).collect())
.unwrap_or_default()
}
fn yaml_documentation_list(root: &YamlMapping) -> Vec<Documentation> {
root.get(YamlValue::from("Documentations"))
.and_then(YamlValue::as_sequence)
.map(|items| {
items
.iter()
.filter_map(YamlValue::as_mapping)
.filter_map(|item| {
let url = yaml_string(item, "DocumentUrl")?;
Some(Documentation {
label: yaml_string(item, "DocumentLabel"),
url,
})
})
.collect()
})
.unwrap_or_default()
}
fn yaml_agreement_list(root: &YamlMapping) -> Vec<PackageAgreement> {
root.get(YamlValue::from("Agreements"))
.and_then(YamlValue::as_sequence)
.map(|items| {
items
.iter()
.filter_map(YamlValue::as_mapping)
.map(|item| PackageAgreement {
label: yaml_string(item, "AgreementLabel"),
text: yaml_string(item, "Agreement"),
url: yaml_string(item, "AgreementUrl"),
})
.collect()
})
.unwrap_or_default()
}
fn yaml_package_dependencies(root: &YamlMapping) -> Vec<String> {
root.get(YamlValue::from("Dependencies"))
.and_then(YamlValue::as_mapping)
.and_then(|deps| deps.get(YamlValue::from("PackageDependencies")))
.and_then(YamlValue::as_sequence)
.map(|items| {
items
.iter()
.filter_map(YamlValue::as_mapping)
.filter_map(|item| yaml_string(item, "PackageIdentifier"))
.collect()
})
.unwrap_or_default()
}
fn yaml_nested_installer_files(root: &YamlMapping) -> Vec<NestedInstallerFile> {
root.get(YamlValue::from("NestedInstallerFiles"))
.and_then(YamlValue::as_sequence)
.map(|items| {
items
.iter()
.filter_map(YamlValue::as_mapping)
.filter_map(|item| {
let relative = yaml_string(item, "RelativeFilePath")?;
Some(NestedInstallerFile {
relative_file_path: relative,
portable_command_alias: yaml_string(item, "PortableCommandAlias"),
})
})
.collect()
})
.unwrap_or_default()
}
fn json_string(value: &JsonValue, key: &str) -> Option<String> {
value.get(key).and_then(JsonValue::as_str).map(str::to_owned)
}
fn json_string_list(value: &JsonValue, key: &str) -> Vec<String> {
value
.get(key)
.and_then(JsonValue::as_array)
.map(|items| items.iter().filter_map(JsonValue::as_str).map(str::to_owned).collect())
.unwrap_or_default()
}
fn json_documentation_list(value: &JsonValue) -> Vec<Documentation> {
value
.get("Documentations")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let url = json_string(item, "DocumentUrl")?;
Some(Documentation {
label: json_string(item, "DocumentLabel"),
url,
})
})
.collect()
})
.unwrap_or_default()
}
fn json_agreement_list(value: &JsonValue) -> Vec<PackageAgreement> {
value
.get("Agreements")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.map(|item| PackageAgreement {
label: json_string(item, "AgreementLabel"),
text: json_string(item, "Agreement"),
url: json_string(item, "AgreementUrl"),
})
.collect()
})
.unwrap_or_default()
}
fn json_package_dependencies(value: &JsonValue) -> Vec<String> {
value
.get("Dependencies")
.and_then(JsonValue::as_object)
.and_then(|deps| deps.get("PackageDependencies"))
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| json_string(item, "PackageIdentifier"))
.collect()
})
.unwrap_or_default()
}
fn json_nested_installer_files(value: &JsonValue) -> Vec<NestedInstallerFile> {
value
.get("NestedInstallerFiles")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let relative = json_string(item, "RelativeFilePath")?;
Some(NestedInstallerFile {
relative_file_path: relative,
portable_command_alias: json_string(item, "PortableCommandAlias"),
})
})
.collect()
})
.unwrap_or_default()
}
fn json_installer_switches(value: &JsonValue) -> InstallerSwitches {
let switches = value
.get("InstallerSwitches")
.or_else(|| value.get("Switches"))
.and_then(JsonValue::as_object);
let string = |key: &str| {
switches
.and_then(|switches| switches.get(key))
.and_then(JsonValue::as_str)
.map(str::to_owned)
};
InstallerSwitches {
silent: string("Silent"),
silent_with_progress: string("SilentWithProgress"),
interactive: string("Interactive"),
custom: string("Custom"),
log: string("Log"),
install_location: string("InstallLocation"),
}
}
fn merge_yaml_mapping(target: &mut YamlMapping, source: &YamlMapping) {
for (key, value) in source {
target.insert(key.clone(), value.clone());
}
}
fn extract_zip_member(bytes: &[u8], member_name: &str) -> Result<Vec<u8>> {
let reader = Cursor::new(bytes.to_vec());
let mut archive = ZipArchive::new(reader).context("failed to read zip payload")?;
let mut file = archive
.by_name(member_name)
.with_context(|| format!("zip payload missing {member_name}"))?;
let mut output = Vec::new();
file.read_to_end(&mut output)
.with_context(|| format!("failed to read {member_name} from zip payload"))?;
Ok(output)
}
fn decompress_mszyml(bytes: &[u8]) -> Result<String> {
if let Ok(output) = decompress_mszyml_payload(if bytes.starts_with(b"CK") { &bytes[2..] } else { bytes }) {
return Ok(output);
}
if !bytes.starts_with(b"CK")
&& let Some(offset) = bytes.windows(2).position(|window| window == b"CK")
{
return decompress_mszyml_payload(&bytes[offset + 2..]);
}
decompress_mszyml_payload(bytes).context("failed to decompress MSZIP payload")
}
fn decompress_mszyml_payload(payload: &[u8]) -> Result<String> {
let mut decoder = flate2::read::DeflateDecoder::new(payload);
let mut output = String::new();
decoder.read_to_string(&mut output)?;
Ok(output)
}
fn cache_control_max_age(response: &Response) -> u64 {
response
.headers()
.get(reqwest::header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.and_then(|header| {
header.split(',').find_map(|part| {
let trimmed = part.trim();
trimmed
.strip_prefix("max-age=")
.and_then(|value| value.parse::<u64>().ok())
})
})
.unwrap_or(60)
}
fn choose_contract(server_versions: &[String]) -> Option<&'static str> {
REST_SUPPORTED_CONTRACTS.iter().copied().find(|candidate| {
let (major, minor) = major_minor(candidate);
server_versions.iter().any(|version| {
let (server_major, server_minor) = major_minor(version);
server_major == major && server_minor == minor
})
})
}
fn major_minor(version: &str) -> (String, String) {
let mut parts = version.split('.');
(
parts.next().unwrap_or_default().to_owned(),
parts.next().unwrap_or_default().to_owned(),
)
}
fn default_market() -> String {
std::env::var("WINGET_RS_MARKET").unwrap_or_else(|_| DEFAULT_MARKET.to_owned())
}
fn select_installer(installers: &[Installer], query: &PackageQuery) -> Option<Installer> {
let requested_locale = query.locale.as_deref();
let requested_architecture = query.installer_architecture.as_deref();
let requested_type = query.installer_type.as_deref();
let requested_scope = query.install_scope.as_deref();
let requested_platform = query.platform.as_deref();
let requested_os_version = query.os_version.as_deref();
let system_architecture = current_architecture();
let system_platform = current_platform();
let current_os_version = current_os_version();
installers
.iter()
.enumerate()
.filter(|(_, installer)| {
installer_matches_requested(
installer,
requested_type,
requested_scope,
requested_platform,
requested_os_version,
system_platform,
current_os_version.as_deref(),
)
})
.filter(|(_, installer)| installer_matches_architecture(installer, requested_architecture, system_architecture))
.max_by_key(|(index, installer)| {
(
installer_rank(installer, requested_locale, requested_architecture, system_architecture),
std::cmp::Reverse(*index),
)
})
.map(|(_, installer)| installer)
.cloned()
}
fn installer_matches_requested(
installer: &Installer,
requested_type: Option<&str>,
requested_scope: Option<&str>,
requested_platform: Option<&str>,
requested_os_version: Option<&str>,
system_platform: Option<&str>,
current_os_version: Option<&str>,
) -> bool {
matches_optional_ci(installer.installer_type.as_deref(), requested_type)
&& matches_optional_ci(installer.scope.as_deref(), requested_scope)
&& installer_matches_platform(installer, requested_platform, system_platform)
&& installer_matches_os_version(installer, requested_os_version, current_os_version)
}
fn installer_matches_platform(
installer: &Installer,
requested_platform: Option<&str>,
system_platform: Option<&str>,
) -> bool {
if installer.platforms.is_empty() {
return true;
}
let Some(effective_platform) = requested_platform.or(system_platform) else {
return true;
};
installer
.platforms
.iter()
.any(|platform| platform.eq_ignore_ascii_case(effective_platform))
}
fn installer_matches_os_version(
installer: &Installer,
requested_os_version: Option<&str>,
current_os_version: Option<&str>,
) -> bool {
let Some(minimum_os_version) = installer.minimum_os_version.as_deref() else {
return true;
};
let Some(actual_os_version) = requested_os_version.or(current_os_version) else {
return true;
};
match (
parse_dotted_version(minimum_os_version),
parse_dotted_version(actual_os_version),
) {
(Some(minimum), Some(actual)) => actual.cmp(&minimum) != Ordering::Less,
_ => minimum_os_version.eq_ignore_ascii_case(actual_os_version),
}
}
fn installer_matches_architecture(
installer: &Installer,
requested_architecture: Option<&str>,
system_architecture: &str,
) -> bool {
let Some(architecture) = installer.architecture.as_deref() else {
return true;
};
let architecture = architecture.to_ascii_lowercase();
if let Some(requested) = requested_architecture {
return architecture.eq_ignore_ascii_case(requested);
}
preferred_architectures(system_architecture)
.iter()
.any(|candidate| architecture.eq_ignore_ascii_case(candidate))
}
fn installer_rank(
installer: &Installer,
requested_locale: Option<&str>,
requested_architecture: Option<&str>,
system_architecture: &str,
) -> (i32, i32, i32) {
let architecture_rank = architecture_rank(
installer.architecture.as_deref(),
requested_architecture.unwrap_or(system_architecture),
requested_architecture.is_some(),
system_architecture,
);
let locale_rank = locale_rank(installer.locale.as_deref(), requested_locale);
let command_rank = if installer.commands.is_empty() { 0 } else { 1 };
(architecture_rank, locale_rank, command_rank)
}
fn architecture_rank(
installer_architecture: Option<&str>,
preferred_architecture: &str,
strict: bool,
system_architecture: &str,
) -> i32 {
let Some(value) = installer_architecture else {
return 0;
};
if value.eq_ignore_ascii_case(preferred_architecture) {
return 5;
}
if value.eq_ignore_ascii_case("neutral") {
return 4;
}
if strict {
return -1;
}
preferred_architectures(system_architecture)
.iter()
.rev()
.position(|candidate| value.eq_ignore_ascii_case(candidate))
.and_then(|index| i32::try_from(index).ok())
.and_then(|index| index.checked_add(1))
.unwrap_or(-1)
}
fn manifest_has_compatible_installer(manifest: &Manifest) -> bool {
if manifest.installers.is_empty() {
return true;
}
let allowed = preferred_architectures(current_architecture());
manifest.installers.iter().any(|installer| {
let arch = installer.architecture.as_deref().unwrap_or("neutral");
allowed.iter().any(|candidate| candidate.eq_ignore_ascii_case(arch))
})
}
fn preferred_architectures(system_architecture: &str) -> &'static [&'static str] {
match system_architecture {
"arm64" => &["arm64", "neutral", "x64", "x86"],
"x64" => &["x64", "neutral", "x86"],
"x86" => &["x86", "neutral"],
_ => &["neutral"],
}
}
fn locale_rank(installer_locale: Option<&str>, requested_locale: Option<&str>) -> i32 {
match (installer_locale, requested_locale) {
(Some(installer), Some(requested)) if installer.eq_ignore_ascii_case(requested) => 3,
(Some(installer), Some(requested))
if installer
.split('-')
.next()
.zip(requested.split('-').next())
.is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) =>
{
2
}
(None, Some(_)) => 1,
(Some(_), None) => 1,
(None, None) => 0,
_ => -1,
}
}
fn matches_optional_ci(value: Option<&str>, requested: Option<&str>) -> bool {
match requested {
Some(requested) => value.is_some_and(|value| value.eq_ignore_ascii_case(requested)),
None => true,
}
}
#[cfg(windows)]
fn current_architecture() -> &'static str {
use std::sync::OnceLock;
static CELL: OnceLock<&'static str> = OnceLock::new();
CELL.get_or_init(|| {
use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2};
let mut process_machine: u16 = 0;
let mut native_machine: u16 = 0;
let handle = unsafe { GetCurrentProcess() };
let ok = unsafe { IsWow64Process2(handle, &mut process_machine, &mut native_machine) };
if ok != 0 {
match native_machine {
0xAA64 => return "arm64",
0x8664 => return "x64",
0x014C => return "x86",
_ => {}
}
}
compiled_architecture()
})
}
#[cfg(not(windows))]
fn current_architecture() -> &'static str {
compiled_architecture()
}
fn compiled_architecture() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "x64",
"x86" => "x86",
"aarch64" => "arm64",
_ => "neutral",
}
}
fn current_platform() -> Option<&'static str> {
if cfg!(windows) { Some("Windows.Desktop") } else { None }
}
#[cfg(windows)]
fn current_os_version() -> Option<String> {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm.open_subkey(r"SOFTWARE\Microsoft\Windows NT\CurrentVersion").ok()?;
let build: String = key.get_value("CurrentBuildNumber").ok()?;
let ubr: u32 = key.get_value("UBR").unwrap_or(0);
let major: u32 = key.get_value("CurrentMajorVersionNumber").unwrap_or(10);
let minor: u32 = key.get_value("CurrentMinorVersionNumber").unwrap_or(0);
Some(format!("{major}.{minor}.{build}.{ubr}"))
}
#[cfg(not(windows))]
fn current_os_version() -> Option<String> {
None
}
fn parse_dotted_version(version: &str) -> Option<Vec<u64>> {
let mut parsed = Vec::new();
for component in version.split('.') {
if component.is_empty() {
continue;
}
parsed.push(component.parse().ok()?);
}
if parsed.is_empty() { None } else { Some(parsed) }
}
fn select_v1_version(
versions: &[V1VersionRow],
requested_version: Option<&str>,
requested_channel: Option<&str>,
package_id: &str,
source_name: &str,
) -> Result<V1VersionRow> {
let mut ordered = versions.to_vec();
ordered.sort_by(|left, right| {
compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
});
if let Some(version) = requested_version {
let channel = requested_channel.unwrap_or_default();
return ordered
.iter()
.find(|candidate| {
candidate.version.eq_ignore_ascii_case(version)
&& (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
})
.cloned()
.ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
}
if let Some(channel) = requested_channel {
return ordered
.iter()
.find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
.cloned()
.ok_or_else(|| {
anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
});
}
ordered
.into_iter()
.next()
.ok_or_else(|| anyhow!("package had no versions"))
}
fn select_v2_version(
versions: &[PackageVersionDataEntry],
requested_version: Option<&str>,
requested_channel: Option<&str>,
package_id: &str,
source_name: &str,
) -> Result<PackageVersionDataEntry> {
let mut ordered = versions.to_vec();
ordered.sort_by(|left, right| compare_version(&right.version, &left.version));
if let Some(channel) = requested_channel {
bail!(
"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."
);
}
if let Some(version) = requested_version {
return ordered
.iter()
.find(|candidate| candidate.version.eq_ignore_ascii_case(version))
.cloned()
.ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
}
ordered
.into_iter()
.next()
.ok_or_else(|| anyhow!("package had no V2 versions"))
}
fn missing_package_version(
package_id: &str,
source_name: &str,
requested_version: &str,
requested_channel: Option<&str>,
) -> anyhow::Error {
anyhow!(MissingPackageVersionError {
package_id: package_id.to_owned(),
source_name: source_name.to_owned(),
requested_version: requested_version.to_owned(),
requested_channel: requested_channel.map(str::to_owned),
})
}
fn missing_error_after_open_refresh<T>(error: anyhow::Error, refresh: &PreindexedRefreshOutcome) -> Result<T> {
if let Some(refresh_error) = refresh.error.as_deref() {
let missing_error = error.to_string();
return Err(error).with_context(|| format!("{missing_error} The source refresh also failed: {refresh_error}"));
}
Err(error)
}
fn select_rest_version<'a>(
versions: &'a [VersionKey],
requested_version: Option<&str>,
requested_channel: Option<&str>,
package_id: &str,
source_name: &str,
) -> Result<&'a VersionKey> {
if let Some(version) = requested_version {
let channel = requested_channel.unwrap_or_default();
return versions
.iter()
.find(|candidate| {
candidate.version.eq_ignore_ascii_case(version)
&& (channel.is_empty() || candidate.channel.eq_ignore_ascii_case(channel))
})
.ok_or_else(|| missing_package_version(package_id, source_name, version, requested_channel));
}
if let Some(channel) = requested_channel {
return versions
.iter()
.find(|candidate| candidate.channel.eq_ignore_ascii_case(channel))
.ok_or_else(|| {
anyhow!("Channel '{channel}' was not found for package '{package_id}' in source '{source_name}'.")
});
}
versions.first().ok_or_else(|| anyhow!("package had no REST versions"))
}
fn sort_versions_desc(versions: &mut [VersionKey]) {
versions.sort_by(|left, right| {
compare_version_and_channel(&right.version, &right.channel, &left.version, &left.channel)
});
}
fn compare_version_and_channel(
left_version: &str,
left_channel: &str,
right_version: &str,
right_channel: &str,
) -> Ordering {
compare_version(left_version, right_version).then_with(|| {
left_channel
.to_ascii_lowercase()
.cmp(&right_channel.to_ascii_lowercase())
})
}
fn compare_version(left: &str, right: &str) -> Ordering {
let left_parts = tokenize_version(left);
let right_parts = tokenize_version(right);
let max_len = left_parts.len().max(right_parts.len());
for index in 0..max_len {
let left_part = left_parts.get(index).map(String::as_str).unwrap_or("0");
let right_part = right_parts.get(index).map(String::as_str).unwrap_or("0");
let numeric = left_part.parse::<u64>().ok().zip(right_part.parse::<u64>().ok());
let ordering = if let Some((left_number, right_number)) = numeric {
left_number.cmp(&right_number)
} else {
left_part.to_ascii_lowercase().cmp(&right_part.to_ascii_lowercase())
};
if ordering != Ordering::Equal {
return ordering;
}
}
Ordering::Equal
}
fn tokenize_version(value: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut current_is_digit = None;
for ch in value.chars() {
if ch == '.' || ch == '-' || ch == '_' || ch == '+' {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
current_is_digit = None;
continue;
}
let is_digit = ch.is_ascii_digit();
match current_is_digit {
Some(previous) if previous != is_digit => {
parts.push(std::mem::take(&mut current));
current.push(ch);
current_is_digit = Some(is_digit);
}
Some(_) => current.push(ch),
None => {
current.push(ch);
current_is_digit = Some(is_digit);
}
}
}
if !current.is_empty() {
parts.push(current);
}
parts
}
fn verify_hash(expected_hash: &str, bytes: &[u8]) -> Result<()> {
if !hash_matches(Some(expected_hash), bytes) {
bail!("downloaded file hash did not match expected SHA256");
}
Ok(())
}
fn verify_installer_hash(expected_hash: Option<&str>, bytes: &[u8], ignore_security_hash: bool) -> Result<()> {
let Some(expected_hash) = expected_hash else {
return Ok(());
};
if ignore_security_hash {
return Ok(());
}
if !hash_matches(Some(expected_hash), bytes) {
let actual = sha256_hex(bytes);
bail!("Installer hash mismatch. Expected: {expected_hash}, Got: {actual}");
}
Ok(())
}
fn hash_matches(expected_hash: Option<&str>, bytes: &[u8]) -> bool {
expected_hash
.map(|expected| sha256_hex(bytes).eq_ignore_ascii_case(expected))
.unwrap_or(true)
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(digest.len() * 2);
for byte in digest {
output.push_str(&format!("{byte:02X}"));
}
output
}
fn pins_db_path(app_root: &Path) -> PathBuf {
if uses_packaged_layout(app_root) {
app_root.join("pinning.db")
} else {
app_root.join("pins.db")
}
}
fn resolve_pin_type_column(conn: &Connection) -> Result<Option<&'static str>> {
let mut has_current_column = false;
let mut has_legacy_column = false;
for column_name in query_rows(conn, "PRAGMA table_info(pin)", Vec::new(), |row| row_string(row, 1))? {
match column_name.as_str() {
"type" => has_current_column = true,
"pin_type" => has_legacy_column = true,
_ => {}
}
}
Ok(if has_current_column {
Some("type")
} else if has_legacy_column {
Some("pin_type")
} else {
None
})
}
fn decode_pin_type(pin_type_int: i64) -> PinType {
match pin_type_int {
4 => PinType::Blocking,
3 => PinType::Gating,
_ => PinType::Pinning,
}
}
#[cfg(windows)]
fn dispatch_installer(
installer_path: &Path,
installer_type: &str,
request: &InstallRequest,
manifest: &Manifest,
installer: &Installer,
) -> Result<i32> {
use std::process::Command;
match installer_type {
"msi" | "wix" => {
let mut cmd = Command::new("msiexec");
cmd.args(installer_command_arguments(
installer_type,
request,
manifest,
installer_path,
installer,
));
let status = cmd.status().context("failed to run msiexec")?;
Ok(status.code().unwrap_or(-1))
}
"msix" | "appx" => {
let mut cmd = Command::new("powershell");
cmd.arg("-NoProfile")
.arg("-Command")
.arg(format!("Add-AppxPackage -Path '{}'", installer_path.display()));
let status = cmd.status().context("failed to run Add-AppxPackage")?;
Ok(status.code().unwrap_or(-1))
}
"zip" => {
if installer
.nested_installer_type
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case("portable"))
{
return install_portable(installer_path, request, manifest, installer);
}
let target = dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Programs");
fs::create_dir_all(&target)?;
let file = fs::File::open(installer_path)?;
let mut archive = ZipArchive::new(file)?;
archive.extract(&target)?;
Ok(0)
}
"portable" => install_portable(installer_path, request, manifest, installer),
_ => {
let mut cmd = Command::new(installer_path);
cmd.args(installer_command_arguments(
installer_type,
request,
manifest,
installer_path,
installer,
));
let status = cmd.status().context("failed to run installer")?;
Ok(status.code().unwrap_or(-1))
}
}
}
#[cfg(not(windows))]
fn dispatch_installer(
_installer_path: &Path,
_installer_type: &str,
_request: &InstallRequest,
_manifest: &Manifest,
_installer: &Installer,
) -> Result<i32> {
bail!("Installing packages is only supported on Windows")
}
#[cfg(any(windows, test))]
fn installer_command_arguments(
installer_type: &str,
request: &InstallRequest,
_manifest: &Manifest,
installer_path: &Path,
installer: &Installer,
) -> Vec<String> {
if let Some(override_args) = request.override_args.as_deref() {
return split_installer_switches(override_args);
}
let mut args = Vec::new();
match installer_type {
"msi" | "wix" => {
args.push("/i".to_owned());
args.push(installer_path.display().to_string());
}
"msix" | "appx" | "zip" => return Vec::new(),
_ => {}
}
let configured = match request.mode {
InstallerMode::Interactive => installer.switches.interactive.as_deref(),
InstallerMode::SilentWithProgress => installer
.switches
.silent_with_progress
.as_deref()
.or(installer.switches.silent.as_deref()),
InstallerMode::Silent => installer
.switches
.silent
.as_deref()
.or(installer.switches.silent_with_progress.as_deref()),
};
if let Some(value) = configured.filter(|value| !value.trim().is_empty()) {
args.extend(split_installer_switches(value));
} else {
args.extend(default_installer_arguments(installer_type, request.mode));
}
append_switches(
&mut args,
resolve_template_switch(
installer.switches.log.as_deref(),
default_log_switch(installer_type),
request.log_path.as_ref().map(|path| path.display().to_string()),
"<LOGPATH>",
),
);
append_switches(&mut args, installer.switches.custom.clone());
append_switches(&mut args, request.custom.clone());
append_switches(
&mut args,
resolve_template_switch(
installer.switches.install_location.as_deref(),
default_install_location_switch(installer_type),
request.install_location.clone(),
"<INSTALLPATH>",
),
);
args
}
#[cfg(any(windows, test))]
fn default_installer_arguments(installer_type: &str, mode: InstallerMode) -> Vec<String> {
match mode {
InstallerMode::Interactive => Vec::new(),
InstallerMode::SilentWithProgress => match installer_type {
"inno" => vec![
"/SP-".to_owned(),
"/SILENT".to_owned(),
"/SUPPRESSMSGBOXES".to_owned(),
"/NORESTART".to_owned(),
],
"burn" | "wix" | "msi" => vec!["/passive".to_owned(), "/norestart".to_owned()],
"nullsoft" | "nsis" => vec!["/S".to_owned()],
_ => vec!["/SILENT".to_owned()],
},
InstallerMode::Silent => match installer_type {
"inno" => vec![
"/SP-".to_owned(),
"/VERYSILENT".to_owned(),
"/SUPPRESSMSGBOXES".to_owned(),
"/NORESTART".to_owned(),
],
"burn" | "wix" | "msi" => vec!["/quiet".to_owned(), "/norestart".to_owned()],
"nullsoft" | "nsis" => vec!["/S".to_owned()],
_ => vec!["/S".to_owned()],
},
}
}
#[cfg(any(windows, test))]
fn default_log_switch(installer_type: &str) -> Option<&'static str> {
match installer_type {
"burn" | "wix" | "msi" => Some("/log \"<LOGPATH>\""),
"inno" => Some("/LOG=\"<LOGPATH>\""),
_ => None,
}
}
#[cfg(any(windows, test))]
fn default_install_location_switch(installer_type: &str) -> Option<&'static str> {
match installer_type {
"burn" | "wix" | "msi" => Some("TARGETDIR=\"<INSTALLPATH>\""),
"nullsoft" | "nsis" => Some("/D=<INSTALLPATH>"),
"inno" => Some("/DIR=\"<INSTALLPATH>\""),
_ => None,
}
}
#[cfg(any(windows, test))]
fn resolve_template_switch(
manifest_value: Option<&str>,
fallback: Option<&str>,
replacement: Option<String>,
token: &str,
) -> Option<String> {
let template = manifest_value.filter(|value| !value.trim().is_empty()).or(fallback)?;
let replacement = replacement?;
Some(template.replace(token, &replacement))
}
#[cfg(any(windows, test))]
fn append_switches(args: &mut Vec<String>, value: Option<String>) {
if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
args.extend(split_installer_switches(&value));
}
}
#[cfg(any(windows, test))]
fn split_installer_switches(value: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in value.chars() {
match ch {
'"' => in_quotes = !in_quotes,
c if c.is_whitespace() && !in_quotes => {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
args.push(current);
}
args
}
#[cfg(windows)]
fn uninstall_package(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
if request.purge && request.preserve {
bail!("--purge and --preserve cannot be used together.");
}
if installed
.installer_category
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case("portable"))
{
return uninstall_portable(installed, request);
}
if (request.purge || request.preserve) && !request.force {
bail!("--purge and --preserve are currently only supported for portable packages.");
}
if let Some(exit_code) = try_uninstall_arp(installed, request)? {
return Ok(exit_code);
}
if let Some(exit_code) = try_uninstall_msix(installed)? {
return Ok(exit_code);
}
bail!(
"No uninstall command found for installed package '{}' ({})",
installed.name,
installed.local_id
);
}
#[cfg(windows)]
fn try_uninstall_arp(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
use std::os::windows::process::CommandExt;
use std::process::Command;
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let arp_paths = [
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
];
for root in [&hklm, &hkcu] {
for arp_path in &arp_paths {
if let Ok(key) = root.open_subkey(arp_path) {
for subkey_name in key.enum_keys().filter_map(|k| k.ok()) {
if let Ok(subkey) = key.open_subkey(&subkey_name) {
let display_name: String = subkey.get_value("DisplayName").unwrap_or_default();
let product_code = subkey.get_value::<String, _>("ProductCode").ok();
if !registry_entry_matches_installed_package(
&subkey_name,
&display_name,
product_code.as_deref(),
installed,
) {
continue;
}
if let Some(exit_code) =
try_run_msi_uninstall(installed, &subkey_name, product_code.as_deref(), request)?
{
return Ok(Some(exit_code));
}
let quiet_uninstall_cmd = subkey.get_value::<String, _>("QuietUninstallString").ok();
let uninstall_cmd: String = if request.mode == InstallerMode::Interactive {
subkey.get_value("UninstallString").ok()
} else {
quiet_uninstall_cmd
.clone()
.or_else(|| subkey.get_value("UninstallString").ok())
}
.context("No uninstall command found in registry")?;
if is_winget_uninstall_command(&uninstall_cmd)
&& let Some(exit_code) = try_uninstall_via_winget(installed, request)?
{
return Ok(Some(exit_code));
}
let log_path = request.log_path.as_ref().map(|value| value.display().to_string());
let uninstall_line = build_uninstall_command_with_mode(
&uninstall_cmd,
request.mode,
quiet_uninstall_cmd.is_some(),
log_path.as_deref(),
);
let mut cmd = Command::new("cmd");
cmd.raw_arg("/S /C");
cmd.raw_arg(format!("\"{uninstall_line}\""));
let status = cmd.status().context("failed to run uninstaller")?;
return Ok(Some(status.code().unwrap_or(-1)));
}
}
}
}
}
Ok(None)
}
#[cfg(windows)]
fn try_uninstall_msix(installed: &ListMatch) -> Result<Option<i32>> {
use std::process::Command;
if !installed.local_id.starts_with("MSIX\\") && installed.package_family_names.is_empty() {
return Ok(None);
}
let package_full_name = installed.local_id.strip_prefix("MSIX\\");
let mut cmd = Command::new("powershell");
cmd.arg("-NoProfile").arg("-Command").arg(build_msix_uninstall_script(
package_full_name,
&installed.package_family_names,
));
let status = cmd.status().context("failed to run Remove-AppxPackage")?;
Ok(Some(status.code().unwrap_or(-1)))
}
#[cfg(windows)]
fn arp_subkey_name(local_id: &str) -> Option<&str> {
local_id.strip_prefix("ARP\\")?.splitn(3, '\\').nth(2)
}
#[cfg(windows)]
fn registry_entry_matches_installed_package(
subkey_name: &str,
display_name: &str,
product_code: Option<&str>,
installed: &ListMatch,
) -> bool {
if let Some(arp_subkey_name) = arp_subkey_name(&installed.local_id)
&& subkey_name.eq_ignore_ascii_case(arp_subkey_name)
{
return true;
}
if installed.product_codes.iter().any(|code| {
code.eq_ignore_ascii_case(subkey_name)
|| product_code
.map(|value| code.eq_ignore_ascii_case(value))
.unwrap_or(false)
}) {
return true;
}
display_name.eq_ignore_ascii_case(&installed.name)
}
#[cfg(all(windows, test))]
fn build_uninstall_command(uninstall_cmd: &str, silent: bool, has_quiet_command: bool) -> String {
build_uninstall_command_with_mode(
uninstall_cmd,
if silent {
InstallerMode::Silent
} else {
InstallerMode::Interactive
},
has_quiet_command,
None,
)
}
#[cfg(windows)]
fn build_uninstall_command_with_mode(
uninstall_cmd: &str,
mode: InstallerMode,
has_quiet_command: bool,
log_path: Option<&str>,
) -> String {
let uninstall_cmd = log_path
.map(|value| uninstall_cmd.replace("<LOGPATH>", value))
.unwrap_or_else(|| uninstall_cmd.to_owned());
if mode == InstallerMode::Interactive || has_quiet_command {
return uninstall_cmd;
}
let lower = uninstall_cmd.to_ascii_lowercase();
if is_winget_uninstall_command_lower(&lower) {
return uninstall_cmd;
}
if lower.contains("/quiet")
|| lower.contains("/passive")
|| lower.contains("/verysilent")
|| lower.contains("/silent")
|| lower.contains(" /s")
{
return uninstall_cmd;
}
format!("{uninstall_cmd} /S")
}
#[cfg(windows)]
fn try_uninstall_via_winget(installed: &ListMatch, request: &UninstallRequest) -> Result<Option<i32>> {
let Some(args) = build_winget_uninstall_arguments(installed, request) else {
return Ok(None);
};
let exit_code = run_winget_uninstall(&args)?;
if exit_code == WINGET_PACKAGE_NOT_FOUND_EXIT_CODE && request.query.install_scope.is_some() {
let Some(args_without_scope) = build_winget_uninstall_arguments_with_scope(installed, request, false) else {
return Ok(Some(exit_code));
};
return Ok(Some(run_winget_uninstall(&args_without_scope)?));
}
Ok(Some(exit_code))
}
#[cfg(windows)]
fn run_winget_uninstall(args: &[String]) -> Result<i32> {
use std::process::Command;
let status = Command::new("winget")
.args(args)
.status()
.context("failed to run delegated winget uninstall")?;
Ok(status.code().unwrap_or(-1))
}
#[cfg(windows)]
fn build_winget_uninstall_arguments(installed: &ListMatch, request: &UninstallRequest) -> Option<Vec<String>> {
build_winget_uninstall_arguments_with_scope(installed, request, true)
}
#[cfg(windows)]
fn build_winget_uninstall_arguments_with_scope(
installed: &ListMatch,
request: &UninstallRequest,
include_scope: bool,
) -> Option<Vec<String>> {
if installed.id.eq_ignore_ascii_case(&installed.local_id) {
return None;
}
let mut args = vec![
"uninstall".to_owned(),
"--id".to_owned(),
installed.id.clone(),
"--exact".to_owned(),
"--disable-interactivity".to_owned(),
];
if let Some(source_name) = installed.source_name.as_deref().filter(|value| !value.is_empty()) {
args.push("--source".to_owned());
args.push(source_name.to_owned());
}
if include_scope && let Some(scope) = request.query.install_scope.as_deref().filter(|value| !value.is_empty()) {
args.push("--scope".to_owned());
args.push(scope.to_owned());
}
if request.mode == InstallerMode::Silent {
args.push("--silent".to_owned());
}
Some(args)
}
#[cfg(windows)]
fn is_winget_uninstall_command(command: &str) -> bool {
is_winget_uninstall_command_lower(&command.to_ascii_lowercase())
}
#[cfg(windows)]
fn is_winget_uninstall_command_lower(command: &str) -> bool {
command.contains("winget uninstall") || command.contains("winget.exe uninstall")
}
#[cfg(windows)]
fn build_msix_uninstall_script(package_full_name: Option<&str>, package_family_names: &[String]) -> String {
let full_name_literal = package_full_name
.map(|value| format!("'{}'", value.replace('\'', "''")))
.unwrap_or_else(|| "$null".to_owned());
let family_names_literal = if package_family_names.is_empty() {
"@()".to_owned()
} else {
format!(
"@({})",
package_family_names
.iter()
.map(|value| format!("'{}'", value.replace('\'', "''")))
.collect::<Vec<_>>()
.join(", ")
)
};
format!(
"$fullName = {full_name_literal}; \
$familyNames = {family_names_literal}; \
$targets = Get-AppxPackage | Where-Object {{ \
(($fullName -ne $null) -and $_.PackageFullName -eq $fullName) -or \
($familyNames.Count -gt 0 -and ($familyNames -contains $_.PackageFamilyName)) }}; \
if (-not $targets) {{ exit 1 }}; \
$targets | Remove-AppxPackage"
)
}
#[cfg(windows)]
fn try_run_msi_uninstall(
installed: &ListMatch,
subkey_name: &str,
product_code: Option<&str>,
request: &UninstallRequest,
) -> Result<Option<i32>> {
use std::process::Command;
let uninstall_code = installed
.product_codes
.iter()
.map(String::as_str)
.chain(product_code)
.chain(Some(subkey_name))
.find(|value| is_product_code_like(value));
let Some(uninstall_code) = uninstall_code else {
return Ok(None);
};
let mut cmd = Command::new("msiexec");
cmd.arg("/x").arg(uninstall_code);
match request.mode {
InstallerMode::Interactive => {}
InstallerMode::SilentWithProgress => {
cmd.arg("/passive").arg("/norestart");
}
InstallerMode::Silent => {
cmd.arg("/quiet").arg("/norestart");
}
}
if let Some(log_path) = &request.log_path {
cmd.arg("/log").arg(log_path);
}
let status = cmd.status().context("failed to run msiexec uninstall")?;
Ok(Some(status.code().unwrap_or(-1)))
}
#[cfg(windows)]
fn uninstall_portable(installed: &ListMatch, request: &UninstallRequest) -> Result<i32> {
let known_symlink = read_portable_symlink_full_path(installed);
let Some(location) = installed.install_location.as_deref() else {
if request.force {
try_remove_portable_symlinks(installed, &known_symlink, None);
try_remove_portable_registry_entry(installed);
return Ok(0);
}
bail!(
"Portable package '{}' does not expose an install location.",
installed.name
);
};
if request.preserve {
return Ok(0);
}
let path = Path::new(location);
let removed = if path.is_dir() {
fs::remove_dir_all(path)?;
true
} else if path.is_file() {
fs::remove_file(path)?;
true
} else {
false
};
if removed || request.force {
try_remove_portable_symlinks(installed, &known_symlink, Some(path));
try_remove_portable_registry_entry(installed);
return Ok(0);
}
bail!("Portable package location not found: {location}")
}
#[cfg(windows)]
fn read_portable_symlink_full_path(installed: &ListMatch) -> Option<String> {
const ARP_PREFIX: &str = r"ARP\";
if let Some(rest) = installed.local_id.strip_prefix(ARP_PREFIX) {
let mut parts = rest.splitn(3, '\\');
let scope = parts.next().unwrap_or("");
let _arch = parts.next();
let subkey_name = parts.next().unwrap_or("");
if subkey_name.is_empty() {
return None;
}
let hive = if scope.eq_ignore_ascii_case("User") {
RegKey::predef(HKEY_CURRENT_USER)
} else {
RegKey::predef(HKEY_LOCAL_MACHINE)
};
let subkey = hive
.open_subkey(format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"
))
.ok()?;
return read_reg_string(&subkey, "PortableSymlinkFullPath");
}
None
}
#[cfg(windows)]
fn try_remove_portable_symlinks(
_installed: &ListMatch,
known_symlink: &Option<String>,
install_location: Option<&Path>,
) {
if let Some(path) = known_symlink.as_deref() {
let _ = fs::remove_file(path);
}
let Some(install_dir) = install_location else { return };
let Some(local) = dirs::data_local_dir() else { return };
let links_root = local.join("Microsoft").join("WinGet").join("Links");
let Ok(entries) = fs::read_dir(&links_root) else { return };
for entry in entries.flatten() {
let path = entry.path();
let Ok(target) = fs::read_link(&path) else { continue };
if path_starts_with_case_insensitive(&target, install_dir) {
let _ = fs::remove_file(&path);
}
}
}
#[cfg(windows)]
fn path_starts_with_case_insensitive(child: &Path, parent: &Path) -> bool {
let lower_components = |p: &Path| -> Vec<String> {
p.components()
.map(|c| c.as_os_str().to_string_lossy().to_ascii_lowercase())
.collect()
};
let parent_components = lower_components(parent);
let child_components = lower_components(child);
if parent_components.is_empty() || parent_components.len() > child_components.len() {
return false;
}
parent_components
.iter()
.zip(child_components.iter())
.all(|(a, b)| a == b)
}
#[cfg(windows)]
fn try_remove_portable_registry_entry(installed: &ListMatch) {
let candidates: Vec<(bool, String)> = if let Some(rest) = installed.local_id.strip_prefix(r"ARP\") {
let mut parts = rest.splitn(3, '\\');
let scope = parts.next().unwrap_or("");
let _arch = parts.next();
let subkey_name = parts.next().unwrap_or("");
if subkey_name.is_empty() {
return;
}
let is_hkcu = scope.eq_ignore_ascii_case("User");
vec![(
is_hkcu,
format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{subkey_name}"),
)]
} else {
let mut found = Vec::new();
for (is_hkcu, hive) in [
(true, RegKey::predef(HKEY_CURRENT_USER)),
(false, RegKey::predef(HKEY_LOCAL_MACHINE)),
] {
let Ok(uninstall) = hive.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") else {
continue;
};
for name in uninstall.enum_keys().flatten() {
let Ok(subkey) = uninstall.open_subkey(&name) else {
continue;
};
if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() == Some(installed.id.as_str()) {
found.push((
is_hkcu,
format!(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{name}"),
));
}
}
}
found
};
for (is_hkcu, path) in candidates {
let hive = if is_hkcu {
RegKey::predef(HKEY_CURRENT_USER)
} else {
RegKey::predef(HKEY_LOCAL_MACHINE)
};
let _ = hive.delete_subkey_all(&path);
}
}
#[cfg(windows)]
fn is_product_code_like(value: &str) -> bool {
value.starts_with('{') && value.ends_with('}')
}
#[cfg(not(windows))]
fn uninstall_package(_installed: &ListMatch, _request: &UninstallRequest) -> Result<i32> {
bail!("Uninstalling packages is only supported on Windows")
}
#[cfg(windows)]
fn install_portable(
installer_path: &Path,
request: &InstallRequest,
manifest: &Manifest,
installer: &Installer,
) -> Result<i32> {
let source_identifier = portable_source_identifier(&request.query);
let existing_entry = read_existing_portable_entry(&manifest.id);
let subkey_name = existing_entry
.as_ref()
.map(|entry| entry.subkey_name.clone())
.unwrap_or_else(|| portable_subkey_name(&manifest.id, &source_identifier));
let target_dir = resolve_portable_install_location(request, &existing_entry, &subkey_name);
let prev_dir_created = existing_entry
.as_ref()
.and_then(|entry| entry.install_directory_created)
== Some(1);
let dir_existed = target_dir.is_dir();
if prev_dir_created && dir_existed {
clean_directory_contents(&target_dir).with_context(|| {
format!(
"failed to clean existing portable install directory: {}",
target_dir.display()
)
})?;
}
fs::create_dir_all(&target_dir)
.with_context(|| format!("failed to create portable install directory: {}", target_dir.display()))?;
let install_directory_created = !dir_existed || prev_dir_created;
let installer_type = installer.installer_type.as_deref().unwrap_or("");
let portable_target_full_path: PathBuf = if installer_type.eq_ignore_ascii_case("zip") {
let file = fs::File::open(installer_path)
.with_context(|| format!("failed to open installer zip: {}", installer_path.display()))?;
let mut archive = ZipArchive::new(file).context("failed to read installer zip")?;
archive
.extract(&target_dir)
.with_context(|| format!("failed to extract zip into: {}", target_dir.display()))?;
installer
.nested_installer_files
.first()
.map(|file| target_dir.join(&file.relative_file_path))
.unwrap_or_else(|| target_dir.clone())
} else {
let basename = installer_path
.file_name()
.ok_or_else(|| anyhow!("portable installer has no filename"))?;
let copied = target_dir.join(basename);
fs::copy(installer_path, &copied)
.with_context(|| format!("failed to copy portable binary into: {}", target_dir.display()))?;
copied
};
let alias = determine_portable_alias(installer);
let symlink_full_path = match alias.as_deref() {
Some(alias) if !alias.is_empty() => try_create_portable_symlink(&portable_target_full_path, alias),
_ => None,
};
let added_to_path = if symlink_full_path.is_some() {
try_add_links_dir_to_user_path()
} else {
false
};
write_portable_arp_entry(&PortableArpEntry {
subkey_name: &subkey_name,
install_location: &target_dir,
portable_target_full_path: &portable_target_full_path,
portable_symlink_full_path: symlink_full_path.as_deref(),
install_directory_created,
added_to_path,
source_identifier: &source_identifier,
manifest,
})?;
Ok(0)
}
#[cfg(windows)]
struct PortableArpEntry<'a> {
subkey_name: &'a str,
install_location: &'a Path,
portable_target_full_path: &'a Path,
portable_symlink_full_path: Option<&'a Path>,
install_directory_created: bool,
added_to_path: bool,
source_identifier: &'a str,
manifest: &'a Manifest,
}
#[cfg(windows)]
fn determine_portable_alias(installer: &Installer) -> Option<String> {
installer
.nested_installer_files
.iter()
.find_map(|file| file.portable_command_alias.clone())
.or_else(|| installer.commands.first().cloned())
}
#[cfg(windows)]
fn winget_links_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Microsoft")
.join("WinGet")
.join("Links")
}
#[cfg(windows)]
fn try_create_portable_symlink(target: &Path, alias: &str) -> Option<PathBuf> {
use std::os::windows::fs::symlink_file;
let links_dir = winget_links_dir();
if fs::create_dir_all(&links_dir).is_err() {
return None;
}
let link_path = links_dir.join(format!("{alias}.exe"));
if link_path.symlink_metadata().is_ok() {
let _ = fs::remove_file(&link_path);
}
symlink_file(target, &link_path).ok().map(|()| link_path)
}
#[cfg(windows)]
fn try_add_links_dir_to_user_path() -> bool {
let links_dir = winget_links_dir();
let links_str = links_dir.to_string_lossy().to_string();
let normalized_links = links_str.to_ascii_lowercase();
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let Ok(env_key) = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) else {
return false;
};
let existing: String = env_key.get_value("Path").unwrap_or_default();
let already_present = existing
.split(';')
.any(|component| component.trim().to_ascii_lowercase() == normalized_links);
if already_present {
return false;
}
let new_path = if existing.is_empty() {
links_str
} else if existing.ends_with(';') {
format!("{existing}{links_str}")
} else {
format!("{existing};{links_str}")
};
if env_key.set_value("Path", &new_path).is_err() {
return false;
}
broadcast_environment_change();
true
}
#[cfg(windows)]
fn broadcast_environment_change() {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::WindowsAndMessaging::{
HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
};
let param: Vec<u16> = OsStr::new("Environment")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let mut result: usize = 0;
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0usize,
param.as_ptr() as isize,
SMTO_ABORTIFHUNG,
5000,
&mut result,
);
}
}
#[cfg(windows)]
fn portable_source_identifier(query: &PackageQuery) -> String {
match query.source.as_deref() {
Some(name) if name.eq_ignore_ascii_case("winget") => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
Some(name) if !name.is_empty() => name.to_owned(),
_ => "Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
}
}
#[cfg(windows)]
fn portable_subkey_name(package_id: &str, source_identifier: &str) -> String {
format!("{package_id}_{source_identifier}")
}
#[cfg(windows)]
struct ExistingPortableEntry {
install_location: Option<PathBuf>,
install_directory_created: Option<u32>,
subkey_name: String,
}
#[cfg(windows)]
fn read_existing_portable_entry(package_id: &str) -> Option<ExistingPortableEntry> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let uninstall = hkcu
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
.ok()?;
for name in uninstall.enum_keys().flatten() {
let Ok(subkey) = uninstall.open_subkey(&name) else {
continue;
};
if read_reg_string(&subkey, "WinGetPackageIdentifier").as_deref() != Some(package_id) {
continue;
}
if !read_reg_string(&subkey, "WinGetInstallerType")
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case("portable"))
{
continue;
}
return Some(ExistingPortableEntry {
install_location: read_reg_string(&subkey, "InstallLocation").map(PathBuf::from),
install_directory_created: read_reg_dword(&subkey, "InstallDirectoryCreated"),
subkey_name: name,
});
}
None
}
#[cfg(windows)]
fn resolve_portable_install_location(
request: &InstallRequest,
existing: &Option<ExistingPortableEntry>,
subkey_name: &str,
) -> PathBuf {
resolve_portable_install_location_pure(
request.install_location.as_deref(),
existing
.as_ref()
.and_then(|e| e.install_location.as_deref())
.and_then(Path::to_str),
subkey_name,
&default_user_portable_root(),
)
}
#[cfg(windows)]
fn resolve_portable_install_location_pure(
request_location: Option<&str>,
existing_location: Option<&str>,
subkey_name: &str,
default_user_portable_root: &Path,
) -> PathBuf {
if let Some(custom) = request_location
&& !custom.is_empty()
{
return PathBuf::from(custom);
}
if let Some(loc) = existing_location
&& !loc.is_empty()
{
return PathBuf::from(loc);
}
default_user_portable_root.join(subkey_name)
}
#[cfg(windows)]
fn default_user_portable_root() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Microsoft")
.join("WinGet")
.join("Packages")
}
#[cfg(windows)]
fn clean_directory_contents(dir: &Path) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
fs::remove_dir_all(&path)?;
} else {
fs::remove_file(&path)?;
}
}
Ok(())
}
#[cfg(windows)]
fn write_portable_arp_entry(entry: &PortableArpEntry<'_>) -> Result<()> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let uninstall_path = format!(
r"Software\Microsoft\Windows\CurrentVersion\Uninstall\{}",
entry.subkey_name
);
let (subkey, _) = hkcu
.create_subkey(&uninstall_path)
.context("failed to create portable ARP registry subkey")?;
let manifest = entry.manifest;
let install_location_str = entry.install_location.to_string_lossy().to_string();
subkey.set_value("WinGetPackageIdentifier", &manifest.id)?;
subkey.set_value("WinGetSourceIdentifier", &entry.source_identifier.to_owned())?;
subkey.set_value("WinGetInstallerType", &"portable".to_owned())?;
subkey.set_value("InstallLocation", &install_location_str)?;
subkey.set_value(
"PortableTargetFullPath",
&entry.portable_target_full_path.to_string_lossy().to_string(),
)?;
if let Some(symlink_path) = entry.portable_symlink_full_path {
subkey.set_value("PortableSymlinkFullPath", &symlink_path.to_string_lossy().to_string())?;
}
subkey.set_value(
"InstallDirectoryAddedToPath",
&(if entry.added_to_path { 1u32 } else { 0u32 }),
)?;
subkey.set_value(
"InstallDirectoryCreated",
&(if entry.install_directory_created { 1u32 } else { 0u32 }),
)?;
subkey.set_value(
"DisplayName",
&if manifest.name.is_empty() {
manifest.id.clone()
} else {
manifest.name.clone()
},
)?;
subkey.set_value("DisplayVersion", &manifest.version)?;
if let Some(publisher) = manifest.publisher.as_ref() {
subkey.set_value("Publisher", publisher)?;
}
subkey.set_value(
"UninstallString",
&format!("winget uninstall --product-code {}", entry.subkey_name),
)?;
let today = Utc::now().format("%Y%m%d").to_string();
subkey.set_value("InstallDate", &today)?;
if let Some(url) = manifest.package_url.as_ref() {
subkey.set_value("URLInfoAbout", url)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::io::{Read as _, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
use std::thread::{self, JoinHandle};
use flate2::Compression;
use flate2::write::DeflateEncoder;
use zip::write::SimpleFileOptions;
use super::*;
fn sample_manifest(id: &str, version: &str, name: &str) -> Manifest {
Manifest {
id: id.to_owned(),
name: name.to_owned(),
version: version.to_owned(),
channel: String::new(),
publisher: Some("Acme".to_owned()),
description: Some("desc".to_owned()),
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers: Vec::new(),
require_explicit_upgrade: false,
}
}
#[test]
fn derive_download_filename_matches_winget_pattern() {
let manifest = sample_manifest("Spotify.Spotify", "1.2.90.451.gb094aab0", "Spotify");
let installer = Installer {
architecture: Some("arm64".to_owned()),
installer_type: Some("exe".to_owned()),
scope: Some("user".to_owned()),
locale: Some("en-US".to_owned()),
..Installer::default()
};
assert_eq!(
derive_download_manifest_filename(&manifest, &installer),
"Spotify_1.2.90.451.gb094aab0_User_Arm64_exe_en-US.yaml"
);
}
#[test]
fn derive_download_filename_omits_optional_components() {
let manifest = sample_manifest("7zip.7zip", "26.01", "7-Zip");
let installer = Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
..Installer::default()
};
assert_eq!(
derive_download_manifest_filename(&manifest, &installer),
"7-Zip_26.01_X64_exe.yaml"
);
}
#[test]
fn derive_download_filename_skips_unknown_version() {
let manifest = sample_manifest("Test.Package", "Unknown", "Test");
let installer = Installer {
architecture: Some("neutral".to_owned()),
installer_type: Some("msi".to_owned()),
..Installer::default()
};
assert_eq!(
derive_download_manifest_filename(&manifest, &installer),
"Test_Neutral_msi.yaml"
);
}
#[test]
fn derive_download_filename_sanitizes_illegal_chars() {
let manifest = sample_manifest("Vendor.App", "1.0", "Some/Bad:Name");
let installer = Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
..Installer::default()
};
let filename = derive_download_manifest_filename(&manifest, &installer);
assert!(!filename.contains('/') && !filename.contains(':'));
assert!(filename.starts_with("Some_Bad_Name_1.0"));
}
#[test]
fn build_download_manifest_yaml_overrides_identity_fields() {
let manifest = sample_manifest("App.Id", "2.0.0", "App Name");
let documents = serde_json::json!({
"PackageIdentifier": "stale-id",
"PackageVersion": "",
"PackageName": "Old Name",
"Channel": "preview",
"Installers": [
{ "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/a" },
{ "Architecture": "x86", "InstallerType": "exe", "InstallerUrl": "https://example.com/b" },
],
});
let installer = Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: Some("https://example.com/a".to_owned()),
..Installer::default()
};
let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
assert!(yaml.contains("PackageIdentifier: App.Id"));
assert!(yaml.contains("PackageVersion: 2.0.0"));
assert!(yaml.contains("PackageName: App Name"));
assert!(yaml.contains("ManifestType: merged"));
assert!(!yaml.contains("Channel: preview"));
assert_eq!(yaml.matches("InstallerUrl").count(), 1);
assert!(yaml.contains("https://example.com/a"));
assert!(!yaml.contains("https://example.com/b"));
}
#[test]
fn build_download_manifest_yaml_preserves_typed_channel() {
let mut manifest = sample_manifest("App.Id", "2.0.0", "App Name");
manifest.channel = "beta".to_owned();
let documents = serde_json::json!({
"PackageIdentifier": "App.Id",
"PackageVersion": "2.0.0",
"Installers": [],
});
let installer = Installer::default();
let yaml = build_download_manifest_yaml(&manifest, &documents, &installer).expect("yaml");
assert!(yaml.contains("Channel: beta"));
}
fn temp_app_root(label: &str) -> PathBuf {
static TEMP_APP_ROOT_COUNTER: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!(
"pinget-rs-tests-{label}-{}-{}",
std::process::id(),
TEMP_APP_ROOT_COUNTER.fetch_add(1, AtomicOrdering::SeqCst)
))
}
#[cfg(windows)]
#[test]
fn path_prefix_check_rejects_string_prefix_collisions() {
assert!(!path_starts_with_case_insensitive(
Path::new(r"C:\Foobar\bin.exe"),
Path::new(r"C:\Foo"),
));
assert!(!path_starts_with_case_insensitive(
Path::new(r"C:\Foo\bin.exe.bak"),
Path::new(r"C:\Foo\bin.exe"),
));
}
#[cfg(windows)]
#[test]
fn path_prefix_check_accepts_real_children_and_self() {
assert!(path_starts_with_case_insensitive(
Path::new(r"C:\Foo\sub\bin.exe"),
Path::new(r"C:\Foo"),
));
assert!(path_starts_with_case_insensitive(
Path::new(r"C:\Foo\bin.exe"),
Path::new(r"C:\Foo\bin.exe"),
));
}
#[cfg(windows)]
#[test]
fn path_prefix_check_is_case_insensitive() {
assert!(path_starts_with_case_insensitive(
Path::new(r"c:\users\test\appdata\local\microsoft\winget\packages\foo\bin.exe"),
Path::new(r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\Foo"),
));
}
#[cfg(windows)]
#[test]
fn portable_source_identifier_maps_winget_catalog_to_canonical_id() {
let query = PackageQuery {
source: Some("winget".to_owned()),
..PackageQuery::default()
};
assert_eq!(
portable_source_identifier(&query),
"Microsoft.Winget.Source_8wekyb3d8bbwe"
);
let query = PackageQuery {
source: Some("WINGET".to_owned()),
..PackageQuery::default()
};
assert_eq!(
portable_source_identifier(&query),
"Microsoft.Winget.Source_8wekyb3d8bbwe"
);
}
#[cfg(windows)]
#[test]
fn portable_source_identifier_passes_through_custom_sources() {
let query = PackageQuery {
source: Some("internal-feed".to_owned()),
..PackageQuery::default()
};
assert_eq!(portable_source_identifier(&query), "internal-feed");
}
#[cfg(windows)]
#[test]
fn portable_source_identifier_defaults_for_missing_or_empty_source() {
let query = PackageQuery {
source: None,
..PackageQuery::default()
};
assert_eq!(
portable_source_identifier(&query),
"Microsoft.Winget.Source_8wekyb3d8bbwe"
);
let query = PackageQuery {
source: Some(String::new()),
..PackageQuery::default()
};
assert_eq!(
portable_source_identifier(&query),
"Microsoft.Winget.Source_8wekyb3d8bbwe"
);
}
#[cfg(windows)]
#[test]
fn portable_subkey_name_matches_winget_format() {
assert_eq!(
portable_subkey_name("BurntSushi.ripgrep.MSVC", "Microsoft.Winget.Source_8wekyb3d8bbwe"),
"BurntSushi.ripgrep.MSVC_Microsoft.Winget.Source_8wekyb3d8bbwe"
);
}
#[cfg(windows)]
#[test]
fn determine_portable_alias_prefers_nested_installer_alias() {
let installer = Installer {
nested_installer_files: vec![NestedInstallerFile {
relative_file_path: "ripgrep/rg.exe".to_owned(),
portable_command_alias: Some("rg".to_owned()),
}],
commands: vec!["other-name".to_owned()],
..Installer::default()
};
assert_eq!(determine_portable_alias(&installer).as_deref(), Some("rg"));
}
#[cfg(windows)]
#[test]
fn determine_portable_alias_falls_back_to_commands() {
let installer = Installer {
commands: vec!["nuget".to_owned()],
..Installer::default()
};
assert_eq!(determine_portable_alias(&installer).as_deref(), Some("nuget"));
let installer = Installer {
nested_installer_files: vec![NestedInstallerFile {
relative_file_path: "subdir/bin.exe".to_owned(),
portable_command_alias: None,
}],
commands: vec!["bin".to_owned()],
..Installer::default()
};
assert_eq!(determine_portable_alias(&installer).as_deref(), Some("bin"));
}
#[cfg(windows)]
#[test]
fn determine_portable_alias_returns_none_without_alias_or_commands() {
let installer = Installer::default();
assert!(determine_portable_alias(&installer).is_none());
}
#[cfg(windows)]
#[test]
fn resolve_portable_install_location_prefers_request_override() {
let result = resolve_portable_install_location_pure(
Some(r"D:\Tools\rg"),
Some(r"C:\Software\rg-test"),
"Sub_Source",
Path::new(r"C:\default"),
);
assert_eq!(result, PathBuf::from(r"D:\Tools\rg"));
}
#[cfg(windows)]
#[test]
fn resolve_portable_install_location_preserves_existing_location_for_upgrades() {
let result = resolve_portable_install_location_pure(
None,
Some(r"C:\Software\rg-test"),
"Sub_Source",
Path::new(r"C:\default"),
);
assert_eq!(result, PathBuf::from(r"C:\Software\rg-test"));
}
#[cfg(windows)]
#[test]
fn resolve_portable_install_location_falls_back_to_default_root() {
let result = resolve_portable_install_location_pure(None, None, "Sub_Source", Path::new(r"C:\default"));
assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
}
#[cfg(windows)]
#[test]
fn resolve_portable_install_location_treats_empty_strings_as_unset() {
let result = resolve_portable_install_location_pure(Some(""), Some(""), "Sub_Source", Path::new(r"C:\default"));
assert_eq!(result, PathBuf::from(r"C:\default\Sub_Source"));
}
#[cfg(windows)]
#[test]
fn clean_directory_contents_removes_files_and_subdirs_but_keeps_root() {
let root = std::env::temp_dir().join(format!(
"pinget-clean-test-{}",
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
fs::create_dir_all(&root).expect("create temp dir");
fs::write(root.join("a.txt"), b"hello").expect("write a.txt");
fs::create_dir_all(root.join("nested")).expect("create nested dir");
fs::write(root.join("nested").join("b.txt"), b"world").expect("write nested/b.txt");
clean_directory_contents(&root).expect("clean root contents");
assert!(root.is_dir(), "root dir itself must survive");
assert_eq!(
fs::read_dir(&root).expect("read root").count(),
0,
"root must be empty after clean"
);
let _ = fs::remove_dir_all(&root);
}
#[cfg(windows)]
#[test]
fn clean_directory_contents_is_a_noop_on_nonexistent_dir() {
let bogus = std::env::temp_dir().join(format!(
"pinget-clean-test-bogus-{}",
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
assert!(!bogus.exists());
clean_directory_contents(&bogus).expect("clean nonexistent dir");
assert!(!bogus.exists());
}
#[cfg(windows)]
#[test]
fn path_prefix_check_handles_mixed_separators() {
assert!(path_starts_with_case_insensitive(
Path::new(r"C:\Foo\sub/bin.exe"),
Path::new(r"C:\Foo"),
));
}
#[test]
fn repository_options_capture_custom_host_settings() {
let options = RepositoryOptions::new(PathBuf::from(r"C:\temp\pinget-test"))
.with_user_agent("pinget-rs-tests/1.0")
.with_pre_indexed_source_auto_update_interval(None);
assert_eq!(options.app_root, PathBuf::from(r"C:\temp\pinget-test"));
assert_eq!(options.user_agent, "pinget-rs-tests/1.0");
assert!(options.pre_indexed_source_auto_update_interval.is_none());
}
#[test]
fn dependency_install_requests_are_silent_and_not_forced() {
let mut parent = InstallRequest::new(PackageQuery {
source: Some("winget".to_owned()),
..PackageQuery::default()
});
parent.mode = InstallerMode::SilentWithProgress;
parent.force = true;
parent.accept_package_agreements = true;
parent.ignore_security_hash = true;
parent.dependency_source = Some("dependencies".to_owned());
let dependency =
Repository::create_dependency_install_request("Microsoft.VCRedist.2015+.x64".to_owned(), &parent);
assert_eq!(dependency.query.id.as_deref(), Some("Microsoft.VCRedist.2015+.x64"));
assert_eq!(dependency.query.source.as_deref(), Some("dependencies"));
assert_eq!(dependency.mode, InstallerMode::Silent);
assert!(!dependency.force);
assert!(dependency.no_upgrade);
assert!(dependency.accept_package_agreements);
assert!(dependency.ignore_security_hash);
}
#[test]
fn storage_paths_use_configured_app_root() {
let app_root = PathBuf::from(r"C:\temp\pinget-test");
let source = SourceRecord {
name: "winget/test".to_owned(),
kind: SourceKind::PreIndexed,
arg: "https://example.com/cache".to_owned(),
identifier: "Test.Source".to_owned(),
trust_level: "None".to_owned(),
explicit: false,
priority: 0,
last_update: None,
source_version: None,
};
assert_eq!(store_path(&app_root), app_root.join("sources.json"));
assert_eq!(
source_state_dir(&app_root, &source),
app_root.join("sources").join("winget_test")
);
assert_eq!(pins_db_path(&app_root), app_root.join("pins.db"));
}
#[cfg(windows)]
#[test]
fn packaged_layout_paths_resolve_from_packaged_app_root() {
let local_app_data = dirs::data_local_dir().expect("LocalAppData");
let app_root = local_app_data
.join("Packages")
.join(PACKAGED_FAMILY_NAME)
.join("LocalState");
assert!(uses_packaged_layout(&app_root));
assert!(
user_settings_path(&app_root)
.display()
.to_string()
.ends_with(&format!(r"Packages\{}\LocalState\settings.json", PACKAGED_FAMILY_NAME))
);
assert!(
packaged_file_cache_root(&app_root)
.display()
.to_string()
.ends_with(&format!(
r"Packages\{}\LocalState\Microsoft\Windows Package Manager",
PACKAGED_FAMILY_NAME
))
);
}
#[cfg(windows)]
#[test]
fn default_app_root_outside_package_avoids_packaged_layout() {
let prior = std::env::var_os("PINGET_APPROOT");
unsafe { std::env::remove_var("PINGET_APPROOT") };
let result = default_app_root();
if let Some(prior) = prior {
unsafe { std::env::set_var("PINGET_APPROOT", prior) };
}
let app_root = result.expect("default app root");
assert!(!uses_packaged_layout(&app_root), "got packaged layout for {app_root:?}");
assert!(
app_root.ends_with(Path::new("Devolutions").join("Pinget")),
"unexpected fallback app root: {app_root:?}"
);
}
#[test]
fn packaged_source_stream_overlays_defaults_and_metadata() {
let store = parse_packaged_source_store(
Some(
"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",
),
Some(
"Sources:\n - Name: corp\n LastUpdate: 1700000000\n SourceVersion: 1.2.3\n",
),
)
.expect("packaged store");
assert!(!store.sources.iter().any(|source| source.name == "winget"));
let corp = store
.sources
.iter()
.find(|source| source.name == "corp")
.expect("corp source");
assert_eq!(corp.kind, SourceKind::Rest);
assert_eq!(corp.identifier, "Contoso.Rest");
assert_eq!(corp.trust_level, "Trusted");
assert!(corp.explicit);
assert_eq!(corp.priority, 7);
assert_eq!(corp.source_version.as_deref(), Some("1.2.3"));
assert_eq!(corp.last_update, DateTime::<Utc>::from_timestamp(1_700_000_000, 0));
}
#[test]
fn system_winget_export_parses_json_lines() {
let output = r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
{"Arg":"https://cdn.contoso.test/cache","Data":"Contoso.Source_8wekyb3d8bbwe","Explicit":true,"Identifier":"Contoso.Source_8wekyb3d8bbwe","Name":"contoso-cache","TrustLevel":["Trusted","StoreOrigin"],"Type":"Microsoft.PreIndexed.Package"}
"#;
let sources = parse_system_winget_source_export(output).expect("source export");
assert_eq!(
sources.iter().map(|source| source.name.as_str()).collect::<Vec<_>>(),
vec!["contoso", "contoso-cache"]
);
assert_eq!(sources[0].kind, SourceKind::Rest);
assert_eq!(sources[0].arg, "https://api.contoso.test/feed");
assert_eq!(sources[0].identifier, "api.contoso.test");
assert_eq!(sources[0].trust_level, "Trusted");
assert!(!sources[0].explicit);
assert_eq!(sources[1].kind, SourceKind::PreIndexed);
assert_eq!(sources[1].identifier, "Contoso.Source_8wekyb3d8bbwe");
assert!(sources[1].explicit);
}
#[test]
fn system_winget_export_parses_wrapped_sources() {
let output = r#"{
"Sources": [
{
"Name": "corp",
"Type": "Microsoft.Rest",
"Arg": "https://packages.contoso.test/api",
"Identifier": "Contoso.Rest",
"TrustLevel": "Trusted",
"Explicit": true,
"Priority": 9
}
]
}"#;
let sources = parse_system_winget_source_export(output).expect("source export");
let source = sources.first().expect("source");
assert_eq!(sources.len(), 1);
assert_eq!(source.name, "corp");
assert_eq!(source.kind, SourceKind::Rest);
assert_eq!(source.identifier, "Contoso.Rest");
assert_eq!(source.trust_level, "Trusted");
assert!(source.explicit);
assert_eq!(source.priority, 9);
}
#[test]
fn system_winget_source_store_detects_secure_settings_stub() {
assert!(is_secure_settings_stub(Some(
"SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"
)));
assert!(!is_secure_settings_stub(Some("Sources:\n - Name: winget\n")));
}
#[test]
fn system_winget_source_store_builds_add_arguments() {
let args = build_winget_source_add_arguments(
"corp",
"https://packages.contoso.test/api",
SourceKind::Rest,
"Trusted",
true,
);
assert_eq!(
args,
vec![
"source",
"add",
"--name",
"corp",
"--arg",
"https://packages.contoso.test/api",
"--type",
"Microsoft.Rest",
"--disable-interactivity",
"--trust-level",
"trusted",
"--explicit",
]
);
}
fn fake_system_winget_source_export(_args: &[String]) -> Result<WingetSourceCommandResult> {
Ok(WingetSourceCommandResult {
exit_code: 0,
stdout: r#"{"Arg":"https://api.contoso.test/feed","Data":"","Explicit":false,"Identifier":"api.contoso.test","Name":"contoso","TrustLevel":["Trusted"],"Type":"Microsoft.Rest"}
"#
.to_owned(),
stderr: String::new(),
})
}
#[test]
fn packaged_secure_settings_stub_delegates_to_system_winget_export() {
let original_runner = {
let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
let original = *runner;
*runner = fake_system_winget_source_export;
original
};
let result = load_packaged_store_from_streams(
Some("SHA256: d52f7aa273206e81585b714fc627ecb6f6e17b6aeba7b28025124da0d25db334"),
None,
);
{
let mut runner = SYSTEM_WINGET_SOURCE_COMMAND_RUNNER.write().expect("runner lock");
*runner = original_runner;
}
let store = result.expect("packaged store").expect("packaged store");
let source = store.sources.first().expect("source");
assert_eq!(store.sources.len(), 1);
assert_eq!(source.name, "contoso");
assert_eq!(source.kind, SourceKind::Rest);
}
#[test]
fn source_metadata_and_named_reset_round_trip() {
let app_root = temp_app_root("source-reset");
let result = (|| -> Result<()> {
let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
repository.add_source_with_metadata(
"test",
"https://example.com/test",
SourceKind::Rest,
Some("trusted"),
true,
4,
)?;
repository.edit_source("test", Some(false), None)?;
let edited = repository
.list_sources()
.into_iter()
.find(|source| source.name == "test")
.expect("source");
assert_eq!(edited.trust_level, "Trusted");
assert!(!edited.explicit);
assert_eq!(edited.priority, 4);
repository.reset_source("test")?;
let reset = repository
.list_sources()
.into_iter()
.find(|source| source.name == "test")
.expect("source");
assert_eq!(reset.trust_level, "Trusted");
assert!(!reset.explicit);
assert_eq!(reset.priority, 4);
assert!(reset.last_update.is_none());
assert!(reset.source_version.is_none());
Ok(())
})();
let _ = fs::remove_dir_all(&app_root);
result.expect("source metadata");
}
#[test]
fn explicit_sources_are_skipped_by_default_search_resolution() {
let app_root = temp_app_root("source-resolution");
let result = (|| -> Result<()> {
let mut repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
repository.add_source_with_metadata(
"explicit-test",
"https://example.com/explicit",
SourceKind::Rest,
Some("trusted"),
true,
10,
)?;
let indexes = repository.resolve_source_indexes(None)?;
let names: Vec<_> = indexes
.into_iter()
.map(|index| repository.list_sources()[index].name.clone())
.collect();
assert!(!names.iter().any(|name| name == "explicit-test"));
Ok(())
})();
let _ = fs::remove_dir_all(&app_root);
result.expect("source resolution");
}
#[test]
fn user_and_admin_settings_round_trip() {
let app_root = temp_app_root("settings");
let result = (|| -> Result<()> {
let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
repository.set_user_settings(
&serde_json::json!({
"visual": {
"progressBar": "retro"
}
}),
false,
)?;
repository.set_user_settings(
&serde_json::json!({
"experimentalFeatures": {
"directMSI": true
}
}),
true,
)?;
let user_settings = repository.get_user_settings()?;
assert_eq!(
user_settings["visual"]["progressBar"],
serde_json::Value::String("retro".to_owned())
);
assert_eq!(
user_settings["experimentalFeatures"]["directMSI"],
serde_json::Value::Bool(true)
);
assert!(repository.test_user_settings(
&serde_json::json!({
"experimentalFeatures": {
"directMSI": true
}
}),
true
)?);
repository.set_admin_setting("LocalManifestFiles", true)?;
repository.set_admin_setting("InstallerHashOverride", true)?;
let admin_settings = repository.get_admin_settings()?;
assert_eq!(admin_settings["LocalManifestFiles"], serde_json::Value::Bool(true));
assert_eq!(admin_settings["InstallerHashOverride"], serde_json::Value::Bool(true));
repository.reset_admin_setting(Some("LocalManifestFiles"), false)?;
let reset_one = repository.get_admin_settings()?;
assert_eq!(reset_one["LocalManifestFiles"], serde_json::Value::Bool(false));
repository.reset_admin_setting(None, true)?;
let reset_all = repository.get_admin_settings()?;
for name in Repository::supported_admin_settings() {
assert_eq!(reset_all[*name], serde_json::Value::Bool(false));
}
Ok(())
})();
let _ = fs::remove_dir_all(&app_root);
result.expect("settings");
}
#[test]
fn show_result_structured_document_is_manifest_oriented() {
let result = ShowResult {
package: SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Test.Package".to_owned(),
name: "Test Package".to_owned(),
moniker: Some("testpkg".to_owned()),
version: Some("1.2.3".to_owned()),
channel: Some("stable".to_owned()),
match_criteria: Some("Id".to_owned()),
},
manifest: Manifest {
id: "Test.Package".to_owned(),
name: "Test Package".to_owned(),
version: "1.2.3".to_owned(),
channel: "stable".to_owned(),
publisher: Some("Contoso".to_owned()),
description: Some("Structured output".to_owned()),
moniker: Some("testpkg".to_owned()),
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: vec!["utility".to_owned()],
agreements: Vec::new(),
package_dependencies: vec!["Microsoft.VCRedist.2015+.x64".to_owned()],
documentation: vec![Documentation {
label: Some("Docs".to_owned()),
url: "https://example.test/docs".to_owned(),
}],
installers: vec![Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("msix".to_owned()),
url: Some("https://example.test/Test.Package.msix".to_owned()),
sha256: Some("ABC123".to_owned()),
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("machine".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches {
silent: Some("/quiet".to_owned()),
..InstallerSwitches::default()
},
commands: vec!["testpkg".to_owned()],
package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
}],
require_explicit_upgrade: false,
},
selected_installer: Some(Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("msix".to_owned()),
url: Some("https://example.test/Test.Package.msix".to_owned()),
sha256: Some("ABC123".to_owned()),
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("machine".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches {
silent: Some("/quiet".to_owned()),
..InstallerSwitches::default()
},
commands: vec!["testpkg".to_owned()],
package_dependencies: vec!["Microsoft.UI.Xaml.2.8".to_owned()],
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
}),
cached_files: vec![PathBuf::from(r"C:\temp\cache\Test.Package.yaml")],
warnings: vec!["cache warmed".to_owned()],
manifest_documents: JsonValue::Array(vec![
serde_json::json!({
"PackageIdentifier": "Test.Package",
"PackageVersion": "1.2.3",
"DefaultLocale": "en-US",
"ManifestType": "version",
"ManifestVersion": "1.10.0"
}),
serde_json::json!({
"PackageIdentifier": "Test.Package",
"PackageVersion": "1.2.3",
"PackageLocale": "en-US",
"PackageName": "Test Package",
"Publisher": "Example",
"License": "MIT",
"ShortDescription": "Structured output",
"ManifestType": "defaultLocale",
"ManifestVersion": "1.10.0"
}),
serde_json::json!({
"PackageIdentifier": "Test.Package",
"PackageVersion": "1.2.3",
"ManifestType": "installer",
"ManifestVersion": "1.10.0",
"Installers": [
{
"Architecture": "x64",
"InstallerType": "msix",
"InstallerUrl": "https://example.test/Test.Package.msix",
"InstallerSha256": "ABC123",
"Commands": ["testpkg"],
"InstallerSwitches": { "Silent": "/quiet" },
"Dependencies": {
"PackageDependencies": [
{ "PackageIdentifier": "Microsoft.VCRedist.2015+.x64" }
]
}
}
]
}),
]),
};
let document = result.structured_document();
assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
assert_eq!(document["ManifestVersion"].as_str(), Some("1.10.0"));
assert_eq!(document["PackageLocale"].as_str(), Some("en-US"));
assert_eq!(
document["Installers"][0]["Dependencies"]["PackageDependencies"][0]["PackageIdentifier"].as_str(),
Some("Microsoft.VCRedist.2015+.x64")
);
assert_eq!(document["Installers"][0]["Commands"][0].as_str(), Some("testpkg"));
assert_eq!(
document["Installers"][0]["InstallerSwitches"]["Silent"].as_str(),
Some("/quiet")
);
}
#[test]
fn manifest_parses_require_explicit_upgrade_at_top_level() {
let yaml = r#"
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
DefaultLocale: en-US
ManifestType: singleton
ManifestVersion: 1.10.0
PackageLocale: en-US
PackageName: Test Package
Publisher: Example
License: MIT
ShortDescription: explicit-upgrade fixture
RequireExplicitUpgrade: true
Installers:
- Architecture: x64
InstallerType: exe
InstallerUrl: https://example.test/Test.Package.exe
InstallerSha256: ABC123
"#;
let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
assert!(manifest.require_explicit_upgrade);
}
#[test]
fn manifest_parses_require_explicit_upgrade_on_installer() {
let yaml = r#"
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
DefaultLocale: en-US
ManifestType: singleton
ManifestVersion: 1.10.0
PackageLocale: en-US
PackageName: Test Package
Publisher: Example
License: MIT
ShortDescription: explicit-upgrade fixture
Installers:
- Architecture: x64
InstallerType: exe
InstallerUrl: https://example.test/Test.Package.x64.exe
InstallerSha256: ABC123
- Architecture: arm64
InstallerType: exe
InstallerUrl: https://example.test/Test.Package.arm64.exe
InstallerSha256: DEF456
RequireExplicitUpgrade: true
"#;
let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
assert!(manifest.require_explicit_upgrade);
assert!(!manifest.installers[0].require_explicit_upgrade);
assert!(manifest.installers[1].require_explicit_upgrade);
}
#[test]
fn manifest_without_require_explicit_upgrade_defaults_to_false() {
let yaml = r#"
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
DefaultLocale: en-US
ManifestType: singleton
ManifestVersion: 1.10.0
PackageLocale: en-US
PackageName: Test Package
Publisher: Example
License: MIT
ShortDescription: baseline fixture
Installers:
- Architecture: x64
InstallerType: exe
InstallerUrl: https://example.test/Test.Package.exe
InstallerSha256: ABC123
"#;
let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("parse");
assert!(!manifest.require_explicit_upgrade);
}
#[test]
fn upgrade_filter_hides_require_explicit_upgrade_by_default() {
let mut pkg = InstalledPackage {
name: "Edge".to_owned(),
local_id: r"ARP\Machine\X64\Edge".to_owned(),
installed_version: "100.0".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.Edge".to_owned(),
name: "Microsoft Edge".to_owned(),
moniker: None,
version: Some("110.0".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: true,
correlated_lacks_compatible_installer: false,
};
let bulk_query = ListQuery {
upgrade_only: true,
..ListQuery::default()
};
assert!(
!installed_package_matches_upgrade_filter(&pkg, &bulk_query),
"RequireExplicitUpgrade row must be hidden from bulk upgrade"
);
let filtered_query = ListQuery {
upgrade_only: true,
id: Some("Microsoft.Edge".to_owned()),
..ListQuery::default()
};
assert!(
installed_package_matches_upgrade_filter(&pkg, &filtered_query),
"RequireExplicitUpgrade row must surface when the user filters for it explicitly"
);
pkg.correlated_requires_explicit_upgrade = false;
assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
}
#[test]
fn upgrade_filter_hides_lacks_compatible_installer_by_default() {
let mut pkg = InstalledPackage {
name: "Foo".to_owned(),
local_id: r"ARP\Machine\X64\Foo".to_owned(),
installed_version: "1.0".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Test.Foo".to_owned(),
name: "Foo".to_owned(),
moniker: None,
version: Some("2.0".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: true,
};
let bulk_query = ListQuery {
upgrade_only: true,
..ListQuery::default()
};
assert!(!installed_package_matches_upgrade_filter(&pkg, &bulk_query));
let filtered_query = ListQuery {
upgrade_only: true,
id: Some("Test.Foo".to_owned()),
..ListQuery::default()
};
assert!(installed_package_matches_upgrade_filter(&pkg, &filtered_query));
pkg.correlated_lacks_compatible_installer = false;
assert!(installed_package_matches_upgrade_filter(&pkg, &bulk_query));
}
fn synthetic_manifest_with_installer_arches(arches: &[&str]) -> Manifest {
let installers = arches
.iter()
.map(|a| Installer {
architecture: Some((*a).to_owned()),
installer_type: None,
url: None,
sha256: None,
product_code: None,
locale: None,
scope: None,
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
})
.collect();
Manifest {
id: "Test".to_owned(),
name: "Test".to_owned(),
version: "1.0".to_owned(),
channel: String::new(),
publisher: None,
description: None,
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers,
require_explicit_upgrade: false,
}
}
#[test]
fn manifest_compatible_installer_neutral_always_matches() {
assert!(manifest_has_compatible_installer(
&synthetic_manifest_with_installer_arches(&["neutral"])
));
}
#[test]
fn manifest_compatible_installer_empty_installers_pass_through() {
assert!(manifest_has_compatible_installer(
&synthetic_manifest_with_installer_arches(&[])
));
}
#[test]
fn manifest_compatible_installer_rejects_alien_arch_only() {
assert!(!manifest_has_compatible_installer(
&synthetic_manifest_with_installer_arches(&["ppc"])
));
}
#[test]
fn manifest_compatible_installer_mixed_set_passes_if_any_match() {
assert!(manifest_has_compatible_installer(
&synthetic_manifest_with_installer_arches(&["ppc", "neutral"])
));
}
#[test]
fn lookup_unique_normalized_identity_returns_unique_match() {
let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
execute_batch_sql(
&connection,
"CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
INSERT INTO norm_names2 VALUES ('microsoftedge', 100);\n\
INSERT INTO norm_publishers2 VALUES ('microsoft', 100);",
)
.expect("seed schema");
let rowid = lookup_unique_normalized_identity(&connection, "microsoftedge", "microsoft")
.expect("query")
.expect("unique match");
assert_eq!(rowid, 100);
}
#[test]
fn lookup_unique_normalized_identity_rejects_ambiguous_match() {
let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
execute_batch_sql(
&connection,
"CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('thegitdevelopmentcommunity', 200);",
)
.expect("seed schema");
let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
assert!(rowid.is_none(), "ambiguous match must not correlate");
}
#[test]
fn lookup_unique_normalized_identity_requires_publisher_intersect() {
let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
execute_batch_sql(
&connection,
"CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
INSERT INTO norm_names2 VALUES ('git', 100), ('git', 200);\n\
INSERT INTO norm_publishers2 VALUES ('thegitdevelopmentcommunity', 100), ('microsoft', 200);",
)
.expect("seed schema");
let rowid = lookup_unique_normalized_identity(&connection, "git", "thegitdevelopmentcommunity").expect("query");
assert_eq!(rowid, Some(100));
}
#[test]
fn lookup_unique_normalized_identity_misses_when_publisher_does_not_match() {
let connection = Repository::open_sqlite_connection(PathBuf::from(":memory:")).expect("open in-memory db");
execute_batch_sql(
&connection,
"CREATE TABLE norm_names2 (norm_name TEXT, package INT64);\n\
CREATE TABLE norm_publishers2 (norm_publisher TEXT, package INT64);\n\
INSERT INTO norm_names2 VALUES ('foo', 100);\n\
INSERT INTO norm_publishers2 VALUES ('bar', 200);",
)
.expect("seed schema");
let rowid = lookup_unique_normalized_identity(&connection, "foo", "bar").expect("query");
assert!(
rowid.is_none(),
"name matches package 100 but publisher matches 200 — no intersection"
);
}
#[test]
fn parse_yaml_manifest_bundle_returns_singleton_document() {
let yaml = r#"
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.10.0
---
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
PackageLocale: en-US
PackageName: Test Package
Publisher: Example
License: MIT
ShortDescription: Structured output
ManifestType: defaultLocale
ManifestVersion: 1.10.0
---
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
ManifestType: installer
ManifestVersion: 1.10.0
Installers:
- Architecture: x64
InstallerType: exe
InstallerUrl: https://example.test/Test.Package.exe
InstallerSha256: ABC123
"#;
let (_manifest, documents) = parse_yaml_manifest_bundle(yaml.as_bytes()).expect("bundle");
assert_eq!(documents["ManifestType"].as_str(), Some("singleton"));
assert_eq!(documents["PackageIdentifier"].as_str(), Some("Test.Package"));
assert_eq!(documents["PackageName"].as_str(), Some("Test Package"));
}
#[test]
fn collapse_structured_documents_returns_plural_show_documents() {
let documents = collapse_structured_documents(&[
JsonValue::Array(vec![
serde_json::json!({
"PackageIdentifier": "Test.Package.One",
"PackageVersion": "1.0.0",
"DefaultLocale": "en-US",
"ManifestType": "version",
"ManifestVersion": "1.10.0"
}),
serde_json::json!({
"PackageIdentifier": "Test.Package.One",
"PackageVersion": "1.0.0",
"PackageLocale": "en-US",
"PackageName": "Test Package One",
"ManifestType": "defaultLocale",
"ManifestVersion": "1.10.0"
}),
serde_json::json!({
"PackageIdentifier": "Test.Package.One",
"PackageVersion": "1.0.0",
"ManifestType": "installer",
"ManifestVersion": "1.10.0",
"Installers": [
{
"Architecture": "x64",
"InstallerType": "exe",
"InstallerUrl": "https://example.test/one.exe",
"InstallerSha256": "ABC123"
}
]
}),
]),
serde_json::json!({
"PackageIdentifier": "Test.Package.Two",
"PackageVersion": "2.0.0",
"PackageLocale": "en-US",
"PackageName": "Test Package Two",
"ManifestType": "singleton",
"ManifestVersion": "1.12.0"
}),
]);
assert_eq!(documents.len(), 2);
assert_eq!(documents[0]["ManifestType"].as_str(), Some("singleton"));
assert_eq!(documents[0]["PackageIdentifier"].as_str(), Some("Test.Package.One"));
assert_eq!(documents[0]["PackageName"].as_str(), Some("Test Package One"));
assert_eq!(documents[1]["ManifestType"].as_str(), Some("singleton"));
assert_eq!(documents[1]["PackageIdentifier"].as_str(), Some("Test.Package.Two"));
}
#[test]
fn unsupported_action_result_marks_noop_and_warning() {
let result = unsupported_action_result("Contoso.App", "1.2.3", "install", INSTALL_UNSUPPORTED_WARNING);
assert!(result.success);
assert!(result.no_op);
assert_eq!(result.exit_code, 0);
assert_eq!(result.warnings, vec![INSTALL_UNSUPPORTED_WARNING.to_owned()]);
}
#[test]
fn create_install_no_op_result_honors_no_upgrade() {
let mut request = InstallRequest::new(PackageQuery {
id: Some("Contoso.App".to_owned()),
..PackageQuery::default()
});
request.no_upgrade = true;
let manifest = Manifest {
id: "Contoso.App".to_owned(),
name: "Contoso App".to_owned(),
version: "2.0.0".to_owned(),
channel: String::new(),
publisher: None,
description: None,
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers: Vec::new(),
require_explicit_upgrade: false,
};
let existing = ListMatch {
name: "Contoso App".to_owned(),
id: "Contoso.App".to_owned(),
local_id: "Contoso.App".to_owned(),
installed_version: "1.0.0".to_owned(),
available_version: Some("2.0.0".to_owned()),
source_name: Some("winget".to_owned()),
publisher: None,
scope: None,
installer_category: None,
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
};
let result =
Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
assert!(result.success);
assert!(result.no_op);
assert_eq!(result.version, "1.0.0");
assert_eq!(
result.warnings,
vec!["Package is already installed; skipping because --no-upgrade was specified.".to_owned()]
);
}
#[test]
fn create_install_no_op_result_skips_reinstall_when_current() {
let request = InstallRequest::new(PackageQuery {
id: Some("Contoso.App".to_owned()),
..PackageQuery::default()
});
let manifest = Manifest {
id: "Contoso.App".to_owned(),
name: "Contoso App".to_owned(),
version: "2.0.0".to_owned(),
channel: String::new(),
publisher: None,
description: None,
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers: Vec::new(),
require_explicit_upgrade: false,
};
let existing = ListMatch {
name: "Contoso App".to_owned(),
id: "Contoso.App".to_owned(),
local_id: "Contoso.App".to_owned(),
installed_version: "2.0.0".to_owned(),
available_version: Some("2.0.0".to_owned()),
source_name: Some("winget".to_owned()),
publisher: None,
scope: None,
installer_category: None,
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
};
let result =
Repository::create_install_no_op_result(&request, &manifest, Some(&existing)).expect("no-op result");
assert!(result.success);
assert!(result.no_op);
assert_eq!(result.version, "2.0.0");
assert_eq!(
result.warnings,
vec!["Package is already installed and up to date; rerun with --force to reinstall.".to_owned()]
);
}
#[test]
fn collapse_structured_document_projects_merged_manifest() {
let document = collapse_structured_document(&serde_json::json!({
"PackageIdentifier": "Test.Package",
"PackageVersion": "1.2.3",
"PackageLocale": "en-US",
"PackageName": "Test Package",
"Publisher": "Example",
"InstallerType": "exe",
"Installers": [
{
"Architecture": "x64",
"InstallerUrl": "https://example.test/Test.Package.exe",
"InstallerSha256": "ABC123"
}
],
"ManifestType": "merged",
"ManifestVersion": "1.10.0"
}));
assert_eq!(document["ManifestType"].as_str(), Some("singleton"));
assert_eq!(document["PackageIdentifier"].as_str(), Some("Test.Package"));
assert_eq!(document["PackageName"].as_str(), Some("Test Package"));
assert_eq!(document["InstallerType"].as_str(), Some("exe"));
assert_eq!(document["Installers"][0]["Architecture"].as_str(), Some("x64"));
}
#[test]
fn parses_fixture_manifest() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("ManifestV1_28-Singleton.yaml");
let bytes = fs::read(root).expect("fixture bytes");
let manifest = parse_yaml_manifest(&bytes).expect("manifest");
assert_eq!(manifest.id, "microsoft.msixsdk");
assert_eq!(manifest.version, "1.7.32");
assert_eq!(manifest.name, "MSIX SDK");
assert!(!manifest.installers.is_empty());
}
#[test]
fn parses_installer_switches_from_yaml_manifest() {
let yaml = r#"
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
PackageName: Test Package
InstallerSwitches:
SilentWithProgress: /SILENT
Installers:
- Architecture: x64
InstallerType: inno
InstallerUrl: https://example.test/Test.Package.exe
InstallerSha256: ABC123
InstallerSwitches:
Silent: /VERYSILENT
Interactive: /HELP
"#;
let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
let installer = &manifest.installers[0];
assert_eq!(installer.switches.silent_with_progress.as_deref(), Some("/SILENT"));
assert_eq!(installer.switches.silent.as_deref(), Some("/VERYSILENT"));
assert_eq!(installer.switches.interactive.as_deref(), Some("/HELP"));
}
#[test]
fn parses_platform_and_minimum_os_version_from_yaml_manifest() {
let yaml = r#"
PackageIdentifier: Test.Package
PackageVersion: 1.2.3
PackageName: Test Package
Platform:
- Windows.Desktop
MinimumOSVersion: 10.0.19041.0
Installers:
- Architecture: x64
InstallerType: exe
InstallerUrl: https://example.test/Test.Package.exe
InstallerSha256: ABC123
- Architecture: x64
InstallerType: msix
Platform:
- Windows.Universal
MinimumOSVersion: 10.0.22621.0
InstallerUrl: https://example.test/Test.Package.msix
InstallerSha256: DEF456
"#;
let manifest = parse_yaml_manifest(yaml.as_bytes()).expect("manifest");
assert_eq!(manifest.installers[0].platforms, vec!["Windows.Desktop".to_owned()]);
assert_eq!(
manifest.installers[0].minimum_os_version.as_deref(),
Some("10.0.19041.0")
);
assert_eq!(manifest.installers[1].platforms, vec!["Windows.Universal".to_owned()]);
assert_eq!(
manifest.installers[1].minimum_os_version.as_deref(),
Some("10.0.22621.0")
);
}
#[test]
fn installer_switch_arguments_prefer_manifest_switches() {
let installer = Installer {
architecture: None,
installer_type: Some("inno".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: None,
scope: None,
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches {
silent: Some("/mysilent".to_owned()),
silent_with_progress: Some("/mysilentwithprogress".to_owned()),
interactive: Some("/myinteractive".to_owned()),
..InstallerSwitches::default()
},
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
};
let manifest = Manifest {
id: "Test.Package".to_owned(),
name: "Test Package".to_owned(),
version: "1.0.0".to_owned(),
channel: String::new(),
publisher: None,
description: None,
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers: Vec::new(),
require_explicit_upgrade: false,
};
let mut silent_request = InstallRequest::new(PackageQuery::default());
silent_request.mode = InstallerMode::Silent;
let mut progress_request = InstallRequest::new(PackageQuery::default());
progress_request.mode = InstallerMode::SilentWithProgress;
let mut interactive_request = InstallRequest::new(PackageQuery::default());
interactive_request.mode = InstallerMode::Interactive;
assert_eq!(
installer_command_arguments(
"inno",
&silent_request,
&manifest,
Path::new("installer.exe"),
&installer
),
vec!["/mysilent".to_owned()]
);
assert_eq!(
installer_command_arguments(
"inno",
&progress_request,
&manifest,
Path::new("installer.exe"),
&installer
),
vec!["/mysilentwithprogress".to_owned()]
);
assert_eq!(
installer_command_arguments(
"inno",
&interactive_request,
&manifest,
Path::new("installer.exe"),
&installer
),
vec!["/myinteractive".to_owned()]
);
}
#[test]
fn installer_switch_arguments_use_inno_defaults_without_manifest_switches() {
let installer = Installer {
architecture: None,
installer_type: Some("inno".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: None,
scope: None,
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
};
let manifest = Manifest {
id: "Test.Package".to_owned(),
name: "Test Package".to_owned(),
version: "1.0.0".to_owned(),
channel: String::new(),
publisher: None,
description: None,
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers: Vec::new(),
require_explicit_upgrade: false,
};
let mut progress_request = InstallRequest::new(PackageQuery::default());
progress_request.mode = InstallerMode::SilentWithProgress;
let mut silent_request = InstallRequest::new(PackageQuery::default());
silent_request.mode = InstallerMode::Silent;
assert_eq!(
installer_command_arguments(
"inno",
&progress_request,
&manifest,
Path::new("installer.exe"),
&installer
),
vec![
"/SP-".to_owned(),
"/SILENT".to_owned(),
"/SUPPRESSMSGBOXES".to_owned(),
"/NORESTART".to_owned()
]
);
assert_eq!(
installer_command_arguments(
"inno",
&silent_request,
&manifest,
Path::new("installer.exe"),
&installer
),
vec![
"/SP-".to_owned(),
"/VERYSILENT".to_owned(),
"/SUPPRESSMSGBOXES".to_owned(),
"/NORESTART".to_owned()
]
);
}
#[test]
fn installer_command_arguments_append_manifest_and_cli_switches() {
let installer = Installer {
architecture: None,
installer_type: Some("msi".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: None,
scope: None,
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches {
custom: Some("ADDLOCAL=Core".to_owned()),
log: Some("/log \"<LOGPATH>\"".to_owned()),
install_location: Some("TARGETDIR=\"<INSTALLPATH>\"".to_owned()),
..InstallerSwitches::default()
},
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
};
let manifest = Manifest {
id: "ShareX.ShareX".to_owned(),
name: "ShareX".to_owned(),
version: "19.0.2".to_owned(),
channel: String::new(),
publisher: None,
description: None,
moniker: None,
package_url: None,
publisher_url: None,
publisher_support_url: None,
license: None,
license_url: None,
privacy_url: None,
author: None,
copyright: None,
copyright_url: None,
release_notes: None,
release_notes_url: None,
tags: Vec::new(),
agreements: Vec::new(),
package_dependencies: Vec::new(),
documentation: Vec::new(),
installers: Vec::new(),
require_explicit_upgrade: false,
};
let mut request = InstallRequest::new(PackageQuery::default());
request.mode = InstallerMode::Silent;
request.log_path = Some(PathBuf::from(r"C:\temp\winget.log"));
request.custom = Some("REBOOT=ReallySuppress".to_owned());
request.install_location = Some(r"C:\Apps\ShareX".to_owned());
assert_eq!(
installer_command_arguments("msi", &request, &manifest, Path::new(r"C:\temp\ShareX.msi"), &installer),
vec![
"/i".to_owned(),
r"C:\temp\ShareX.msi".to_owned(),
"/quiet".to_owned(),
"/norestart".to_owned(),
"/log".to_owned(),
r"C:\temp\winget.log".to_owned(),
"ADDLOCAL=Core".to_owned(),
"REBOOT=ReallySuppress".to_owned(),
r"TARGETDIR=C:\Apps\ShareX".to_owned(),
]
);
}
#[test]
fn decompresses_mszyml_payload() {
let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder.write_all(payload.as_bytes()).expect("write payload");
let compressed = encoder.finish().expect("finish payload");
let mut mszip = b"CK".to_vec();
mszip.extend_from_slice(&compressed);
let decompressed = decompress_mszyml(&mszip).expect("decompress");
let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
assert_eq!(parsed.versions[0].version, "1.2.3");
}
#[test]
fn decompresses_mszyml_payload_with_prefix() {
let payload = "sV: 1.0.0\nvD:\n - v: 1.2.3\n rP: manifests/test.yaml\n s256H: ABCD\n";
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder.write_all(payload.as_bytes()).expect("write payload");
let compressed = encoder.finish().expect("finish payload");
let mut prefixed = vec![0u8; 28];
prefixed.extend_from_slice(b"CK");
prefixed.extend_from_slice(&compressed);
let decompressed = decompress_mszyml(&prefixed).expect("decompress");
let parsed = serde_yaml::from_str::<PackageVersionDataDocument>(&decompressed).expect("parse");
assert_eq!(parsed.versions[0].version, "1.2.3");
}
#[test]
fn compares_versions_naturally() {
assert_eq!(compare_version("1.10.0", "1.9.9"), Ordering::Greater);
assert_eq!(compare_version("2.0", "10.0"), Ordering::Less);
assert_eq!(compare_version("1.0.0-preview2", "1.0.0-preview1"), Ordering::Greater);
}
#[test]
fn installer_hash_verification_fails_on_mismatch_without_override() {
let result = verify_installer_hash(Some("DEADBEEF"), b"pinget", false);
assert!(result.is_err());
assert!(
result
.expect_err("hash mismatch")
.to_string()
.contains("Installer hash mismatch")
);
}
#[test]
fn installer_hash_verification_allows_mismatch_with_override() {
verify_installer_hash(Some("DEADBEEF"), b"pinget", true).expect("hash override");
verify_installer_hash(None, b"pinget", false).expect("missing hash");
}
#[test]
fn search_query_many_includes_tag_and_command_conditions() {
let query = PackageQuery {
query: Some("terminal".to_owned()),
..PackageQuery::default()
};
let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
assert!(where_clause.contains("tags2"));
assert!(where_clause.contains("commands2"));
assert_eq!(params.len(), 5);
}
#[test]
fn show_query_single_omits_tag_and_command_conditions() {
let query = PackageQuery {
query: Some("terminal".to_owned()),
..PackageQuery::default()
};
let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Single);
assert!(!where_clause.contains("tags2"));
assert!(!where_clause.contains("commands2"));
assert_eq!(params.len(), 3);
}
#[test]
fn explicit_tag_filter_uses_exact_match() {
let query = PackageQuery {
tag: Some("terminal".to_owned()),
..PackageQuery::default()
};
let (where_clause, params) = build_preindexed_where_clause(&query, true, SearchSemantics::Many);
assert!(where_clause.contains("tags2"));
assert_eq!(params, vec!["terminal".to_owned()]);
}
#[test]
fn rest_tag_filter_uses_exact_match() {
let query = PackageQuery {
tag: Some("terminal".to_owned()),
..PackageQuery::default()
};
let info = RestInformation {
required_package_match_fields: Vec::new(),
unsupported_package_match_fields: Vec::new(),
required_query_parameters: Vec::new(),
..RestInformation::default()
};
let body = build_rest_search_body(&query, &info, SearchSemantics::Many).expect("rest body");
let filters = body["Filters"].as_array().expect("filters");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0]["PackageMatchField"], "Tag");
assert_eq!(filters[0]["RequestMatch"]["MatchType"], "Exact");
assert_eq!(filters[0]["RequestMatch"]["KeyWord"], "terminal");
}
#[test]
fn selects_installer_using_requested_filters() {
let installers = vec![
Installer {
architecture: Some("x86".to_owned()),
installer_type: Some("zip".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("msix".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: vec!["demo".to_owned()],
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
];
let query = PackageQuery {
installer_type: Some("msix".to_owned()),
installer_architecture: Some("x64".to_owned()),
install_scope: Some("user".to_owned()),
locale: Some("en-US".to_owned()),
..PackageQuery::default()
};
let selected = select_installer(&installers, &query).expect("selected installer");
assert_eq!(selected.installer_type.as_deref(), Some("msix"));
assert_eq!(selected.architecture.as_deref(), Some("x64"));
}
#[test]
fn selects_first_installer_when_rank_ties() {
let installers = vec![
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("machine".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
];
let selected = select_installer(&installers, &PackageQuery::default()).expect("selected installer");
assert_eq!(selected.scope.as_deref(), Some("machine"));
}
#[test]
fn selects_installer_using_requested_platform() {
let installers = vec![
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: vec!["Windows.Universal".to_owned()],
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: vec!["Windows.Desktop".to_owned()],
minimum_os_version: None,
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
];
let query = PackageQuery {
installer_type: Some("exe".to_owned()),
platform: Some("Windows.Desktop".to_owned()),
..PackageQuery::default()
};
let selected = select_installer(&installers, &query).expect("selected installer");
assert_eq!(selected.platforms, vec!["Windows.Desktop".to_owned()]);
}
#[test]
fn selects_installer_using_requested_os_version() {
let installers = vec![
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: Some("10.0.22621.0".to_owned()),
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
Installer {
architecture: Some("x64".to_owned()),
installer_type: Some("exe".to_owned()),
url: None,
sha256: None,
product_code: None,
locale: Some("en-US".to_owned()),
scope: Some("user".to_owned()),
release_date: None,
package_family_name: None,
upgrade_code: None,
platforms: Vec::new(),
minimum_os_version: Some("10.0.19041.0".to_owned()),
switches: InstallerSwitches::default(),
commands: Vec::new(),
package_dependencies: Vec::new(),
require_explicit_upgrade: false,
nested_installer_type: None,
nested_installer_files: Vec::new(),
},
];
let query = PackageQuery {
installer_type: Some("exe".to_owned()),
os_version: Some("10.0.19045.0".to_owned()),
..PackageQuery::default()
};
let selected = select_installer(&installers, &query).expect("selected installer");
assert_eq!(selected.minimum_os_version.as_deref(), Some("10.0.19041.0"));
}
#[test]
fn derives_correlation_name_candidates() {
let candidates = correlation_name_candidates("PowerToys (Preview) x64");
assert!(candidates.contains(&"PowerToys (Preview) x64".to_owned()));
assert!(candidates.contains(&"PowerToys".to_owned()));
}
#[test]
fn correlates_installed_package_to_available_match() {
let installed = InstalledPackage {
name: "PowerToys (Preview) x64".to_owned(),
local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
installed_version: "0.98.1".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.PowerToys".to_owned(),
name: "PowerToys".to_owned(),
moniker: None,
version: Some("0.98.1".to_owned()),
channel: None,
match_criteria: None,
}];
let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
assert_eq!(correlated.id, "Microsoft.PowerToys");
}
#[test]
fn list_query_uses_available_lookup_for_tag_filters() {
let query = ListQuery {
tag: Some("terminal".to_owned()),
..ListQuery::default()
};
assert!(list_query_needs_available_lookup(&query));
}
#[test]
fn list_tag_filter_requires_correlated_match() {
let package = InstalledPackage {
name: "PowerToys (Preview) x64".to_owned(),
local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
installed_version: "0.98.1".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let query = ListQuery {
tag: Some("powertoys".to_owned()),
..ListQuery::default()
};
assert!(!list_package_matches(&package, &query));
}
#[test]
fn list_lookup_ignores_display_count() {
let query = ListQuery {
query: Some("git".to_owned()),
count: Some(5),
..ListQuery::default()
};
let package_query = package_query_from_list_query(&query);
assert_eq!(package_query.count, Some(LIST_LOOKUP_MAX_RESULTS));
}
#[test]
fn correlation_tiebreaks_on_unnormalized_name_prefix() {
let installed = InstalledPackage {
name: "Notepad++ (ARM 64-bit)".to_owned(),
local_id: r"ARP\Machine\X64\Notepad++".to_owned(),
installed_version: "8.8.9".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "ndd.Notepad--".to_owned(),
name: "Notepad--".to_owned(),
moniker: None,
version: Some("1.0.0".to_owned()),
channel: None,
match_criteria: None,
},
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Notepad++.Notepad++".to_owned(),
name: "Notepad++".to_owned(),
moniker: None,
version: Some("8.9.5".to_owned()),
channel: None,
match_criteria: None,
},
];
let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
assert_eq!(correlated.id, "Notepad++.Notepad++");
}
#[test]
fn loose_correlation_prefers_anchored_candidate_over_word_fragment() {
let installed = InstalledPackage {
name: "Microsoft .NET SDK 10.0.101 (arm64) from Visual Studio".to_owned(),
local_id: r"ARP\Machine\X64\{7E9F8584-06E7-445E-9165-7486CC1B56C3}".to_owned(),
installed_version: "40.10.18029".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("msi".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "ZeroBrane.Studio".to_owned(),
name: "Studio".to_owned(),
moniker: None,
version: Some("1.0".to_owned()),
channel: None,
match_criteria: None,
},
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "BrickLink.Studio".to_owned(),
name: "Studio".to_owned(),
moniker: None,
version: Some("1.0".to_owned()),
channel: None,
match_criteria: None,
},
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.DotNet.SDK.10".to_owned(),
name: "Microsoft .NET SDK 10.0".to_owned(),
moniker: None,
version: Some("10.0.204".to_owned()),
channel: None,
match_criteria: None,
},
];
let correlated = correlate_installed_package(&installed, &candidates, true).expect("correlated");
assert_eq!(correlated.id, "Microsoft.DotNet.SDK.10");
}
#[test]
fn msix_packages_do_not_correlate_via_name() {
let installed = InstalledPackage {
name: "Microsoft Teams".to_owned(),
local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
installed_version: "25290.205.4069.4894".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("msix".to_owned()),
install_location: None,
package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.Teams".to_owned(),
name: "Microsoft Teams".to_owned(),
moniker: None,
version: Some("26106.1906.4665.7308".to_owned()),
channel: None,
match_criteria: None,
}];
assert!(correlate_installed_package(&installed, &candidates, true).is_none());
}
#[test]
fn name_fallback_refuses_ambiguous_winners() {
let installed = InstalledPackage {
name: "Git".to_owned(),
local_id: r"ARP\Machine\X64\Git_is1".to_owned(),
installed_version: "2.53.0".to_owned(),
publisher: Some("The Git Development Community".to_owned()),
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Git.Git".to_owned(),
name: "Git".to_owned(),
moniker: None,
version: Some("2.54.0".to_owned()),
channel: None,
match_criteria: None,
},
SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.Git".to_owned(),
name: "Git".to_owned(),
moniker: None,
version: Some("2.53.0.0.7".to_owned()),
channel: None,
match_criteria: None,
},
];
assert!(correlate_installed_package(&installed, &candidates, true).is_none());
}
#[test]
fn msix_with_resource_string_name_does_not_correlate() {
let installed = InstalledPackage {
name: "ms-resource:appDisplayName".to_owned(),
local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
installed_version: "1.28.239.0".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("msix".to_owned()),
install_location: None,
package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.AppInstaller".to_owned(),
name: "App Installer".to_owned(),
moniker: None,
version: Some("1.28.240.0".to_owned()),
channel: None,
match_criteria: None,
}];
assert!(correlate_installed_package(&installed, &candidates, true).is_none());
}
#[test]
fn strict_list_correlation_avoids_short_substring_matches() {
let installed = InstalledPackage {
name: "GitHub".to_owned(),
local_id: r"ARP\User\X64\GitHub".to_owned(),
installed_version: "1.0.0".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let candidates = vec![SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Git.Git.PreRelease".to_owned(),
name: "Git".to_owned(),
moniker: None,
version: Some("2.54.0".to_owned()),
channel: None,
match_criteria: None,
}];
assert!(correlate_installed_package(&installed, &candidates, false).is_none());
}
#[test]
fn detects_available_upgrade_from_correlated_package() {
let package = InstalledPackage {
name: "AzCopy v10".to_owned(),
local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
installed_version: "10.32.2".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.Azure.AZCopy.10".to_owned(),
name: "AzCopy v10".to_owned(),
moniker: None,
version: Some("10.32.3".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
assert!(installed_package_has_upgrade(&package));
}
#[test]
fn include_unknown_treats_unknown_version_as_upgradable_when_correlated() {
let package = InstalledPackage {
name: "Example Tool".to_owned(),
local_id: r"ARP\User\X64\ExampleTool".to_owned(),
installed_version: "Unknown".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Contoso.ExampleTool".to_owned(),
name: "Example Tool".to_owned(),
moniker: None,
version: Some("2.0.0".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let query = ListQuery {
upgrade_only: true,
include_unknown: true,
..Default::default()
};
assert!(installed_package_matches_upgrade_filter(&package, &query));
assert_eq!(
list_match_from_installed(package).available_version.as_deref(),
Some("2.0.0")
);
}
#[test]
fn create_repair_list_query_includes_installed_selectors() {
let request = RepairRequest {
query: PackageQuery {
query: Some("powertoys".to_owned()),
id: Some("Microsoft.PowerToys".to_owned()),
name: Some("PowerToys".to_owned()),
moniker: Some("powertoys".to_owned()),
source: Some("winget".to_owned()),
exact: true,
version: Some("0.98.1".to_owned()),
install_scope: Some("Machine".to_owned()),
..PackageQuery::default()
},
manifest_path: None,
product_code: Some("{GUID}".to_owned()),
mode: InstallerMode::Silent,
log_path: Some(PathBuf::from("repair.log")),
accept_package_agreements: true,
force: true,
ignore_security_hash: true,
};
let query = Repository::create_repair_list_query(&request);
assert_eq!(query.query.as_deref(), Some("powertoys"));
assert_eq!(query.id.as_deref(), Some("Microsoft.PowerToys"));
assert_eq!(query.name.as_deref(), Some("PowerToys"));
assert_eq!(query.moniker.as_deref(), Some("powertoys"));
assert_eq!(query.product_code.as_deref(), Some("{GUID}"));
assert_eq!(query.version.as_deref(), Some("0.98.1"));
assert_eq!(query.source.as_deref(), Some("winget"));
assert!(query.exact);
assert_eq!(query.install_scope.as_deref(), Some("Machine"));
assert_eq!(query.count, Some(100));
}
#[test]
fn create_repair_install_request_forces_reinstall_of_resolved_installed_package() {
let request = RepairRequest {
query: PackageQuery {
name: Some("PowerToys".to_owned()),
source: Some("winget".to_owned()),
locale: Some("en-US".to_owned()),
installer_architecture: Some("x64".to_owned()),
install_scope: Some("Machine".to_owned()),
..PackageQuery::default()
},
manifest_path: None,
product_code: None,
mode: InstallerMode::SilentWithProgress,
log_path: Some(PathBuf::from("repair.log")),
accept_package_agreements: true,
force: false,
ignore_security_hash: true,
};
let installed = ListMatch {
name: "PowerToys".to_owned(),
id: "Microsoft.PowerToys".to_owned(),
local_id: r"ARP\Machine\X64\Microsoft.PowerToys".to_owned(),
installed_version: "0.98.1".to_owned(),
available_version: Some("0.98.2".to_owned()),
source_name: Some("winget".to_owned()),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
};
let install_request = Repository::create_repair_install_request(&request, Some(&installed));
assert_eq!(install_request.query.id.as_deref(), Some("Microsoft.PowerToys"));
assert!(install_request.query.query.is_none());
assert!(install_request.query.name.is_none());
assert!(install_request.query.moniker.is_none());
assert_eq!(install_request.query.source.as_deref(), Some("winget"));
assert_eq!(install_request.query.version.as_deref(), Some("0.98.1"));
assert_eq!(install_request.query.locale.as_deref(), Some("en-US"));
assert_eq!(install_request.query.installer_architecture.as_deref(), Some("x64"));
assert_eq!(install_request.query.install_scope.as_deref(), Some("Machine"));
assert!(install_request.query.exact);
assert_eq!(install_request.mode, InstallerMode::SilentWithProgress);
assert_eq!(install_request.log_path, Some(PathBuf::from("repair.log")));
assert!(install_request.accept_package_agreements);
assert!(install_request.force);
assert!(install_request.ignore_security_hash);
}
#[test]
fn source_scoped_pin_operations_round_trip() {
let app_root = temp_app_root("pins");
let result = (|| -> Result<()> {
let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
repository.add_pin("Contoso.Tool", "1.0", "winget", PinType::Pinning)?;
repository.add_pin("Contoso.Tool", "*", "test", PinType::Blocking)?;
repository.add_pin("Fabrikam.Tool", "*", "", PinType::Pinning)?;
assert_eq!(repository.list_pins(None)?.len(), 3);
assert_eq!(repository.list_pins(Some("winget"))?.len(), 1);
assert_eq!(repository.list_pins(Some("test"))?.len(), 1);
assert!(repository.remove_pin("Contoso.Tool", Some("winget"))?);
assert_eq!(repository.list_pins(None)?.len(), 2);
assert!(!repository.remove_pin("Contoso.Tool", Some("winget"))?);
repository.reset_pins(Some("test"))?;
let remaining = repository.list_pins(None)?;
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].package_id, "Fabrikam.Tool");
repository.reset_pins(None)?;
assert!(repository.list_pins(None)?.is_empty());
Ok(())
})();
let _ = fs::remove_dir_all(&app_root);
result.expect("pin round trip");
}
#[test]
fn pin_operations_work_with_packaged_pin_schema() {
let app_root = temp_app_root("pins_packaged_schema");
let result = (|| -> Result<()> {
let db_path = pins_db_path(&app_root);
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)?;
}
let conn = Repository::open_sqlite_connection(db_path.clone())?;
execute_batch_sql(
&conn,
"CREATE TABLE pin (
package_id TEXT NOT NULL,
source_id TEXT NOT NULL,
type INTEGER NOT NULL,
version TEXT NOT NULL,
PRIMARY KEY (package_id, source_id)
)",
)?;
conn.cacheflush().context("failed to flush pin database")?;
drop(conn);
let repository = Repository::open_with_options(RepositoryOptions::new(app_root.clone()))?;
repository.add_pin("Contoso.Tool", "1.2.*", "winget", PinType::Gating)?;
let pins = repository.list_pins(None)?;
assert_eq!(pins.len(), 1);
assert_eq!(pins[0].package_id, "Contoso.Tool");
assert_eq!(pins[0].version, "1.2.*");
assert_eq!(pins[0].source_id, "winget");
assert_eq!(pins[0].pin_type, PinType::Gating);
let conn = Repository::open_sqlite_connection(db_path)?;
let stored_pin = query_optional_value(
&conn,
"SELECT type, version FROM pin WHERE package_id = ?1 AND source_id = ?2",
vec![
SqlValue::Text("Contoso.Tool".to_owned()),
SqlValue::Text("winget".to_owned()),
],
|row| Ok((row_i64(row, 0)?, row_string(row, 1)?)),
)?
.ok_or_else(|| anyhow!("expected stored pin"))?;
assert_eq!(stored_pin.0, 3);
assert_eq!(stored_pin.1, "1.2.*");
Ok(())
})();
let _ = fs::remove_dir_all(&app_root);
result.expect("packaged pin schema round trip");
}
#[test]
fn version_pin_patterns_match_exact_and_prefix_values() {
assert!(version_matches_pin_pattern("1.2.3", "*"));
assert!(version_matches_pin_pattern("1.2.3", "1.2.3"));
assert!(version_matches_pin_pattern("1.2.3", "1.2.*"));
assert!(!version_matches_pin_pattern("1.3.0", "1.2.*"));
assert!(!version_matches_pin_pattern("1.2.3", "2.0.0"));
}
#[test]
fn source_specific_pins_override_source_agnostic_pins() {
let item = ListMatch {
name: "Contoso Tool".to_owned(),
id: "Contoso.Tool".to_owned(),
local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
installed_version: "1.0.0".to_owned(),
available_version: Some("2.0.0".to_owned()),
source_name: Some("winget".to_owned()),
publisher: None,
scope: None,
installer_category: None,
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
};
let pins = vec![
PinRecord {
package_id: "Contoso.Tool".to_owned(),
version: "*".to_owned(),
source_id: String::new(),
pin_type: PinType::Blocking,
},
PinRecord {
package_id: "Contoso.Tool".to_owned(),
version: "2.0.*".to_owned(),
source_id: "winget".to_owned(),
pin_type: PinType::Pinning,
},
];
assert!(!is_upgrade_blocked_by_pin(&item, &pins));
}
#[test]
fn blocking_pin_blocks_upgrade() {
let item = ListMatch {
name: "Contoso Tool".to_owned(),
id: "Contoso.Tool".to_owned(),
local_id: r"ARP\User\X64\Contoso.Tool".to_owned(),
installed_version: "1.0.0".to_owned(),
available_version: Some("2.0.0".to_owned()),
source_name: Some("winget".to_owned()),
publisher: None,
scope: None,
installer_category: None,
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
};
let pins = vec![PinRecord {
package_id: "Contoso.Tool".to_owned(),
version: "*".to_owned(),
source_id: "winget".to_owned(),
pin_type: PinType::Blocking,
}];
assert!(is_upgrade_blocked_by_pin(&item, &pins));
}
#[cfg(windows)]
#[test]
fn parses_msix_package_full_name_into_version_and_family() {
let parsed = parse_msix_package_full_name("Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe")
.expect("package metadata");
assert_eq!(parsed.version, "0.98.1.0");
assert_eq!(parsed.family_name, "Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe");
}
#[cfg(windows)]
#[test]
fn recognizes_windows_system_paths() {
assert!(is_windows_system_path(r"C:\Windows\SystemApps\Contoso"));
assert!(!is_windows_system_path(
r"C:\Users\mamoreau\AppData\Local\PowerToys\WinUI3Apps"
));
}
#[test]
fn search_ranking_prefers_exact_name_over_tag_match() {
let query = PackageQuery {
query: Some("PowerToys".to_owned()),
..Default::default()
};
let exact_name = SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.PowerToys".to_owned(),
name: "PowerToys".to_owned(),
moniker: Some("powertoys".to_owned()),
version: Some("0.98.1".to_owned()),
channel: None,
match_criteria: Some("Moniker: powertoys".to_owned()),
};
let tag_match = SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "JiriPolasek.QRCodesforCommandPalette".to_owned(),
name: "QR Codes for Command Palette".to_owned(),
moniker: None,
version: Some("0.4.0.0".to_owned()),
channel: None,
match_criteria: Some("Tag: microsoft-powertoys".to_owned()),
};
assert!(search_match_sort_score(&exact_name, &query) > search_match_sort_score(&tag_match, &query));
}
#[test]
fn select_best_text_match_prefers_exact_tag_value() {
let values = vec![
"microsoft-powertoys".to_owned(),
"powertoys-run".to_owned(),
"powertoys".to_owned(),
];
assert_eq!(
select_best_text_match(values, "PowerToys", false).as_deref(),
Some("powertoys")
);
}
#[test]
fn search_match_criteria_is_blank_for_name_match() {
let query = PackageQuery {
query: Some("PowerToys".to_owned()),
..Default::default()
};
let result = infer_match_criteria(
"Microsoft.PowerToys",
"PowerToys",
Some("powertoys"),
&query,
SearchSemantics::Many,
|_| Ok(Some("powertoys".to_owned())),
|_| Ok(None),
)
.expect("match criteria");
assert_eq!(result, None);
}
#[test]
fn search_ranking_prefers_direct_name_match_even_with_unknown_version() {
let query = PackageQuery {
query: Some("PowerToys".to_owned()),
..Default::default()
};
let unknown_version = SearchMatch {
source_name: "msstore".to_owned(),
source_kind: SourceKind::Rest,
id: "XP89DCGQ3K6VLD".to_owned(),
name: "Microsoft PowerToys".to_owned(),
moniker: None,
version: Some("Unknown".to_owned()),
channel: None,
match_criteria: None,
};
let tag_match = SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Riri.QRCodeforCmdPal".to_owned(),
name: "QR Code for CmdPal".to_owned(),
moniker: None,
version: Some("0.0.1.0".to_owned()),
channel: None,
match_criteria: Some("Tag: powertoys".to_owned()),
};
assert!(search_match_sort_score(&unknown_version, &query) > search_match_sort_score(&tag_match, &query));
}
#[test]
fn search_source_fetch_results_ignore_small_display_count() {
let query = PackageQuery {
count: Some(5),
..Default::default()
};
assert_eq!(source_fetch_results(&query, SearchSemantics::Many), 50);
assert_eq!(source_fetch_results(&query, SearchSemantics::Single), 5);
}
#[test]
fn search_ranking_prefers_direct_name_prefix_over_tag_only_match() {
let query = PackageQuery {
query: Some("PowerToys".to_owned()),
..Default::default()
};
let prefix_name = SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "advaith.CurrencyConverterPowerToys".to_owned(),
name: "PowerToys-Run-Currency-Converter".to_owned(),
moniker: None,
version: Some("1.5.4".to_owned()),
channel: None,
match_criteria: None,
};
let exact_tag = SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Riri.QRCodeforCmdPal".to_owned(),
name: "QR Code for CmdPal".to_owned(),
moniker: None,
version: Some("0.0.1.0".to_owned()),
channel: None,
match_criteria: Some("Tag: powertoys".to_owned()),
};
assert!(search_match_sort_score(&prefix_name, &query) > search_match_sort_score(&exact_tag, &query));
}
#[test]
fn list_sort_prefers_main_package_then_sparse_app() {
let main = InstalledPackage {
name: "PowerToys (Preview) x64".to_owned(),
local_id: r"ARP\User\X64\PowerToys".to_owned(),
installed_version: "0.98.1".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let sparse = InstalledPackage {
name: "PowerToys.SparseApp".to_owned(),
local_id: r"MSIX\Microsoft.PowerToys.SparseApp_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
installed_version: "0.98.1.0".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("msix".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let extension = InstalledPackage {
name: "PowerToys FileLocksmith Context Menu".to_owned(),
local_id: r"MSIX\Microsoft.PowerToys.FileLocksmithContextMenu_0.98.1.0_neutral__8wekyb3d8bbwe".to_owned(),
installed_version: "0.98.1.0".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("msix".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
assert!(list_sort_weight(&main) < list_sort_weight(&sparse));
assert!(list_sort_weight(&sparse) < list_sort_weight(&extension));
}
#[test]
#[cfg(windows)]
fn arp_subkey_name_extracts_registry_subkey() {
assert_eq!(Some("ShareX"), arp_subkey_name(r"ARP\Machine\X64\ShareX"));
assert_eq!(None, arp_subkey_name(r"MSIX\ShareX_19.0.2_x64__name"));
}
#[test]
#[cfg(windows)]
fn registry_entry_matching_prefers_local_identity_over_correlated_id() {
let installed = ListMatch {
name: "ShareX".to_owned(),
id: "ShareX.ShareX".to_owned(),
local_id: r"ARP\Machine\X64\ShareX".to_owned(),
installed_version: "19.0.2".to_owned(),
available_version: None,
source_name: Some("winget".to_owned()),
publisher: Some("ShareX Team".to_owned()),
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: Some(r"C:\Program Files\ShareX".to_owned()),
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
};
assert!(registry_entry_matches_installed_package(
"ShareX", "ShareX", None, &installed
));
assert!(!registry_entry_matches_installed_package(
"ShareX.ShareX",
"ShareX.ShareX",
None,
&installed
));
}
#[test]
#[cfg(windows)]
fn build_uninstall_command_only_appends_silent_flag_when_needed() {
assert_eq!(
r#""C:\Program Files\ShareX\unins000.exe" /S"#,
build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, true, false)
);
assert_eq!(
r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#,
build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe" /VERYSILENT"#, true, false)
);
assert_eq!(
r#""C:\Program Files\ShareX\unins000.exe""#,
build_uninstall_command(r#""C:\Program Files\ShareX\unins000.exe""#, false, false)
);
assert_eq!(
"winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
build_uninstall_command(
"winget uninstall --product-code JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe",
true,
false,
)
);
}
#[test]
#[cfg(windows)]
fn build_winget_uninstall_arguments_prefers_correlated_identity() {
let installed = ListMatch {
name: "lazygit".to_owned(),
id: "JesseDuffield.lazygit".to_owned(),
local_id: r"ARP\User\X64\JesseDuffield.lazygit".to_owned(),
installed_version: "0.61.1".to_owned(),
available_version: None,
source_name: Some("winget".to_owned()),
publisher: Some("Jesse Duffield".to_owned()),
scope: Some("User".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: Some(
r"C:\Users\test\AppData\Local\Microsoft\WinGet\Packages\JesseDuffield.lazygit".to_owned(),
),
package_family_names: Vec::new(),
product_codes: vec!["JesseDuffield.lazygit_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned()],
upgrade_codes: Vec::new(),
};
let mut request = UninstallRequest::new(PackageQuery {
id: Some("JesseDuffield.lazygit".to_owned()),
install_scope: Some("user".to_owned()),
..PackageQuery::default()
});
request.mode = InstallerMode::Silent;
assert_eq!(
Some(vec![
"uninstall".to_owned(),
"--id".to_owned(),
"JesseDuffield.lazygit".to_owned(),
"--exact".to_owned(),
"--disable-interactivity".to_owned(),
"--source".to_owned(),
"winget".to_owned(),
"--scope".to_owned(),
"user".to_owned(),
"--silent".to_owned(),
]),
build_winget_uninstall_arguments(&installed, &request)
);
assert_eq!(
Some(vec![
"uninstall".to_owned(),
"--id".to_owned(),
"JesseDuffield.lazygit".to_owned(),
"--exact".to_owned(),
"--disable-interactivity".to_owned(),
"--source".to_owned(),
"winget".to_owned(),
"--silent".to_owned(),
]),
build_winget_uninstall_arguments_with_scope(&installed, &request, false)
);
}
fn version_entry(version: &str, arp_min: Option<&str>, arp_max: Option<&str>) -> PackageVersionDataEntry {
PackageVersionDataEntry {
version: version.to_owned(),
manifest_relative_path: format!("manifests/x/{version}"),
manifest_hash: "00".repeat(32),
arp_min_version: arp_min.map(str::to_owned),
arp_max_version: arp_max.map(str::to_owned),
}
}
fn v1_version(version: &str, channel: &str) -> V1VersionRow {
V1VersionRow {
version: version.to_owned(),
channel: channel.to_owned(),
pathpart_id: 0,
manifest_hash: None,
}
}
#[derive(Clone)]
struct TestHttpResponse {
status: u16,
body: Vec<u8>,
source_version: Option<String>,
}
struct TestHttpServer {
addr: SocketAddr,
routes: Arc<Mutex<HashMap<String, TestHttpResponse>>>,
counts: Arc<Mutex<HashMap<String, usize>>>,
running: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl TestHttpServer {
fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test HTTP server");
let addr = listener.local_addr().expect("test HTTP server address");
let routes = Arc::new(Mutex::new(HashMap::new()));
let counts = Arc::new(Mutex::new(HashMap::new()));
let running = Arc::new(AtomicBool::new(true));
let thread_routes = Arc::clone(&routes);
let thread_counts = Arc::clone(&counts);
let thread_running = Arc::clone(&running);
let handle = thread::spawn(move || {
while thread_running.load(AtomicOrdering::SeqCst) {
let Ok((stream, _)) = listener.accept() else {
continue;
};
handle_test_http_request(stream, &thread_routes, &thread_counts);
}
});
Self {
addr,
routes,
counts,
running,
handle: Some(handle),
}
}
fn url(&self) -> String {
format!("http://{}", self.addr)
}
fn set_bytes(&self, path: &str, body: Vec<u8>) {
self.set_response(
path,
TestHttpResponse {
status: 200,
body,
source_version: None,
},
);
}
fn set_source_package(&self, body: Vec<u8>, source_version: &str) {
self.set_response(
"/source.msix",
TestHttpResponse {
status: 200,
body,
source_version: Some(source_version.to_owned()),
},
);
}
fn set_status(&self, path: &str, status: u16) {
self.set_response(
path,
TestHttpResponse {
status,
body: Vec::new(),
source_version: None,
},
);
}
fn set_response(&self, path: &str, response: TestHttpResponse) {
self.routes
.lock()
.expect("test HTTP routes")
.insert(path.to_owned(), response);
}
fn request_count(&self, path: &str) -> usize {
self.counts
.lock()
.expect("test HTTP counts")
.get(path)
.copied()
.unwrap_or_default()
}
}
impl Drop for TestHttpServer {
fn drop(&mut self) {
self.running.store(false, AtomicOrdering::SeqCst);
let _ = TcpStream::connect(self.addr);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
fn handle_test_http_request(
mut stream: TcpStream,
routes: &Arc<Mutex<HashMap<String, TestHttpResponse>>>,
counts: &Arc<Mutex<HashMap<String, usize>>>,
) {
let mut buffer = [0u8; 2048];
let read = stream.read(&mut buffer).unwrap_or(0);
let request = String::from_utf8_lossy(&buffer[..read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
*counts
.lock()
.expect("test HTTP counts")
.entry(path.to_owned())
.or_default() += 1;
let response = routes.lock().expect("test HTTP routes").get(path).cloned();
let response = response.unwrap_or(TestHttpResponse {
status: 404,
body: Vec::new(),
source_version: None,
});
let reason = if response.status == 200 { "OK" } else { "Not Found" };
let mut headers = format!(
"HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n",
response.status,
reason,
response.body.len()
);
if let Some(source_version) = response.source_version {
headers.push_str(&format!("x-ms-meta-sourceversion: {source_version}\r\n"));
}
headers.push_str("\r\n");
stream.write_all(headers.as_bytes()).expect("write test HTTP headers");
stream.write_all(&response.body).expect("write test HTTP body");
}
fn test_source_record(source_url: String) -> SourceRecord {
SourceRecord {
name: "test-winget".to_owned(),
kind: SourceKind::PreIndexed,
arg: source_url,
identifier: "Test.Source".to_owned(),
trust_level: "Trusted".to_owned(),
explicit: false,
priority: 0,
last_update: Some(Utc::now()),
source_version: None,
}
}
fn open_test_preindexed_repository(
app_root: &Path,
source: &SourceRecord,
interval: Option<Duration>,
) -> Repository {
fs::create_dir_all(app_root).expect("create app root");
save_store(
app_root,
&SourceStore {
sources: vec![source.clone()],
},
)
.expect("save source store");
Repository::open_with_options(
RepositoryOptions::new(app_root.to_path_buf()).with_pre_indexed_source_auto_update_interval(interval),
)
.expect("open repository")
}
fn write_test_preindexed_index(app_root: &Path, source: &SourceRecord, versions: &[&str]) {
let index_path = preindexed_index_path(app_root, source);
if let Some(parent) = index_path.parent() {
fs::create_dir_all(parent).expect("create source state directory");
}
fs::write(index_path, make_v1_index_bytes(versions)).expect("write preindexed index");
}
#[test]
fn replace_file_failure_keeps_existing_target() {
let root = temp_app_root("replace_file_failure");
fs::create_dir_all(&root).expect("create replace root");
let target = root.join("index.db");
let missing_source = root.join("missing.tmp");
fs::write(&target, b"old-index").expect("write existing target");
let error = replace_file(&missing_source, &target).expect_err("missing replacement should fail");
assert!(format!("{error:#}").contains("failed to replace"));
assert_eq!(fs::read(&target).expect("existing target remains"), b"old-index");
let _ = fs::remove_dir_all(root);
}
fn make_source_package(versions: &[&str]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
archive
.start_file("Public/index.db", options)
.expect("start index entry");
archive
.write_all(&make_v1_index_bytes(versions))
.expect("write index entry");
archive.finish().expect("finish source package").into_inner()
}
fn make_v1_index_bytes(versions: &[&str]) -> Vec<u8> {
let root = temp_app_root("preindexed_index_bytes");
fs::create_dir_all(&root).expect("create index temp root");
let db_path = root.join("index.db");
let connection = Repository::open_sqlite_connection(db_path.clone()).expect("open index db");
let mut sql = String::from(
"CREATE TABLE ids (id TEXT);
CREATE TABLE names (name TEXT);
CREATE TABLE monikers (moniker TEXT);
CREATE TABLE versions (version TEXT);
CREATE TABLE channels (channel TEXT);
CREATE TABLE pathparts (parent INT64, pathpart TEXT);
CREATE TABLE manifest (
id INT64,
name INT64,
moniker INT64,
version INT64,
channel INT64,
pathpart INT64
);
INSERT INTO ids(rowid, id) VALUES (1, 'Test.Package');
INSERT INTO names(rowid, name) VALUES (1, 'Test Package');
INSERT INTO channels(rowid, channel) VALUES (1, '');
INSERT INTO pathparts(rowid, parent, pathpart) VALUES (1, NULL, 'manifests');
INSERT INTO pathparts(rowid, parent, pathpart) VALUES (2, 1, 'Test.Package.yaml');",
);
for (index, version) in versions.iter().enumerate() {
let rowid = index + 1;
sql.push_str(&format!(
"INSERT INTO versions(rowid, version) VALUES ({rowid}, '{version}');
INSERT INTO manifest(id, name, moniker, version, channel, pathpart) VALUES (1, 1, NULL, {rowid}, 1, 2);"
));
}
execute_batch_sql(&connection, &sql).expect("seed preindexed V1 database");
let _ = query_rows(&connection, "PRAGMA wal_checkpoint(TRUNCATE)", Vec::new(), |_| Ok(()))
.expect("checkpoint preindexed V1 database");
connection.cacheflush().expect("flush preindexed V1 database");
drop(connection);
let bytes = fs::read(&db_path).expect("read index bytes");
let _ = fs::remove_dir_all(root);
bytes
}
fn test_manifest_yaml(version: &str) -> Vec<u8> {
format!(
"PackageIdentifier: Test.Package\nPackageVersion: {version}\nPackageName: Test Package\nPublisher: Test\n"
)
.into_bytes()
}
fn show_test_package(version: Option<&str>) -> PackageQuery {
PackageQuery {
id: Some("Test.Package".to_owned()),
source: Some("test-winget".to_owned()),
exact: true,
version: version.map(str::to_owned),
..PackageQuery::default()
}
}
#[test]
fn preindexed_explicit_missing_version_refreshes_once_and_returns_requested_version() {
let app_root = temp_app_root("preindexed_explicit_refresh");
let server = TestHttpServer::start();
server.set_status("/source2.msix", 404);
server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "2");
server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
let source = test_source_record(server.url());
write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
let mut repository = open_test_preindexed_repository(&app_root, &source, None);
let result = repository
.show(&show_test_package(Some("2.0.0")))
.expect("show after refresh");
assert_eq!(result.manifest.version, "2.0.0");
assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("2"));
let _ = fs::remove_dir_all(app_root);
}
#[test]
fn preindexed_explicit_missing_version_fails_after_refresh_instead_of_returning_latest() {
let app_root = temp_app_root("preindexed_explicit_still_missing");
let server = TestHttpServer::start();
server.set_status("/source2.msix", 404);
server.set_source_package(make_source_package(&["1.0.0"]), "same");
let source = test_source_record(server.url());
write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
let mut repository = open_test_preindexed_repository(&app_root, &source, None);
let error = repository
.show(&show_test_package(Some("2.0.0")))
.expect_err("missing explicit version must fail");
assert!(format!("{error:#}").contains("2.0.0"));
let _ = fs::remove_dir_all(app_root);
}
#[test]
fn preindexed_stale_index_refreshes_for_latest_requests() {
let app_root = temp_app_root("preindexed_ttl_refresh");
let server = TestHttpServer::start();
server.set_status("/source2.msix", 404);
server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("2.0.0"));
let mut source = test_source_record(server.url());
source.last_update = Some(Utc::now() - Duration::days(2));
write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
let result = repository
.show(&show_test_package(None))
.expect("show latest after TTL refresh");
assert_eq!(result.manifest.version, "2.0.0");
assert_eq!(repository.list_sources()[0].source_version.as_deref(), Some("fresh"));
let _ = fs::remove_dir_all(app_root);
}
#[test]
fn preindexed_stale_refresh_failure_falls_back_for_latest_requests() {
let app_root = temp_app_root("preindexed_ttl_fallback");
let server = TestHttpServer::start();
server.set_status("/source2.msix", 404);
server.set_status("/source.msix", 404);
server.set_bytes("/manifests/Test.Package.yaml", test_manifest_yaml("1.0.0"));
let mut source = test_source_record(server.url());
source.last_update = Some(Utc::now() - Duration::days(2));
write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
let result = repository
.show(&show_test_package(None))
.expect("show latest from stale cache after refresh failure");
assert_eq!(result.manifest.version, "1.0.0");
let _ = fs::remove_dir_all(app_root);
}
#[test]
fn preindexed_explicit_missing_version_does_not_refresh_twice_after_stale_refresh() {
let app_root = temp_app_root("preindexed_no_double_refresh");
let server = TestHttpServer::start();
server.set_status("/source2.msix", 404);
server.set_source_package(make_source_package(&["1.0.0", "2.0.0"]), "fresh");
let mut source = test_source_record(server.url());
source.last_update = Some(Utc::now() - Duration::days(2));
write_test_preindexed_index(&app_root, &source, &["1.0.0"]);
let mut repository = open_test_preindexed_repository(&app_root, &source, Some(Duration::minutes(1)));
let error = repository
.show(&show_test_package(Some("3.0.0")))
.expect_err("still-missing explicit version must fail after one stale refresh");
assert!(format!("{error:#}").contains("3.0.0"));
assert_eq!(server.request_count("/source.msix"), 1);
let _ = fs::remove_dir_all(app_root);
}
#[test]
fn select_v2_version_without_explicit_filters_returns_latest() {
let versions = vec![
version_entry("1.0.0", None, None),
version_entry("1.2.0", None, None),
version_entry("1.1.0", None, None),
];
let selected = select_v2_version(&versions, None, None, "Test.Package", "test").expect("latest version");
assert_eq!(selected.version, "1.2.0");
}
#[test]
fn select_v2_version_with_missing_requested_version_throws() {
let versions = vec![version_entry("1.2.0", None, None), version_entry("1.1.0", None, None)];
let error = select_v2_version(&versions, Some("9.9.9"), None, "Test.Package", "test")
.expect_err("missing requested version");
assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
assert!(error.to_string().contains("9.9.9"));
}
#[test]
fn select_v2_version_with_requested_channel_throws_instead_of_ignoring_channel() {
let versions = vec![version_entry("1.2.0", None, None)];
let error = select_v2_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
.expect_err("unsupported channel request");
assert!(error.to_string().contains("beta"));
assert!(error.to_string().contains("channel metadata"));
}
#[test]
fn select_v1_version_channel_only_returns_latest_matching_channel() {
let versions = vec![
v1_version("1.2.0", "stable"),
v1_version("1.3.0", "stable"),
v1_version("2.0.0", "preview"),
];
let selected =
select_v1_version(&versions, None, Some("stable"), "Test.Package", "test").expect("latest stable version");
assert_eq!(selected.version, "1.3.0");
}
#[test]
fn select_rest_version_channel_mismatch_throws() {
let versions = vec![
VersionKey {
version: "1.2.0".to_owned(),
channel: "stable".to_owned(),
},
VersionKey {
version: "1.1.0".to_owned(),
channel: "beta".to_owned(),
},
];
let error = select_rest_version(&versions, Some("1.2.0"), Some("beta"), "Test.Package", "test")
.expect_err("missing version and channel pair");
assert!(error.downcast_ref::<MissingPackageVersionError>().is_some());
}
#[test]
fn map_arp_version_returns_catalog_version_inside_range() {
let entries = vec![
version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
version_entry("10.0.107", Some("10.1.726.21808"), Some("10.1.726.21808")),
];
assert_eq!(
map_arp_version_to_catalog(&entries, "10.1.826.23019").as_deref(),
Some("10.0.108")
);
}
#[test]
fn map_arp_version_returns_none_when_no_range_matches() {
let entries = vec![
version_entry("10.0.300", Some("10.3.26.23102"), Some("10.3.26.23102")),
version_entry("10.0.108", Some("10.1.826.23019"), Some("10.1.826.23019")),
];
assert!(map_arp_version_to_catalog(&entries, "40.10.18029").is_none());
}
#[test]
fn map_arp_version_skips_entries_missing_arp_bounds() {
let entries = vec![
version_entry("9.0.300", None, None),
version_entry("9.0.117", Some("9.1.1726.23010"), Some("9.1.1726.23010")),
];
assert_eq!(
map_arp_version_to_catalog(&entries, "9.1.1726.23010").as_deref(),
Some("9.0.117")
);
assert!(map_arp_version_to_catalog(&entries, "1.0.0").is_none());
}
#[test]
fn map_arp_version_handles_inclusive_range_endpoints() {
let entries = vec![version_entry("3.14.5", Some("3.14.5.0"), Some("3.14.5.999"))];
assert_eq!(
map_arp_version_to_catalog(&entries, "3.14.5.0").as_deref(),
Some("3.14.5")
);
assert_eq!(
map_arp_version_to_catalog(&entries, "3.14.5.999").as_deref(),
Some("3.14.5")
);
assert_eq!(
map_arp_version_to_catalog(&entries, "3.14.5.500").as_deref(),
Some("3.14.5")
);
}
#[test]
fn map_arp_version_ignores_unknown_marker() {
let entries = vec![version_entry("1.0.0", Some("1.0.0"), Some("1.0.0"))];
assert!(map_arp_version_to_catalog(&entries, "Unknown").is_none());
assert!(map_arp_version_to_catalog(&entries, "").is_none());
}
#[test]
fn latest_arp_anchored_skips_internal_versions() {
let entries = vec![
version_entry("8000.836.2153.0", None, None),
version_entry("1.8.6", Some("8000.806.2252.0"), Some("8000.806.2252.0")),
version_entry("1.8.5", Some("8000.770.947.0"), Some("8000.770.947.0")),
version_entry("1.8.0", Some("8000.616.304.0"), Some("8000.616.304.0")),
];
assert_eq!(latest_arp_anchored_version(&entries).as_deref(), Some("1.8.6"));
}
#[test]
fn latest_arp_anchored_returns_none_without_bounds() {
let entries = vec![
version_entry("1.28.240.0", None, None),
version_entry("1.27.470.0", None, None),
];
assert!(latest_arp_anchored_version(&entries).is_none());
}
#[test]
fn list_match_from_installed_prefers_correlated_id_and_source_name() {
let package = InstalledPackage {
name: "AzCopy v10".to_owned(),
local_id: r"ARP\Machine\X64\AzCopy".to_owned(),
installed_version: "10.32.2".to_owned(),
publisher: Some("Microsoft".to_owned()),
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.Azure.AZCopy.10".to_owned(),
name: "AzCopy v10".to_owned(),
moniker: None,
version: Some("10.32.3".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let item = list_match_from_installed(package);
assert_eq!(item.id, "Microsoft.Azure.AZCopy.10");
assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy");
assert_eq!(item.source_name.as_deref(), Some("winget"));
assert_eq!(item.available_version.as_deref(), Some("10.32.3"));
}
#[test]
fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() {
let package = InstalledPackage {
name: "Atlassian CLI".to_owned(),
local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(),
installed_version: "1.3.18-stable".to_owned(),
publisher: Some("Atlassian".to_owned()),
scope: Some("User".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Atlassian.AtlassianCLI".to_owned(),
name: "Atlassian CLI".to_owned(),
moniker: None,
version: Some("1.3.18-stable".to_owned()),
channel: None,
match_criteria: Some("ProductCode".to_owned()),
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let item = list_match_from_installed(package);
assert_eq!(item.id, "Atlassian.AtlassianCLI");
assert_eq!(
item.local_id,
r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe"
);
assert_ne!(item.id, item.local_id);
assert_eq!(item.source_name.as_deref(), Some("winget"));
}
#[test]
fn list_response_serialization_keeps_pascal_case_fields() {
let response = ListResponse {
matches: vec![ListMatch {
name: "PowerToys".to_owned(),
id: "Microsoft.PowerToys".to_owned(),
local_id: r"ARP\Machine\X64\PowerToys".to_owned(),
installed_version: "0.98.1".to_owned(),
available_version: Some("0.99.0".to_owned()),
source_name: Some("winget".to_owned()),
publisher: Some("Microsoft".to_owned()),
scope: Some("Machine".to_owned()),
installer_category: Some("msi".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
}],
warnings: Vec::new(),
truncated: false,
};
let value = serde_json::to_value(&response).expect("serialize list response");
let match_value = &value["Matches"][0];
assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys");
assert_eq!(match_value["InstalledVersion"], "0.98.1");
assert_eq!(match_value["AvailableVersion"], "0.99.0");
assert_eq!(match_value["SourceName"], "winget");
assert!(match_value.get("local_id").is_none());
assert!(match_value.get("installed_version").is_none());
assert!(match_value.get("available_version").is_none());
assert!(match_value.get("source_name").is_none());
}
#[test]
fn search_response_serialization_preserves_minimum_pascal_case_shape() {
let response = SearchResponse {
matches: vec![SearchMatch {
source_name: "msstore".to_owned(),
source_kind: SourceKind::Rest,
id: "9WZDNCRFJBMP".to_owned(),
name: "Microsoft To Do".to_owned(),
moniker: None,
version: Some("2.123.456.0".to_owned()),
channel: None,
match_criteria: None,
}],
warnings: Vec::new(),
truncated: false,
};
let value = serde_json::to_value(&response).expect("serialize search response");
let match_value = &value["Matches"][0];
assert_eq!(match_value["Name"], "Microsoft To Do");
assert_eq!(match_value["Id"], "9WZDNCRFJBMP");
assert_eq!(match_value["Version"], "2.123.456.0");
assert_eq!(match_value["SourceName"], "msstore");
assert!(match_value.get("source_name").is_none());
}
#[test]
fn versions_result_serialization_keeps_pascal_case_fields() {
let result = VersionsResult {
package: SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.PowerToys".to_owned(),
name: "PowerToys".to_owned(),
moniker: None,
version: Some("0.99.0".to_owned()),
channel: None,
match_criteria: None,
},
versions: vec![
VersionKey {
version: "0.99.0".to_owned(),
channel: "stable".to_owned(),
},
VersionKey {
version: "0.100.0-preview".to_owned(),
channel: "preview".to_owned(),
},
],
warnings: Vec::new(),
};
let value = serde_json::to_value(&result).expect("serialize versions result");
assert_eq!(value["Versions"][0]["Version"], "0.99.0");
assert_eq!(value["Versions"][0]["Channel"], "stable");
assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview");
assert_eq!(value["Versions"][1]["Channel"], "preview");
assert!(value.get("versions").is_none());
}
#[test]
fn version_data_parses_arp_bounds_from_winget_payload() {
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";
let document = serde_yaml::from_str::<PackageVersionDataDocument>(payload).expect("parse versionData.mszyml");
assert_eq!(document.versions.len(), 2);
assert_eq!(document.versions[0].arp_min_version.as_deref(), Some("10.3.26.23102"));
assert_eq!(document.versions[0].arp_max_version.as_deref(), Some("10.3.26.23102"));
assert!(document.versions[1].arp_min_version.is_none());
assert!(document.versions[1].arp_max_version.is_none());
}
#[test]
fn msix_resource_string_name_resolves_to_catalog_name() {
let mut package = InstalledPackage {
name: "ms-resource:appDisplayName".to_owned(),
local_id: r"MSIX\Microsoft.DesktopAppInstaller_1.28.239.0_arm64__8wekyb3d8bbwe".to_owned(),
installed_version: "1.28.239.0".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("msix".to_owned()),
install_location: None,
package_family_names: vec!["Microsoft.DesktopAppInstaller_8wekyb3d8bbwe".to_owned()],
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.AppInstaller".to_owned(),
name: "App Installer".to_owned(),
moniker: None,
version: Some("1.28.240.0".to_owned()),
channel: None,
match_criteria: Some("PackageFamilyName".to_owned()),
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
apply_msix_resource_string_name_fix(&mut package);
assert_eq!(package.name, "App Installer");
}
#[test]
fn msix_resource_string_fix_is_a_noop_when_not_msix() {
let mut package = InstalledPackage {
name: "ms-resource:appDisplayName".to_owned(),
local_id: r"ARP\Machine\X64\{deadbeef}".to_owned(),
installed_version: "1.0".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("msi".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Some.Package".to_owned(),
name: "Should Not Apply".to_owned(),
moniker: None,
version: Some("1.0".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
apply_msix_resource_string_name_fix(&mut package);
assert_eq!(package.name, "ms-resource:appDisplayName");
}
#[test]
fn msix_resource_string_fix_skips_resolved_names() {
let mut package = InstalledPackage {
name: "Microsoft Teams".to_owned(),
local_id: r"MSIX\MSTeams_25290.205.4069.4894_arm64__8wekyb3d8bbwe".to_owned(),
installed_version: "25290.205.4069.4894".to_owned(),
publisher: None,
scope: Some("User".to_owned()),
installer_category: Some("msix".to_owned()),
install_location: None,
package_family_names: vec!["MSTeams_8wekyb3d8bbwe".to_owned()],
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: "Microsoft.Teams".to_owned(),
name: "Microsoft Teams Catalog Name".to_owned(),
moniker: None,
version: Some("26106.1906.4665.7308".to_owned()),
channel: None,
match_criteria: Some("PackageFamilyName".to_owned()),
}),
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
apply_msix_resource_string_name_fix(&mut package);
assert_eq!(package.name, "Microsoft Teams");
}
#[cfg(windows)]
#[test]
fn unflip_packed_guid_reverses_msi_installer_packing() {
assert_eq!(
unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F652").as_deref(),
Some("{9292cbd9-b395-42d4-8847-c0e8004c6f25}")
);
assert_eq!(
unflip_packed_guid("A3A70C74FE2431248AD5F8A59570C782").as_deref(),
Some("{47c07a3a-42ef-4213-a85d-8f5a59077c28}")
);
assert!(unflip_packed_guid("nothex").is_none());
assert!(unflip_packed_guid("9DBC2929593B4D2488740C8E00C4F65").is_none()); assert!(unflip_packed_guid("ZZZZZZZZ593B4D2488740C8E00C4F652").is_none());
}
fn installed_with_correlated(id: &str, installed_version: &str, canonical: bool) -> InstalledPackage {
InstalledPackage {
name: format!("{id} install"),
local_id: format!(r"ARP\Machine\X64\{id}"),
installed_version: installed_version.to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("msi".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: Some(SearchMatch {
source_name: "winget".to_owned(),
source_kind: SourceKind::PreIndexed,
id: id.to_owned(),
name: id.to_owned(),
moniker: None,
version: Some("99.0.0".to_owned()),
channel: None,
match_criteria: None,
}),
installed_version_canonical: canonical,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
}
}
#[test]
fn dedupe_keeps_canonical_row_over_raw_arp_row() {
let raw = installed_with_correlated("Microsoft.DotNet.SDK.10", "40.10.18029", false);
let canonical = installed_with_correlated("Microsoft.DotNet.SDK.10", "10.0.108", true);
let result = dedupe_correlated_for_upgrade(vec![raw, canonical]);
assert_eq!(result.len(), 1);
assert_eq!(result[0].installed_version, "10.0.108");
assert!(result[0].installed_version_canonical);
}
#[test]
fn dedupe_keeps_highest_among_canonical_rows() {
let lower = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.7", true);
let higher = installed_with_correlated("Microsoft.WindowsAppRuntime.1.7", "1.7.9", true);
let result = dedupe_correlated_for_upgrade(vec![lower, higher]);
assert_eq!(result.len(), 1);
assert_eq!(result[0].installed_version, "1.7.9");
}
#[test]
fn dedupe_leaves_uncorrelated_rows_alone() {
let uncorrelated = InstalledPackage {
name: "Foo".to_owned(),
local_id: r"ARP\Machine\X64\Foo".to_owned(),
installed_version: "1.0".to_owned(),
publisher: None,
scope: Some("Machine".to_owned()),
installer_category: Some("exe".to_owned()),
install_location: None,
package_family_names: Vec::new(),
product_codes: Vec::new(),
upgrade_codes: Vec::new(),
correlated: None,
installed_version_canonical: false,
correlated_requires_explicit_upgrade: false,
correlated_lacks_compatible_installer: false,
};
let result = dedupe_correlated_for_upgrade(vec![uncorrelated]);
assert_eq!(result.len(), 1);
}
}