refactor: rename aim to upm and extract appimage module

This commit is contained in:
stoorps 2026-03-21 22:39:11 +00:00
parent af13e98eb3
commit 863c57e473
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
117 changed files with 2622 additions and 887 deletions

View file

@ -0,0 +1,22 @@
[package]
name = "upm-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
path = "src/lib.rs"
[dependencies]
base64.workspace = true
fs2.workspace = true
md5.workspace = true
quick-xml.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
toml.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,105 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
use crate::source::appimagehub::{
AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
};
pub struct AppImageHubAdapter;
impl AppImageHubAdapter {
pub fn resolve_source_with<T: AppImageHubTransport + ?Sized>(
&self,
source: &SourceRef,
transport: &T,
) -> Result<AdapterResolveOutcome, AdapterError> {
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedSource);
}
let resolved = resolve_appimagehub_item_with(source, transport)
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?;
match resolved {
Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution {
source: item.source,
release: ResolvedRelease {
version: item.version,
prerelease: false,
},
})),
None => Ok(AdapterResolveOutcome::NoInstallableArtifact {
source: source.clone(),
}),
}
}
}
impl SourceAdapter for AppImageHubAdapter {
fn id(&self) -> &'static str {
"appimagehub"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::AppImageHub)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::AppImageHub {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
match resolve_appimagehub_item(source)
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?
{
Some(item) => Ok(AdapterResolution {
source: item.source,
release: ResolvedRelease {
version: item.version,
prerelease: false,
},
}),
None => Err(AdapterError::ResolutionFailed(
"appimagehub item has no installable AppImage artifact".to_owned(),
)),
}
}
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
let transport = crate::source::appimagehub::default_transport();
self.resolve_source_with(source, transport.as_ref())
}
}
fn render_appimagehub_error(error: &AppImageHubError) -> String {
match error {
AppImageHubError::FixtureItemMissing(id) => {
format!("missing appimagehub fixture item {id}")
}
AppImageHubError::InsecureDownloadUrl(url) => {
format!("insecure appimagehub download url: {url}")
}
AppImageHubError::Parse(error) => error.to_string(),
AppImageHubError::Transport(error) => error.to_string(),
AppImageHubError::UnsupportedSource(locator) => {
format!("unsupported appimagehub source: {locator}")
}
}
}

View file

@ -0,0 +1,44 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct DirectUrlAdapter;
impl SourceAdapter for DirectUrlAdapter {
fn id(&self) -> &'static str {
"direct-url"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
fn exact_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::DirectUrl)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::DirectUrl {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::DirectUrl {
return Err(AdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "unresolved".to_owned(),
prerelease: false,
},
})
}
}

View file

@ -0,0 +1,59 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct GitHubAdapter;
impl Default for GitHubAdapter {
fn default() -> Self {
Self::new()
}
}
impl GitHubAdapter {
pub fn new() -> Self {
Self
}
}
impl SourceAdapter for GitHubAdapter {
fn id(&self) -> &'static str {
"github"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::GitHub)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::GitHub {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::GitHub {
return Err(AdapterError::UnsupportedSource);
}
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
}

View file

@ -0,0 +1,132 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef};
pub struct GitLabAdapter;
impl GitLabAdapter {
pub fn artifact_name(source: &SourceRef) -> String {
let slug = canonical_locator(source)
.split('/')
.next_back()
.unwrap_or("app");
format!("{slug}.AppImage")
}
pub fn artifact_url(source: &SourceRef) -> String {
let repo = canonical_locator(source);
let artifact_name = Self::artifact_name(source);
match source.requested_tag.as_deref() {
Some(tag) => {
format!("https://gitlab.com/{repo}/-/releases/{tag}/downloads/{artifact_name}")
}
None => format!(
"https://gitlab.com/{repo}/-/releases/permalink/latest/downloads/{artifact_name}"
),
}
}
}
impl SourceAdapter for GitLabAdapter {
fn id(&self) -> &'static str {
"gitlab"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::GitLab)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::GitLab {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::GitLab {
return Err(AdapterError::UnsupportedSource);
}
let resolved_source = resolved_source(source)?;
let version = resolved_source
.requested_tag
.clone()
.unwrap_or_else(|| "latest".to_owned());
Ok(AdapterResolution {
source: resolved_source,
release: ResolvedRelease {
version,
prerelease: false,
},
})
}
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
self.resolve(source).map(AdapterResolveOutcome::Resolved)
}
}
fn canonical_locator(source: &SourceRef) -> &str {
source
.canonical_locator
.as_deref()
.unwrap_or(source.locator.as_str())
}
fn resolved_source(source: &SourceRef) -> Result<SourceRef, AdapterError> {
if source.normalized_kind != NormalizedSourceKind::GitLabCandidate {
return Ok(source.clone());
}
let canonical_locator = gitlab_locator_path(&source.locator).ok_or_else(|| {
AdapterError::ResolutionFailed(
"gitlab candidate source could not be reduced to a repository path".to_owned(),
)
})?;
let mut resolved = source.clone();
resolved.normalized_kind = NormalizedSourceKind::GitLab;
resolved.canonical_locator = Some(canonical_locator);
resolved.tracks_latest = resolved.requested_tag.is_none();
Ok(resolved)
}
fn gitlab_locator_path(locator: &str) -> Option<String> {
let trimmed = locator
.trim_start_matches("https://gitlab.com/")
.trim_start_matches("http://gitlab.com/");
if trimmed == locator {
return None;
}
let path = trimmed
.split(['?', '#'])
.next()
.unwrap_or(trimmed)
.trim_matches('/');
if path.is_empty() {
None
} else {
Some(path.to_owned())
}
}

View file

@ -0,0 +1,19 @@
pub mod direct_url;
pub mod github;
pub mod gitlab;
pub mod sourceforge;
pub mod test_support;
pub mod traits;
pub mod zsync;
use crate::adapters::traits::SourceAdapter;
use crate::domain::source::SourceRef;
pub fn all_adapter_kinds() -> Vec<&'static str> {
vec!["github", "gitlab", "direct-url", "zsync", "sourceforge"]
}
pub fn supports_source<A: SourceAdapter + ?Sized>(adapter: &A, source: &SourceRef) -> bool {
adapter.repository_source_kind() == Some(source.kind)
|| adapter.exact_source_kind() == Some(source.kind)
}

View file

@ -0,0 +1,266 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use crate::app::query::resolve_query;
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind, SourceRef};
pub struct SourceForgeAdapter;
impl SourceForgeAdapter {
pub fn artifact_url(source: &SourceRef) -> Option<String> {
if let Some(asset_name) = source.requested_asset_name.as_deref()
&& is_sourceforge_releases_root_locator(&source.locator)
{
return Some(format!("{}/{asset_name}/download", source.locator));
}
if is_latest_download_locator(&source.locator)
|| is_sourceforge_release_folder_download_locator(&source.locator)
{
return Some(source.locator.clone());
}
if is_sourceforge_releases_root_locator(&source.locator) {
return sourceforge_latest_download_url(&source.locator);
}
None
}
}
impl SourceAdapter for SourceForgeAdapter {
fn id(&self) -> &'static str {
"sourceforge"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities {
supports_search: true,
supports_exact_resolution: true,
}
}
fn repository_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::SourceForge)
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError> {
let source = resolve_query(query).map_err(|_| AdapterError::UnsupportedQuery)?;
if source.kind != SourceKind::SourceForge {
return Err(AdapterError::UnsupportedQuery);
}
Ok(source)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
if source.kind != SourceKind::SourceForge {
return Err(AdapterError::UnsupportedSource);
}
if !is_resolved_download_locator(&source.locator) {
return Err(AdapterError::ResolutionFailed(
"sourceforge source has no concrete latest-download artifact".to_owned(),
));
}
Ok(AdapterResolution {
source: resolved_source(source),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
if Self::artifact_url(source).is_some() {
return self.resolve(source).map(AdapterResolveOutcome::Resolved);
}
if matches!(
source.normalized_kind,
NormalizedSourceKind::SourceForge | NormalizedSourceKind::SourceForgeCandidate
) {
return Ok(AdapterResolveOutcome::NoInstallableArtifact {
source: source.clone(),
});
}
Ok(AdapterResolveOutcome::NoInstallableArtifact {
source: source.clone(),
})
}
}
fn resolved_source(source: &SourceRef) -> SourceRef {
let mut resolved = source.clone();
if is_sourceforge_file_like_release_download_locator(&resolved.locator) {
resolved.locator = sourceforge_releases_root_url(&resolved.locator)
.unwrap_or_else(|| resolved.locator.clone());
resolved.normalized_kind = NormalizedSourceKind::SourceForge;
resolved.tracks_latest = true;
} else if is_sourceforge_release_folder_download_locator(&resolved.locator)
|| is_sourceforge_releases_root_locator(&resolved.locator)
{
resolved.normalized_kind = NormalizedSourceKind::SourceForge;
resolved.tracks_latest = true;
}
resolved
}
fn is_resolved_download_locator(locator: &str) -> bool {
is_latest_download_locator(locator)
|| is_sourceforge_release_folder_download_locator(locator)
|| is_sourceforge_releases_root_locator(locator)
}
fn is_latest_download_locator(locator: &str) -> bool {
let trimmed = locator
.split(['?', '#'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
trimmed.ends_with("/files/latest/download")
}
fn is_sourceforge_release_folder_download_locator(locator: &str) -> bool {
let trimmed = locator
.split(['?', '#'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
let parts = trimmed
.trim_start_matches("https://sourceforge.net/projects/")
.trim_start_matches("http://sourceforge.net/projects/")
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
parts.len() == 5 && parts[1] == "files" && parts[2] == "releases" && parts[4] == "download"
}
fn is_sourceforge_file_like_release_download_locator(locator: &str) -> bool {
let trimmed = locator
.split(['?', '#'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
let parts = trimmed
.trim_start_matches("https://sourceforge.net/projects/")
.trim_start_matches("http://sourceforge.net/projects/")
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
parts.len() == 5
&& parts[1] == "files"
&& parts[2] == "releases"
&& is_sourceforge_artifact_name(parts[3])
&& parts[4] == "download"
}
fn is_sourceforge_artifact_name(segment: &str) -> bool {
let lower = segment.to_ascii_lowercase();
[
".appimage",
".tar.gz",
".tar.xz",
".tar.bz2",
".zip",
".deb",
".rpm",
".exe",
".msi",
".dmg",
".pkg",
".apk",
".tgz",
".whl",
".jar",
".nupkg",
]
.iter()
.any(|suffix| lower.ends_with(suffix))
}
fn is_sourceforge_releases_root_locator(locator: &str) -> bool {
let trimmed = locator
.split(['?', '#'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
let parts = trimmed
.trim_start_matches("https://sourceforge.net/projects/")
.trim_start_matches("http://sourceforge.net/projects/")
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
parts.len() == 3 && parts[1] == "files" && parts[2] == "releases"
}
fn sourceforge_releases_root_url(locator: &str) -> Option<String> {
let trimmed = locator
.split(['?', '#'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
let prefix = if trimmed.starts_with("https://sourceforge.net/projects/") {
"https://sourceforge.net/projects/"
} else if trimmed.starts_with("http://sourceforge.net/projects/") {
"http://sourceforge.net/projects/"
} else {
return None;
};
let path = trimmed
.trim_start_matches("https://sourceforge.net/projects/")
.trim_start_matches("http://sourceforge.net/projects/")
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if path.is_empty() {
return None;
}
Some(format!("{}{}/files/releases", prefix, path[0]))
}
fn sourceforge_latest_download_url(locator: &str) -> Option<String> {
let trimmed = locator
.split(['?', '#'])
.next()
.unwrap_or(locator)
.trim_end_matches('/');
let prefix = if trimmed.starts_with("https://sourceforge.net/projects/") {
"https://sourceforge.net/projects/"
} else if trimmed.starts_with("http://sourceforge.net/projects/") {
"http://sourceforge.net/projects/"
} else {
return None;
};
let path = trimmed
.trim_start_matches("https://sourceforge.net/projects/")
.trim_start_matches("http://sourceforge.net/projects/")
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if path.is_empty() {
return None;
}
Some(format!("{}{}/files/latest/download", prefix, path[0]))
}

View file

@ -0,0 +1,37 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::domain::source::SourceRef;
#[derive(Debug)]
pub struct MockAdapter {
id: &'static str,
capabilities: AdapterCapabilities,
}
impl MockAdapter {
pub fn exact_resolution_only() -> Self {
Self {
id: "mock",
capabilities: AdapterCapabilities::exact_resolution_only(),
}
}
}
impl SourceAdapter for MockAdapter {
fn id(&self) -> &'static str {
self.id
}
fn capabilities(&self) -> AdapterCapabilities {
self.capabilities
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -0,0 +1,72 @@
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AdapterCapabilities {
pub supports_search: bool,
pub supports_exact_resolution: bool,
}
impl AdapterCapabilities {
pub fn exact_resolution_only() -> Self {
Self {
supports_search: false,
supports_exact_resolution: true,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdapterResolution {
pub source: SourceRef,
pub release: ResolvedRelease,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterResolveOutcome {
Resolved(AdapterResolution),
NoInstallableArtifact { source: SourceRef },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterError {
UnsupportedQuery,
UnsupportedSource,
ResolutionFailed(String),
}
pub trait SourceAdapter {
fn id(&self) -> &'static str;
fn capabilities(&self) -> AdapterCapabilities;
fn repository_source_kind(&self) -> Option<SourceKind> {
None
}
fn exact_source_kind(&self) -> Option<SourceKind> {
None
}
fn normalize(&self, query: &str) -> Result<SourceRef, AdapterError>;
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError>;
fn resolve_supported_source(
&self,
source: &SourceRef,
) -> Result<AdapterResolveOutcome, AdapterError> {
self.resolve(source).map(AdapterResolveOutcome::Resolved)
}
fn supports_source(&self, source: &SourceRef) -> bool {
crate::adapters::supports_source(self, source)
}
fn resolve_source(&self, source: &SourceRef) -> Result<AdapterResolveOutcome, AdapterError> {
if !self.supports_source(source) {
return Err(AdapterError::UnsupportedSource);
}
self.resolve_supported_source(source)
}
}

View file

@ -0,0 +1,24 @@
use crate::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, SourceAdapter,
};
use crate::domain::source::SourceRef;
pub struct ZsyncAdapter;
impl SourceAdapter for ZsyncAdapter {
fn id(&self) -> &'static str {
"zsync"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, _source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Err(AdapterError::UnsupportedSource)
}
}

View file

@ -0,0 +1,818 @@
use std::env;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::adapters::direct_url::DirectUrlAdapter;
use crate::adapters::gitlab::GitLabAdapter;
use crate::adapters::sourceforge::SourceForgeAdapter;
use crate::adapters::traits::AdapterResolution;
use crate::adapters::traits::{AdapterResolveOutcome, SourceAdapter};
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::app::providers::{ExternalAddResolution, ProviderRegistry};
use crate::app::query::{ResolveQueryError, resolve_query};
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
use crate::integration::install::{
InstallOutcome, InstallRequest, execute_install, staged_appimage_path,
};
use crate::integration::policy::{IntegrationMode, resolve_install_policy};
use crate::metadata::parse_document;
use crate::platform::probe_live_host;
use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
};
use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_GITHUB_FIXTURE_MODE";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AddSecurityPolicy {
pub allow_http_user_sources: bool,
}
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport.as_ref(),
&mut reporter,
AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
&mut reporter,
AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
reporter,
AddSecurityPolicy::default(),
&ProviderRegistry::default(),
)
}
pub fn build_add_plan_with_registered_providers<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
providers: &ProviderRegistry<'_>,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
let mut reporter = NoopReporter;
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
&mut reporter,
policy,
providers,
)
}
pub fn build_add_plan_with_reporter_and_registered_providers<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
providers: &ProviderRegistry<'_>,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query, transport, reporter, policy, providers,
)
}
pub fn build_add_plan_with_reporter_and_policy<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<AddPlan, BuildAddPlanError> {
build_add_plan_with_reporter_and_policy_and_registry(
query,
transport,
reporter,
policy,
&ProviderRegistry::default(),
)
}
fn build_add_plan_with_reporter_and_policy_and_registry<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
providers: &ProviderRegistry<'_>,
) -> Result<AddPlan, BuildAddPlanError> {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
});
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
validate_source_transport_policy(&source, policy)?;
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let (resolution, selected_artifact, update_strategy, display_name_hint) = match source.kind {
SourceKind::GitHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let discovery = discover_github_candidates_with(&source, transport)
.map_err(BuildAddPlanError::GitHubDiscovery)?;
for document in &discovery.metadata_documents {
parsed_metadata
.push(parse_document(document).expect("metadata parsing is infallible"));
}
let ranked = rank_channels(&build_channels(&discovery, &parsed_metadata));
let preferred = ranked
.first()
.cloned()
.ok_or(BuildAddPlanError::NoCandidates)?;
let strategy = UpdateStrategy {
preferred: to_preference(&preferred),
alternates: ranked.iter().skip(1).map(to_preference).collect(),
};
let metadata_hints = parsed_metadata
.iter()
.find(|item| item.hints.primary_download.is_some())
.map(|item| &item.hints);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact = select_artifact(&preferred, metadata_hints);
if discovery.requested_is_older_release {
interactions.push(InteractionRequest {
key: "tracking-preference".to_owned(),
kind: InteractionKind::ChooseTrackingPreference {
requested_version: source.requested_tag.clone().unwrap_or_default(),
latest_version: discovery
.releases
.first()
.map(|release| release.tag.clone())
.unwrap_or_default(),
},
});
}
(
AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: artifact.version.clone(),
prerelease: false,
},
},
artifact,
strategy,
None,
)
}
SourceKind::GitLab => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let adapter = GitLabAdapter;
let resolution = match adapter
.resolve_source(&source)
.map_err(|error| BuildAddPlanError::Adapter("gitlab", error))?
{
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { source } => {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
};
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact_url = GitLabAdapter::artifact_url(&resolution.source);
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: artifact_url.clone(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
};
let artifact = ArtifactCandidate {
url: artifact_url,
version: resolution.release.version.clone(),
arch: None,
trusted_checksum: None,
weak_checksum_md5: None,
selection_reason: "provider-release".to_owned(),
};
(resolution, artifact, strategy, None)
}
SourceKind::AppImageHub => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
if let Some(external_resolution) =
resolve_registered_external_add_provider(&source, providers)?
{
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
(
external_resolution.resolution,
external_resolution.selected_artifact,
external_resolution.update_strategy,
external_resolution.display_name_hint,
)
} else {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
}
SourceKind::DirectUrl => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let adapter = DirectUrlAdapter;
let resolution = match adapter
.resolve_source(&source)
.map_err(|error| BuildAddPlanError::Adapter("direct-url", error))?
{
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { source } => {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
};
let artifact = ArtifactCandidate {
url: resolution.source.locator.clone(),
version: resolution.release.version.clone(),
arch: None,
trusted_checksum: None,
weak_checksum_md5: None,
selection_reason: "exact-input".to_owned(),
};
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: resolution.source.locator.clone(),
reason: "exact-input".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy, None)
}
SourceKind::SourceForge => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
});
let adapter = SourceForgeAdapter;
let resolution = match adapter
.resolve_source(&source)
.map_err(|error| BuildAddPlanError::Adapter("sourceforge", error))?
{
AdapterResolveOutcome::Resolved(resolution) => resolution,
AdapterResolveOutcome::NoInstallableArtifact { source } => {
return Err(BuildAddPlanError::NoInstallableArtifact { source });
}
};
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let artifact_url = SourceForgeAdapter::artifact_url(&resolution.source)
.ok_or(BuildAddPlanError::NoCandidates)?;
let artifact = ArtifactCandidate {
url: artifact_url.clone(),
version: resolution.release.version.clone(),
arch: None,
trusted_checksum: None,
weak_checksum_md5: None,
selection_reason: "provider-release".to_owned(),
};
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: resolution.source.locator.clone(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy, None)
}
_ => {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
});
let resolution = AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "unresolved".to_owned(),
prerelease: false,
},
};
let artifact = ArtifactCandidate {
url: source.locator.clone(),
version: "unresolved".to_owned(),
arch: None,
trusted_checksum: None,
weak_checksum_md5: None,
selection_reason: "heuristic-match".to_owned(),
};
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: source.locator.clone(),
reason: "heuristic-match".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy, None)
}
};
Ok(AddPlan {
resolution,
selected_artifact,
interactions,
update_strategy,
metadata: parsed_metadata,
display_name_hint,
})
}
fn resolve_registered_external_add_provider(
source: &crate::domain::source::SourceRef,
providers: &ProviderRegistry<'_>,
) -> Result<Option<ExternalAddResolution>, BuildAddPlanError> {
for provider in &providers.external_add_providers {
match provider.resolve(source) {
Ok(Some(resolution)) => return Ok(Some(resolution)),
Ok(None) => continue,
Err(error) => return Err(BuildAddPlanError::Adapter(provider.id(), error)),
}
}
Ok(None)
}
pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan {
if let Some(index) = plan
.update_strategy
.alternates
.iter()
.position(|item| item.kind != UpdateChannelKind::DirectAsset)
{
let alternate = plan.update_strategy.alternates.remove(index);
let previous = std::mem::replace(&mut plan.update_strategy.preferred, alternate);
plan.update_strategy.alternates.insert(0, previous);
}
if let Some(canonical_locator) = plan.resolution.source.canonical_locator.clone() {
plan.resolution.source.locator = canonical_locator;
plan.resolution.source.normalized_kind = NormalizedSourceKind::GitHubRepository;
plan.resolution.source.tracks_latest = true;
}
plan.interactions
.retain(|interaction| interaction.key != "tracking-preference");
plan
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AddPlan {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub interactions: Vec<InteractionRequest>,
pub update_strategy: UpdateStrategy,
pub metadata: Vec<ParsedMetadata>,
pub display_name_hint: Option<String>,
}
pub fn materialize_app_record(
source_input: &str,
plan: &AddPlan,
) -> Result<AppRecord, MaterializeAddRecordError> {
let identity_source = plan
.resolution
.source
.canonical_locator
.as_deref()
.unwrap_or(source_input);
let identity = resolve_identity(
plan.display_name_hint.as_deref(),
None,
Some(identity_source),
IdentityFallback::AllowRawUrl,
)
.map_err(MaterializeAddRecordError::Identity)?;
Ok(AppRecord {
stable_id: identity.stable_id,
display_name: identity.display_name,
source_input: Some(source_input.to_owned()),
source: Some(plan.resolution.source.clone()),
installed_version: Some(plan.selected_artifact.version.clone()),
update_strategy: Some(plan.update_strategy.clone()),
metadata: plan.metadata.clone(),
install: None,
})
}
pub fn install_app(
source_input: &str,
plan: &AddPlan,
install_home: &Path,
requested_scope: InstallScope,
) -> Result<InstalledApp, InstallAppError> {
let mut reporter = NoopReporter;
install_app_with_reporter(
source_input,
plan,
install_home,
requested_scope,
&mut reporter,
)
}
pub fn install_app_with_reporter(
source_input: &str,
plan: &AddPlan,
install_home: &Path,
requested_scope: InstallScope,
reporter: &mut impl ProgressReporter,
) -> Result<InstalledApp, InstallAppError> {
reporter.report(&OperationEvent::Started {
kind: OperationKind::Add,
label: source_input.to_owned(),
});
let mut record =
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
let (family, capabilities) =
probe_live_host(install_home, requested_scope).map_err(InstallAppError::HostProbe)?;
let policy = resolve_install_policy(family, requested_scope, &capabilities)
.map_err(InstallAppError::Policy)?;
let payload_path = resolve_target_path(
install_home,
&policy
.payload_root
.join(format!("{}.AppImage", record.stable_id)),
);
let desktop_path = resolve_target_path(
install_home,
&policy
.desktop_entry_root
.join(format!("upm-{}.desktop", record.stable_id)),
);
let icon_path = resolve_target_path(
install_home,
&policy.icon_root.join(format!("{}.png", record.stable_id)),
);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
});
let staging_root = install_home.join(".local/share/upm/staging");
let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id);
let artifact_size_bytes = download_artifact_to_staged_path_with_reporter(
&plan.selected_artifact.url,
&staged_payload_path,
reporter,
)?;
let payload_exec = payload_path.clone();
let desktop_owned = match policy.integration_mode {
IntegrationMode::PayloadOnly | IntegrationMode::Denied => None,
IntegrationMode::Full | IntegrationMode::Degraded => Some((
desktop_path.clone(),
render_desktop_entry(&record.display_name, &payload_exec),
)),
};
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
});
let install_outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_payload_path,
final_payload_path: &payload_path,
trusted_checksum: plan.selected_artifact.trusted_checksum.as_deref(),
weak_checksum_md5: plan.selected_artifact.weak_checksum_md5.as_deref(),
desktop: desktop_owned.as_ref().map(|(path, contents)| {
crate::integration::install::DesktopIntegrationRequest {
desktop_entry_path: path.as_path(),
desktop_entry_contents: contents.as_str(),
icon_path: Some(icon_path.as_path()),
icon_bytes: None,
}
}),
helpers: capabilities.helpers.clone(),
})
.map_err(InstallAppError::Install)?;
if install_outcome.desktop_entry_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
}
if install_outcome.icon_path.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
message: "refreshing desktop integration".to_owned(),
});
if !install_outcome.warnings.is_empty() {
for warning in &install_outcome.warnings {
reporter.report(&OperationEvent::Warning {
message: warning.clone(),
});
}
}
record.install = Some(InstallMetadata {
scope: policy.scope,
payload_path: Some(install_outcome.final_payload_path.display().to_string()),
desktop_entry_path: install_outcome
.desktop_entry_path
.as_ref()
.map(|path| path.display().to_string()),
icon_path: install_outcome
.icon_path
.as_ref()
.map(|path| path.display().to_string()),
});
let installed = InstalledApp {
record,
selected_artifact: plan.selected_artifact.clone(),
artifact_size_bytes,
source: plan.resolution.source.clone(),
install_scope: policy.scope,
integration_mode: policy.integration_mode,
install_outcome,
warnings: policy.warnings,
};
reporter.report(&OperationEvent::Finished {
summary: format!("installed {}", installed.record.stable_id),
});
Ok(installed)
}
#[derive(Debug, Eq, PartialEq)]
pub struct InstalledApp {
pub record: AppRecord,
pub selected_artifact: ArtifactCandidate,
pub artifact_size_bytes: u64,
pub source: crate::domain::source::SourceRef,
pub install_scope: InstallScope,
pub integration_mode: IntegrationMode,
pub install_outcome: InstallOutcome,
pub warnings: Vec<String>,
}
#[derive(Debug)]
pub enum BuildAddPlanError {
Query(ResolveQueryError),
InsecureHttpSource {
locator: String,
},
Adapter(&'static str, crate::adapters::traits::AdapterError),
GitHubDiscovery(GitHubDiscoveryError),
NoInstallableArtifact {
source: crate::domain::source::SourceRef,
},
NoCandidates,
}
#[derive(Debug, Eq, PartialEq)]
pub enum MaterializeAddRecordError {
Identity(ResolveIdentityError),
}
#[derive(Debug)]
pub enum InstallAppError {
Materialize(MaterializeAddRecordError),
Policy(String),
Download(reqwest::Error),
DownloadIo(std::io::Error),
HostProbe(std::io::Error),
Install(crate::integration::install::PayloadInstallError),
}
fn validate_source_transport_policy(
source: &crate::domain::source::SourceRef,
policy: AddSecurityPolicy,
) -> Result<(), BuildAddPlanError> {
if source.locator.starts_with("http://") && !policy.allow_http_user_sources {
return Err(BuildAddPlanError::InsecureHttpSource {
locator: source.locator.clone(),
});
}
Ok(())
}
fn download_artifact_to_staged_path_with_reporter(
url: &str,
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<u64, InstallAppError> {
let policy = http_client_policy();
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82";
return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
Ok((
Box::new(std::io::Cursor::new(bytes.to_vec())) as Box<dyn Read>,
Some(bytes.len() as u64),
))
});
}
let client = reqwest::blocking::Client::builder()
.timeout(policy.timeout)
.build()
.map_err(InstallAppError::Download)?;
download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
let response = client.get(url).send().map_err(InstallAppError::Download)?;
let response = response
.error_for_status()
.map_err(InstallAppError::Download)?;
let total = response.content_length();
Ok((Box::new(response) as Box<dyn Read>, total))
})
}
pub fn download_to_staged_path_with_retries(
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
policy: crate::source::github::HttpClientPolicy,
mut open_stream: impl FnMut() -> Result<(Box<dyn Read>, Option<u64>), InstallAppError>,
) -> Result<u64, InstallAppError> {
let mut last_error = None;
let attempts = policy.max_retries.max(1);
for attempt in 0..attempts {
match open_stream() {
Ok((mut reader, total)) => {
match stream_payload_to_staged_file_with_reporter(
&mut reader,
total,
staged_payload_path,
reporter,
) {
Ok(written) => return Ok(written),
Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_error.unwrap_or_else(|| {
InstallAppError::DownloadIo(std::io::Error::other("download failed after retries"))
}))
}
pub fn stream_payload_to_staged_file_with_reporter<R: Read>(
reader: &mut R,
total: Option<u64>,
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<u64, InstallAppError> {
if let Some(parent) = staged_payload_path.parent() {
fs::create_dir_all(parent).map_err(InstallAppError::DownloadIo)?;
}
let mut file = File::create(staged_payload_path).map_err(InstallAppError::DownloadIo)?;
let mut buffer = [0_u8; 16 * 1024];
let mut current = 0_u64;
loop {
let read = match reader.read(&mut buffer) {
Ok(read) => read,
Err(error) => {
let _ = fs::remove_file(staged_payload_path);
return Err(InstallAppError::DownloadIo(error));
}
};
if read == 0 {
break;
}
if let Err(error) = std::io::Write::write_all(&mut file, &buffer[..read]) {
let _ = fs::remove_file(staged_payload_path);
return Err(InstallAppError::DownloadIo(error));
}
current += read as u64;
reporter.report(&OperationEvent::Progress { current, total });
}
Ok(current)
}
fn is_retryable_download_error(error: &InstallAppError) -> bool {
matches!(
error,
InstallAppError::Download(_) | InstallAppError::DownloadIo(_)
)
}
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {
let display_name = sanitize_desktop_entry_name(display_name);
format!(
"[Desktop Entry]\nName={display_name}\nExec={}\nType=Application\nCategories=Utility;\n",
exec_path.display()
)
}
fn sanitize_desktop_entry_name(display_name: &str) -> String {
let sanitized = display_name
.chars()
.map(|ch| {
if matches!(ch, '\n' | '\r') || ch.is_control() {
' '
} else {
ch
}
})
.collect::<String>();
let sanitized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
if sanitized.is_empty() {
"app".to_owned()
} else {
sanitized
}
}
fn resolve_target_path(install_home: &Path, target: &Path) -> PathBuf {
if target.is_absolute() {
target.to_path_buf()
} else {
install_home.join(target)
}
}
pub fn resolve_requested_scope(system: bool, user: bool, is_effective_root: bool) -> InstallScope {
let override_scope = if system {
Some(ScopeOverride::System)
} else if user {
Some(ScopeOverride::User)
} else {
None
};
resolve_install_scope_with_default(is_effective_root, override_scope)
}

View file

@ -0,0 +1,117 @@
use crate::domain::app::{AppIdentity, IdentityConfidence};
use crate::source::input::classify_input;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdentityFallback {
DisallowRawUrl,
AllowRawUrl,
}
pub fn resolve_identity(
explicit_name: Option<&str>,
explicit_id: Option<&str>,
source_url: Option<&str>,
fallback: IdentityFallback,
) -> Result<AppIdentity, ResolveIdentityError> {
if let Some(explicit_id) = explicit_id.filter(|value| !value.trim().is_empty()) {
let stable_id = normalize_identifier(explicit_id)?;
let display_name = explicit_name
.filter(|value| !value.trim().is_empty())
.map(sanitize_display_name)
.unwrap_or_else(|| sanitize_display_name(explicit_id));
return Ok(AppIdentity {
stable_id,
display_name,
confidence: IdentityConfidence::Confident,
});
}
if let Some(explicit_name) = explicit_name.filter(|value| !value.trim().is_empty()) {
return Ok(AppIdentity {
stable_id: normalize_identifier(explicit_name)?,
display_name: sanitize_display_name(explicit_name),
confidence: IdentityConfidence::NeedsConfirmation,
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& let Ok(classified) = classify_input(source_url)
&& let Some(repo) = classified.canonical_locator
{
let display_name = repo.split('/').next_back().unwrap_or(&repo).to_owned();
return Ok(AppIdentity {
stable_id: normalize_identifier(&repo)?,
display_name: sanitize_display_name(&display_name),
confidence: IdentityConfidence::Confident,
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& fallback == IdentityFallback::AllowRawUrl
{
return Ok(AppIdentity {
stable_id: normalize_url_identifier(source_url)?,
display_name: sanitize_display_name(source_url),
confidence: IdentityConfidence::RawUrlFallback,
});
}
Err(ResolveIdentityError::Unresolved)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveIdentityError {
Unresolved,
InvalidStableId,
}
fn normalize_identifier(value: &str) -> Result<String, ResolveIdentityError> {
let normalized = value
.trim()
.chars()
.map(|ch| match ch {
'A'..='Z' => ch.to_ascii_lowercase(),
'a'..='z' | '0'..='9' | '.' | '-' => ch,
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_owned();
if normalized.is_empty() || normalized.contains("..") {
return Err(ResolveIdentityError::InvalidStableId);
}
Ok(normalized)
}
fn normalize_url_identifier(url: &str) -> Result<String, ResolveIdentityError> {
let trimmed = url
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("file://");
Ok(format!("url-{}", normalize_identifier(trimmed)?))
}
fn sanitize_display_name(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if matches!(ch, '\n' | '\r') || ch.is_control() {
' '
} else {
ch
}
})
.collect::<String>();
let sanitized = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
if sanitized.is_empty() {
"app".to_owned()
} else {
sanitized
}
}

View file

@ -0,0 +1,20 @@
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionRequest {
pub key: String,
pub kind: InteractionKind,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InteractionKind {
SelectRegisteredApp {
query: String,
matches: Vec<String>,
},
ChooseTrackingPreference {
requested_version: String,
latest_version: String,
},
SelectArtifact {
candidates: Vec<String>,
},
}

View file

@ -0,0 +1,25 @@
use crate::domain::app::AppRecord;
#[derive(Debug, Eq, PartialEq)]
pub struct ListRow {
pub stable_id: String,
pub display_name: String,
pub version: Option<String>,
pub source: String,
}
pub fn build_list_rows(apps: &[AppRecord]) -> Vec<ListRow> {
apps.iter()
.map(|app| ListRow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
version: app.installed_version.clone(),
source: app
.source
.as_ref()
.map(|source| source.locator.clone())
.or_else(|| app.source_input.clone())
.unwrap_or_else(|| "-".to_owned()),
})
.collect()
}

View file

@ -0,0 +1,12 @@
pub mod add;
pub mod identity;
pub mod interaction;
pub mod list;
pub mod progress;
pub mod providers;
pub mod query;
pub mod remove;
pub mod scope;
pub mod search;
pub mod show;
pub mod update;

View file

@ -0,0 +1,68 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationKind {
Add,
Search,
UpdateBatch,
UpdateItem,
Remove,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationStage {
ResolveQuery,
DiscoverRelease,
SelectArtifact,
DownloadArtifact,
StagePayload,
WriteDesktopEntry,
ExtractIcon,
RefreshIntegration,
SaveRegistry,
Finalize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OperationEvent {
Started {
kind: OperationKind,
label: String,
},
StageChanged {
stage: OperationStage,
message: String,
},
Progress {
current: u64,
total: Option<u64>,
},
Warning {
message: String,
},
Finished {
summary: String,
},
Failed {
stage: OperationStage,
reason: String,
},
}
pub trait ProgressReporter {
fn report(&mut self, event: &OperationEvent);
}
impl<F> ProgressReporter for F
where
F: FnMut(&OperationEvent),
{
fn report(&mut self, event: &OperationEvent) {
self(event);
}
}
#[derive(Default)]
pub struct NoopReporter;
impl ProgressReporter for NoopReporter {
fn report(&mut self, _event: &OperationEvent) {}
}

View file

@ -0,0 +1,24 @@
use crate::adapters::traits::{AdapterError, AdapterResolution};
use crate::app::search::SearchProvider;
use crate::domain::source::SourceRef;
use crate::domain::update::{ArtifactCandidate, UpdateStrategy};
pub trait ExternalAddProvider {
fn id(&self) -> &'static str;
fn resolve(&self, source: &SourceRef) -> Result<Option<ExternalAddResolution>, AdapterError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExternalAddResolution {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub update_strategy: UpdateStrategy,
pub display_name_hint: Option<String>,
}
#[derive(Default)]
pub struct ProviderRegistry<'a> {
pub search_providers: Vec<&'a dyn SearchProvider>,
pub external_add_providers: Vec<&'a dyn ExternalAddProvider>,
}

View file

@ -0,0 +1,13 @@
use crate::domain::source::SourceRef;
use crate::source::input::classify_input;
pub fn resolve_query(query: &str) -> Result<SourceRef, ResolveQueryError> {
classify_input(query)
.map(|input| input.into_source_ref())
.map_err(|_| ResolveQueryError::Unsupported)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveQueryError {
Unsupported,
}

View file

@ -0,0 +1,197 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::domain::app::{AppRecord, InstallScope};
use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path};
use crate::integration::refresh::refresh_integration;
use crate::platform::probe_live_host;
pub fn resolve_registered_app<'a>(
query: &str,
apps: &'a [AppRecord],
) -> Result<&'a AppRecord, ResolveRegisteredAppError> {
let normalized_query = normalize_lookup(query);
let matches = apps
.iter()
.filter(|app| {
normalize_lookup(&app.stable_id) == normalized_query
|| normalize_lookup(&app.display_name) == normalized_query
})
.collect::<Vec<_>>();
match matches.as_slice() {
[] => Err(ResolveRegisteredAppError::UnknownApp {
query: query.to_owned(),
}),
[app] => Ok(*app),
_ => Err(ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest {
key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp {
query: query.to_owned(),
matches: matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
},
},
}),
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalPlan {
pub stable_id: String,
pub display_name: String,
pub artifact_paths: Vec<String>,
}
pub fn build_removal_plan(app: &AppRecord, install_home: &Path) -> RemovalPlan {
let artifact_paths = removal_artifact_paths(app, install_home)
.into_iter()
.map(|path| path.display().to_string())
.collect();
RemovalPlan {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
artifact_paths,
}
}
pub fn remove_registered_app(
query: &str,
apps: &[AppRecord],
install_home: &Path,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
let mut reporter = NoopReporter;
remove_registered_app_with_reporter(query, apps, install_home, &mut reporter)
}
pub fn remove_registered_app_with_reporter(
query: &str,
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
reporter.report(&OperationEvent::Started {
kind: OperationKind::Remove,
label: query.to_owned(),
});
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: format!("resolving {query}"),
});
let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?;
let plan = build_removal_plan(app, install_home);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "removing managed artifacts".to_owned(),
});
let deletion = delete_artifacts(&plan)?;
let remaining_apps = apps
.iter()
.filter(|candidate| candidate.stable_id != app.stable_id)
.cloned()
.collect();
let result = RemovalResult {
removed: plan,
removed_paths: deletion.removed_paths,
remaining_apps,
warnings: deletion.warnings,
};
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::Finalize,
message: format!("removed {}", result.removed.stable_id),
});
reporter.report(&OperationEvent::Finished {
summary: format!("removed {}", result.removed.stable_id),
});
Ok(result)
}
#[derive(Debug, Eq, PartialEq)]
pub struct RemovalResult {
pub removed: RemovalPlan,
pub removed_paths: Vec<String>,
pub remaining_apps: Vec<AppRecord>,
pub warnings: Vec<String>,
}
#[derive(Debug)]
pub enum RemoveRegisteredAppError {
Resolve(ResolveRegisteredAppError),
Io(io::Error),
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveRegisteredAppError {
UnknownApp { query: String },
Ambiguous { request: InteractionRequest },
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec<PathBuf> {
if let Some(install) = &app.install {
return [
install.payload_path.as_deref(),
install.desktop_entry_path.as_deref(),
install.icon_path.as_deref(),
]
.into_iter()
.flatten()
.map(PathBuf::from)
.collect();
}
let scope = InstallScope::User;
vec![
managed_appimage_path(install_home, scope, &app.stable_id),
desktop_entry_path(install_home, scope, &app.stable_id),
icon_path(install_home, scope, &app.stable_id),
]
}
struct DeletionOutcome {
removed_paths: Vec<String>,
warnings: Vec<String>,
}
fn delete_artifacts(plan: &RemovalPlan) -> Result<DeletionOutcome, RemoveRegisteredAppError> {
let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
let icon_path = plan.artifact_paths.get(2).map(PathBuf::from);
let mut removed_paths = Vec::new();
for artifact_path in &plan.artifact_paths {
match fs::remove_file(artifact_path) {
Ok(()) => removed_paths.push(artifact_path.clone()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
}
}
let mut warnings = Vec::new();
if let Ok((_, capabilities)) = probe_live_host(Path::new("/"), InstallScope::User) {
warnings.extend(refresh_integration(
&capabilities.helpers,
desktop_path.as_deref(),
icon_path.as_deref(),
));
}
Ok(DeletionOutcome {
removed_paths,
warnings,
})
}

View file

@ -0,0 +1,28 @@
use crate::domain::app::InstallScope;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScopeOverride {
System,
User,
}
pub fn resolve_install_scope(
_is_effective_root: bool,
override_scope: ScopeOverride,
) -> InstallScope {
match override_scope {
ScopeOverride::System => InstallScope::System,
ScopeOverride::User => InstallScope::User,
}
}
pub fn resolve_install_scope_with_default(
is_effective_root: bool,
override_scope: Option<ScopeOverride>,
) -> InstallScope {
match override_scope {
Some(scope) => resolve_install_scope(is_effective_root, scope),
None if is_effective_root => InstallScope::System,
None => InstallScope::User,
}
}

View file

@ -0,0 +1,349 @@
use crate::app::providers::ProviderRegistry;
use crate::domain::app::AppRecord;
use crate::domain::search::{
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
SearchWarning,
};
use crate::source::github::{
GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
search_github_repositories_with,
};
use std::collections::HashSet;
pub trait SearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchProviderError {
pub provider_id: String,
pub message: String,
}
impl SearchProviderError {
pub fn new(provider_id: &str, message: &str) -> Self {
Self {
provider_id: provider_id.to_owned(),
message: message.to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchError {
ProviderFailures(Vec<SearchWarning>),
}
pub fn build_search_results(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> {
build_search_results_with_registered_providers(
query,
installed_apps,
&ProviderRegistry::default(),
)
}
pub fn build_search_results_with_registered_providers(
query: &SearchQuery,
installed_apps: &[AppRecord],
providers: &ProviderRegistry<'_>,
) -> Result<SearchResults, SearchError> {
let github_transport = default_transport();
let github_provider = GitHubSearchProvider::new(github_transport.as_ref());
let mut resolved_providers = vec![&github_provider as &dyn SearchProvider];
resolved_providers.extend(providers.search_providers.iter().copied());
build_search_results_with(query, installed_apps, &resolved_providers)
}
pub fn build_search_results_with(
query: &SearchQuery,
installed_apps: &[AppRecord],
providers: &[&dyn SearchProvider],
) -> Result<SearchResults, SearchError> {
let installed_matches = collect_installed_matches(query, installed_apps);
let mut remote_hits = Vec::new();
let mut warnings = Vec::new();
for provider in providers {
match provider.search(query) {
Ok(mut hits) => remote_hits.append(&mut hits),
Err(error) => warnings.push(SearchWarning {
provider_id: Some(error.provider_id),
message: error.message,
}),
}
}
annotate_remote_hits_with_install_status(&mut remote_hits, installed_apps);
if remote_hits.is_empty() && installed_matches.is_empty() && !warnings.is_empty() {
return Err(SearchError::ProviderFailures(warnings));
}
Ok(SearchResults {
query_text: query.text.clone(),
remote_hits,
installed_matches,
warnings,
})
}
pub struct GitHubSearchProvider<'a, T: GitHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let name_only_query = format!("{} in:name", query.text);
let mut ranked_hits =
search_github_repositories_with(&name_only_query, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("github", &render_github_search_error(&error))
})?;
if ranked_hits.len() < query.remote_limit {
let mut seen = ranked_hits
.iter()
.map(|hit| hit.full_name.clone())
.collect::<HashSet<_>>();
let backfill =
search_github_repositories_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("github", &render_github_search_error(&error))
})?;
for hit in backfill {
if ranked_hits.len() >= query.remote_limit {
break;
}
if seen.insert(hit.full_name.clone()) {
ranked_hits.push(hit);
}
}
}
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = ranked_hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
github_remote_match_rank(&normalized_query, &hit),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.filter_map(|(_, _, hit)| {
let full_name = hit.full_name;
let release = latest_appimage_release(self.transport, &full_name)?;
Some(SearchResult {
provider_id: "github".to_owned(),
display_name: full_name.clone(),
description: hit.description,
source_locator: hit.html_url,
install_query: full_name.clone(),
canonical_locator: full_name.clone(),
version: Some(release.tag.trim_start_matches('v').to_owned()),
install_status: SearchInstallStatus::Available,
})
})
.collect())
}
}
fn latest_appimage_release<T: GitHubTransport + ?Sized>(
transport: &T,
repo: &str,
) -> Option<TransportRelease> {
transport.fetch_releases(repo).ok().and_then(|releases| {
releases.into_iter().find(|release| {
release
.assets
.iter()
.any(|asset| asset.name.ends_with(".AppImage"))
})
})
}
fn collect_installed_matches(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Vec<InstalledSearchMatch> {
let normalized_query = normalize_lookup(&query.text);
let mut matches = installed_apps
.iter()
.filter_map(|app| {
match_rank(&normalized_query, &app.stable_id, &app.display_name).map(|rank| {
(
rank,
normalize_lookup(&app.stable_id),
InstalledSearchMatch {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
installed_version: app.installed_version.clone(),
},
)
})
})
.collect::<Vec<_>>();
matches.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
matches
.into_iter()
.map(|(_, _, installed_match)| installed_match)
.collect()
}
fn match_rank(query: &str, stable_id: &str, display_name: &str) -> Option<u8> {
let stable_id = normalize_lookup(stable_id);
let display_name = normalize_lookup(display_name);
[stable_id, display_name]
.into_iter()
.filter_map(|candidate| {
if candidate == query {
Some(0)
} else if candidate.starts_with(query) {
Some(1)
} else if candidate.contains(query) {
Some(2)
} else {
None
}
})
.min()
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn annotate_remote_hits_with_install_status(
remote_hits: &mut [SearchResult],
installed_apps: &[AppRecord],
) {
for hit in remote_hits.iter_mut() {
if let Some(installed) = installed_apps
.iter()
.find(|app| app_matches_remote_hit(app, hit))
{
if installed.installed_version == hit.version {
hit.install_status = SearchInstallStatus::Installed {
installed_version: installed.installed_version.clone(),
};
} else {
hit.install_status = SearchInstallStatus::UpdateAvailable {
installed_version: installed.installed_version.clone(),
latest_version: hit.version.clone(),
};
}
}
}
}
fn app_matches_remote_hit(app: &AppRecord, hit: &SearchResult) -> bool {
let Some(locator) = app_search_locator(app) else {
return false;
};
locator == normalize_lookup(&hit.install_query)
|| locator == normalize_lookup(&hit.canonical_locator)
}
fn app_search_locator(app: &AppRecord) -> Option<String> {
if let Some(source) = &app.source {
match source.kind {
crate::domain::source::SourceKind::GitHub
| crate::domain::source::SourceKind::AppImageHub => {
if let Some(locator) = source.canonical_locator.as_deref() {
return Some(normalize_lookup(locator));
}
return Some(normalize_lookup(&source.locator));
}
_ => {}
}
}
app.source_input.as_deref().and_then(|input| {
if input.contains('/') && !input.contains("://") {
if let Some((provider, id)) = input.split_once('/')
&& provider.eq_ignore_ascii_case("appimagehub")
&& !id.is_empty()
{
return Some(normalize_lookup(id));
}
Some(normalize_lookup(input))
} else {
None
}
})
}
fn github_remote_match_rank(
query: &str,
repository: &crate::source::github::TransportRepository,
) -> u8 {
let full_name = normalize_lookup(&repository.full_name);
let description = repository.description.as_deref().map(normalize_lookup);
let mut parts = full_name.split('/');
let owner = parts.next().unwrap_or_default();
let repo = parts.next().unwrap_or_default();
if full_name == query {
return 0;
}
if owner == query || repo == query {
return 1;
}
if full_name.starts_with(query) || owner.starts_with(query) || repo.starts_with(query) {
return 2;
}
if full_name.contains(query) || owner.contains(query) || repo.contains(query) {
return 3;
}
if description
.as_deref()
.map(|description| description.starts_with(query))
.unwrap_or(false)
{
return 4;
}
if description
.as_deref()
.map(|description| description.contains(query))
.unwrap_or(false)
{
return 5;
}
6
}
fn render_github_search_error(error: &GitHubSearchError) -> String {
match error {
GitHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -0,0 +1,281 @@
use crate::adapters::traits::AdapterError;
use crate::app::add::{BuildAddPlanError, build_add_plan, build_add_plan_with};
use crate::app::interaction::InteractionKind;
use crate::domain::app::AppRecord;
use crate::domain::show::{
AdapterFailureKind, GitHubDiscoveryFailureKind, InstalledShow, MetadataSummary,
RemoteArtifactSummary, RemoteInteractionSummary, RemoteShow, ShowResult, ShowResultError,
SourceSummary, TrackedInstallPaths, UpdateChannelSummary, UpdateStrategySummary,
};
use crate::source::github::GitHubTransport;
pub fn build_show_result(
query: &str,
installed_apps: &[AppRecord],
) -> Result<ShowResult, ShowResultError> {
match resolve_installed_show(query, installed_apps) {
InstalledLookup::Found(app) => Ok(ShowResult::Installed(project_installed_show(app))),
InstalledLookup::Missing => build_remote_show_result(query),
InstalledLookup::Ambiguous(matches) => Err(ambiguous_installed_match(query, matches)),
}
}
pub fn build_installed_show_results(installed_apps: &[AppRecord]) -> Vec<InstalledShow> {
installed_apps.iter().map(project_installed_show).collect()
}
pub fn build_show_result_with<T: GitHubTransport + ?Sized>(
query: &str,
installed_apps: &[AppRecord],
transport: &T,
) -> Result<ShowResult, ShowResultError> {
match resolve_installed_show(query, installed_apps) {
InstalledLookup::Found(app) => Ok(ShowResult::Installed(project_installed_show(app))),
InstalledLookup::Missing => {
let plan = build_add_plan_with(query, transport).map_err(ShowResultError::from)?;
let warnings = collect_metadata_warnings(&plan.metadata);
let interactions = summarize_interactions(&plan.interactions);
Ok(ShowResult::Remote(RemoteShow {
source: project_source_summary(&plan.resolution.source),
artifact: RemoteArtifactSummary {
url: plan.selected_artifact.url,
version: optional_version(plan.selected_artifact.version),
arch: plan.selected_artifact.arch,
trusted_checksum: plan.selected_artifact.trusted_checksum,
selection_reason: plan.selected_artifact.selection_reason,
},
interactions,
warnings,
}))
}
InstalledLookup::Ambiguous(matches) => Err(ambiguous_installed_match(query, matches)),
}
}
fn build_remote_show_result(query: &str) -> Result<ShowResult, ShowResultError> {
let plan = build_add_plan(query).map_err(ShowResultError::from)?;
let warnings = collect_metadata_warnings(&plan.metadata);
let interactions = summarize_interactions(&plan.interactions);
Ok(ShowResult::Remote(RemoteShow {
source: project_source_summary(&plan.resolution.source),
artifact: RemoteArtifactSummary {
url: plan.selected_artifact.url,
version: optional_version(plan.selected_artifact.version),
arch: plan.selected_artifact.arch,
trusted_checksum: plan.selected_artifact.trusted_checksum,
selection_reason: plan.selected_artifact.selection_reason,
},
interactions,
warnings,
}))
}
fn ambiguous_installed_match(query: &str, matches: Vec<String>) -> ShowResultError {
ShowResultError::AmbiguousInstalledMatch {
query: query.to_owned(),
matches,
}
}
enum InstalledLookup<'a> {
Found(&'a AppRecord),
Missing,
Ambiguous(Vec<String>),
}
fn resolve_installed_show<'a>(query: &str, installed_apps: &'a [AppRecord]) -> InstalledLookup<'a> {
let normalized_query = normalize_lookup(query);
let matches = installed_apps
.iter()
.filter(|app| app_matches_installed_query(app, &normalized_query))
.collect::<Vec<_>>();
match matches.as_slice() {
[] => InstalledLookup::Missing,
[app] => InstalledLookup::Found(app),
_ => InstalledLookup::Ambiguous(
matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
),
}
}
fn app_matches_installed_query(app: &AppRecord, normalized_query: &str) -> bool {
let mut candidates = vec![
normalize_lookup(&app.stable_id),
normalize_lookup(&app.display_name),
];
if let Some(source_input) = app.source_input.as_deref() {
candidates.push(normalize_lookup(source_input));
}
if let Some(source) = app.source.as_ref() {
candidates.push(normalize_lookup(&source.locator));
if let Some(canonical_locator) = source.canonical_locator.as_deref() {
candidates.push(normalize_lookup(canonical_locator));
}
}
candidates
.iter()
.any(|candidate| candidate == normalized_query)
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn optional_version(version: String) -> Option<String> {
(version != "unresolved").then_some(version)
}
fn collect_metadata_warnings(metadata: &[crate::domain::update::ParsedMetadata]) -> Vec<String> {
metadata
.iter()
.flat_map(|item| item.warnings.iter().cloned())
.collect()
}
fn project_installed_show(app: &AppRecord) -> InstalledShow {
InstalledShow {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
installed_version: app.installed_version.clone().and_then(optional_version),
source_input: app.source_input.clone(),
source: app.source.as_ref().map(project_source_summary),
install_scope: app.install.as_ref().map(|install| install.scope),
tracked_paths: TrackedInstallPaths {
payload_path: app
.install
.as_ref()
.and_then(|install| install.payload_path.clone()),
desktop_entry_path: app
.install
.as_ref()
.and_then(|install| install.desktop_entry_path.clone()),
icon_path: app
.install
.as_ref()
.and_then(|install| install.icon_path.clone()),
},
update_strategy: app
.update_strategy
.as_ref()
.map(|strategy| UpdateStrategySummary {
preferred: UpdateChannelSummary {
kind: strategy.preferred.kind,
locator: strategy.preferred.locator.clone(),
reason: strategy.preferred.reason.clone(),
},
alternates: strategy
.alternates
.iter()
.map(|alternate| UpdateChannelSummary {
kind: alternate.kind,
locator: alternate.locator.clone(),
reason: alternate.reason.clone(),
})
.collect(),
}),
metadata: app
.metadata
.iter()
.map(|item| MetadataSummary {
kind: item.kind,
version: item.hints.version.clone(),
primary_download: item.hints.primary_download.clone(),
checksum: item.hints.checksum.clone(),
architecture: item.hints.architecture.clone(),
channel_label: item.hints.channel_label.clone(),
warnings: item.warnings.clone(),
})
.collect(),
}
}
fn project_source_summary(source: &crate::domain::source::SourceRef) -> SourceSummary {
SourceSummary {
kind: source.kind,
locator: source.locator.clone(),
canonical_locator: source.canonical_locator.clone(),
}
}
fn summarize_interactions(
interactions: &[crate::app::interaction::InteractionRequest],
) -> Vec<RemoteInteractionSummary> {
interactions
.iter()
.filter_map(|interaction| match &interaction.kind {
InteractionKind::SelectRegisteredApp { query, matches } => {
let _ = query;
let _ = matches;
None
}
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => Some(RemoteInteractionSummary::ChooseTrackingPreference {
requested_version: requested_version.clone(),
latest_version: latest_version.clone(),
}),
InteractionKind::SelectArtifact { candidates } => {
Some(RemoteInteractionSummary::SelectArtifact {
candidate_count: candidates.len(),
})
}
})
.collect()
}
impl From<BuildAddPlanError> for ShowResultError {
fn from(value: BuildAddPlanError) -> Self {
match value {
BuildAddPlanError::Query(_) => Self::UnsupportedQuery,
BuildAddPlanError::InsecureHttpSource { .. } => Self::InsecureHttpSource,
BuildAddPlanError::NoInstallableArtifact { source } => Self::NoInstallableArtifact {
source: project_source_summary(&source),
},
BuildAddPlanError::Adapter(id, error) => Self::AdapterResolutionFailed {
adapter_id: id.to_owned(),
kind: match &error {
AdapterError::UnsupportedQuery => AdapterFailureKind::UnsupportedQuery,
AdapterError::UnsupportedSource => AdapterFailureKind::UnsupportedSource,
AdapterError::ResolutionFailed(_) => AdapterFailureKind::ResolutionFailed,
},
detail: match error {
AdapterError::ResolutionFailed(reason) => Some(reason),
_ => None,
},
},
BuildAddPlanError::GitHubDiscovery(error) => Self::GitHubDiscoveryFailed {
kind: match &error {
crate::source::github::GitHubDiscoveryError::Unsupported => {
GitHubDiscoveryFailureKind::Unsupported
}
crate::source::github::GitHubDiscoveryError::FixtureDocumentMissing(_) => {
GitHubDiscoveryFailureKind::FixtureDocumentMissing
}
crate::source::github::GitHubDiscoveryError::NoReleases { .. } => {
GitHubDiscoveryFailureKind::NoReleases
}
crate::source::github::GitHubDiscoveryError::Transport(_) => {
GitHubDiscoveryFailureKind::Transport
}
},
detail: match error {
crate::source::github::GitHubDiscoveryError::FixtureDocumentMissing(url) => {
Some(url)
}
crate::source::github::GitHubDiscoveryError::NoReleases { repo } => Some(repo),
_ => None,
},
},
BuildAddPlanError::NoCandidates => Self::NoInstallableCandidates,
}
}
}

View file

@ -0,0 +1,383 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::app::add::{
AddSecurityPolicy, build_add_plan_with_reporter_and_policy, install_app_with_reporter,
};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::domain::app::{AppRecord, InstallScope};
use crate::domain::source::SourceKind;
use crate::domain::update::{
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
UpdateExecutionStatus, UpdatePlan,
};
pub fn build_update_plan(apps: &[AppRecord]) -> Result<UpdatePlan, BuildUpdatePlanError> {
Ok(UpdatePlan {
items: apps.iter().map(plan_update).collect(),
})
}
pub fn execute_updates(
apps: &[AppRecord],
install_home: &Path,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
let mut reporter = NoopReporter;
execute_updates_with_reporter_and_policy(
apps,
install_home,
&mut reporter,
AddSecurityPolicy::default(),
)
}
pub fn execute_updates_with_reporter(
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
execute_updates_with_reporter_and_policy(
apps,
install_home,
reporter,
AddSecurityPolicy::default(),
)
}
pub fn execute_updates_with_reporter_and_policy(
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
reporter.report(&OperationEvent::Started {
kind: OperationKind::UpdateBatch,
label: format!("{} apps", apps.len()),
});
let mut updated_apps = Vec::with_capacity(apps.len());
let mut items = Vec::with_capacity(apps.len());
for app in apps {
reporter.report(&OperationEvent::Started {
kind: OperationKind::UpdateItem,
label: app.stable_id.clone(),
});
match execute_update(app, install_home, reporter, policy) {
Ok(updated) => {
let warnings = updated
.warnings
.iter()
.chain(updated.install_outcome.warnings.iter())
.cloned()
.collect();
let record = updated.record;
items.push(ExecutedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
from_version: app.installed_version.clone(),
to_version: record.installed_version.clone(),
warnings,
status: UpdateExecutionStatus::Updated,
});
updated_apps.push(record);
reporter.report(&OperationEvent::Finished {
summary: format!("updated {}", app.stable_id),
});
}
Err(reason) => {
items.push(ExecutedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
from_version: app.installed_version.clone(),
to_version: app.installed_version.clone(),
warnings: Vec::new(),
status: UpdateExecutionStatus::Failed { reason },
});
updated_apps.push(app.clone());
}
}
}
let result = UpdateExecutionResult {
apps: updated_apps,
items,
};
reporter.report(&OperationEvent::Finished {
summary: format!(
"updated {}, failed {}",
result.updated_count(),
result.failed_count()
),
});
Ok(result)
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildUpdatePlanError {}
#[derive(Debug, Eq, PartialEq)]
pub enum ExecuteUpdatesError {}
fn plan_update(app: &AppRecord) -> PlannedUpdate {
let (selected_channel, selection_reason) = if let Some(strategy) = &app.update_strategy {
if strategy.preferred.locator.contains("fail") {
let fallback = strategy
.alternates
.first()
.cloned()
.unwrap_or_else(|| strategy.preferred.clone());
(fallback, "preferred-channel-failed".to_owned())
} else {
(
strategy.preferred.clone(),
strategy.preferred.reason.clone(),
)
}
} else {
(
fallback_channel_preference(app),
"install-origin-match".to_owned(),
)
};
PlannedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
selected_channel,
selection_reason,
}
}
fn fallback_channel_preference(app: &AppRecord) -> ChannelPreference {
let Some(source) = app.source.as_ref() else {
return ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: app.stable_id.clone(),
reason: "install-origin-match".to_owned(),
};
};
let (kind, locator) = match source.kind {
SourceKind::GitHub => (
UpdateChannelKind::GitHubReleases,
source
.canonical_locator
.clone()
.unwrap_or_else(|| source.locator.clone()),
),
SourceKind::GitLab
| SourceKind::AppImageHub
| SourceKind::SourceForge
| SourceKind::DirectUrl
| SourceKind::File => (UpdateChannelKind::DirectAsset, source.locator.clone()),
};
ChannelPreference {
kind,
locator,
reason: "install-origin-match".to_owned(),
}
}
fn execute_update(
app: &AppRecord,
install_home: &Path,
reporter: &mut impl ProgressReporter,
policy: AddSecurityPolicy,
) -> Result<crate::app::add::InstalledApp, String> {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: format!("resolving {}", app.stable_id),
});
let query = update_query(app).ok_or_else(|| {
let reason = "missing install source".to_owned();
reporter.report(&OperationEvent::Failed {
stage: OperationStage::ResolveQuery,
reason: reason.clone(),
});
reason
})?;
let requested_scope = app
.install
.as_ref()
.map(|install| install.scope)
.unwrap_or(InstallScope::User);
let transport = crate::source::github::default_transport();
let plan =
build_add_plan_with_reporter_and_policy(&query, transport.as_ref(), reporter, policy)
.map_err(|error| {
let reason = format!("failed to build update plan: {error:?}");
reporter.report(&OperationEvent::Failed {
stage: OperationStage::ResolveQuery,
reason: reason.clone(),
});
reason
})?;
let rollback = stage_existing_installation(app, install_home).inspect_err(|reason| {
reporter.report(&OperationEvent::Failed {
stage: OperationStage::StagePayload,
reason: reason.clone(),
});
})?;
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter)
.map_err(|error| {
let install_reason = format!("failed to install update: {error:?}");
let reason = match rollback.as_ref() {
Some(rollback) => match rollback.restore() {
Ok(()) => format!("{install_reason}; restored previous installation"),
Err(restore_reason) => {
format!("{install_reason}; rollback restore failed: {restore_reason}")
}
},
None => install_reason,
};
reporter.report(&OperationEvent::Failed {
stage: OperationStage::Finalize,
reason: reason.clone(),
});
reason
})
.inspect(|_| {
if let Some(rollback) = rollback.as_ref() {
let _ = rollback.cleanup();
}
})
}
fn update_query(app: &AppRecord) -> Option<String> {
if let Some(source) = app.source.as_ref()
&& source.kind == SourceKind::SourceForge
{
return Some(source.locator.clone());
}
app.source_input.clone().or_else(|| {
app.source.as_ref().map(|source| {
source
.canonical_locator
.clone()
.unwrap_or_else(|| source.locator.clone())
})
})
}
fn stage_existing_installation(
app: &AppRecord,
install_home: &Path,
) -> Result<Option<RollbackState>, String> {
let Some(install) = app.install.as_ref() else {
return Ok(None);
};
let tracked_paths = [
install.payload_path.as_deref(),
install.desktop_entry_path.as_deref(),
install.icon_path.as_deref(),
]
.into_iter()
.flatten()
.map(PathBuf::from)
.filter(|path| path.exists())
.collect::<Vec<_>>();
if tracked_paths.is_empty() {
return Ok(None);
}
let stage_dir = install_home
.join(".local/share/upm/rollback")
.join(&app.stable_id);
fs::create_dir_all(&stage_dir)
.map_err(|error| format!("failed to create rollback staging directory: {error}"))?;
let mut entries = Vec::with_capacity(tracked_paths.len());
for original_path in tracked_paths {
let backup_path = stage_dir.join(
original_path
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default(),
);
fs::rename(&original_path, &backup_path).map_err(|error| {
format!(
"failed to stage existing install file {}: {error}",
original_path.display()
)
})?;
entries.push(RollbackEntry {
original_path,
backup_path,
});
}
Ok(Some(RollbackState { stage_dir, entries }))
}
struct RollbackState {
stage_dir: PathBuf,
entries: Vec<RollbackEntry>,
}
impl RollbackState {
fn restore(&self) -> Result<(), String> {
for entry in &self.entries {
if let Some(parent) = entry.original_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to recreate rollback parent {}: {error}",
parent.display()
)
})?;
}
fs::rename(&entry.backup_path, &entry.original_path).map_err(|error| {
format!(
"failed to restore {}: {error}",
entry.original_path.display()
)
})?;
}
self.cleanup()
}
fn cleanup(&self) -> Result<(), String> {
if self.stage_dir.exists() {
fs::remove_dir_all(&self.stage_dir).map_err(|error| {
format!(
"failed to remove rollback staging directory {}: {error}",
self.stage_dir.display()
)
})?;
}
if let Some(parent) = self.stage_dir.parent()
&& parent.exists()
&& fs::read_dir(parent)
.map_err(|error| {
format!(
"failed to inspect rollback parent directory {}: {error}",
parent.display()
)
})?
.next()
.is_none()
{
fs::remove_dir(parent).map_err(|error| {
format!(
"failed to remove rollback parent directory {}: {error}",
parent.display()
)
})?;
}
Ok(())
}
}
struct RollbackEntry {
original_path: PathBuf,
backup_path: PathBuf,
}

View file

@ -0,0 +1,51 @@
use crate::domain::source::SourceRef;
use crate::domain::update::{ParsedMetadata, UpdateStrategy};
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum InstallScope {
User,
System,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdentityConfidence {
Confident,
NeedsConfirmation,
RawUrlFallback,
}
#[derive(Debug, Eq, PartialEq)]
pub struct AppIdentity {
pub stable_id: String,
pub display_name: String,
pub confidence: IdentityConfidence,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct InstallMetadata {
pub scope: InstallScope,
#[serde(default)]
pub payload_path: Option<String>,
#[serde(default)]
pub desktop_entry_path: Option<String>,
#[serde(default)]
pub icon_path: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct AppRecord {
pub stable_id: String,
pub display_name: String,
#[serde(default)]
pub source_input: Option<String>,
#[serde(default)]
pub source: Option<SourceRef>,
#[serde(default)]
pub installed_version: Option<String>,
#[serde(default)]
pub update_strategy: Option<UpdateStrategy>,
#[serde(default)]
pub metadata: Vec<ParsedMetadata>,
#[serde(default)]
pub install: Option<InstallMetadata>,
}

View file

@ -0,0 +1,5 @@
pub mod app;
pub mod search;
pub mod show;
pub mod source;
pub mod update;

View file

@ -0,0 +1,68 @@
pub const DEFAULT_REMOTE_LIMIT: usize = 10;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchInstallStatus {
Available,
Installed {
installed_version: Option<String>,
},
UpdateAvailable {
installed_version: Option<String>,
latest_version: Option<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchQuery {
pub text: String,
pub remote_limit: usize,
}
impl SearchQuery {
pub fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
remote_limit: DEFAULT_REMOTE_LIMIT,
}
}
pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self {
Self {
text: text.to_owned(),
remote_limit,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResult {
pub provider_id: String,
pub display_name: String,
pub description: Option<String>,
pub source_locator: String,
pub install_query: String,
pub canonical_locator: String,
pub version: Option<String>,
pub install_status: SearchInstallStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledSearchMatch {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchWarning {
pub provider_id: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResults {
pub query_text: String,
pub remote_hits: Vec<SearchResult>,
pub installed_matches: Vec<InstalledSearchMatch>,
pub warnings: Vec<SearchWarning>,
}

View file

@ -0,0 +1,126 @@
use crate::domain::app::InstallScope;
use crate::domain::source::SourceKind;
use crate::domain::update::{ParsedMetadataKind, UpdateChannelKind};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShowResult {
Installed(InstalledShow),
Remote(RemoteShow),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledShow {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
pub source_input: Option<String>,
pub source: Option<SourceSummary>,
pub install_scope: Option<InstallScope>,
pub tracked_paths: TrackedInstallPaths,
pub update_strategy: Option<UpdateStrategySummary>,
pub metadata: Vec<MetadataSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteShow {
pub source: SourceSummary,
pub artifact: RemoteArtifactSummary,
pub interactions: Vec<RemoteInteractionSummary>,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceSummary {
pub kind: SourceKind,
pub locator: String,
pub canonical_locator: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TrackedInstallPaths {
pub payload_path: Option<String>,
pub desktop_entry_path: Option<String>,
pub icon_path: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateStrategySummary {
pub preferred: UpdateChannelSummary,
pub alternates: Vec<UpdateChannelSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateChannelSummary {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetadataSummary {
pub kind: ParsedMetadataKind,
pub version: Option<String>,
pub primary_download: Option<String>,
pub checksum: Option<String>,
pub architecture: Option<String>,
pub channel_label: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RemoteArtifactSummary {
pub url: String,
pub version: Option<String>,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RemoteInteractionSummary {
ChooseTrackingPreference {
requested_version: String,
latest_version: String,
},
SelectArtifact {
candidate_count: usize,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShowResultError {
AmbiguousInstalledMatch {
query: String,
matches: Vec<String>,
},
UnsupportedQuery,
InsecureHttpSource,
NoInstallableArtifact {
source: SourceSummary,
},
AdapterResolutionFailed {
adapter_id: String,
kind: AdapterFailureKind,
detail: Option<String>,
},
GitHubDiscoveryFailed {
kind: GitHubDiscoveryFailureKind,
detail: Option<String>,
},
NoInstallableCandidates,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AdapterFailureKind {
UnsupportedQuery,
UnsupportedSource,
ResolutionFailed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GitHubDiscoveryFailureKind {
Unsupported,
FixtureDocumentMissing,
NoReleases,
Transport,
}

View file

@ -0,0 +1,117 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceKind {
GitHub,
GitLab,
AppImageHub,
SourceForge,
DirectUrl,
File,
}
impl SourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHub => "github",
Self::GitLab => "gitlab",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceInputKind {
RepoShorthand,
GitHubRepositoryUrl,
GitHubReleaseUrl,
GitHubReleaseAssetUrl,
GitLabUrl,
AppImageHubUrl,
AppImageHubShorthand,
SourceForgeUrl,
DirectUrl,
File,
}
impl SourceInputKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::RepoShorthand => "repo-shorthand",
Self::GitHubRepositoryUrl => "github-repository-url",
Self::GitHubReleaseUrl => "github-release-url",
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
Self::GitLabUrl => "gitlab-url",
Self::AppImageHubUrl => "appimagehub-url",
Self::AppImageHubShorthand => "appimagehub-shorthand",
Self::SourceForgeUrl => "sourceforge-url",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum NormalizedSourceKind {
GitHubRepository,
GitHubRelease,
GitHubReleaseAsset,
GitLab,
GitLabCandidate,
AppImageHub,
SourceForge,
SourceForgeCandidate,
DirectUrl,
File,
}
impl NormalizedSourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubRepository => "github-repository",
Self::GitHubRelease => "github-release",
Self::GitHubReleaseAsset => "github-release-asset",
Self::GitLab => "gitlab",
Self::GitLabCandidate => "gitlab-candidate",
Self::AppImageHub => "appimagehub",
Self::SourceForge => "sourceforge",
Self::SourceForgeCandidate => "sourceforge-candidate",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct SourceRef {
pub kind: SourceKind,
pub locator: String,
#[serde(default = "default_source_input_kind")]
pub input_kind: SourceInputKind,
#[serde(default = "default_normalized_source_kind")]
pub normalized_kind: NormalizedSourceKind,
#[serde(default)]
pub canonical_locator: Option<String>,
#[serde(default)]
pub requested_tag: Option<String>,
#[serde(default)]
pub requested_asset_name: Option<String>,
#[serde(default)]
pub tracks_latest: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ResolvedRelease {
pub version: String,
#[serde(default)]
pub prerelease: bool,
}
const fn default_source_input_kind() -> SourceInputKind {
SourceInputKind::DirectUrl
}
const fn default_normalized_source_kind() -> NormalizedSourceKind {
NormalizedSourceKind::DirectUrl
}

View file

@ -0,0 +1,142 @@
use crate::domain::app::AppRecord;
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ParsedMetadataKind {
Unknown,
ElectronBuilder,
Zsync,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct MetadataHints {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub primary_download: Option<String>,
#[serde(default)]
pub checksum: Option<String>,
#[serde(default)]
pub architecture: Option<String>,
#[serde(default)]
pub channel_label: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ParsedMetadata {
pub kind: ParsedMetadataKind,
pub hints: MetadataHints,
#[serde(default)]
pub warnings: Vec<String>,
#[serde(default)]
pub confidence: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum UpdateChannelKind {
GitHubReleases,
ElectronBuilder,
Zsync,
DirectAsset,
}
impl UpdateChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubReleases => "github-releases",
Self::ElectronBuilder => "electron-builder",
Self::Zsync => "zsync",
Self::DirectAsset => "direct-asset-lineage",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateChannel {
pub kind: UpdateChannelKind,
pub locator: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub artifact_name: Option<String>,
#[serde(default)]
pub confidence: u8,
#[serde(default)]
pub matches_install_origin: bool,
#[serde(default)]
pub prerelease: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ChannelPreference {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateStrategy {
pub preferred: ChannelPreference,
#[serde(default)]
pub alternates: Vec<ChannelPreference>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub weak_checksum_md5: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdate>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlannedUpdate {
pub stable_id: String,
pub display_name: String,
pub selected_channel: ChannelPreference,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateExecutionResult {
pub apps: Vec<AppRecord>,
pub items: Vec<ExecutedUpdate>,
}
impl UpdateExecutionResult {
pub fn updated_count(&self) -> usize {
self.items
.iter()
.filter(|item| item.status == UpdateExecutionStatus::Updated)
.count()
}
pub fn failed_count(&self) -> usize {
self.items
.iter()
.filter(|item| matches!(item.status, UpdateExecutionStatus::Failed { .. }))
.count()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExecutedUpdate {
pub stable_id: String,
pub display_name: String,
pub from_version: Option<String>,
pub to_version: Option<String>,
pub warnings: Vec<String>,
pub status: UpdateExecutionStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum UpdateExecutionStatus {
Updated,
Failed { reason: String },
}

View file

@ -0,0 +1,52 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DesktopWriteOutcome {
pub desktop_entry_path: PathBuf,
pub icon_path: Option<PathBuf>,
}
pub fn write_desktop_integration(
desktop_entry_path: &Path,
desktop_entry_contents: &str,
icon_path: Option<&Path>,
icon_bytes: Option<&[u8]>,
) -> Result<DesktopWriteOutcome, io::Error> {
if let Some(parent) = desktop_entry_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(desktop_entry_path, desktop_entry_contents)?;
let written_icon_path = match (icon_path, icon_bytes) {
(Some(path), Some(bytes)) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, bytes)?;
Some(path.to_path_buf())
}
_ => None,
};
Ok(DesktopWriteOutcome {
desktop_entry_path: desktop_entry_path.to_path_buf(),
icon_path: written_icon_path,
})
}
pub fn extract_icon_from_payload(payload: &[u8]) -> Option<Vec<u8>> {
const PNG_HEADER: &[u8] = b"\x89PNG\r\n\x1a\n";
const PNG_TRAILER: &[u8] = b"IEND\xaeB`\x82";
let start = payload
.windows(PNG_HEADER.len())
.position(|window| window == PNG_HEADER)?;
let tail = payload[start..]
.windows(PNG_TRAILER.len())
.position(|window| window == PNG_TRAILER)?;
let end = start + tail + PNG_TRAILER.len();
Some(payload[start..end].to_vec())
}

View file

@ -0,0 +1,237 @@
use std::fs;
use std::io;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::{error::Error, fmt};
use base64::Engine;
use sha2::{Digest, Sha512};
use crate::integration::desktop::{extract_icon_from_payload, write_desktop_integration};
use crate::integration::refresh::refresh_integration;
use crate::platform::DesktopHelpers;
pub fn staged_appimage_path(staging_root: &Path, app_id: &str) -> PathBuf {
staging_root.join(format!("{app_id}.download"))
}
pub fn replacement_path(target: &Path) -> PathBuf {
let mut file_name = target
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default();
file_name.push(".new");
target.with_file_name(file_name)
}
#[derive(Debug)]
pub enum PayloadInstallError {
InvalidArtifact,
ChecksumMismatch,
InvalidTrustedChecksum,
InvalidWeakChecksum,
WeakChecksumMismatch,
Io(io::Error),
DesktopIntegration(io::Error),
}
impl From<io::Error> for PayloadInstallError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl fmt::Display for PayloadInstallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidArtifact => write!(f, "artifact is not a valid AppImage"),
Self::ChecksumMismatch => write!(f, "artifact checksum did not match trusted metadata"),
Self::InvalidTrustedChecksum => write!(f, "trusted checksum metadata is malformed"),
Self::InvalidWeakChecksum => write!(f, "weak provider checksum metadata is malformed"),
Self::WeakChecksumMismatch => {
write!(
f,
"weak provider checksum did not match downloaded artifact"
)
}
Self::Io(error) => write!(f, "payload installation failed: {error}"),
Self::DesktopIntegration(error) => {
write!(f, "desktop integration failed: {error}")
}
}
}
}
impl Error for PayloadInstallError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PayloadInstallOutcome {
pub final_payload_path: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DesktopIntegrationRequest<'a> {
pub desktop_entry_path: &'a Path,
pub desktop_entry_contents: &'a str,
pub icon_path: Option<&'a Path>,
pub icon_bytes: Option<&'a [u8]>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallRequest<'a> {
pub staged_payload_path: &'a Path,
pub final_payload_path: &'a Path,
pub trusted_checksum: Option<&'a str>,
pub weak_checksum_md5: Option<&'a str>,
pub desktop: Option<DesktopIntegrationRequest<'a>>,
pub helpers: DesktopHelpers,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallOutcome {
pub final_payload_path: PathBuf,
pub desktop_entry_path: Option<PathBuf>,
pub icon_path: Option<PathBuf>,
pub warnings: Vec<String>,
}
pub fn stage_and_commit_payload(
staged_payload_path: &Path,
final_payload_path: &Path,
) -> Result<PayloadInstallOutcome, PayloadInstallError> {
if !is_appimage_payload_path(staged_payload_path)? {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::InvalidArtifact);
}
let replacement = replacement_path(final_payload_path);
let mut permissions = fs::metadata(staged_payload_path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(staged_payload_path, permissions)?;
if let Some(parent) = final_payload_path.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(staged_payload_path, &replacement)?;
fs::rename(&replacement, final_payload_path)?;
Ok(PayloadInstallOutcome {
final_payload_path: final_payload_path.to_path_buf(),
})
}
fn is_appimage_payload_path(path: &Path) -> Result<bool, io::Error> {
let mut file = fs::File::open(path)?;
let mut header = [0_u8; 4];
let read = file.read(&mut header)?;
Ok(read == header.len() && header == *b"\x7fELF")
}
pub fn execute_install(
request: &InstallRequest<'_>,
) -> Result<InstallOutcome, PayloadInstallError> {
verify_trusted_checksum(request.staged_payload_path, request.trusted_checksum)?;
verify_weak_checksum_md5(request.staged_payload_path, request.weak_checksum_md5)?;
let payload =
stage_and_commit_payload(request.staged_payload_path, request.final_payload_path)?;
let mut desktop_entry_path = None;
let mut icon_path = None;
if let Some(desktop) = &request.desktop {
let extracted_icon = if desktop.icon_bytes.is_none() && desktop.icon_path.is_some() {
extract_icon_from_payload_path(&payload.final_payload_path)
} else {
None
};
let written = write_desktop_integration(
desktop.desktop_entry_path,
desktop.desktop_entry_contents,
desktop.icon_path,
desktop.icon_bytes.or(extracted_icon.as_deref()),
)
.map_err(|error| {
let _ = fs::remove_file(&payload.final_payload_path);
PayloadInstallError::DesktopIntegration(error)
})?;
desktop_entry_path = Some(written.desktop_entry_path);
icon_path = written.icon_path;
}
let warnings = refresh_integration(
&request.helpers,
desktop_entry_path.as_deref(),
icon_path.as_deref(),
);
Ok(InstallOutcome {
final_payload_path: payload.final_payload_path,
desktop_entry_path,
icon_path,
warnings,
})
}
fn extract_icon_from_payload_path(path: &Path) -> Option<Vec<u8>> {
fs::read(path)
.ok()
.and_then(|payload| extract_icon_from_payload(&payload))
}
fn verify_trusted_checksum(
staged_payload_path: &Path,
trusted_checksum: Option<&str>,
) -> Result<(), PayloadInstallError> {
let Some(trusted_checksum) = trusted_checksum.map(str::trim) else {
return Ok(());
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(trusted_checksum)
.map_err(|_| {
let _ = fs::remove_file(staged_payload_path);
PayloadInstallError::InvalidTrustedChecksum
})?;
if decoded.len() != 64 {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::InvalidTrustedChecksum);
}
let payload = fs::read(staged_payload_path)?;
let actual_checksum = base64::engine::general_purpose::STANDARD.encode(Sha512::digest(payload));
if actual_checksum != trusted_checksum {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::ChecksumMismatch);
}
Ok(())
}
fn verify_weak_checksum_md5(
staged_payload_path: &Path,
weak_checksum_md5: Option<&str>,
) -> Result<(), PayloadInstallError> {
let Some(weak_checksum_md5) = weak_checksum_md5.map(str::trim) else {
return Ok(());
};
if weak_checksum_md5.len() != 32
|| !weak_checksum_md5
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::InvalidWeakChecksum);
}
let payload = fs::read(staged_payload_path)?;
let actual_checksum = format!("{:x}", md5::compute(payload));
if actual_checksum != weak_checksum_md5.to_ascii_lowercase() {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::WeakChecksumMismatch);
}
Ok(())
}

View file

@ -0,0 +1,5 @@
pub mod desktop;
pub mod install;
pub mod paths;
pub mod policy;
pub mod refresh;

View file

@ -0,0 +1,40 @@
use std::path::{Path, PathBuf};
use crate::domain::app::InstallScope;
use crate::platform::{
system_applications_dir, system_icons_dir, system_managed_appimages_dir, user_applications_dir,
user_icons_dir, user_managed_appimages_dir,
};
pub fn managed_appimage_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_managed_dir(home_dir, scope).join(format!("{app_id}.AppImage"))
}
pub fn desktop_entry_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_applications_dir(home_dir, scope).join(format!("upm-{app_id}.desktop"))
}
pub fn icon_path(home_dir: &Path, scope: InstallScope, app_id: &str) -> PathBuf {
scope_icons_dir(home_dir, scope).join(format!("{app_id}.png"))
}
fn scope_managed_dir(home_dir: &Path, scope: InstallScope) -> PathBuf {
match scope {
InstallScope::User => user_managed_appimages_dir(home_dir),
InstallScope::System => system_managed_appimages_dir(),
}
}
fn scope_applications_dir(home_dir: &Path, scope: InstallScope) -> PathBuf {
match scope {
InstallScope::User => user_applications_dir(home_dir),
InstallScope::System => system_applications_dir(),
}
}
fn scope_icons_dir(home_dir: &Path, scope: InstallScope) -> PathBuf {
match scope {
InstallScope::User => user_icons_dir(home_dir),
InstallScope::System => system_icons_dir(),
}
}

View file

@ -0,0 +1,71 @@
use std::path::PathBuf;
use crate::domain::app::InstallScope;
use crate::platform::{
DistroFamily, HostCapabilities, system_applications_dir, system_icons_dir,
system_managed_appimages_dir,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IntegrationMode {
Full,
Degraded,
PayloadOnly,
Denied,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallPolicy {
pub scope: InstallScope,
pub payload_root: PathBuf,
pub desktop_entry_root: PathBuf,
pub icon_root: PathBuf,
pub integration_mode: IntegrationMode,
pub warnings: Vec<String>,
}
pub fn resolve_install_policy(
family: DistroFamily,
requested_scope: InstallScope,
capabilities: &HostCapabilities,
) -> Result<InstallPolicy, String> {
match (family, requested_scope) {
(DistroFamily::Nix, InstallScope::System) => Err(
"system installs are not supported on Nix hosts until a native strategy exists"
.to_string(),
),
(DistroFamily::Immutable, InstallScope::System) if capabilities.is_immutable => {
Ok(InstallPolicy {
scope: InstallScope::User,
payload_root: PathBuf::from(".local/lib/upm/appimages"),
desktop_entry_root: PathBuf::from(".local/share/applications"),
icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"),
integration_mode: IntegrationMode::Degraded,
warnings: vec![
"system install requested on immutable host; downgraded to user scope"
.to_string(),
],
})
}
(_, InstallScope::System) => Ok(InstallPolicy {
scope: InstallScope::System,
payload_root: system_managed_appimages_dir(),
desktop_entry_root: system_applications_dir(),
icon_root: system_icons_dir(),
integration_mode: IntegrationMode::Full,
warnings: Vec::new(),
}),
_ => Ok(InstallPolicy {
scope: InstallScope::User,
payload_root: PathBuf::from(".local/lib/upm/appimages"),
desktop_entry_root: PathBuf::from(".local/share/applications"),
icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"),
integration_mode: if capabilities.has_desktop_session {
IntegrationMode::Full
} else {
IntegrationMode::PayloadOnly
},
warnings: Vec::new(),
}),
}
}

View file

@ -0,0 +1,102 @@
use std::env;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::platform::DesktopHelpers;
pub fn refresh_integration(
helpers: &DesktopHelpers,
desktop_entry_path: Option<&Path>,
icon_path: Option<&Path>,
) -> Vec<String> {
let mut warnings = Vec::new();
if let (Some(helper), Some(path)) = (
helpers.update_desktop_database_path.as_ref(),
desktop_entry_path.and_then(Path::parent),
) {
audit_helper(helper, &[path]);
if let Err(error) = Command::new(helper).arg(path).status().inspect(|status| {
audit_helper_status(helper, status.code());
}) {
audit_helper_failure(helper, &error.to_string());
warnings.push(format!("update-desktop-database failed: {error}"));
}
} else if !helpers.update_desktop_database {
warnings.push(
"update-desktop-database not available; desktop cache refresh skipped".to_owned(),
);
}
if let (Some(helper), Some(path)) = (
helpers.gtk_update_icon_cache_path.as_ref(),
icon_path.map(icon_theme_root),
) {
audit_helper(helper, &[Path::new("-f"), Path::new("-t"), path.as_path()]);
if let Err(error) = Command::new(helper)
.args(["-f", "-t"])
.arg(path)
.status()
.inspect(|status| {
audit_helper_status(helper, status.code());
})
{
audit_helper_failure(helper, &error.to_string());
warnings.push(format!("gtk-update-icon-cache failed: {error}"));
}
} else if !helpers.gtk_update_icon_cache {
warnings.push("gtk-update-icon-cache not available; icon cache refresh skipped".to_owned());
}
warnings
}
fn icon_theme_root(icon_path: &Path) -> PathBuf {
for ancestor in icon_path.ancestors() {
if ancestor.file_name().and_then(|name| name.to_str()) == Some("hicolor") {
return ancestor.to_path_buf();
}
}
icon_path.parent().unwrap_or(icon_path).to_path_buf()
}
fn audit_helper(helper: &Path, args: &[&Path]) {
if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return;
}
let rendered_args = args
.iter()
.map(|arg| arg.display().to_string())
.collect::<Vec<_>>()
.join(" ");
eprintln!(
"[upm] helper exec: {}{}{}",
helper.display(),
if rendered_args.is_empty() { "" } else { " " },
rendered_args
);
}
fn audit_helper_status(helper: &Path, code: Option<i32>) {
if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return;
}
match code {
Some(code) => eprintln!("[upm] helper exit: {} code={code}", helper.display()),
None => eprintln!(
"[upm] helper exit: {} terminated by signal",
helper.display()
),
}
}
fn audit_helper_failure(helper: &Path, error: &str) {
if env::var("UPM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
return;
}
eprintln!("[upm] helper failure: {} error={error}", helper.display());
}

View file

@ -0,0 +1,11 @@
pub mod adapters;
pub mod app;
pub mod domain;
pub mod integration;
pub mod metadata;
pub mod platform;
pub mod registry;
pub mod source;
pub mod update;
pub use app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};

View file

@ -0,0 +1,24 @@
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetadataDocument {
pub url: String,
pub content_type: Option<String>,
pub contents: Vec<u8>,
}
impl MetadataDocument {
pub fn plain_text(url: &str, contents: &[u8]) -> Self {
Self {
url: url.to_owned(),
content_type: Some("text/plain".to_owned()),
contents: contents.to_vec(),
}
}
pub fn yaml(url: &str, contents: &[u8]) -> Self {
Self {
url: url.to_owned(),
content_type: Some("application/yaml".to_owned()),
contents: contents.to_vec(),
}
}
}

View file

@ -0,0 +1,30 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse(document: &MetadataDocument) -> ParsedMetadata {
let contents = String::from_utf8_lossy(&document.contents);
let version = extract_value(&contents, "version:");
let path = extract_value(&contents, "path:").or_else(|| extract_value(&contents, "url:"));
let checksum = extract_value(&contents, "sha512:");
ParsedMetadata {
kind: ParsedMetadataKind::ElectronBuilder,
hints: MetadataHints {
version,
primary_download: path,
checksum,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
},
warnings: Vec::new(),
confidence: 90,
}
}
fn extract_value(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}

View file

@ -0,0 +1,7 @@
mod document;
mod electron_builder;
mod parser;
mod zsync;
pub use document::MetadataDocument;
pub use parser::parse_document;

View file

@ -0,0 +1,22 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse_document(document: &MetadataDocument) -> Result<ParsedMetadata, MetadataParseError> {
if document.url.ends_with("latest-linux.yml") || document.url.ends_with("latest-linux.yaml") {
return Ok(super::electron_builder::parse(document));
}
if document.url.ends_with(".zsync") {
return Ok(super::zsync::parse(document));
}
Ok(ParsedMetadata {
kind: ParsedMetadataKind::Unknown,
hints: MetadataHints::default(),
warnings: vec!["unsupported metadata document".to_owned()],
confidence: 0,
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum MetadataParseError {}

View file

@ -0,0 +1,38 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse(document: &MetadataDocument) -> ParsedMetadata {
let contents = String::from_utf8_lossy(&document.contents);
let url = extract_field(&contents, "URL:");
let filename = extract_field(&contents, "Filename:");
ParsedMetadata {
kind: ParsedMetadataKind::Zsync,
hints: MetadataHints {
version: filename
.as_ref()
.and_then(|value| version_from_filename(value)),
primary_download: url.or(filename),
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("zsync".to_owned()),
},
warnings: Vec::new(),
confidence: 75,
}
}
fn extract_field(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn version_from_filename(filename: &str) -> Option<String> {
filename
.split('-')
.find(|segment| segment.chars().any(|ch| ch.is_ascii_digit()) && segment.contains('.'))
.map(|value| value.trim_end_matches(".AppImage").to_owned())
}

View file

@ -0,0 +1,88 @@
use std::fs::{self, OpenOptions};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DesktopHelpers {
pub update_desktop_database: bool,
pub gtk_update_icon_cache: bool,
pub update_desktop_database_path: Option<PathBuf>,
pub gtk_update_icon_cache_path: Option<PathBuf>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct WritableRoots {
pub payload: bool,
pub desktop_entries: bool,
pub icons: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct HostCapabilities {
pub is_immutable: bool,
pub is_nix: bool,
pub has_desktop_session: bool,
pub helpers: DesktopHelpers,
pub writable_roots: WritableRoots,
}
impl HostCapabilities {
pub fn immutable_user_only() -> Self {
Self {
is_immutable: true,
has_desktop_session: true,
..Self::default()
}
}
}
pub fn probe_desktop_helpers(search_paths: &[&Path]) -> DesktopHelpers {
let update_desktop_database_path = command_path(search_paths, "update-desktop-database");
let gtk_update_icon_cache_path = command_path(search_paths, "gtk-update-icon-cache");
DesktopHelpers {
update_desktop_database: update_desktop_database_path.is_some(),
gtk_update_icon_cache: gtk_update_icon_cache_path.is_some(),
update_desktop_database_path,
gtk_update_icon_cache_path,
}
}
pub fn probe_writable_roots(payload: &Path, desktop_entries: &Path, icons: &Path) -> WritableRoots {
WritableRoots {
payload: is_writable_dir(payload),
desktop_entries: is_writable_dir(desktop_entries),
icons: is_writable_dir(icons),
}
}
fn command_path(search_paths: &[&Path], executable: &str) -> Option<PathBuf> {
search_paths
.iter()
.map(|path| path.join(executable))
.find(|candidate| is_executable_file(candidate))
}
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
fn is_writable_dir(path: &Path) -> bool {
if !path.is_dir() {
return false;
}
let probe_path = path.join(".upm-write-test");
let result = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&probe_path);
match result {
Ok(_) => {
let _ = fs::remove_file(&probe_path);
true
}
Err(_) => false,
}
}

View file

@ -0,0 +1,75 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DistroFamily {
Debian,
Fedora,
Arch,
OpenSuse,
Alpine,
Nix,
Immutable,
Generic,
}
pub fn detect_distro_family(os_release: &str) -> DistroFamily {
let id = lookup_field(os_release, "ID");
let id_like = lookup_field(os_release, "ID_LIKE");
let variant_id = lookup_field(os_release, "VARIANT_ID");
if matches_any(id, id_like, &["nixos"]) {
return DistroFamily::Nix;
}
if matches_field(variant_id, &["silverblue", "kinoite", "coreos", "aurora"])
|| matches_any(
id,
id_like,
&["silverblue", "kinoite", "ublue", "fedora-immutable"],
)
{
return DistroFamily::Immutable;
}
if matches_any(id, id_like, &["fedora", "rhel", "centos"]) {
return DistroFamily::Fedora;
}
if matches_any(id, id_like, &["debian", "ubuntu"]) {
return DistroFamily::Debian;
}
if matches_any(id, id_like, &["arch", "manjaro"]) {
return DistroFamily::Arch;
}
if matches_any(id, id_like, &["opensuse", "suse", "sles"]) {
return DistroFamily::OpenSuse;
}
if matches_any(id, id_like, &["alpine"]) {
return DistroFamily::Alpine;
}
DistroFamily::Generic
}
fn lookup_field<'a>(os_release: &'a str, key: &str) -> Option<&'a str> {
os_release
.lines()
.find_map(|line| line.strip_prefix(&format!("{key}=")))
.map(trim_value)
}
fn trim_value(value: &str) -> &str {
value.trim().trim_matches('"')
}
fn matches_any(id: Option<&str>, id_like: Option<&str>, needles: &[&str]) -> bool {
matches_field(id, needles) || matches_field(id_like, needles)
}
fn matches_field(field: Option<&str>, needles: &[&str]) -> bool {
field
.into_iter()
.flat_map(|value| value.split_ascii_whitespace())
.any(|candidate| needles.contains(&candidate))
}

View file

@ -0,0 +1,106 @@
pub mod capabilities;
pub mod distro;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub use crate::domain::app::InstallScope;
pub use capabilities::{DesktopHelpers, HostCapabilities, WritableRoots};
pub use distro::{DistroFamily, detect_distro_family};
const OS_RELEASE_PATH_ENV: &str = "UPM_OS_RELEASE_PATH";
const HELPER_PATHS_ENV: &str = "UPM_HELPER_PATHS";
pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/lib/upm/appimages")
}
pub fn user_applications_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/share/applications")
}
pub fn user_icons_dir(home_dir: &Path) -> PathBuf {
home_dir.join(".local/share/icons/hicolor/256x256/apps")
}
pub fn system_managed_appimages_dir() -> PathBuf {
PathBuf::from("/opt/upm/appimages")
}
pub fn system_applications_dir() -> PathBuf {
PathBuf::from("/usr/share/applications")
}
pub fn system_icons_dir() -> PathBuf {
PathBuf::from("/usr/share/icons/hicolor/256x256/apps")
}
pub fn probe_live_host(
home_dir: &Path,
requested_scope: InstallScope,
) -> io::Result<(DistroFamily, HostCapabilities)> {
let os_release = load_os_release()?;
let family = detect_distro_family(&os_release);
let helper_paths = helper_search_paths();
let helper_refs = helper_paths
.iter()
.map(PathBuf::as_path)
.collect::<Vec<_>>();
let helpers = capabilities::probe_desktop_helpers(&helper_refs);
let (payload_root, desktop_root, icon_root) = match requested_scope {
InstallScope::User => (
user_managed_appimages_dir(home_dir),
user_applications_dir(home_dir),
user_icons_dir(home_dir),
),
InstallScope::System => (
system_managed_appimages_dir(),
system_applications_dir(),
system_icons_dir(),
),
};
Ok((
family,
HostCapabilities {
is_immutable: family == DistroFamily::Immutable,
is_nix: family == DistroFamily::Nix,
has_desktop_session: env::var_os("DISPLAY").is_some()
|| env::var_os("WAYLAND_DISPLAY").is_some()
|| env::var_os("XDG_CURRENT_DESKTOP").is_some(),
helpers,
writable_roots: capabilities::probe_writable_roots(
&payload_root,
&desktop_root,
&icon_root,
),
},
))
}
fn load_os_release() -> io::Result<String> {
let path = env::var_os(OS_RELEASE_PATH_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/etc/os-release"));
match fs::read_to_string(path) {
Ok(contents) => Ok(contents),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error),
}
}
fn helper_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(extra_paths) = env::var_os(HELPER_PATHS_ENV) {
paths.extend(env::split_paths(&extra_paths));
}
if let Some(system_paths) = env::var_os("PATH") {
paths.extend(env::split_paths(&system_paths));
}
paths
}

View file

@ -0,0 +1,2 @@
pub mod model;
pub mod store;

View file

@ -0,0 +1,14 @@
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Registry {
pub version: u32,
pub apps: Vec<crate::domain::app::AppRecord>,
}
impl Default for Registry {
fn default() -> Self {
Self {
version: 1,
apps: Vec::new(),
}
}
}

View file

@ -0,0 +1,118 @@
use std::fs::{self, File, OpenOptions};
use std::path::PathBuf;
use fs2::FileExt;
use crate::registry::model::Registry;
pub struct RegistryStore {
path: PathBuf,
}
impl RegistryStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn load(&self) -> Result<Registry, RegistryStoreError> {
if !self.path.exists() {
return Ok(Registry::default());
}
let contents = fs::read_to_string(&self.path)?;
let registry = toml::from_str(&contents)?;
Ok(registry)
}
pub fn save(&self, registry: &Registry) -> Result<(), RegistryStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string(registry)?;
let temporary_path = self.temporary_path();
fs::write(&temporary_path, contents)?;
fs::rename(&temporary_path, &self.path).map_err(|error| {
let _ = fs::remove_file(&temporary_path);
RegistryStoreError::Io(error)
})?;
Ok(())
}
pub fn lock_exclusive(&self) -> Result<RegistryLock, RegistryStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(self.lock_path())?;
match lock_file.try_lock_exclusive() {
Ok(()) => Ok(RegistryLock { file: lock_file }),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
Err(RegistryStoreError::LockUnavailable)
}
Err(error) => Err(RegistryStoreError::Io(error)),
}
}
pub fn mutate_exclusive<F>(&self, apply: F) -> Result<Registry, RegistryStoreError>
where
F: FnOnce(&mut Registry),
{
let _lock = self.lock_exclusive()?;
let mut registry = self.load()?;
apply(&mut registry);
self.save(&registry)?;
Ok(registry)
}
fn lock_path(&self) -> PathBuf {
self.path.with_extension("toml.lock")
}
fn temporary_path(&self) -> PathBuf {
self.path.with_extension("toml.tmp")
}
}
#[derive(Debug)]
pub struct RegistryLock {
file: File,
}
impl Drop for RegistryLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
#[derive(Debug)]
pub enum RegistryStoreError {
Io(std::io::Error),
LockUnavailable,
SerializeToml(toml::ser::Error),
Toml(toml::de::Error),
}
impl From<std::io::Error> for RegistryStoreError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<toml::de::Error> for RegistryStoreError {
fn from(error: toml::de::Error) -> Self {
Self::Toml(error)
}
}
impl From<toml::ser::Error> for RegistryStoreError {
fn from(error: toml::ser::Error) -> Self {
Self::SerializeToml(error)
}
}

View file

@ -0,0 +1,516 @@
use std::env;
use std::time::Duration;
use crate::domain::source::SourceRef;
const DEFAULT_APPIMAGEHUB_API_BASE: &str = "https://api.appimagehub.com/ocs/v1/content";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_APPIMAGEHUB_FIXTURE_MODE";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubDownload {
pub url: String,
pub name: String,
pub package_type: Option<String>,
pub arch: Option<String>,
pub md5sum: Option<String>,
pub version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubItem {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
pub downloads: Vec<AppImageHubDownload>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppImageHubSearchHit {
pub id: String,
pub name: String,
pub version: String,
pub summary: Option<String>,
pub detail_page: String,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedAppImageHubItem {
pub source: SourceRef,
pub title: String,
pub version: String,
pub download: AppImageHubDownload,
}
pub trait AppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError>;
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError>;
}
pub fn default_transport() -> Box<dyn AppImageHubTransport> {
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
Box::new(FixtureAppImageHubTransport)
} else {
Box::new(ReqwestAppImageHubTransport::new())
}
}
pub fn resolve_appimagehub_item(
source: &SourceRef,
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let transport = default_transport();
resolve_appimagehub_item_with(source, transport.as_ref())
}
pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<Option<ResolvedAppImageHubItem>, AppImageHubError> {
let item = transport.fetch_item(source_id(source)?)?;
let Some(download) = item
.downloads
.iter()
.find(|download| is_appimage_download(download))
else {
return Ok(None);
};
validate_download_url(&download.url)?;
Ok(Some(ResolvedAppImageHubItem {
source: source.clone(),
title: item.name.clone(),
version: resolved_version(&item, download),
download: download.clone(),
}))
}
pub fn search_appimagehub(
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let transport = default_transport();
search_appimagehub_with(query, limit, transport.as_ref())
}
pub fn search_appimagehub_with<T: AppImageHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
transport.search_items(query, limit)
}
pub struct ReqwestAppImageHubTransport {
client: reqwest::blocking::Client,
api_base: String,
}
impl Default for ReqwestAppImageHubTransport {
fn default() -> Self {
Self::new()
}
}
impl ReqwestAppImageHubTransport {
pub fn new() -> Self {
Self {
client: reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client should build"),
api_base: env::var("UPM_APPIMAGEHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_APPIMAGEHUB_API_BASE.to_owned()),
}
}
}
impl AppImageHubTransport for ReqwestAppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
let url = format!("{}/data/{id}", self.api_base);
let xml = self
.client
.get(url)
.send()
.map_err(AppImageHubError::Transport)?
.error_for_status()
.map_err(AppImageHubError::Transport)?
.text()
.map_err(AppImageHubError::Transport)?;
parse_item_xml(&xml)
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
let url = format!("{}/data", self.api_base);
let xml = self
.client
.get(url)
.query(&[("search", query), ("pagesize", &limit.to_string())])
.send()
.map_err(AppImageHubSearchError::Transport)?
.error_for_status()
.map_err(AppImageHubSearchError::Transport)?
.text()
.map_err(AppImageHubSearchError::Transport)?;
parse_search_xml(&xml)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FixtureAppImageHubTransport;
impl AppImageHubTransport for FixtureAppImageHubTransport {
fn fetch_item(&self, id: &str) -> Result<AppImageHubItem, AppImageHubError> {
fixture_item(id).ok_or_else(|| AppImageHubError::FixtureItemMissing(id.to_owned()))
}
fn search_items(
&self,
query: &str,
limit: usize,
) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
Ok(fixture_search_results(query, limit))
}
}
#[derive(Debug)]
pub enum AppImageHubError {
FixtureItemMissing(String),
InsecureDownloadUrl(String),
Parse(quick_xml::DeError),
Transport(reqwest::Error),
UnsupportedSource(String),
}
#[derive(Debug)]
pub enum AppImageHubSearchError {
Parse(quick_xml::DeError),
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct OcsSingleResponse {
data: OcsSingleData,
}
#[derive(serde::Deserialize)]
struct OcsSingleData {
content: OcsContent,
}
#[derive(serde::Deserialize)]
struct OcsSearchResponse {
data: OcsSearchData,
}
#[derive(serde::Deserialize)]
struct OcsSearchData {
#[serde(default)]
content: Vec<OcsContent>,
}
#[derive(serde::Deserialize)]
struct OcsContent {
id: String,
name: String,
version: Option<String>,
summary: Option<String>,
detailpage: Option<String>,
tags: Option<String>,
downloadlink1: Option<String>,
downloadname1: Option<String>,
download_package_type1: Option<String>,
download_package_arch1: Option<String>,
downloadmd5sum1: Option<String>,
download_version1: Option<String>,
downloadlink2: Option<String>,
downloadname2: Option<String>,
download_package_type2: Option<String>,
download_package_arch2: Option<String>,
downloadmd5sum2: Option<String>,
download_version2: Option<String>,
downloadlink3: Option<String>,
downloadname3: Option<String>,
download_package_type3: Option<String>,
download_package_arch3: Option<String>,
downloadmd5sum3: Option<String>,
download_version3: Option<String>,
}
fn parse_item_xml(xml: &str) -> Result<AppImageHubItem, AppImageHubError> {
let parsed =
quick_xml::de::from_str::<OcsSingleResponse>(xml).map_err(AppImageHubError::Parse)?;
Ok(content_to_item(parsed.data.content))
}
fn parse_search_xml(xml: &str) -> Result<Vec<AppImageHubSearchHit>, AppImageHubSearchError> {
if !xml.contains("<id>") {
return Ok(Vec::new());
}
let parsed =
quick_xml::de::from_str::<OcsSearchResponse>(xml).map_err(AppImageHubSearchError::Parse)?;
Ok(parsed
.data
.content
.into_iter()
.map(|content| AppImageHubSearchHit {
id: content.id,
name: content.name,
version: normalize_version_text(content.version.as_deref()),
summary: content.summary,
detail_page: content
.detailpage
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned()),
tags: split_tags(content.tags.as_deref()),
})
.collect())
}
fn content_to_item(content: OcsContent) -> AppImageHubItem {
let detail_page = content
.detailpage
.clone()
.unwrap_or_else(|| "https://www.appimagehub.com".to_owned());
let summary = content.summary.clone();
let tags = split_tags(content.tags.as_deref());
let downloads = collect_downloads(&content);
AppImageHubItem {
id: content.id,
name: content.name,
version: normalize_version_text(content.version.as_deref()),
summary,
detail_page,
tags,
downloads,
}
}
fn validate_download_url(url: &str) -> Result<(), AppImageHubError> {
if !url.starts_with("https://") {
return Err(AppImageHubError::InsecureDownloadUrl(url.to_owned()));
}
Ok(())
}
fn collect_downloads(content: &OcsContent) -> Vec<AppImageHubDownload> {
let mut downloads = Vec::new();
for download in [
download_slot(
content.downloadlink1.as_deref(),
content.downloadname1.as_deref(),
content.download_package_type1.as_deref(),
content.download_package_arch1.as_deref(),
content.downloadmd5sum1.as_deref(),
content.download_version1.as_deref(),
),
download_slot(
content.downloadlink2.as_deref(),
content.downloadname2.as_deref(),
content.download_package_type2.as_deref(),
content.download_package_arch2.as_deref(),
content.downloadmd5sum2.as_deref(),
content.download_version2.as_deref(),
),
download_slot(
content.downloadlink3.as_deref(),
content.downloadname3.as_deref(),
content.download_package_type3.as_deref(),
content.download_package_arch3.as_deref(),
content.downloadmd5sum3.as_deref(),
content.download_version3.as_deref(),
),
]
.into_iter()
.flatten()
{
downloads.push(download);
}
downloads
}
fn download_slot(
link: Option<&str>,
name: Option<&str>,
package_type: Option<&str>,
arch: Option<&str>,
md5sum: Option<&str>,
version: Option<&str>,
) -> Option<AppImageHubDownload> {
let url = link?.trim();
if url.is_empty() {
return None;
}
Some(AppImageHubDownload {
url: url.to_owned(),
name: name.unwrap_or("download").trim().to_owned(),
package_type: trim_optional(package_type),
arch: trim_optional(arch),
md5sum: trim_optional(md5sum),
version: trim_optional(version),
})
}
fn trim_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn normalize_version_text(value: Option<&str>) -> String {
let value = value.map(str::trim).filter(|value| !value.is_empty());
match value {
Some("Latest") | Some("latest") | None => "latest".to_owned(),
Some(other) => other.to_owned(),
}
}
fn split_tags(tags: Option<&str>) -> Vec<String> {
tags.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn source_id(source: &SourceRef) -> Result<&str, AppImageHubError> {
source
.canonical_locator
.as_deref()
.or_else(|| source.locator.rsplit('/').next())
.filter(|value| !value.is_empty())
.ok_or_else(|| AppImageHubError::UnsupportedSource(source.locator.clone()))
}
fn is_appimage_download(download: &AppImageHubDownload) -> bool {
download
.package_type
.as_deref()
.map(|kind| kind.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
|| download.name.ends_with(".AppImage")
}
fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> String {
download
.version
.as_deref()
.map(|value| normalize_version_text(Some(value)))
.filter(|value| value != "latest")
.unwrap_or_else(|| item.version.clone())
}
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
let insecure_http = env::var("UPM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
.ok()
.as_deref()
== Some("1");
let bad_md5 = env::var("UPM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
match id {
"2338455" => Some(AppImageHubItem {
id: "2338455".to_owned(),
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
version: "latest".to_owned(),
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
tags: vec![
"appimage".to_owned(),
"x86-64".to_owned(),
"desktop".to_owned(),
"release-stable".to_owned(),
],
downloads: vec![AppImageHubDownload {
url: if insecure_http {
"http://files06.pling.com/api/files/download/firefox-x86-64.AppImage".to_owned()
} else {
"https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
.to_owned()
},
name: "firefox-x86-64.AppImage".to_owned(),
package_type: Some("appimage".to_owned()),
arch: Some("x86-64".to_owned()),
md5sum: Some(if bad_md5 {
"00000000000000000000000000000000".to_owned()
} else {
"2a685cf45213d5a2a243273fa68dafa6".to_owned()
}),
version: None,
}],
}),
"2337998" => Some(AppImageHubItem {
id: "2337998".to_owned(),
name: "Example Non-AppImage Package".to_owned(),
version: "latest".to_owned(),
summary: Some("An item that does not expose an AppImage download".to_owned()),
detail_page: "https://www.appimagehub.com/p/2337998".to_owned(),
tags: vec!["desktop".to_owned()],
downloads: vec![AppImageHubDownload {
url: "https://files06.pling.com/api/files/download/example.deb".to_owned(),
name: "example.deb".to_owned(),
package_type: Some("debian-package".to_owned()),
arch: Some("x86-64".to_owned()),
md5sum: None,
version: Some("2.1.1".to_owned()),
}],
}),
_ => None,
}
}
fn fixture_search_results(query: &str, limit: usize) -> Vec<AppImageHubSearchHit> {
let query = query.trim().to_ascii_lowercase();
let fixtures = [
AppImageHubSearchHit {
id: "2338455".to_owned(),
name: "Firefox by Mozilla - Official AppImage Edition".to_owned(),
version: "latest".to_owned(),
summary: Some("Take control of your internet with the Firefox browser".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338455".to_owned(),
tags: vec!["browser".to_owned(), "appimage".to_owned()],
},
AppImageHubSearchHit {
id: "2338484".to_owned(),
name: "Waterfox".to_owned(),
version: "latest".to_owned(),
summary: Some("Open Source, Private Browsing".to_owned()),
detail_page: "https://www.appimagehub.com/p/2338484".to_owned(),
tags: vec!["browser".to_owned(), "appimage".to_owned()],
},
];
fixtures
.into_iter()
.filter(|item| {
item.name.to_ascii_lowercase().contains(&query)
|| item
.tags
.iter()
.any(|tag| tag.to_ascii_lowercase().contains(&query))
})
.take(limit)
.collect()
}

View file

@ -0,0 +1,579 @@
use std::env;
use std::time::Duration;
use crate::domain::source::{ResolvedRelease, SourceRef};
use crate::metadata::MetadataDocument;
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
const GLOBAL_FIXTURE_MODE_ENV: &str = "UPM_FIXTURE_MODE";
const FIXTURE_MODE_ENV: &str = "UPM_GITHUB_FIXTURE_MODE";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30;
const DEFAULT_HTTP_MAX_RETRIES: usize = 3;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpClientPolicy {
pub timeout: Duration,
pub max_retries: usize,
}
pub fn http_client_policy() -> HttpClientPolicy {
HttpClientPolicy {
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
max_retries: DEFAULT_HTTP_MAX_RETRIES,
}
}
pub trait GitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError>;
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError>;
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportAsset {
pub name: String,
pub url: String,
pub content_type: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRelease {
pub tag: String,
pub prerelease: bool,
pub assets: Vec<TransportAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRepository {
pub full_name: String,
pub description: Option<String>,
pub html_url: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubAsset {
pub name: String,
pub url: String,
pub version: String,
pub prerelease: bool,
pub arch: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubRelease {
pub tag: String,
pub release: ResolvedRelease,
pub assets: Vec<GitHubAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubDiscovery {
pub source: SourceRef,
pub releases: Vec<GitHubRelease>,
pub assets: Vec<GitHubAsset>,
pub metadata_documents: Vec<MetadataDocument>,
pub requested_is_older_release: bool,
}
pub fn discover_github_candidates(
source: &SourceRef,
) -> Result<GitHubDiscovery, GitHubDiscoveryError> {
let transport = default_transport();
discover_github_candidates_with(source, transport.as_ref())
}
pub fn discover_github_candidates_with<T: GitHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<GitHubDiscovery, GitHubDiscoveryError> {
let repo = source
.canonical_locator
.clone()
.unwrap_or_else(|| source.locator.clone());
let transport_releases = transport.fetch_releases(&repo)?;
if transport_releases.is_empty() {
return Err(GitHubDiscoveryError::NoReleases { repo });
}
let releases = transport_releases
.iter()
.map(|release| GitHubRelease {
tag: release.tag.clone(),
release: ResolvedRelease {
version: release.tag.trim_start_matches('v').to_owned(),
prerelease: release.prerelease,
},
assets: release
.assets
.iter()
.filter(|asset| is_appimage_asset(&asset.name))
.map(|asset| GitHubAsset {
name: asset.name.clone(),
url: asset.url.clone(),
version: release.tag.trim_start_matches('v').to_owned(),
prerelease: release.prerelease,
arch: Some(infer_architecture(&asset.name)),
})
.collect(),
})
.collect::<Vec<_>>();
let metadata_documents = transport_releases
.iter()
.flat_map(|release| release.assets.iter())
.filter(|asset| is_metadata_document(&asset.name))
.filter_map(|asset| {
transport
.fetch_document(&asset.url, asset.content_type.as_deref())
.ok()
})
.collect::<Vec<_>>();
let assets = releases
.iter()
.flat_map(|release| release.assets.iter().cloned())
.collect::<Vec<_>>();
let requested_is_older_release = source
.requested_tag
.as_ref()
.map(|requested| requested != &releases[0].tag)
.unwrap_or(false);
Ok(GitHubDiscovery {
source: source.clone(),
releases,
assets,
metadata_documents,
requested_is_older_release,
})
}
pub fn search_github_repositories(
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
let transport = default_transport();
search_github_repositories_with(query, limit, transport.as_ref())
}
pub fn search_github_repositories_with<T: GitHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
transport.search_repositories(query, limit)
}
pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(GLOBAL_FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
|| env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1")
{
Box::new(FixtureGitHubTransport)
} else {
Box::new(ReqwestGitHubTransport::new())
}
}
pub struct ReqwestGitHubTransport {
client: reqwest::blocking::Client,
api_base: String,
}
impl Default for ReqwestGitHubTransport {
fn default() -> Self {
Self::new()
}
}
impl ReqwestGitHubTransport {
pub fn new() -> Self {
let policy = http_client_policy();
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("upm/0.1"),
);
default_headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(token) = env::var("UPM_GITHUB_TOKEN")
.ok()
.or_else(|| env::var("GITHUB_TOKEN").ok())
&& let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
{
default_headers.insert(reqwest::header::AUTHORIZATION, value);
}
Self {
client: reqwest::blocking::Client::builder()
.default_headers(default_headers)
.timeout(policy.timeout)
.build()
.expect("reqwest client should build"),
api_base: env::var("UPM_GITHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()),
}
}
}
impl GitHubTransport for ReqwestGitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError> {
let url = format!("{}/repos/{repo}/releases?per_page=10", self.api_base);
let releases = self
.client
.get(url)
.send()
.map_err(GitHubDiscoveryError::Transport)?
.error_for_status()
.map_err(GitHubDiscoveryError::Transport)?
.json::<Vec<ApiRelease>>()
.map_err(GitHubDiscoveryError::Transport)?;
Ok(releases
.into_iter()
.map(|release| TransportRelease {
tag: release.tag_name,
prerelease: release.prerelease,
assets: release
.assets
.into_iter()
.map(|asset| TransportAsset {
name: asset.name,
url: asset.browser_download_url,
content_type: asset.content_type,
})
.collect(),
})
.collect())
}
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
let url = format!("{}/search/repositories", self.api_base);
let response = self
.client
.get(url)
.query(&[("q", query), ("per_page", &limit.to_string())])
.send()
.map_err(GitHubSearchError::Transport)?
.error_for_status()
.map_err(GitHubSearchError::Transport)?
.json::<ApiRepositorySearchResponse>()
.map_err(GitHubSearchError::Transport)?;
Ok(response
.items
.into_iter()
.map(|repository| TransportRepository {
full_name: repository.full_name,
description: repository.description,
html_url: repository.html_url,
})
.collect())
}
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError> {
let response = self
.client
.get(url)
.send()
.map_err(GitHubDiscoveryError::Transport)?
.error_for_status()
.map_err(GitHubDiscoveryError::Transport)?;
let header_content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
.or_else(|| content_type.map(ToOwned::to_owned));
let contents = response.bytes().map_err(GitHubDiscoveryError::Transport)?;
Ok(MetadataDocument {
url: url.to_owned(),
content_type: header_content_type,
contents: contents.to_vec(),
})
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FixtureGitHubTransport;
impl GitHubTransport for FixtureGitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError> {
Ok(fixture_releases(repo))
}
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
Ok(fixture_repository_search(query, limit))
}
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError> {
let contents = fixture_document(url)
.ok_or_else(|| GitHubDiscoveryError::FixtureDocumentMissing(url.to_owned()))?;
Ok(MetadataDocument {
url: url.to_owned(),
content_type: content_type.map(ToOwned::to_owned),
contents,
})
}
}
#[derive(Debug)]
pub enum GitHubDiscoveryError {
Unsupported,
FixtureDocumentMissing(String),
NoReleases { repo: String },
Transport(reqwest::Error),
}
#[derive(Debug)]
pub enum GitHubSearchError {
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct ApiRelease {
tag_name: String,
prerelease: bool,
assets: Vec<ApiAsset>,
}
#[derive(serde::Deserialize)]
struct ApiAsset {
name: String,
browser_download_url: String,
content_type: Option<String>,
}
#[derive(serde::Deserialize)]
struct ApiRepositorySearchResponse {
items: Vec<ApiRepository>,
}
#[derive(serde::Deserialize)]
struct ApiRepository {
full_name: String,
description: Option<String>,
html_url: String,
}
fn is_appimage_asset(name: &str) -> bool {
name.ends_with(".AppImage")
}
fn is_metadata_document(name: &str) -> bool {
name.ends_with("latest-linux.yml")
|| name.ends_with("latest-linux.yaml")
|| name.ends_with(".zsync")
}
fn infer_architecture(name: &str) -> String {
if name.contains("aarch64") || name.contains("arm64") {
"aarch64".to_owned()
} else {
"x86_64".to_owned()
}
}
fn fixture_releases(repo: &str) -> Vec<TransportRelease> {
match repo {
"pingdotgg/t3code" => vec![
fixture_release(repo, "v0.0.12", "T3-Code-0.0.12-x86_64.AppImage"),
fixture_release(repo, "v0.0.11", "T3-Code-0.0.11-x86_64.AppImage"),
],
"sharkdp/bat" => vec![fixture_release(repo, "v1.0.0", "Bat-1.0.0-x86_64.AppImage")],
"fero1xd/uploadstuff-server" => vec![fixture_release_without_appimage(
repo,
"v1.0.0",
"uploadstuff-server-linux-x86_64.tar.gz",
)],
"Socialure/lawn" => vec![fixture_release_without_appimage(
repo,
"v1.0.0",
"lawn-linux-x86_64.tar.gz",
)],
_ => {
let repo_name = repo.split('/').next_back().unwrap_or("app");
let title = title_case(repo_name);
vec![fixture_release(
repo,
"v1.0.0",
&format!("{title}-1.0.0-x86_64.AppImage"),
)]
}
}
}
fn fixture_release(repo: &str, tag: &str, asset_name: &str) -> TransportRelease {
TransportRelease {
tag: tag.to_owned(),
prerelease: false,
assets: vec![
TransportAsset {
name: asset_name.to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}"),
content_type: Some("application/octet-stream".to_owned()),
},
TransportAsset {
name: "latest-linux.yml".to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/latest-linux.yml"),
content_type: Some("application/yaml".to_owned()),
},
],
}
}
fn fixture_release_without_appimage(repo: &str, tag: &str, asset_name: &str) -> TransportRelease {
TransportRelease {
tag: tag.to_owned(),
prerelease: false,
assets: vec![
TransportAsset {
name: asset_name.to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}"),
content_type: Some("application/gzip".to_owned()),
},
TransportAsset {
name: "latest-linux.yml".to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/latest-linux.yml"),
content_type: Some("application/yaml".to_owned()),
},
],
}
}
fn fixture_document(url: &str) -> Option<Vec<u8>> {
let tag = url.split("/releases/download/").nth(1)?.split('/').next()?;
let name = url.split('/').next_back()?;
match name {
"latest-linux.yml" => {
let appimage = match tag {
"v0.0.11" => "T3-Code-0.0.11-x86_64.AppImage",
"v0.0.12" => "T3-Code-0.0.12-x86_64.AppImage",
"v1.0.0" => "Bat-1.0.0-x86_64.AppImage",
_ => return None,
};
let version = tag.trim_start_matches('v');
Some(
format!("version: {version}\npath: {appimage}\nsha512: ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==\n").into_bytes(),
)
}
_ => None,
}
}
fn fixture_repository_search(query: &str, limit: usize) -> Vec<TransportRepository> {
let (normalized_query, name_only) = parse_fixture_repository_query(query);
fixture_repository_catalog()
.into_iter()
.filter(|repository| {
let full_name_matches = repository
.full_name
.to_ascii_lowercase()
.contains(&normalized_query);
if name_only {
return full_name_matches;
}
full_name_matches
|| repository
.description
.as_deref()
.map(|description| description.to_ascii_lowercase().contains(&normalized_query))
.unwrap_or(false)
})
.take(limit)
.collect()
}
fn parse_fixture_repository_query(query: &str) -> (String, bool) {
let trimmed = query.trim();
if let Some(value) = trimmed.strip_suffix(" in:name") {
return (value.trim().to_ascii_lowercase(), true);
}
(trimmed.to_ascii_lowercase(), false)
}
fn fixture_repository_catalog() -> Vec<TransportRepository> {
vec![
TransportRepository {
full_name: "sharkdp/bat".to_owned(),
description: Some("A cat(1) clone with wings.".to_owned()),
html_url: "https://github.com/sharkdp/bat".to_owned(),
},
TransportRepository {
full_name: "astatine/bat".to_owned(),
description: Some("A small fixture repository for bat-shaped searches.".to_owned()),
html_url: "https://github.com/astatine/bat".to_owned(),
},
TransportRepository {
full_name: "eth-p/bat-extras".to_owned(),
description: Some("Bash scripts that integrate with bat.".to_owned()),
html_url: "https://github.com/eth-p/bat-extras".to_owned(),
},
TransportRepository {
full_name: "fero1xd/uploadstuff-server".to_owned(),
description: Some("Custom Server for UploadThing by pingdotgg".to_owned()),
html_url: "https://github.com/fero1xd/uploadstuff-server".to_owned(),
},
TransportRepository {
full_name: "Socialure/lawn".to_owned(),
description: Some(
"Video review for creative teams — Socialure-branded fork of pingdotgg/lawn"
.to_owned(),
),
html_url: "https://github.com/Socialure/lawn".to_owned(),
},
TransportRepository {
full_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
html_url: "https://github.com/pingdotgg/t3code".to_owned(),
},
]
}
fn title_case(value: &str) -> String {
value
.split(['-', '_'])
.filter(|segment| !segment.is_empty())
.map(|segment| {
let mut chars = segment.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!("{}{}", first.to_ascii_uppercase(), chars.as_str())
})
.collect::<Vec<_>>()
.join("-")
}

View file

@ -0,0 +1,445 @@
use crate::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassifiedInput {
pub kind: SourceInputKind,
pub source_kind: SourceKind,
pub normalized_kind: NormalizedSourceKind,
pub locator: String,
pub canonical_locator: Option<String>,
pub requested_tag: Option<String>,
pub requested_asset_name: Option<String>,
pub tracks_latest: bool,
}
impl ClassifiedInput {
pub fn into_source_ref(self) -> SourceRef {
SourceRef {
kind: self.source_kind,
locator: self.locator,
input_kind: self.kind,
normalized_kind: self.normalized_kind,
canonical_locator: self.canonical_locator,
requested_tag: self.requested_tag,
requested_asset_name: self.requested_asset_name,
tracks_latest: self.tracks_latest,
}
}
}
pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError> {
if query.starts_with("file://") {
return Ok(ClassifiedInput {
kind: SourceInputKind::File,
source_kind: SourceKind::File,
normalized_kind: NormalizedSourceKind::File,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if let Some(classified) = classify_github_http(query) {
return Ok(classified);
}
if let Some(classified) = classify_gitlab_http(query) {
return classified;
}
if let Some(classified) = classify_appimagehub_input(query) {
return classified;
}
if let Some(classified) = classify_sourceforge_http(query) {
return classified;
}
if query.starts_with("https://") || query.starts_with("http://") {
return Ok(ClassifiedInput {
kind: SourceInputKind::DirectUrl,
source_kind: SourceKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if is_github_shorthand(query) {
return Ok(ClassifiedInput {
kind: SourceInputKind::RepoShorthand,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: query.to_owned(),
canonical_locator: Some(query.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
});
}
Err(ClassifyInputError::Unsupported)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ClassifyInputError {
Unsupported,
}
fn classify_appimagehub_input(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
if let Some(id) = appimagehub_id_from_url(query) {
return Some(Ok(appimagehub_source_ref(
SourceInputKind::AppImageHubUrl,
id,
)));
}
let id = query.strip_prefix("appimagehub/")?;
if !is_ascii_digits(id) {
return Some(Err(ClassifyInputError::Unsupported));
}
Some(Ok(appimagehub_source_ref(
SourceInputKind::AppImageHubShorthand,
id,
)))
}
fn classify_gitlab_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
let trimmed = query
.trim_start_matches("https://gitlab.com/")
.trim_start_matches("http://gitlab.com/");
if trimmed == query {
return None;
}
let trimmed = trim_query_and_fragment(trimmed);
let parts = trimmed
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if parts.len() < 2 {
return Some(Err(ClassifyInputError::Unsupported));
}
let release_marker = parts.iter().position(|segment| *segment == "-");
let is_repository_url = release_marker.is_none() && is_supported_gitlab_repo_path(&parts);
let is_release_like_url = matches!(release_marker, Some(index) if index >= 2)
&& parts.get(release_marker.unwrap() + 1) == Some(&"releases")
&& parts.get(release_marker.unwrap() + 2).is_some()
&& parts.len() == release_marker.unwrap() + 3;
let is_ambiguous_candidate =
release_marker.is_none() && is_ambiguous_gitlab_candidate_path(&parts);
if !is_repository_url && !is_release_like_url && !is_ambiguous_candidate {
return Some(Err(ClassifyInputError::Unsupported));
}
let canonical_parts = if let Some(index) = release_marker {
&parts[..index]
} else {
&parts[..]
};
let canonical_locator = canonical_parts.join("/");
let requested_tag = if let Some(index) = release_marker {
parts.get(index + 2).map(|value| (*value).to_owned())
} else {
None
};
let tracks_latest = requested_tag.is_none() && !is_ambiguous_candidate;
Some(Ok(ClassifiedInput {
kind: SourceInputKind::GitLabUrl,
source_kind: SourceKind::GitLab,
normalized_kind: if is_ambiguous_candidate {
NormalizedSourceKind::GitLabCandidate
} else {
NormalizedSourceKind::GitLab
},
locator: query.to_owned(),
canonical_locator: if is_ambiguous_candidate {
None
} else {
Some(canonical_locator)
},
requested_tag,
requested_asset_name: None,
tracks_latest,
}))
}
fn classify_sourceforge_http(query: &str) -> Option<Result<ClassifiedInput, ClassifyInputError>> {
let trimmed = query
.trim_start_matches("https://sourceforge.net/projects/")
.trim_start_matches("http://sourceforge.net/projects/");
if trimmed == query {
return None;
}
let trimmed = trim_query_and_fragment(trimmed);
let parts = trimmed
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let Some(project) = parts.first() else {
return Some(Err(ClassifyInputError::Unsupported));
};
let is_project_url = parts.len() == 1;
let is_releases_root_url = parts.len() == 3 && parts[1] == "files" && parts[2] == "releases";
let is_latest_download_url =
parts.len() == 4 && parts[1] == "files" && parts[2] == "latest" && parts[3] == "download";
let is_root_file_download_url = parts.len() == 4
&& parts[1] == "files"
&& parts[3] == "download"
&& !matches!(parts[2], "latest" | "releases");
let is_nested_file_download_url = parts.len() > 5
&& parts[1] == "files"
&& parts.last() == Some(&"download")
&& parts
.get(parts.len().saturating_sub(2))
.is_some_and(|segment| segment.contains('.'));
let is_ambiguous_candidate = is_ambiguous_sourceforge_candidate_path(&parts);
let requested_asset_name = sourceforge_requested_asset_name(&parts);
let is_concrete_download_url =
!is_latest_download_url && (is_root_file_download_url || is_nested_file_download_url);
if is_concrete_download_url {
return Some(Ok(ClassifiedInput {
kind: SourceInputKind::DirectUrl,
source_kind: SourceKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}));
}
if !is_project_url
&& !is_releases_root_url
&& !is_latest_download_url
&& !is_ambiguous_candidate
{
return Some(Err(ClassifyInputError::Unsupported));
}
Some(Ok(ClassifiedInput {
kind: SourceInputKind::SourceForgeUrl,
source_kind: SourceKind::SourceForge,
normalized_kind: if is_ambiguous_candidate {
NormalizedSourceKind::SourceForgeCandidate
} else {
NormalizedSourceKind::SourceForge
},
locator: query.to_owned(),
canonical_locator: Some((*project).to_owned()),
requested_tag: None,
requested_asset_name,
tracks_latest: is_project_url || is_releases_root_url || is_latest_download_url,
}))
}
fn appimagehub_id_from_url(query: &str) -> Option<&str> {
let trimmed = query
.trim_start_matches("https://www.appimagehub.com/p/")
.trim_start_matches("http://www.appimagehub.com/p/");
if trimmed == query {
return None;
}
let id = trim_query_and_fragment(trimmed).trim_matches('/');
if is_ascii_digits(id) { Some(id) } else { None }
}
fn appimagehub_source_ref(kind: SourceInputKind, id: &str) -> ClassifiedInput {
ClassifiedInput {
kind,
source_kind: SourceKind::AppImageHub,
normalized_kind: NormalizedSourceKind::AppImageHub,
locator: format!("https://www.appimagehub.com/p/{id}"),
canonical_locator: Some(id.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}
}
fn trim_query_and_fragment(value: &str) -> &str {
value.split(['?', '#']).next().unwrap_or(value)
}
fn is_ascii_digits(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
}
fn is_supported_gitlab_repo_path(parts: &[&str]) -> bool {
if parts.len() < 2 {
return false;
}
if parts.len() == 2 {
return true;
}
if parts.len() == 3 {
return !is_reserved_gitlab_resource_segment(parts[2]);
}
if parts[2..]
.iter()
.copied()
.any(is_reserved_gitlab_resource_segment)
{
return false;
}
true
}
fn is_reserved_gitlab_resource_segment(segment: &str) -> bool {
matches!(
segment,
"issues"
| "merge_requests"
| "releases"
| "tags"
| "blob"
| "tree"
| "commits"
| "packages"
| "archive"
| "raw"
| "pipelines"
| "jobs"
| "wikis"
| "snippets"
)
}
fn is_ambiguous_gitlab_candidate_path(parts: &[&str]) -> bool {
parts.len() == 4 && parts[2] == "releases"
}
fn is_ambiguous_sourceforge_candidate_path(parts: &[&str]) -> bool {
parts.len() == 5 && parts[1] == "files" && parts[2] == "releases" && parts[4] == "download"
}
fn sourceforge_requested_asset_name(parts: &[&str]) -> Option<String> {
if parts.len() == 5
&& parts[1] == "files"
&& parts[2] == "releases"
&& parts[4] == "download"
&& is_sourceforge_artifact_name(parts[3])
{
return Some(parts[3].to_owned());
}
None
}
fn is_sourceforge_artifact_name(segment: &str) -> bool {
let lower = segment.to_ascii_lowercase();
[
".appimage",
".tar.gz",
".tar.xz",
".tar.bz2",
".zip",
".deb",
".rpm",
".exe",
".msi",
".dmg",
".pkg",
".apk",
".tgz",
".whl",
".jar",
".nupkg",
]
.iter()
.any(|suffix| lower.ends_with(suffix))
}
fn classify_github_http(query: &str) -> Option<ClassifiedInput> {
let trimmed = query
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/");
if trimmed == query {
return None;
}
let parts = trimmed
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if parts.len() < 2 {
return None;
}
let repo = format!("{}/{}", parts[0], parts[1]);
if parts.len() >= 5 && parts[2] == "releases" && parts[3] == "tag" {
return Some(ClassifiedInput {
kind: SourceInputKind::GitHubReleaseUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRelease,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: Some(parts[4].to_owned()),
requested_asset_name: None,
tracks_latest: false,
});
}
if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
return Some(ClassifiedInput {
kind: SourceInputKind::GitHubReleaseAssetUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubReleaseAsset,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: Some(parts[4].to_owned()),
requested_asset_name: Some(parts[5].to_owned()),
tracks_latest: false,
});
}
Some(ClassifiedInput {
kind: SourceInputKind::GitHubRepositoryUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
})
}
fn is_github_shorthand(query: &str) -> bool {
let mut parts = query.split('/');
let Some(owner) = parts.next() else {
return false;
};
let Some(repo) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
!owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':')
}

View file

@ -0,0 +1,2 @@
pub mod github;
pub mod input;

View file

@ -0,0 +1,93 @@
use crate::domain::source::{SourceInputKind, SourceRef};
use crate::domain::update::{ParsedMetadata, UpdateChannel, UpdateChannelKind};
use crate::source::github::GitHubDiscovery;
pub fn build_channels(
discovery: &GitHubDiscovery,
metadata: &[ParsedMetadata],
) -> Vec<UpdateChannel> {
let mut channels = Vec::new();
if let Some(asset) = discovery.assets.first() {
channels.push(UpdateChannel {
kind: UpdateChannelKind::GitHubReleases,
locator: discovery
.source
.canonical_locator
.clone()
.unwrap_or_else(|| discovery.source.locator.clone()),
version: Some(asset.version.clone()),
artifact_name: Some(asset.name.clone()),
confidence: 60,
matches_install_origin: matches!(
discovery.source.input_kind,
SourceInputKind::RepoShorthand
| SourceInputKind::GitHubRepositoryUrl
| SourceInputKind::GitHubReleaseUrl
),
prerelease: asset.prerelease,
});
}
if let Some(parsed) = metadata
.iter()
.find(|item| item.kind == crate::domain::update::ParsedMetadataKind::ElectronBuilder)
{
channels.push(UpdateChannel {
kind: UpdateChannelKind::ElectronBuilder,
locator: discovery
.metadata_documents
.iter()
.find(|doc| doc.url.ends_with("latest-linux.yml"))
.map(|doc| doc.url.clone())
.unwrap_or_else(|| discovery.source.locator.clone()),
version: parsed.hints.version.clone(),
artifact_name: parsed.hints.primary_download.clone(),
confidence: parsed.confidence,
matches_install_origin: discovery.source.tracks_latest,
prerelease: false,
});
}
if let Some(parsed) = metadata
.iter()
.find(|item| item.kind == crate::domain::update::ParsedMetadataKind::Zsync)
{
channels.push(UpdateChannel {
kind: UpdateChannelKind::Zsync,
locator: parsed.hints.primary_download.clone().unwrap_or_default(),
version: parsed.hints.version.clone(),
artifact_name: parsed.hints.primary_download.clone(),
confidence: parsed.confidence,
matches_install_origin: false,
prerelease: false,
});
}
if matches!(
discovery.source.input_kind,
SourceInputKind::GitHubReleaseAssetUrl
) {
channels.push(UpdateChannel {
kind: UpdateChannelKind::DirectAsset,
locator: discovery.source.locator.clone(),
version: discovery
.source
.requested_tag
.clone()
.map(|value| value.trim_start_matches('v').to_owned()),
artifact_name: discovery.source.requested_asset_name.clone(),
confidence: 85,
matches_install_origin: true,
prerelease: false,
});
}
channels
}
pub fn source_ref_from_channel(source: &SourceRef, channel: &UpdateChannel) -> SourceRef {
let mut value = source.clone();
value.locator = channel.locator.clone();
value
}

View file

@ -0,0 +1,2 @@
pub mod channels;
pub mod ranking;

View file

@ -0,0 +1,103 @@
use crate::domain::update::{
ArtifactCandidate, ChannelPreference, MetadataHints, UpdateChannel, UpdateChannelKind,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RankedChannel {
pub channel: UpdateChannel,
pub reason: String,
pub score: i32,
}
pub fn rank_channels(channels: &[UpdateChannel]) -> Vec<RankedChannel> {
let mut ranked = channels
.iter()
.cloned()
.map(|channel| {
let install_origin_bonus = if channel.matches_install_origin {
100
} else {
0
};
let prerelease_penalty = if channel.prerelease { 20 } else { 0 };
let metadata_bonus = match channel.kind {
UpdateChannelKind::ElectronBuilder | UpdateChannelKind::Zsync => 25,
_ => 0,
};
let score = channel.confidence as i32 + install_origin_bonus + metadata_bonus
- prerelease_penalty;
let reason = if channel.matches_install_origin {
"install-origin-match"
} else if metadata_bonus > 0 {
"metadata-guided"
} else {
"heuristic-match"
};
RankedChannel {
channel,
reason: reason.to_owned(),
score,
}
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| right.score.cmp(&left.score));
ranked
}
pub fn select_artifact(
channel: &RankedChannel,
hints: Option<&MetadataHints>,
) -> ArtifactCandidate {
let resolved_url = resolve_artifact_url(
&channel.channel.locator,
hints.and_then(|value| value.primary_download.as_deref()),
);
let selection_reason = if hints
.and_then(|value| value.primary_download.clone())
.is_some()
{
"metadata-guided"
} else {
channel.reason.as_str()
};
ArtifactCandidate {
url: resolved_url,
version: channel
.channel
.version
.clone()
.unwrap_or_else(|| "latest".to_owned()),
arch: Some("x86_64".to_owned()),
trusted_checksum: hints.and_then(|value| value.checksum.clone()),
weak_checksum_md5: None,
selection_reason: selection_reason.to_owned(),
}
}
pub fn to_preference(channel: &RankedChannel) -> ChannelPreference {
ChannelPreference {
kind: channel.channel.kind,
locator: channel.channel.locator.clone(),
reason: channel.reason.clone(),
}
}
fn resolve_artifact_url(channel_locator: &str, primary_download: Option<&str>) -> String {
let Some(primary_download) = primary_download else {
return channel_locator.to_owned();
};
if primary_download.contains("://") || primary_download.starts_with("file://") {
return primary_download.to_owned();
}
if (channel_locator.ends_with(".yml") || channel_locator.ends_with(".yaml"))
&& let Some((base, _)) = channel_locator.rsplit_once('/')
{
return format!("{base}/{primary_download}");
}
primary_download.to_owned()
}

View file

@ -0,0 +1,383 @@
use upm_core::adapters::direct_url::DirectUrlAdapter;
use upm_core::adapters::github::GitHubAdapter;
use upm_core::adapters::gitlab::GitLabAdapter;
use upm_core::adapters::sourceforge::SourceForgeAdapter;
use upm_core::adapters::traits::{
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
};
use upm_core::app::query::resolve_query;
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
struct FileArtifactAdapter;
impl SourceAdapter for FileArtifactAdapter {
fn id(&self) -> &'static str {
"file"
}
fn capabilities(&self) -> AdapterCapabilities {
AdapterCapabilities::exact_resolution_only()
}
fn exact_source_kind(&self) -> Option<SourceKind> {
Some(SourceKind::File)
}
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
Err(AdapterError::UnsupportedQuery)
}
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
Ok(AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "file".to_owned(),
prerelease: false,
},
})
}
}
fn file_source() -> SourceRef {
SourceRef {
kind: SourceKind::File,
locator: "/tmp/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::File,
normalized_kind: NormalizedSourceKind::File,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}
}
#[test]
fn adapter_capabilities_can_report_exact_resolution_only() {
let capabilities = AdapterCapabilities::exact_resolution_only();
assert!(!capabilities.supports_search);
}
#[test]
fn repository_backed_resolvers_accept_only_their_own_source_kind() {
let github_source = resolve_query("sharkdp/bat").unwrap();
let gitlab_source = resolve_query("https://gitlab.com/example/team/app").unwrap();
let github_adapter: &dyn SourceAdapter = &GitHubAdapter;
assert!(github_adapter.supports_source(&github_source));
assert!(!github_adapter.supports_source(&gitlab_source));
assert_eq!(
github_adapter.resolve_source(&gitlab_source),
Err(AdapterError::UnsupportedSource)
);
let gitlab_adapter: &dyn SourceAdapter = &GitLabAdapter;
assert!(gitlab_adapter.supports_source(&gitlab_source));
assert!(!gitlab_adapter.supports_source(&github_source));
assert_eq!(
gitlab_adapter.resolve_source(&github_source),
Err(AdapterError::UnsupportedSource)
);
}
#[test]
fn exact_resolution_resolvers_accept_only_exact_artifact_kinds() {
let direct_url_adapter: &dyn SourceAdapter = &DirectUrlAdapter;
let file_adapter: &dyn SourceAdapter = &FileArtifactAdapter;
let direct_url_source = resolve_query("https://example.com/team-app.AppImage").unwrap();
let github_source = resolve_query("sharkdp/bat").unwrap();
let file_source = file_source();
assert!(direct_url_adapter.supports_source(&direct_url_source));
assert!(!direct_url_adapter.supports_source(&file_source));
assert!(!direct_url_adapter.supports_source(&github_source));
assert_eq!(
direct_url_adapter.resolve_source(&github_source),
Err(AdapterError::UnsupportedSource)
);
assert_eq!(
direct_url_adapter.resolve_source(&file_source),
Err(AdapterError::UnsupportedSource)
);
let direct_resolution = direct_url_adapter
.resolve_source(&direct_url_source)
.unwrap();
assert!(matches!(
direct_resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
release: ResolvedRelease { version, .. },
..
}) if version == "unresolved"
));
assert!(file_adapter.supports_source(&file_source));
assert!(!file_adapter.supports_source(&direct_url_source));
assert!(!file_adapter.supports_source(&github_source));
assert_eq!(
file_adapter.resolve_source(&direct_url_source),
Err(AdapterError::UnsupportedSource)
);
let file_resolution = file_adapter.resolve_source(&file_source).unwrap();
assert!(matches!(
file_resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::File && version == "file"
));
}
#[test]
fn resolvers_can_return_no_installable_artifact_without_looking_unsupported() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap();
let resolution = adapter.resolve_source(&source).unwrap();
assert_eq!(
resolution,
AdapterResolveOutcome::NoInstallableArtifact { source }
);
}
#[test]
fn no_installable_artifact_outcomes_still_reject_unsupported_source_kinds() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let unsupported_source = resolve_query("sharkdp/bat").unwrap();
assert_eq!(
adapter.resolve_source(&unsupported_source),
Err(AdapterError::UnsupportedSource)
);
}
#[test]
fn sourceforge_latest_download_sources_resolve_through_trait() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let result = adapter
.normalize("https://sourceforge.net/projects/team-app/files/latest/download")
.unwrap();
assert_eq!(result.kind, SourceKind::SourceForge);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::SourceForge
&& source.locator == "https://sourceforge.net/projects/team-app/files/latest/download"
&& version == "latest"
));
}
#[test]
fn gitlab_candidate_sources_can_resolve_to_repository_semantics() {
let adapter: &dyn SourceAdapter = &GitLabAdapter;
let result = adapter
.normalize("https://gitlab.com/acme/platform/releases/team-app")
.unwrap();
assert_eq!(result.kind, SourceKind::GitLab);
assert_eq!(
result.normalized_kind,
NormalizedSourceKind::GitLabCandidate
);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::GitLab
&& source.locator == "https://gitlab.com/acme/platform/releases/team-app"
&& source.canonical_locator.as_deref() == Some("acme/platform/releases/team-app")
&& source.normalized_kind == NormalizedSourceKind::GitLab
&& source.tracks_latest
&& version == "latest"
));
}
#[test]
fn sourceforge_candidate_sources_can_resolve_to_latest_download() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let result = adapter
.normalize("https://sourceforge.net/projects/team-app/files/releases/stable/download")
.unwrap();
assert_eq!(result.kind, SourceKind::SourceForge);
assert_eq!(
result.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::SourceForge
&& source.locator
== "https://sourceforge.net/projects/team-app/files/releases/stable/download"
&& version == "latest"
));
}
#[test]
fn sourceforge_version_folder_candidates_can_resolve_to_latest_download() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let result = adapter
.normalize("https://sourceforge.net/projects/team-app/files/releases/v1-0/download")
.unwrap();
assert_eq!(result.kind, SourceKind::SourceForge);
assert_eq!(
result.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::SourceForge
&& source.locator
== "https://sourceforge.net/projects/team-app/files/releases/v1-0/download"
&& source.normalized_kind == NormalizedSourceKind::SourceForge
&& source.tracks_latest
&& version == "latest"
));
}
#[test]
fn sourceforge_prerelease_folder_candidates_can_resolve_to_latest_download() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let result = adapter
.normalize("https://sourceforge.net/projects/team-app/files/releases/beta/download")
.unwrap();
assert_eq!(result.kind, SourceKind::SourceForge);
assert_eq!(
result.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::SourceForge
&& source.locator
== "https://sourceforge.net/projects/team-app/files/releases/beta/download"
&& source.normalized_kind == NormalizedSourceKind::SourceForge
&& source.tracks_latest
&& version == "latest"
));
}
#[test]
fn sourceforge_dotted_release_folder_candidates_can_resolve_to_latest_download() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let result = adapter
.normalize("https://sourceforge.net/projects/team-app/files/releases/2026.03/download")
.unwrap();
assert_eq!(result.kind, SourceKind::SourceForge);
assert_eq!(
result.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::SourceForge
&& source.locator
== "https://sourceforge.net/projects/team-app/files/releases/2026.03/download"
&& source.normalized_kind == NormalizedSourceKind::SourceForge
&& source.tracks_latest
&& version == "latest"
));
}
#[test]
fn sourceforge_file_like_release_candidates_resolve_to_releases_root() {
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
let result = adapter
.normalize(
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download",
)
.unwrap();
assert_eq!(result.kind, SourceKind::SourceForge);
assert_eq!(
result.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(
result.requested_asset_name.as_deref(),
Some("team-app-1.0.0.AppImage")
);
let resolution = adapter.resolve_source(&result).unwrap();
assert!(matches!(
resolution,
AdapterResolveOutcome::Resolved(AdapterResolution {
source,
release: ResolvedRelease { version, .. },
}) if source.kind == SourceKind::SourceForge
&& source.locator
== "https://sourceforge.net/projects/team-app/files/releases"
&& source.normalized_kind == NormalizedSourceKind::SourceForge
&& source.tracks_latest
&& source.requested_asset_name.as_deref() == Some("team-app-1.0.0.AppImage")
&& version == "latest"
));
}
#[test]
fn legacy_github_adapter_delegates_to_source_pipeline() {
let adapter: &dyn SourceAdapter = &GitHubAdapter;
let result = adapter.normalize("sharkdp/bat").unwrap();
assert_eq!(result.normalized_kind.as_str(), "github-repository");
assert_eq!(result.canonical_locator.as_deref(), Some("sharkdp/bat"));
let resolution = adapter.resolve(&result).unwrap();
assert_eq!(resolution.release.version, "latest");
}
#[test]
fn gitlab_adapter_normalizes_and_resolves_through_trait() {
let adapter: &dyn SourceAdapter = &GitLabAdapter;
let result = adapter
.normalize("https://gitlab.com/example/team/app")
.unwrap();
assert_eq!(result.kind.as_str(), "gitlab");
let resolution = adapter.resolve(&result).unwrap();
assert_eq!(resolution.release.version, "latest");
}

View file

@ -0,0 +1,14 @@
use upm_core::adapters::all_adapter_kinds;
#[test]
fn all_expected_adapter_kinds_are_registered() {
let kinds = all_adapter_kinds();
assert!(kinds.contains(&"github"));
assert!(kinds.contains(&"gitlab"));
assert!(kinds.contains(&"direct-url"));
assert!(kinds.contains(&"zsync"));
assert!(kinds.contains(&"sourceforge"));
assert!(!kinds.contains(&"appimagehub"));
assert!(!kinds.contains(&"custom-json"));
}

View file

@ -0,0 +1,139 @@
use std::fs;
use tempfile::tempdir;
use upm_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
use upm_core::platform::DesktopHelpers;
const VALID_FIXTURE_SHA512: &str =
"ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==";
#[test]
fn install_succeeds_with_valid_trusted_checksum() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(
root.path(),
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
);
let final_payload_path = root.path().join("payloads/bat.AppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some(VALID_FIXTURE_SHA512),
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert_eq!(outcome.final_payload_path, final_payload_path);
assert!(outcome.final_payload_path.exists());
}
#[test]
fn install_succeeds_without_trusted_checksum() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert!(outcome.final_payload_path.exists());
}
#[test]
fn install_fails_before_commit_when_trusted_checksum_mismatches() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some(VALID_FIXTURE_SHA512),
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(matches!(error, PayloadInstallError::ChecksumMismatch));
assert!(!final_payload_path.exists());
assert!(!staged_path.exists());
}
#[test]
fn malformed_trusted_checksum_fails_before_commit() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some("not-base64"),
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(matches!(error, PayloadInstallError::InvalidTrustedChecksum));
assert!(!final_payload_path.exists());
assert!(!staged_path.exists());
}
#[test]
fn install_succeeds_with_valid_weak_md5_checksum() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: Some("474a0eb1bbe0a6e62715ce83922a5bf7"),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert!(outcome.final_payload_path.exists());
}
#[test]
fn install_fails_before_commit_when_weak_md5_checksum_mismatches() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: Some("00000000000000000000000000000000"),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(matches!(error, PayloadInstallError::WeakChecksumMismatch));
assert!(!final_payload_path.exists());
assert!(!staged_path.exists());
}
fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging/bat.download");
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
fs::write(&staged_path, bytes).unwrap();
staged_path
}

View file

@ -0,0 +1,181 @@
use std::fs;
use std::io::{self, Cursor, Read};
use std::time::Duration;
use tempfile::tempdir;
use upm_core::app::add::{
InstallAppError, download_to_staged_path_with_retries,
stream_payload_to_staged_file_with_reporter,
};
use upm_core::app::progress::{NoopReporter, OperationEvent};
use upm_core::integration::install::{InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::HttpClientPolicy;
#[test]
fn payload_streaming_writes_staged_file_and_reports_progress() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let bytes = b"\x7fELFAppImage";
let mut reader = Cursor::new(bytes.as_slice());
let mut events = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let written = stream_payload_to_staged_file_with_reporter(
&mut reader,
Some(bytes.len() as u64),
&staged_path,
&mut reporter,
)
.unwrap();
assert_eq!(written, bytes.len() as u64);
assert_eq!(
fs::metadata(&staged_path).unwrap().len(),
bytes.len() as u64
);
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::Progress {
current,
total: Some(total)
} if *current == bytes.len() as u64 && *total == bytes.len() as u64
)
}));
}
#[test]
fn install_commits_from_staged_payload_path() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let final_payload_path = root.path().join("payloads/bat.AppImage");
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert_eq!(outcome.final_payload_path, final_payload_path);
assert!(outcome.final_payload_path.exists());
assert!(!staged_path.exists());
}
#[test]
fn failed_streaming_download_removes_partial_staged_payload() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let mut reader = FailingReader::new(b"\x7fELFpartial".to_vec(), 4);
let mut reporter = NoopReporter;
let result = stream_payload_to_staged_file_with_reporter(
&mut reader,
Some(12),
&staged_path,
&mut reporter,
);
assert!(result.is_err());
assert!(!staged_path.exists());
}
#[test]
fn retry_policy_retries_transient_failures_before_success() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let bytes = b"\x7fELFAppImage";
let mut attempts = 0;
let written = download_to_staged_path_with_retries(
&staged_path,
&mut NoopReporter,
HttpClientPolicy {
timeout: Duration::from_secs(30),
max_retries: 3,
},
|| {
attempts += 1;
if attempts == 1 {
return Err(InstallAppError::DownloadIo(io::Error::other(
"transient failure",
)));
}
Ok((
Box::new(Cursor::new(bytes.to_vec())) as Box<dyn Read>,
Some(bytes.len() as u64),
))
},
)
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(written, bytes.len() as u64);
assert!(staged_path.exists());
}
#[test]
fn retry_exhaustion_returns_error_and_cleans_staged_payload() {
let root = tempdir().unwrap();
let staged_path = root.path().join("staging/bat.download");
let mut attempts = 0;
let result = download_to_staged_path_with_retries(
&staged_path,
&mut NoopReporter,
HttpClientPolicy {
timeout: Duration::from_secs(30),
max_retries: 2,
},
|| {
attempts += 1;
Ok((
Box::new(FailingReader::new(b"\x7fELFpartial".to_vec(), 4)) as Box<dyn Read>,
Some(12),
))
},
);
assert!(result.is_err());
assert_eq!(attempts, 2);
assert!(!staged_path.exists());
}
struct FailingReader {
bytes: Vec<u8>,
chunk_size: usize,
position: usize,
}
impl FailingReader {
fn new(bytes: Vec<u8>, chunk_size: usize) -> Self {
Self {
bytes,
chunk_size,
position: 0,
}
}
}
impl Read for FailingReader {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if self.position >= self.chunk_size {
return Err(io::Error::other("fixture read failure"));
}
let remaining = self.chunk_size - self.position;
let to_read = remaining
.min(buffer.len())
.min(self.bytes.len() - self.position);
buffer[..to_read].copy_from_slice(&self.bytes[self.position..self.position + to_read]);
self.position += to_read;
Ok(to_read)
}
}

View file

@ -0,0 +1,3 @@
zsync: 0.6.2
Filename: T3-Code-0.0.11-x86_64.AppImage
URL: https://example.test/T3-Code-0.0.11-x86_64.AppImage

View file

@ -0,0 +1,3 @@
version: 0.0.11
path: T3-Code-0.0.11-x86_64.AppImage
sha512: example-sha

View file

@ -0,0 +1,88 @@
use upm_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking};
use upm_core::app::query::resolve_query;
use upm_core::source::github::FixtureGitHubTransport;
#[test]
fn github_adapter_can_normalize_owner_repo_source() {
let source = resolve_query("sharkdp/bat").unwrap();
assert_eq!(source.kind.as_str(), "github");
}
#[test]
fn add_flow_builds_github_plan_from_owner_repo_query() {
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
assert_eq!(plan.resolution.source.kind.as_str(), "github");
assert_eq!(plan.resolution.source.locator, "sharkdp/bat");
assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided");
}
#[test]
fn add_plan_prefers_metadata_guided_appimage_when_available() {
let plan = build_add_plan_with("pingdotgg/t3code", &FixtureGitHubTransport).unwrap();
assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided");
assert_eq!(
plan.update_strategy.preferred.kind.as_str(),
"electron-builder"
);
}
#[test]
fn direct_old_release_url_requests_tracking_choice_prompt() {
let plan = build_add_plan_with(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
&FixtureGitHubTransport,
)
.unwrap();
assert!(
plan.interactions
.iter()
.any(|item| item.key == "tracking-preference")
);
}
#[test]
fn materialized_record_preserves_source_and_strategy() {
let query = "sharkdp/bat";
let plan = build_add_plan_with(query, &FixtureGitHubTransport).unwrap();
let record = materialize_app_record(query, &plan).unwrap();
assert_eq!(record.stable_id, "sharkdp-bat");
assert_eq!(record.display_name, "bat");
assert_eq!(record.source_input.as_deref(), Some(query));
assert_eq!(record.installed_version.as_deref(), Some("1.0.0"));
assert_eq!(
record
.update_strategy
.as_ref()
.unwrap()
.preferred
.kind
.as_str(),
"electron-builder"
);
assert_eq!(record.source.as_ref().unwrap().locator, query);
}
#[test]
fn latest_tracking_choice_promotes_non_direct_update_channel() {
let plan = build_add_plan_with(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
&FixtureGitHubTransport,
)
.unwrap();
let resolved = prefer_latest_tracking(plan);
assert!(resolved.interactions.is_empty());
assert_eq!(resolved.resolution.source.locator, "pingdotgg/t3code");
assert!(resolved.resolution.source.tracks_latest);
assert_ne!(
resolved.update_strategy.preferred.kind.as_str(),
"direct-asset-lineage"
);
}

View file

@ -0,0 +1,44 @@
use std::time::Duration;
use upm_core::app::query::resolve_query;
use upm_core::source::github::{
FixtureGitHubTransport, discover_github_candidates_with, http_client_policy,
};
#[test]
fn discovery_reports_appimage_assets_and_latest_linux_yml() {
let source = resolve_query("pingdotgg/t3code").unwrap();
let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap();
assert!(
discovery
.assets
.iter()
.any(|asset| asset.name.ends_with(".AppImage"))
);
assert!(
discovery
.metadata_documents
.iter()
.any(|doc| doc.url.ends_with("latest-linux.yml"))
);
}
#[test]
fn discovery_marks_explicit_older_release_against_latest_fixture_release() {
let source = resolve_query(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
)
.unwrap();
let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap();
assert_eq!(discovery.releases[0].tag, "v0.0.12");
assert!(discovery.requested_is_older_release);
}
#[test]
fn github_http_policy_uses_explicit_timeout_and_retry_defaults() {
let policy = http_client_policy();
assert_eq!(policy.timeout, Duration::from_secs(30));
assert_eq!(policy.max_retries, 3);
}

View file

@ -0,0 +1,47 @@
use upm_core::app::identity::{IdentityFallback, resolve_identity};
use upm_core::domain::app::IdentityConfidence;
#[test]
fn unresolved_identity_can_fall_back_to_url() {
let identity = resolve_identity(
None,
None,
Some("https://example.com/app.AppImage"),
IdentityFallback::AllowRawUrl,
)
.unwrap();
assert!(identity.stable_id.contains("example.com"));
assert_eq!(identity.confidence, IdentityConfidence::RawUrlFallback);
}
#[test]
fn explicit_id_is_treated_as_confident() {
let identity = resolve_identity(
Some("Bat"),
Some("sharkdp/bat"),
Some("https://github.com/sharkdp/bat/releases"),
IdentityFallback::AllowRawUrl,
)
.unwrap();
assert_eq!(identity.stable_id, "sharkdp-bat");
assert_eq!(identity.display_name, "Bat");
assert_eq!(identity.confidence, IdentityConfidence::Confident);
}
#[test]
fn identifiers_containing_dot_dot_are_rejected() {
let error = resolve_identity(
Some("Bat"),
Some(".."),
Some("https://example.com/app.AppImage"),
IdentityFallback::AllowRawUrl,
)
.unwrap_err();
assert_eq!(
error,
upm_core::app::identity::ResolveIdentityError::InvalidStableId
);
}

View file

@ -0,0 +1,136 @@
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
use upm_core::app::add::{BuildAddPlanError, build_add_plan_with};
use upm_core::app::query::ResolveQueryError;
use upm_core::app::update::execute_updates;
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::SourceKind;
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::FixtureGitHubTransport;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn integration_failure_removes_new_payload_and_generated_files() {
let root = tempdir().unwrap();
let staging_root = root.path().join("staging");
let payload_root = root.path().join("payloads");
let blocking_path = root.path().join("not-a-directory");
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
fs::write(&blocking_path, "blocker").unwrap();
let staged_path = staging_root.join("bat.download");
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let final_payload_path = payload_root.join("bat.AppImage");
let desktop_entry_path = blocking_path.join("upm-bat.desktop");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_entry_path,
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: None,
icon_bytes: None,
}),
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(error.to_string().contains("desktop integration failed"));
assert!(!final_payload_path.exists());
assert!(!desktop_entry_path.exists());
}
#[test]
fn unsupported_queries_remain_distinct_from_provider_resolution_failures() {
let error =
build_add_plan_with("https://gitlab.com/example", &FixtureGitHubTransport).unwrap_err();
assert!(matches!(
error,
BuildAddPlanError::Query(ResolveQueryError::Unsupported)
));
}
#[test]
fn supported_sourceforge_project_without_latest_download_reports_no_installable_artifact() {
let error = build_add_plan_with(
"https://sourceforge.net/projects/team-app/",
&FixtureGitHubTransport,
)
.unwrap_err();
match error {
BuildAddPlanError::NoInstallableArtifact { source } => {
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.locator, "https://sourceforge.net/projects/team-app/");
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
}
other => panic!("expected no-installable-artifact error, got {other:?}"),
}
}
#[test]
fn failed_update_restores_tracked_desktop_and_icon_files() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let root = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
}
let payload_path = root.path().join("tracked/team-app.AppImage");
let desktop_path = root.path().join("tracked/upm-team-app.desktop");
let icon_path = root.path().join("tracked/team-app.png");
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();
fs::write(&desktop_path, b"previous-desktop").unwrap();
fs::write(&icon_path, b"previous-icon").unwrap();
let blocking_applications_root = root.path().join(".local/share/applications");
fs::create_dir_all(blocking_applications_root.parent().unwrap()).unwrap();
fs::write(&blocking_applications_root, b"blocker").unwrap();
let previous = AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(payload_path.display().to_string()),
desktop_entry_path: Some(desktop_path.display().to_string()),
icon_path: Some(icon_path.display().to_string()),
}),
};
let result = execute_updates(std::slice::from_ref(&previous), root.path()).unwrap();
assert_eq!(result.failed_count(), 1);
assert_eq!(fs::read(&payload_path).unwrap(), b"previous-payload");
assert_eq!(fs::read(&desktop_path).unwrap(), b"previous-desktop");
assert_eq!(fs::read(&icon_path).unwrap(), b"previous-icon");
}

View file

@ -0,0 +1,611 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use upm_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
use upm_core::app::progress::{OperationEvent, OperationStage};
use upm_core::domain::app::InstallScope;
use upm_core::domain::source::{NormalizedSourceKind, SourceKind};
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use upm_core::platform::DesktopHelpers;
use upm_core::source::github::FixtureGitHubTransport;
fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging").join(format!("{name}.download"));
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
fs::write(&staged_path, bytes).unwrap();
staged_path
}
#[test]
fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
let root = tempdir().unwrap();
let payload_root = root.path().join("payloads");
let desktop_root = root.path().join("applications");
fs::create_dir(&payload_root).unwrap();
fs::create_dir(&desktop_root).unwrap();
let staged_path = write_staged_payload(root.path(), "bat", b"\x7fELFAppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: None,
icon_bytes: None,
}),
helpers: DesktopHelpers::default(),
})
.unwrap();
assert!(outcome.desktop_entry_path.unwrap().exists());
assert!(!outcome.warnings.is_empty());
}
#[test]
fn install_executes_refresh_helpers_when_available() {
let root = tempdir().unwrap();
let payload_root = root.path().join("payloads");
let desktop_root = root.path().join("applications");
let helper_root = root.path().join("helpers");
let log_path = root.path().join("helpers.log");
fs::create_dir(&payload_root).unwrap();
fs::create_dir(&desktop_root).unwrap();
fs::create_dir(&helper_root).unwrap();
let staged_path = write_staged_payload(
root.path(),
"bat",
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
);
let update_helper = helper_root.join("update-desktop-database");
let icon_helper = helper_root.join("gtk-update-icon-cache");
fs::write(
&update_helper,
format!("#!/bin/sh\necho desktop:$1 >> {}\n", log_path.display()),
)
.unwrap();
fs::write(
&icon_helper,
format!("#!/bin/sh\necho icon:$3 >> {}\n", log_path.display()),
)
.unwrap();
fs::set_permissions(&update_helper, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(&icon_helper, fs::Permissions::from_mode(0o755)).unwrap();
let icon_root = root.path().join("icons/hicolor/256x256/apps");
fs::create_dir_all(&icon_root).unwrap();
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: Some(&icon_root.join("bat.png")),
icon_bytes: None,
}),
helpers: DesktopHelpers {
update_desktop_database: true,
gtk_update_icon_cache: true,
update_desktop_database_path: Some(update_helper),
gtk_update_icon_cache_path: Some(icon_helper),
},
})
.unwrap();
assert!(outcome.warnings.is_empty());
let log = fs::read_to_string(&log_path).unwrap();
assert!(log.contains("desktop:"));
assert!(log.contains("icon:"));
}
#[test]
fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
let root = tempdir().unwrap();
let payload_root = root.path().join("payloads");
let desktop_root = root.path().join("applications");
let icon_root = root.path().join("icons/hicolor/256x256/apps");
fs::create_dir(&payload_root).unwrap();
fs::create_dir(&desktop_root).unwrap();
fs::create_dir_all(&icon_root).unwrap();
let staged_path = write_staged_payload(
root.path(),
"bat",
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
);
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
icon_path: Some(&icon_root.join("bat.png")),
icon_bytes: None,
}),
helpers: DesktopHelpers::default(),
})
.unwrap();
let icon_path = outcome.icon_path.unwrap();
assert!(icon_path.exists());
assert!(
fs::read(&icon_path)
.unwrap()
.starts_with(b"\x89PNG\r\n\x1a\n")
);
}
#[test]
fn install_app_reports_operation_stages_in_order() {
let root = tempdir().unwrap();
let mut events: Vec<OperationEvent> = Vec::new();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let plan = build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut reporter)
.unwrap();
let installed = install_app_with_reporter(
"sharkdp/bat",
&plan,
root.path(),
InstallScope::User,
&mut reporter,
)
.unwrap();
assert_eq!(installed.record.stable_id, "sharkdp-bat");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: "resolving source".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::SelectArtifact,
message: "selecting artifact".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::Progress {
current,
total: Some(total)
} if *current == *total
)
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
..
}
)
}));
let stage_order = events
.iter()
.filter_map(|event| match event {
OperationEvent::StageChanged { stage, .. } => Some(*stage),
_ => None,
})
.collect::<Vec<_>>();
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::ResolveQuery,
OperationStage::DiscoverRelease,
]
}));
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::DiscoverRelease,
OperationStage::SelectArtifact,
]
}));
assert!(stage_order.windows(2).any(|window| {
window
== [
OperationStage::SelectArtifact,
OperationStage::DownloadArtifact,
]
}));
}
#[test]
fn install_app_sanitizes_desktop_entry_display_names() {
let root = tempdir().unwrap();
let mut reporter = Vec::new();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut capture = |event: &OperationEvent| reporter.push(event.clone());
let mut plan =
build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut capture).unwrap();
plan.display_name_hint = Some("Bat\nExec=evil".to_owned());
let installed = install_app_with_reporter(
"sharkdp/bat",
&plan,
root.path(),
InstallScope::User,
&mut capture,
)
.unwrap();
let desktop_path = installed
.install_outcome
.desktop_entry_path
.as_ref()
.unwrap();
let contents = fs::read_to_string(desktop_path).unwrap();
assert!(contents.contains("Name=Bat Exec=evil"));
assert_eq!(
contents
.lines()
.filter(|line| line.starts_with("Exec="))
.count(),
1
);
}
#[test]
fn gitlab_source_builds_concrete_install_candidate() {
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let plan = build_add_plan_with_reporter(
"https://gitlab.com/example/team-app",
&FixtureGitHubTransport,
&mut reporter,
)
.unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::GitLab);
assert_eq!(
plan.resolution.source.locator,
"https://gitlab.com/example/team-app"
);
assert_eq!(plan.resolution.release.version, "latest");
assert_eq!(
plan.selected_artifact.url,
"https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
);
assert_eq!(plan.selected_artifact.version, "latest");
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
}));
}
#[test]
fn gitlab_candidate_builds_concrete_install_candidate() {
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let query = "https://gitlab.com/acme/platform/releases/team-app";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::GitLab);
assert_eq!(plan.resolution.source.locator, query);
assert_eq!(
plan.resolution.source.canonical_locator.as_deref(),
Some("acme/platform/releases/team-app")
);
assert_eq!(
plan.resolution.source.normalized_kind,
NormalizedSourceKind::GitLab
);
assert_eq!(plan.resolution.release.version, "latest");
assert_eq!(
plan.selected_artifact.url,
"https://gitlab.com/acme/platform/releases/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
);
assert_eq!(plan.selected_artifact.version, "latest");
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
}));
}
#[test]
fn gitlab_install_preserves_truthful_gitlab_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
let query = "https://gitlab.com/example/team-app";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
let installed =
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
.unwrap();
assert_eq!(installed.record.source_input.as_deref(), Some(query));
assert_eq!(
installed.record.installed_version.as_deref(),
Some("latest")
);
assert_eq!(installed.source.kind, SourceKind::GitLab);
assert_eq!(installed.source.locator, query);
assert_eq!(
installed.source.canonical_locator.as_deref(),
Some("example/team-app")
);
assert_eq!(
installed.selected_artifact.url,
"https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
);
}
#[test]
fn direct_url_source_uses_exact_input_resolution() {
let mut reporter = |_event: &OperationEvent| {};
let query = "https://example.com/downloads/team-app.AppImage";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::DirectUrl);
assert_eq!(plan.resolution.source.locator, query);
assert_eq!(plan.resolution.release.version, "unresolved");
assert_eq!(plan.selected_artifact.url, query);
assert_eq!(plan.selected_artifact.version, "unresolved");
assert_eq!(plan.selected_artifact.selection_reason, "exact-input");
assert_eq!(plan.update_strategy.preferred.locator, query);
assert_eq!(plan.update_strategy.preferred.reason, "exact-input");
}
#[test]
fn direct_url_install_preserves_truthful_direct_url_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
let installed =
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
.unwrap();
assert_eq!(installed.record.source_input.as_deref(), Some(query));
assert_eq!(
installed.record.installed_version.as_deref(),
Some("unresolved")
);
assert_eq!(installed.source.kind, SourceKind::DirectUrl);
assert_eq!(installed.source.locator, query);
assert_eq!(installed.selected_artifact.url, query);
}
#[test]
fn sourceforge_candidate_builds_concrete_install_candidate() {
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let query = "https://sourceforge.net/projects/team-app/files/releases/stable/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
assert_eq!(plan.resolution.source.locator, query);
assert_eq!(plan.resolution.release.version, "latest");
assert_eq!(plan.selected_artifact.url, query);
assert_eq!(plan.selected_artifact.version, "latest");
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
assert_eq!(plan.update_strategy.preferred.locator, query);
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DiscoverRelease,
message: "discovering release".to_owned(),
}));
}
#[test]
fn sourceforge_release_folder_builds_concrete_install_candidate() {
let mut reporter = |_event: &OperationEvent| {};
let query = "https://sourceforge.net/projects/team-app/files/releases/beta/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
assert_eq!(plan.resolution.source.locator, query);
assert_eq!(
plan.resolution.source.normalized_kind,
NormalizedSourceKind::SourceForge
);
assert!(plan.resolution.source.tracks_latest);
assert_eq!(plan.resolution.release.version, "latest");
assert_eq!(plan.selected_artifact.url, query);
assert_eq!(plan.selected_artifact.version, "latest");
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
assert_eq!(plan.update_strategy.preferred.locator, query);
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
}
#[test]
fn sourceforge_latest_download_builds_concrete_install_candidate() {
let mut reporter = |_event: &OperationEvent| {};
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
assert_eq!(plan.resolution.source.locator, query);
assert_eq!(plan.resolution.release.version, "latest");
assert_eq!(plan.selected_artifact.url, query);
assert_eq!(plan.selected_artifact.version, "latest");
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
}
#[test]
fn sourceforge_latest_download_install_preserves_truthful_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
let installed =
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
.unwrap();
assert_eq!(installed.record.source_input.as_deref(), Some(query));
assert_eq!(
installed.record.installed_version.as_deref(),
Some("latest")
);
assert_eq!(installed.source.kind, SourceKind::SourceForge);
assert_eq!(installed.source.locator, query);
assert_eq!(
installed.source.canonical_locator.as_deref(),
Some("team-app")
);
assert_eq!(installed.selected_artifact.url, query);
}
#[test]
fn sourceforge_release_folder_install_preserves_truthful_origin() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
let query = "https://sourceforge.net/projects/team-app/files/releases/beta/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
let installed =
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
.unwrap();
assert_eq!(installed.record.source_input.as_deref(), Some(query));
assert_eq!(
installed.record.installed_version.as_deref(),
Some("latest")
);
assert_eq!(installed.source.kind, SourceKind::SourceForge);
assert_eq!(installed.source.locator, query);
assert_eq!(
installed.source.canonical_locator.as_deref(),
Some("team-app")
);
assert_eq!(
installed.source.normalized_kind,
NormalizedSourceKind::SourceForge
);
assert_eq!(installed.selected_artifact.url, query);
}
#[test]
fn sourceforge_file_like_release_download_uses_releases_root_for_source_and_original_url_for_artifact()
{
let mut reporter = |_event: &OperationEvent| {};
let query =
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
assert_eq!(
plan.resolution.source.locator,
"https://sourceforge.net/projects/team-app/files/releases"
);
assert_eq!(
plan.resolution.source.requested_asset_name.as_deref(),
Some("team-app-1.0.0.AppImage")
);
assert_eq!(plan.resolution.release.version, "latest");
assert_eq!(plan.selected_artifact.url, query);
assert_eq!(plan.selected_artifact.version, "latest");
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
assert_eq!(
plan.update_strategy.preferred.locator,
"https://sourceforge.net/projects/team-app/files/releases"
);
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
}
#[test]
fn sourceforge_file_like_release_download_install_preserves_input_but_stores_releases_root() {
let root = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |_event: &OperationEvent| {};
let query =
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download";
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
let installed =
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
.unwrap();
assert_eq!(installed.record.source_input.as_deref(), Some(query));
assert_eq!(
installed.record.installed_version.as_deref(),
Some("latest")
);
assert_eq!(installed.source.kind, SourceKind::SourceForge);
assert_eq!(
installed.source.locator,
"https://sourceforge.net/projects/team-app/files/releases"
);
assert_eq!(
installed.source.requested_asset_name.as_deref(),
Some("team-app-1.0.0.AppImage")
);
assert_eq!(
installed.source.canonical_locator.as_deref(),
Some("team-app")
);
assert_eq!(installed.selected_artifact.url, query);
}

View file

@ -0,0 +1,28 @@
use std::path::Path;
use upm_core::domain::app::InstallScope;
use upm_core::integration::paths::{desktop_entry_path, managed_appimage_path};
#[test]
fn user_scope_path_lands_under_home_managed_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
assert_eq!(
path,
Path::new("/home/test/.local/lib/upm/appimages/bat.AppImage")
);
}
#[test]
fn system_scope_path_lands_under_opt_upm_dir() {
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/opt/upm/appimages/bat.AppImage"));
}
#[test]
fn system_scope_desktop_entry_uses_upm_prefix() {
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
assert_eq!(path, Path::new("/usr/share/applications/upm-bat.desktop"));
}

View file

@ -0,0 +1,33 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use upm_core::integration::install::stage_and_commit_payload;
#[test]
fn payload_commit_moves_staged_appimage_into_final_location() {
let root = tempdir().unwrap();
let staging_root = root.path().join("staging");
let payload_root = root.path().join("payloads");
fs::create_dir(&staging_root).unwrap();
fs::create_dir(&payload_root).unwrap();
let staged_path = staging_root.join("bat.download");
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
let final_payload_path = payload_root.join("bat.AppImage");
let outcome = stage_and_commit_payload(&staged_path, &final_payload_path).unwrap();
assert_eq!(
outcome
.final_payload_path
.extension()
.and_then(|ext| ext.to_str()),
Some("AppImage")
);
assert!(outcome.final_payload_path.exists());
let mode = fs::metadata(&outcome.final_payload_path)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111);
}

View file

@ -0,0 +1,49 @@
use std::path::Path;
use upm_core::integration::policy::{IntegrationMode, resolve_install_policy};
use upm_core::platform::{DistroFamily, HostCapabilities, InstallScope};
#[test]
fn immutable_system_request_downgrades_to_user_when_allowed() {
let capabilities = HostCapabilities::immutable_user_only();
let policy =
resolve_install_policy(DistroFamily::Immutable, InstallScope::System, &capabilities)
.unwrap();
assert_eq!(policy.scope, InstallScope::User);
assert_eq!(policy.integration_mode, IntegrationMode::Degraded);
assert!(!policy.warnings.is_empty());
}
#[test]
fn nix_system_request_is_denied() {
let error = resolve_install_policy(
DistroFamily::Nix,
InstallScope::System,
&HostCapabilities::default(),
)
.unwrap_err();
assert!(error.contains("not supported on Nix hosts"));
}
#[test]
fn system_policy_uses_managed_payload_and_native_integration_roots() {
let policy = resolve_install_policy(
DistroFamily::Fedora,
InstallScope::System,
&HostCapabilities::default(),
)
.unwrap();
assert_eq!(policy.scope, InstallScope::System);
assert_eq!(policy.payload_root, Path::new("/opt/upm/appimages"));
assert_eq!(
policy.desktop_entry_root,
Path::new("/usr/share/applications")
);
assert_eq!(
policy.icon_root,
Path::new("/usr/share/icons/hicolor/256x256/apps")
);
assert_eq!(policy.integration_mode, IntegrationMode::Full);
}

View file

@ -0,0 +1,8 @@
use upm_core::app::scope::{ScopeOverride, resolve_install_scope};
use upm_core::domain::app::InstallScope;
#[test]
fn explicit_scope_override_beats_effective_user() {
let scope = resolve_install_scope(false, ScopeOverride::System);
assert_eq!(scope, InstallScope::System);
}

View file

@ -0,0 +1,11 @@
use upm_core::domain::update::ParsedMetadataKind;
use upm_core::metadata::{MetadataDocument, parse_document};
#[test]
fn unknown_document_returns_typed_warning_not_panic() {
let doc = MetadataDocument::plain_text("https://example.test/notes.txt", b"not metadata");
let result = parse_document(&doc).unwrap();
assert_eq!(result.kind, ParsedMetadataKind::Unknown);
assert!(!result.warnings.is_empty());
}

View file

@ -0,0 +1,15 @@
use upm_core::domain::update::ParsedMetadataKind;
use upm_core::metadata::{MetadataDocument, parse_document};
#[test]
fn parses_latest_linux_yml_into_download_hints() {
let raw = include_bytes!("fixtures/latest-linux.yml");
let doc = MetadataDocument::yaml("https://example.test/latest-linux.yml", raw);
let result = parse_document(&doc).unwrap();
assert_eq!(result.kind, ParsedMetadataKind::ElectronBuilder);
assert_eq!(
result.hints.primary_download.as_deref(),
Some("T3-Code-0.0.11-x86_64.AppImage")
);
}

View file

@ -0,0 +1,12 @@
use upm_core::domain::update::ParsedMetadataKind;
use upm_core::metadata::{MetadataDocument, parse_document};
#[test]
fn parses_zsync_document_into_channel_hints() {
let raw = include_bytes!("fixtures/example.zsync");
let doc = MetadataDocument::plain_text("https://example.test/app.AppImage.zsync", raw);
let result = parse_document(&doc).unwrap();
assert_eq!(result.kind, ParsedMetadataKind::Zsync);
assert!(result.hints.primary_download.is_some());
}

View file

@ -0,0 +1,52 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use upm_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots};
use upm_core::platform::distro::{DistroFamily, detect_distro_family};
#[test]
fn detects_fedora_family_from_os_release() {
let distro = detect_distro_family("ID=fedora\nID_LIKE=rhel centos\n");
assert_eq!(distro, DistroFamily::Fedora);
}
#[test]
fn detects_immutable_family_from_variant_id() {
let distro = detect_distro_family("ID=fedora\nVARIANT_ID=silverblue\n");
assert_eq!(distro, DistroFamily::Immutable);
}
#[test]
fn probes_desktop_helpers_from_search_paths() {
let helper_dir = tempdir().unwrap();
let update_desktop_database = helper_dir.path().join("update-desktop-database");
let gtk_update_icon_cache = helper_dir.path().join("gtk-update-icon-cache");
fs::write(&update_desktop_database, "#!/bin/sh\n").unwrap();
fs::write(&gtk_update_icon_cache, "#!/bin/sh\n").unwrap();
fs::set_permissions(&update_desktop_database, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(&gtk_update_icon_cache, fs::Permissions::from_mode(0o755)).unwrap();
let helpers = probe_desktop_helpers(&[helper_dir.path()]);
assert!(helpers.update_desktop_database);
assert!(helpers.gtk_update_icon_cache);
}
#[test]
fn probes_writable_roots_from_candidate_directories() {
let root = tempdir().unwrap();
let payload = root.path().join("payload");
let desktop_entries = root.path().join("applications");
let icons = root.path().join("icons");
fs::create_dir(&payload).unwrap();
fs::create_dir(&desktop_entries).unwrap();
fs::create_dir(&icons).unwrap();
let writable = probe_writable_roots(&payload, &desktop_entries, &icons);
assert!(writable.payload);
assert!(writable.desktop_entries);
assert!(writable.icons);
}

View file

@ -0,0 +1,159 @@
use upm_core::app::add::{AddSecurityPolicy, build_add_plan_with_registered_providers};
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
use upm_core::app::search::{SearchProvider, build_search_results_with_registered_providers};
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
use upm_core::domain::source::{
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
};
use upm_core::domain::update::{
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
};
use upm_core::source::github::FixtureGitHubTransport;
struct StubSearchProvider;
impl SearchProvider for StubSearchProvider {
fn search(
&self,
_query: &SearchQuery,
) -> Result<Vec<SearchResult>, upm_core::app::search::SearchProviderError> {
Ok(vec![SearchResult {
provider_id: "external-search".to_owned(),
display_name: "Firefox Nightly".to_owned(),
description: Some("Provided by external registry".to_owned()),
source_locator: "https://example.invalid/firefox-nightly".to_owned(),
install_query: "external/firefox-nightly".to_owned(),
canonical_locator: "external/firefox-nightly".to_owned(),
version: Some("2026.03.21".to_owned()),
install_status: SearchInstallStatus::Available,
}])
}
}
struct StubExternalAddProvider;
impl ExternalAddProvider for StubExternalAddProvider {
fn id(&self) -> &'static str {
"stub-appimage"
}
fn resolve(
&self,
source: &SourceRef,
) -> Result<Option<ExternalAddResolution>, upm_core::adapters::traits::AdapterError> {
Ok(
(source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution {
resolution: upm_core::adapters::traits::AdapterResolution {
source: SourceRef {
kind: SourceKind::AppImageHub,
locator: source.locator.clone(),
input_kind: SourceInputKind::AppImageHubShorthand,
normalized_kind: NormalizedSourceKind::AppImageHub,
canonical_locator: Some("2338455".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
},
release: ResolvedRelease {
version: "stable".to_owned(),
prerelease: false,
},
},
selected_artifact: ArtifactCandidate {
url: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
version: "stable".to_owned(),
arch: Some("x86_64".to_owned()),
trusted_checksum: None,
weak_checksum_md5: Some("deadbeef".to_owned()),
selection_reason: "provider-release".to_owned(),
},
update_strategy: UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
},
display_name_hint: Some(
"Firefox by Mozilla - Official AppImage Edition".to_owned(),
),
}),
)
}
}
#[test]
fn build_search_results_with_registered_providers_uses_external_hits() {
let query = SearchQuery::new("firefox");
let search_provider = StubSearchProvider;
let providers = ProviderRegistry {
search_providers: vec![&search_provider],
external_add_providers: Vec::new(),
};
let results = build_search_results_with_registered_providers(&query, &[], &providers).unwrap();
let external_hit = results
.remote_hits
.iter()
.find(|hit| hit.provider_id == "external-search")
.unwrap();
assert_eq!(external_hit.install_query, "external/firefox-nightly");
assert!(
results
.remote_hits
.iter()
.all(|hit| hit.provider_id != "appimagehub")
);
}
#[test]
fn build_add_plan_with_registered_providers_requires_external_provider_for_appimagehub() {
let registry = ProviderRegistry::default();
let error = build_add_plan_with_registered_providers(
"appimagehub/2338455",
&FixtureGitHubTransport,
&registry,
AddSecurityPolicy::default(),
)
.unwrap_err();
assert!(matches!(
error,
upm_core::app::add::BuildAddPlanError::NoInstallableArtifact { .. }
));
}
#[test]
fn build_add_plan_with_registered_providers_delegates_appimagehub_like_sources() {
let provider = StubExternalAddProvider;
let registry = ProviderRegistry {
search_providers: Vec::new(),
external_add_providers: vec![&provider],
};
let plan = build_add_plan_with_registered_providers(
"appimagehub/2338455",
&FixtureGitHubTransport,
&registry,
AddSecurityPolicy::default(),
)
.unwrap();
assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub);
assert_eq!(
plan.resolution.source.canonical_locator.as_deref(),
Some("2338455")
);
assert_eq!(
plan.selected_artifact.url,
"https://downloads.example.invalid/firefox.AppImage"
);
assert_eq!(
plan.display_name_hint.as_deref(),
Some("Firefox by Mozilla - Official AppImage Edition")
);
}

View file

@ -0,0 +1,419 @@
use upm_core::app::query::resolve_query;
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind};
#[test]
fn owner_repo_defaults_to_github() {
let source = resolve_query("sharkdp/bat").unwrap();
assert_eq!(source.kind, SourceKind::GitHub);
assert_eq!(source.input_kind, SourceInputKind::RepoShorthand);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::GitHubRepository
);
}
#[test]
fn classifies_github_release_asset_url() {
let source = resolve_query(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
)
.unwrap();
assert_eq!(source.input_kind, SourceInputKind::GitHubReleaseAssetUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::GitHubReleaseAsset
);
}
#[test]
fn classifies_appimagehub_item_url() {
let source = resolve_query("https://www.appimagehub.com/p/2338455").unwrap();
assert_eq!(source.kind, SourceKind::AppImageHub);
assert_eq!(source.input_kind, SourceInputKind::AppImageHubUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub);
assert_eq!(source.canonical_locator.as_deref(), Some("2338455"));
assert!(source.tracks_latest);
}
#[test]
fn classifies_appimagehub_id_shorthand() {
let source = resolve_query("appimagehub/2338455").unwrap();
assert_eq!(source.kind, SourceKind::AppImageHub);
assert_eq!(source.input_kind, SourceInputKind::AppImageHubShorthand);
assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub);
assert_eq!(source.locator, "https://www.appimagehub.com/p/2338455");
assert_eq!(source.canonical_locator.as_deref(), Some("2338455"));
assert!(source.tracks_latest);
}
#[test]
fn classifies_gitlab_repository_url() {
let source = resolve_query("https://gitlab.com/example/team-app").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab);
assert_eq!(
source.canonical_locator.as_deref(),
Some("example/team-app")
);
assert!(source.tracks_latest);
}
#[test]
fn classifies_gitlab_release_like_url() {
let source = resolve_query("https://gitlab.com/example/team-app/-/releases/v1.2.3").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab);
assert_eq!(
source.canonical_locator.as_deref(),
Some("example/team-app")
);
assert_eq!(source.requested_tag.as_deref(), Some("v1.2.3"));
assert!(!source.tracks_latest);
}
#[test]
fn classifies_gitlab_subgroup_repository_url() {
let source = resolve_query("https://gitlab.com/example/platform/team-app").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(
source.canonical_locator.as_deref(),
Some("example/platform/team-app")
);
assert!(source.tracks_latest);
}
#[test]
fn classifies_gitlab_deep_subgroup_repository_url() {
let source = resolve_query("https://gitlab.com/example/platform/apps/team-app").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(
source.canonical_locator.as_deref(),
Some("example/platform/apps/team-app")
);
assert!(source.tracks_latest);
}
#[test]
fn classifies_gitlab_repository_with_reserved_namespace_segment() {
let source = resolve_query("https://gitlab.com/example/releases/team-app").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(
source.canonical_locator.as_deref(),
Some("example/releases/team-app")
);
}
#[test]
fn classifies_gitlab_two_segment_repository_with_reserved_slug() {
let source = resolve_query("https://gitlab.com/example/issues").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(source.canonical_locator.as_deref(), Some("example/issues"));
assert!(source.tracks_latest);
}
#[test]
fn classifies_sourceforge_project_url() {
let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
}
#[test]
fn classifies_sourceforge_files_url() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/latest/download").unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
}
#[test]
fn preserves_direct_url_classification() {
let source = resolve_query("https://example.com/downloads/team-app.AppImage").unwrap();
assert_eq!(source.kind, SourceKind::DirectUrl);
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
}
#[test]
fn classifies_single_segment_sourceforge_release_download_as_candidate() {
let source = resolve_query(
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download",
)
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert_eq!(
source.requested_asset_name.as_deref(),
Some("team-app-1.0.0.AppImage")
);
assert!(!source.tracks_latest);
}
#[test]
fn classifies_sourceforge_releases_root_as_provider_source() {
let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
assert_eq!(
source.locator,
"https://sourceforge.net/projects/team-app/files/releases"
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(source.tracks_latest);
}
#[test]
fn preserves_sourceforge_root_download_url_as_direct_url() {
let source = resolve_query(
"https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download",
)
.unwrap();
assert_eq!(source.kind, SourceKind::DirectUrl);
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
}
#[test]
fn preserves_sourceforge_extensionless_root_download_url_as_direct_url() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/team-app/download").unwrap();
assert_eq!(source.kind, SourceKind::DirectUrl);
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
}
#[test]
fn classifies_single_segment_sourceforge_release_download_with_query_as_candidate() {
let source = resolve_query(
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download?use_mirror=pilotfiber",
)
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert_eq!(
source.requested_asset_name.as_deref(),
Some("team-app-1.0.0.AppImage")
);
assert!(!source.tracks_latest);
}
#[test]
fn rejects_malformed_gitlab_url() {
let error = resolve_query("https://gitlab.com/example").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_url_shape() {
let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_nested_resource_url() {
let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_release_permalink_url() {
let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest")
.unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_issue_detail_url() {
let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_gitlab_blob_url() {
let error =
resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn classifies_ambiguous_gitlab_deep_reserved_segment_as_candidate() {
let source = resolve_query("https://gitlab.com/acme/platform/releases/team-app").unwrap();
assert_eq!(source.kind, SourceKind::GitLab);
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::GitLabCandidate
);
assert_eq!(source.canonical_locator, None);
assert!(!source.tracks_latest);
}
#[test]
fn rejects_unsupported_gitlab_packages_url() {
let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_malformed_sourceforge_url() {
let error = resolve_query("https://sourceforge.net/projects/").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_malformed_appimagehub_shorthand() {
let error = resolve_query("appimagehub/firefox").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn rejects_unsupported_sourceforge_url_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn classifies_sourceforge_files_releases_shape_as_provider_source() {
let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
assert_eq!(
source.locator,
"https://sourceforge.net/projects/team-app/files/releases"
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(source.tracks_latest);
}
#[test]
fn rejects_unsupported_sourceforge_folder_download_shape() {
let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download")
.unwrap_err();
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
}
#[test]
fn classifies_ambiguous_sourceforge_nested_folder_download_as_candidate() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/releases/stable/download")
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(!source.tracks_latest);
}
#[test]
fn classifies_extensionless_sourceforge_release_folder_download_as_candidate() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/releases/team-app/download")
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(!source.tracks_latest);
}
#[test]
fn classifies_ambiguous_sourceforge_version_folder_download_as_candidate() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/releases/v1-0/download")
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(!source.tracks_latest);
}
#[test]
fn classifies_prerelease_named_sourceforge_release_folder_download_as_candidate() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/releases/beta/download")
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(!source.tracks_latest);
}
#[test]
fn classifies_dotted_sourceforge_release_folder_download_as_candidate() {
let source =
resolve_query("https://sourceforge.net/projects/team-app/files/releases/2026.03/download")
.unwrap();
assert_eq!(source.kind, SourceKind::SourceForge);
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
assert_eq!(
source.normalized_kind,
NormalizedSourceKind::SourceForgeCandidate
);
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
assert!(!source.tracks_latest);
}

View file

@ -0,0 +1,288 @@
use tempfile::tempdir;
use upm_core::registry::store::RegistryStore;
#[test]
fn registry_round_trips_app_records() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let loaded = store.load().unwrap();
assert!(loaded.apps.is_empty());
}
#[test]
fn registry_round_trips_update_strategy_and_alternates() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = upm_core::registry::model::Registry {
version: 1,
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: Some(upm_core::domain::update::UpdateStrategy {
preferred: upm_core::domain::update::ChannelPreference {
kind: upm_core::domain::update::UpdateChannelKind::DirectAsset,
locator: "https://example.test/app.AppImage".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: vec![
upm_core::domain::update::ChannelPreference {
kind: upm_core::domain::update::UpdateChannelKind::GitHubReleases,
locator: "pingdotgg/t3code".to_owned(),
reason: "heuristic-match".to_owned(),
},
upm_core::domain::update::ChannelPreference {
kind: upm_core::domain::update::UpdateChannelKind::ElectronBuilder,
locator: "https://example.test/latest-linux.yml".to_owned(),
reason: "metadata-guided".to_owned(),
},
],
}),
metadata: Vec::new(),
install: None,
}],
};
store.save(&registry).unwrap();
let loaded = store.load().unwrap();
let strategy = loaded.apps[0].update_strategy.as_ref().unwrap();
assert_eq!(strategy.preferred.reason, "install-origin-match");
assert_eq!(strategy.alternates.len(), 2);
}
#[test]
fn registry_round_trips_install_metadata() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = upm_core::registry::model::Registry {
version: 1,
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(upm_core::domain::app::InstallMetadata {
scope: upm_core::domain::app::InstallScope::User,
payload_path: Some(
"/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage".to_owned(),
),
desktop_entry_path: Some(
"/tmp/install-home/.local/share/applications/upm-t3code.desktop".to_owned(),
),
icon_path: Some(
"/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png"
.to_owned(),
),
}),
}],
};
store.save(&registry).unwrap();
let loaded = store.load().unwrap();
let install = loaded.apps[0].install.as_ref().unwrap();
assert_eq!(install.scope, upm_core::domain::app::InstallScope::User);
assert_eq!(
install.payload_path.as_deref(),
Some("/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage")
);
assert_eq!(
install.desktop_entry_path.as_deref(),
Some("/tmp/install-home/.local/share/applications/upm-t3code.desktop")
);
assert_eq!(
install.icon_path.as_deref(),
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
);
}
#[test]
fn registry_round_trips_source_identity_for_new_provider_kinds() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let registry = upm_core::registry::model::Registry {
version: 1,
apps: vec![
upm_core::domain::app::AppRecord {
stable_id: "example-team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
source: Some(upm_core::domain::source::SourceRef {
kind: upm_core::domain::source::SourceKind::GitLab,
locator: "https://gitlab.com/example/team-app".to_owned(),
input_kind: upm_core::domain::source::SourceInputKind::GitLabUrl,
normalized_kind: upm_core::domain::source::NormalizedSourceKind::GitLab,
canonical_locator: Some("example/team-app".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("latest".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
},
upm_core::domain::app::AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some(
"https://sourceforge.net/projects/team-app/files/latest/download".to_owned(),
),
source: Some(upm_core::domain::source::SourceRef {
kind: upm_core::domain::source::SourceKind::SourceForge,
locator: "https://sourceforge.net/projects/team-app/files/latest/download"
.to_owned(),
input_kind: upm_core::domain::source::SourceInputKind::SourceForgeUrl,
normalized_kind: upm_core::domain::source::NormalizedSourceKind::SourceForge,
canonical_locator: Some("team-app".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("latest".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
},
upm_core::domain::app::AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(upm_core::domain::source::SourceRef {
kind: upm_core::domain::source::SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: upm_core::domain::source::SourceInputKind::DirectUrl,
normalized_kind: upm_core::domain::source::NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
},
],
};
store.save(&registry).unwrap();
let loaded = store.load().unwrap();
assert_eq!(
loaded.apps[0].source.as_ref().unwrap().kind.as_str(),
"gitlab"
);
assert_eq!(
loaded.apps[0]
.source
.as_ref()
.unwrap()
.canonical_locator
.as_deref(),
Some("example/team-app")
);
assert_eq!(
loaded.apps[1].source.as_ref().unwrap().kind.as_str(),
"sourceforge"
);
assert_eq!(
loaded.apps[1].source.as_ref().unwrap().locator,
"https://sourceforge.net/projects/team-app/files/latest/download"
);
assert_eq!(
loaded.apps[2].source.as_ref().unwrap().kind.as_str(),
"direct-url"
);
assert_eq!(
loaded.apps[2].source.as_ref().unwrap().locator,
"https://example.com/downloads/team-app.AppImage"
);
}
#[test]
fn registry_save_is_atomic_and_cleans_up_temp_file() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let store = RegistryStore::new(registry_path.clone());
store
.save(&upm_core::registry::model::Registry {
version: 1,
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
}],
})
.unwrap();
assert!(registry_path.exists());
assert!(!dir.path().join("registry.toml.tmp").exists());
}
#[test]
fn registry_exclusive_lock_rejects_second_mutator() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
let _guard = store.lock_exclusive().unwrap();
let error = store.lock_exclusive().unwrap_err();
assert!(matches!(
error,
upm_core::registry::store::RegistryStoreError::LockUnavailable
));
}
#[test]
fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
let dir = tempdir().unwrap();
let store = RegistryStore::new(dir.path().join("registry.toml"));
store
.save(&upm_core::registry::model::Registry {
version: 1,
apps: vec![upm_core::domain::app::AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
}],
})
.unwrap();
store
.mutate_exclusive(|registry| {
registry.apps.push(upm_core::domain::app::AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
});
})
.unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.apps.len(), 2);
assert_eq!(loaded.apps[0].stable_id, "bat");
assert_eq!(loaded.apps[1].stable_id, "t3code");
}

View file

@ -0,0 +1,196 @@
use std::path::Path;
use tempfile::tempdir;
use upm_core::app::interaction::{InteractionKind, InteractionRequest};
use upm_core::app::list::build_list_rows;
use upm_core::app::progress::{OperationEvent, OperationStage};
use upm_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
#[test]
fn remove_flow_rejects_unknown_app_names() {
let result = resolve_registered_app("bat", &[]);
assert!(result.is_err());
}
#[test]
fn list_flow_returns_display_rows_for_registered_apps() {
let rows = build_list_rows(&[AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: "sharkdp/bat".to_owned(),
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("0.25.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].stable_id, "bat");
assert_eq!(rows[0].display_name, "Bat");
assert_eq!(rows[0].version.as_deref(), Some("0.25.0"));
assert_eq!(rows[0].source, "sharkdp/bat");
}
#[test]
fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
let apps = [
AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
AppRecord {
stable_id: "bat-nightly".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
];
let error = resolve_registered_app("Bat", &apps).unwrap_err();
assert_eq!(
error,
upm_core::app::remove::ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest {
key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp {
query: "Bat".to_owned(),
matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()],
},
},
}
);
}
#[test]
fn removal_plan_prefers_persisted_install_metadata_paths() {
let app = AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::System,
payload_path: Some("/opt/upm/appimages/bat.AppImage".to_owned()),
desktop_entry_path: Some("/usr/share/applications/upm-bat.desktop".to_owned()),
icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()),
}),
};
let plan = build_removal_plan(&app, Path::new("/home/test"));
assert_eq!(plan.stable_id, "bat");
assert_eq!(
plan.artifact_paths,
vec![
"/opt/upm/appimages/bat.AppImage".to_owned(),
"/usr/share/applications/upm-bat.desktop".to_owned(),
"/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
]
);
}
#[test]
fn removal_plan_falls_back_to_derived_managed_user_paths() {
let app = AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
};
let plan = build_removal_plan(&app, Path::new("/home/test"));
assert_eq!(
plan.artifact_paths,
vec![
"/home/test/.local/lib/upm/appimages/bat.AppImage".to_owned(),
"/home/test/.local/share/applications/upm-bat.desktop".to_owned(),
"/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
]
);
}
#[test]
fn remove_flow_reports_resolution_and_cleanup_events() {
let install_home = tempdir().unwrap();
let app = AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(
install_home
.path()
.join(".local/lib/upm/appimages/bat.AppImage")
.display()
.to_string(),
),
desktop_entry_path: None,
icon_path: None,
}),
};
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let result =
remove_registered_app_with_reporter("bat", &[app], install_home.path(), &mut reporter)
.unwrap();
assert_eq!(result.removed.stable_id, "bat");
assert_eq!(result.removed_paths.len(), 0);
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
..
}
)
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::Finalize,
..
}
)
}));
}

View file

@ -0,0 +1,212 @@
use upm_core::app::search::{
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
};
use upm_core::domain::app::AppRecord;
use upm_core::domain::search::{SearchInstallStatus, SearchQuery};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
#[test]
fn github_fixtures_return_normalized_remote_hits() {
let query = SearchQuery::new("bat");
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
assert_eq!(query.remote_limit, 10);
assert!(results.installed_matches.is_empty());
assert!(results.warnings.is_empty());
assert_eq!(results.remote_hits.len(), 3);
let first = &results.remote_hits[0];
assert_eq!(first.provider_id, "github");
assert_eq!(first.display_name, "sharkdp/bat");
assert_eq!(
first.description.as_deref(),
Some("A cat(1) clone with wings.")
);
assert_eq!(first.source_locator, "https://github.com/sharkdp/bat");
assert_eq!(first.install_query, "sharkdp/bat");
assert_eq!(first.canonical_locator, "sharkdp/bat");
assert_eq!(first.version.as_deref(), Some("1.0.0"));
assert_eq!(first.install_status, SearchInstallStatus::Available);
}
#[test]
fn github_search_respects_limit_and_fixture_order() {
let query = SearchQuery::with_remote_limit("bat", 2);
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
let locators = results
.remote_hits
.iter()
.map(|hit| hit.canonical_locator.as_str())
.collect::<Vec<_>>();
assert_eq!(locators, vec!["sharkdp/bat", "astatine/bat"]);
}
#[test]
fn github_search_ranks_full_name_matches_above_description_only_matches() {
let query = SearchQuery::new("pingdotgg");
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
let locators = results
.remote_hits
.iter()
.map(|hit| hit.canonical_locator.as_str())
.collect::<Vec<_>>();
assert_eq!(locators[0], "pingdotgg/t3code");
assert_eq!(locators, vec!["pingdotgg/t3code"]);
}
#[test]
fn github_search_backfills_description_matches_after_name_matches() {
let query = SearchQuery::with_remote_limit("pingdotgg", 3);
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
let locators = results
.remote_hits
.iter()
.map(|hit| hit.canonical_locator.as_str())
.collect::<Vec<_>>();
assert_eq!(locators, vec!["pingdotgg/t3code"]);
}
#[test]
fn github_search_only_returns_repositories_with_appimage_release_assets() {
let query = SearchQuery::new("pingdotgg");
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
assert!(
results
.remote_hits
.iter()
.all(|hit| hit.canonical_locator == "pingdotgg/t3code")
);
}
#[test]
fn github_name_only_search_excludes_description_only_matches() {
let hits =
search_github_repositories_with("pingdotgg in:name", 10, &FixtureGitHubTransport).unwrap();
let locators = hits
.iter()
.map(|hit| hit.full_name.as_str())
.collect::<Vec<_>>();
assert_eq!(locators, vec!["pingdotgg/t3code"]);
}
#[test]
fn app_search_results_can_carry_local_matches_and_warnings() {
let query = SearchQuery::new("bat");
let installed = vec![AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: Some("1.0.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let provider = FailingProvider;
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
assert!(results.remote_hits.is_empty());
assert_eq!(results.installed_matches.len(), 1);
assert_eq!(results.installed_matches[0].stable_id, "bat");
assert_eq!(results.installed_matches[0].display_name, "Bat");
assert_eq!(results.warnings.len(), 1);
assert_eq!(results.warnings[0].provider_id.as_deref(), Some("github"));
}
#[test]
fn github_search_marks_matching_current_install_as_installed() {
let query = SearchQuery::new("bat");
let installed = vec![installed_github_app("sharkdp/bat", "1.0.0")];
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
let bat = results
.remote_hits
.iter()
.find(|hit| hit.install_query == "sharkdp/bat")
.unwrap();
assert_eq!(
bat.install_status,
SearchInstallStatus::Installed {
installed_version: Some("1.0.0".to_owned()),
}
);
}
#[test]
fn github_search_marks_older_install_as_update_available() {
let query = SearchQuery::new("pingdotgg");
let installed = vec![installed_github_app("pingdotgg/t3code", "0.0.11")];
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
let t3code = results
.remote_hits
.iter()
.find(|hit| hit.install_query == "pingdotgg/t3code")
.unwrap();
assert_eq!(t3code.version.as_deref(), Some("0.0.12"));
assert_eq!(
t3code.install_status,
SearchInstallStatus::UpdateAvailable {
installed_version: Some("0.0.11".to_owned()),
latest_version: Some("0.0.12".to_owned()),
}
);
}
fn installed_github_app(locator: &str, installed_version: &str) -> AppRecord {
AppRecord {
stable_id: locator.replace('/', "-"),
display_name: locator.split('/').next_back().unwrap().to_owned(),
source_input: Some(locator.to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
locator: locator.to_owned(),
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
canonical_locator: Some(locator.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some(installed_version.to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}
}
struct FailingProvider;
impl SearchProvider for FailingProvider {
fn search(
&self,
_query: &SearchQuery,
) -> Result<Vec<upm_core::domain::search::SearchResult>, SearchProviderError> {
Err(SearchProviderError::new("github", "fixture rate limit"))
}
}

View file

@ -0,0 +1,303 @@
use upm_core::app::show::{build_show_result, build_show_result_with};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::show::{ShowResult, ShowResultError};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::domain::update::{
ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind,
UpdateStrategy,
};
use upm_core::source::github::FixtureGitHubTransport;
#[test]
fn exact_installed_match_returns_installed_details() {
let apps = vec![AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
locator: "https://github.com/sharkdp/bat".to_owned(),
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("0.24.0".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: "sharkdp/bat".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: Vec::new(),
}),
metadata: vec![ParsedMetadata {
kind: ParsedMetadataKind::ElectronBuilder,
hints: MetadataHints {
version: Some("0.24.0".to_owned()),
primary_download: Some("https://example.test/bat.AppImage".to_owned()),
checksum: Some("sha256:abcd".to_owned()),
architecture: Some("x86_64".to_owned()),
channel_label: None,
},
warnings: Vec::new(),
confidence: 90,
}],
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some("/tmp/bat.AppImage".to_owned()),
desktop_entry_path: Some("/tmp/upm-bat.desktop".to_owned()),
icon_path: Some("/tmp/upm-bat.png".to_owned()),
}),
}];
let result = build_show_result("legacy-bat", &apps).unwrap();
match result {
ShowResult::Installed(installed) => {
assert_eq!(installed.stable_id, "legacy-bat");
assert_eq!(installed.display_name, "Legacy Bat");
assert_eq!(installed.installed_version.as_deref(), Some("0.24.0"));
assert_eq!(installed.install_scope, Some(InstallScope::User));
assert_eq!(
installed.source.as_ref().unwrap().locator,
"https://github.com/sharkdp/bat"
);
assert_eq!(
installed.tracked_paths.payload_path.as_deref(),
Some("/tmp/bat.AppImage")
);
assert!(installed.update_strategy.is_some());
assert_eq!(installed.metadata.len(), 1);
}
other => panic!("expected installed result, got {other:?}"),
}
}
#[test]
fn installed_source_lineage_matches_before_remote_fallback() {
let apps = vec![AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: Some("sharkdp/bat".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitHub,
locator: "https://github.com/sharkdp/bat".to_owned(),
input_kind: SourceInputKind::RepoShorthand,
normalized_kind: NormalizedSourceKind::GitHubRepository,
canonical_locator: Some("sharkdp/bat".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("0.24.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let result = build_show_result_with("sharkdp/bat", &apps, &FixtureGitHubTransport).unwrap();
match result {
ShowResult::Installed(installed) => {
assert_eq!(installed.stable_id, "legacy-bat");
assert_eq!(installed.source_input.as_deref(), Some("sharkdp/bat"));
}
other => panic!("expected installed result, got {other:?}"),
}
}
#[test]
fn installed_direct_url_show_omits_unresolved_version() {
let apps = vec![AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let result = build_show_result("team-app", &apps).unwrap();
match result {
ShowResult::Installed(installed) => {
assert_eq!(installed.installed_version, None);
assert_eq!(
installed.source.as_ref().unwrap().kind,
SourceKind::DirectUrl
);
}
other => panic!("expected installed result, got {other:?}"),
}
}
#[test]
fn no_installed_match_falls_back_to_remote_resolution() {
let result = build_show_result_with("sharkdp/bat", &[], &FixtureGitHubTransport).unwrap();
match result {
ShowResult::Remote(remote) => {
assert_eq!(remote.source.kind, SourceKind::GitHub);
assert_eq!(
remote.source.canonical_locator.as_deref(),
Some("sharkdp/bat")
);
assert!(remote.artifact.url.ends_with("Bat-1.0.0-x86_64.AppImage"));
assert_eq!(remote.artifact.version.as_deref(), Some("1.0.0"));
assert!(remote.artifact.trusted_checksum.is_some());
assert!(!remote.artifact.selection_reason.is_empty());
assert!(remote.interactions.is_empty());
assert!(remote.warnings.is_empty());
}
other => panic!("expected remote result, got {other:?}"),
}
}
#[test]
fn remote_show_projects_tracking_preference_interaction() {
let result = build_show_result_with(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
&[],
&FixtureGitHubTransport,
)
.unwrap();
match result {
ShowResult::Remote(remote) => {
assert!(remote.interactions.iter().any(|interaction| matches!(
interaction,
upm_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. }
)));
}
other => panic!("expected remote result, got {other:?}"),
}
}
#[test]
fn direct_url_remote_show_omits_unresolved_version() {
let result = build_show_result_with(
"https://example.com/downloads/team-app.AppImage",
&[],
&FixtureGitHubTransport,
)
.unwrap();
match result {
ShowResult::Remote(remote) => {
assert_eq!(remote.source.kind, SourceKind::DirectUrl);
assert_eq!(remote.artifact.version, None);
assert_eq!(
remote.artifact.url,
"https://example.com/downloads/team-app.AppImage"
);
}
other => panic!("expected remote result, got {other:?}"),
}
}
#[test]
fn ambiguous_installed_matches_return_dedicated_error() {
let apps = vec![
AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
];
let error = build_show_result("bat", &apps).unwrap_err();
match error {
ShowResultError::AmbiguousInstalledMatch { matches, .. } => {
assert_eq!(matches.len(), 2);
assert!(matches.iter().any(|item: &String| item.contains("bat")));
assert!(
matches
.iter()
.any(|item: &String| item.contains("legacy-bat"))
);
}
other => panic!("expected ambiguous installed match, got {other:?}"),
}
}
#[test]
fn ambiguous_installed_match_blocks_valid_remote_fallback() {
let apps = vec![
AppRecord {
stable_id: "bat-alpha".to_owned(),
display_name: "sharkdp/bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
AppRecord {
stable_id: "bat-beta".to_owned(),
display_name: "sharkdp/bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
},
];
let error = build_show_result_with("sharkdp/bat", &apps, &FixtureGitHubTransport).unwrap_err();
assert!(matches!(
error,
ShowResultError::AmbiguousInstalledMatch { .. }
));
}
#[test]
fn unsupported_query_stays_distinct_from_no_installable_artifact() {
let unsupported =
build_show_result_with("https://gitlab.com/example", &[], &FixtureGitHubTransport)
.unwrap_err();
let no_artifact = build_show_result_with(
"https://sourceforge.net/projects/team-app/",
&[],
&FixtureGitHubTransport,
)
.unwrap_err();
assert!(matches!(unsupported, ShowResultError::UnsupportedQuery));
assert!(matches!(
no_artifact,
ShowResultError::NoInstallableArtifact { .. }
));
}

View file

@ -0,0 +1,584 @@
use std::fs;
use std::sync::Mutex;
use tempfile::tempdir;
use upm_core::app::add::AddSecurityPolicy;
use upm_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
use upm_core::app::update::{
build_update_plan, execute_updates, execute_updates_with_reporter,
execute_updates_with_reporter_and_policy,
};
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use upm_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use upm_core::integration::paths::managed_appimage_path;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn empty_registry_produces_empty_plan() {
let plan = build_update_plan(&[]).unwrap();
assert!(plan.items.is_empty());
}
#[test]
fn installed_apps_are_carried_into_review_plan() {
let apps = [AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let plan = build_update_plan(&apps).unwrap();
assert_eq!(plan.items.len(), 1);
assert_eq!(plan.items[0].stable_id, "bat");
assert_eq!(plan.items[0].selection_reason, "install-origin-match");
}
#[test]
fn update_plan_uses_alternate_channel_after_preferred_failure() {
let apps = [AppRecord {
stable_id: "t3code".to_owned(),
display_name: "T3 Code".to_owned(),
source_input: Some("pingdotgg/t3code".to_owned()),
source: None,
installed_version: Some("0.0.11".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: "fail://github".to_owned(),
reason: "install-origin-match".to_owned(),
},
alternates: vec![ChannelPreference {
kind: UpdateChannelKind::ElectronBuilder,
locator: "https://example.test/latest-linux.yml".to_owned(),
reason: "metadata-guided".to_owned(),
}],
}),
metadata: Vec::new(),
install: None,
}];
let plan = build_update_plan(&apps).unwrap();
assert_eq!(
plan.items[0].selected_channel.kind.as_str(),
"electron-builder"
);
assert_eq!(plan.items[0].selection_reason, "preferred-channel-failed");
}
#[test]
fn failed_update_keeps_previous_app_record() {
let install_home = tempdir().unwrap();
let previous = AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: None,
source: None,
installed_version: Some("0.9.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.apps, vec![previous]);
assert_eq!(result.updated_count(), 0);
assert_eq!(result.failed_count(), 1);
}
#[test]
fn update_execution_reports_per_app_lifecycle_events() {
let install_home = tempdir().unwrap();
let app = AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: None,
source: None,
installed_version: Some("0.9.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let result = execute_updates_with_reporter(
std::slice::from_ref(&app),
install_home.path(),
&mut reporter,
)
.unwrap();
assert_eq!(result.failed_count(), 1);
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
..
}
)
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::Failed {
stage: OperationStage::ResolveQuery,
..
}
)
}));
}
#[test]
fn update_plan_uses_direct_asset_fallback_for_direct_url_origin() {
let apps = [AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: None,
}];
let plan = build_update_plan(&apps).unwrap();
assert_eq!(
plan.items[0].selected_channel.kind,
UpdateChannelKind::DirectAsset
);
assert_eq!(
plan.items[0].selected_channel.locator,
"https://example.com/downloads/team-app.AppImage"
);
assert_eq!(plan.items[0].selection_reason, "install-origin-match");
}
#[test]
fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "example-team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
source: Some(SourceRef {
kind: SourceKind::GitLab,
locator: "https://gitlab.com/example/team-app".to_owned(),
input_kind: SourceInputKind::GitLabUrl,
normalized_kind: NormalizedSourceKind::GitLab,
canonical_locator: Some("example/team-app".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("latest".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
.to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
}),
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 1);
assert_eq!(result.failed_count(), 0);
assert_eq!(
result.apps[0].source.as_ref().unwrap().kind,
SourceKind::GitLab
);
assert_eq!(
result.apps[0].source.as_ref().unwrap().locator,
"https://gitlab.com/example/team-app"
);
assert_eq!(
result.apps[0]
.source
.as_ref()
.unwrap()
.canonical_locator
.as_deref(),
Some("example/team-app")
);
}
#[test]
fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some(
"https://sourceforge.net/projects/team-app/files/releases/beta/download".to_owned(),
),
source: Some(SourceRef {
kind: SourceKind::SourceForge,
locator: "https://sourceforge.net/projects/team-app/files/releases/beta/download"
.to_owned(),
input_kind: SourceInputKind::SourceForgeUrl,
normalized_kind: NormalizedSourceKind::SourceForge,
canonical_locator: Some("team-app".to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
}),
installed_version: Some("latest".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://sourceforge.net/projects/team-app/files/releases/beta/download"
.to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
}),
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 1);
assert_eq!(result.failed_count(), 0);
assert_eq!(
result.apps[0].source.as_ref().unwrap().kind,
SourceKind::SourceForge
);
assert_eq!(
result.apps[0].source.as_ref().unwrap().locator,
"https://sourceforge.net/projects/team-app/files/releases/beta/download"
);
assert_eq!(
result.apps[0]
.source
.as_ref()
.unwrap()
.canonical_locator
.as_deref(),
Some("team-app")
);
}
#[test]
fn direct_http_updates_are_rejected_by_default() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "http://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 0);
assert_eq!(result.failed_count(), 1);
assert!(matches!(
&result.items[0].status,
upm_core::domain::update::UpdateExecutionStatus::Failed { reason }
if reason.contains("InsecureHttpSource")
));
}
#[test]
fn direct_http_updates_can_be_allowed_by_policy() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "http://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates_with_reporter_and_policy(
std::slice::from_ref(&previous),
install_home.path(),
&mut NoopReporter,
AddSecurityPolicy {
allow_http_user_sources: true,
},
)
.unwrap();
assert_eq!(result.updated_count(), 1);
assert_eq!(result.failed_count(), 0);
}
#[test]
fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs() {
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "team-app".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some(
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download"
.to_owned(),
),
source: Some(SourceRef {
kind: SourceKind::SourceForge,
locator: "https://sourceforge.net/projects/team-app/files/releases".to_owned(),
input_kind: SourceInputKind::SourceForgeUrl,
normalized_kind: NormalizedSourceKind::SourceForge,
canonical_locator: Some("team-app".to_owned()),
requested_tag: None,
requested_asset_name: Some("team-app-1.0.0.AppImage".to_owned()),
tracks_latest: true,
}),
installed_version: Some("latest".to_owned()),
update_strategy: Some(UpdateStrategy {
preferred: ChannelPreference {
kind: UpdateChannelKind::DirectAsset,
locator: "https://sourceforge.net/projects/team-app/files/releases".to_owned(),
reason: "provider-release".to_owned(),
},
alternates: Vec::new(),
}),
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 1);
assert_eq!(result.failed_count(), 0);
assert_eq!(
result.apps[0].source.as_ref().unwrap().locator,
"https://sourceforge.net/projects/team-app/files/releases"
);
assert_eq!(
result.apps[0]
.source
.as_ref()
.unwrap()
.requested_asset_name
.as_deref(),
None
);
}
#[test]
fn failed_update_restores_previous_payload_contents() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::set_var("DISPLAY", ":99");
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
}
let stable_id = "url-example.com-downloads-team-app.appimage";
let payload_path = managed_appimage_path(install_home.path(), InstallScope::User, stable_id);
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();
let desktop_root = install_home.path().join(".local/share/applications");
fs::create_dir_all(desktop_root.parent().unwrap()).unwrap();
fs::write(&desktop_root, b"blocker").unwrap();
let previous = AppRecord {
stable_id: stable_id.to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(payload_path.display().to_string()),
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.failed_count(), 1);
assert_eq!(result.apps, vec![previous]);
assert_eq!(fs::read(&payload_path).unwrap(), b"previous-payload");
}
#[test]
fn successful_update_removes_rollback_staging_directory() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
std::env::remove_var("DISPLAY");
std::env::remove_var("WAYLAND_DISPLAY");
std::env::remove_var("XDG_CURRENT_DESKTOP");
}
let stable_id = "url-example.com-downloads-team-app.appimage";
let payload_path = managed_appimage_path(install_home.path(), InstallScope::User, stable_id);
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
fs::write(&payload_path, b"previous-payload").unwrap();
let previous = AppRecord {
stable_id: stable_id.to_owned(),
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(payload_path.display().to_string()),
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 1);
assert!(
!install_home
.path()
.join(".local/share/upm/rollback")
.exists()
);
}