refactor: rename aim to upm and extract appimage module
This commit is contained in:
parent
af13e98eb3
commit
863c57e473
117 changed files with 2622 additions and 887 deletions
22
crates/upm-core/Cargo.toml
Normal file
22
crates/upm-core/Cargo.toml
Normal 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
|
||||
105
crates/upm-core/src/adapters/appimagehub.rs
Normal file
105
crates/upm-core/src/adapters/appimagehub.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/upm-core/src/adapters/direct_url.rs
Normal file
44
crates/upm-core/src/adapters/direct_url.rs
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
59
crates/upm-core/src/adapters/github.rs
Normal file
59
crates/upm-core/src/adapters/github.rs
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
132
crates/upm-core/src/adapters/gitlab.rs
Normal file
132
crates/upm-core/src/adapters/gitlab.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
19
crates/upm-core/src/adapters/mod.rs
Normal file
19
crates/upm-core/src/adapters/mod.rs
Normal 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)
|
||||
}
|
||||
266
crates/upm-core/src/adapters/sourceforge.rs
Normal file
266
crates/upm-core/src/adapters/sourceforge.rs
Normal 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]))
|
||||
}
|
||||
37
crates/upm-core/src/adapters/test_support.rs
Normal file
37
crates/upm-core/src/adapters/test_support.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
72
crates/upm-core/src/adapters/traits.rs
Normal file
72
crates/upm-core/src/adapters/traits.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
24
crates/upm-core/src/adapters/zsync.rs
Normal file
24
crates/upm-core/src/adapters/zsync.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
818
crates/upm-core/src/app/add.rs
Normal file
818
crates/upm-core/src/app/add.rs
Normal 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)
|
||||
}
|
||||
117
crates/upm-core/src/app/identity.rs
Normal file
117
crates/upm-core/src/app/identity.rs
Normal 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
|
||||
}
|
||||
}
|
||||
20
crates/upm-core/src/app/interaction.rs
Normal file
20
crates/upm-core/src/app/interaction.rs
Normal 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>,
|
||||
},
|
||||
}
|
||||
25
crates/upm-core/src/app/list.rs
Normal file
25
crates/upm-core/src/app/list.rs
Normal 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()
|
||||
}
|
||||
12
crates/upm-core/src/app/mod.rs
Normal file
12
crates/upm-core/src/app/mod.rs
Normal 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;
|
||||
68
crates/upm-core/src/app/progress.rs
Normal file
68
crates/upm-core/src/app/progress.rs
Normal 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) {}
|
||||
}
|
||||
24
crates/upm-core/src/app/providers.rs
Normal file
24
crates/upm-core/src/app/providers.rs
Normal 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>,
|
||||
}
|
||||
13
crates/upm-core/src/app/query.rs
Normal file
13
crates/upm-core/src/app/query.rs
Normal 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,
|
||||
}
|
||||
197
crates/upm-core/src/app/remove.rs
Normal file
197
crates/upm-core/src/app/remove.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
28
crates/upm-core/src/app/scope.rs
Normal file
28
crates/upm-core/src/app/scope.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
349
crates/upm-core/src/app/search.rs
Normal file
349
crates/upm-core/src/app/search.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
281
crates/upm-core/src/app/show.rs
Normal file
281
crates/upm-core/src/app/show.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
383
crates/upm-core/src/app/update.rs
Normal file
383
crates/upm-core/src/app/update.rs
Normal 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,
|
||||
}
|
||||
51
crates/upm-core/src/domain/app.rs
Normal file
51
crates/upm-core/src/domain/app.rs
Normal 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>,
|
||||
}
|
||||
5
crates/upm-core/src/domain/mod.rs
Normal file
5
crates/upm-core/src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod app;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod source;
|
||||
pub mod update;
|
||||
68
crates/upm-core/src/domain/search.rs
Normal file
68
crates/upm-core/src/domain/search.rs
Normal 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>,
|
||||
}
|
||||
126
crates/upm-core/src/domain/show.rs
Normal file
126
crates/upm-core/src/domain/show.rs
Normal 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,
|
||||
}
|
||||
117
crates/upm-core/src/domain/source.rs
Normal file
117
crates/upm-core/src/domain/source.rs
Normal 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
|
||||
}
|
||||
142
crates/upm-core/src/domain/update.rs
Normal file
142
crates/upm-core/src/domain/update.rs
Normal 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 },
|
||||
}
|
||||
52
crates/upm-core/src/integration/desktop.rs
Normal file
52
crates/upm-core/src/integration/desktop.rs
Normal 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())
|
||||
}
|
||||
237
crates/upm-core/src/integration/install.rs
Normal file
237
crates/upm-core/src/integration/install.rs
Normal 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(())
|
||||
}
|
||||
5
crates/upm-core/src/integration/mod.rs
Normal file
5
crates/upm-core/src/integration/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod desktop;
|
||||
pub mod install;
|
||||
pub mod paths;
|
||||
pub mod policy;
|
||||
pub mod refresh;
|
||||
40
crates/upm-core/src/integration/paths.rs
Normal file
40
crates/upm-core/src/integration/paths.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
71
crates/upm-core/src/integration/policy.rs
Normal file
71
crates/upm-core/src/integration/policy.rs
Normal 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
102
crates/upm-core/src/integration/refresh.rs
Normal file
102
crates/upm-core/src/integration/refresh.rs
Normal 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());
|
||||
}
|
||||
11
crates/upm-core/src/lib.rs
Normal file
11
crates/upm-core/src/lib.rs
Normal 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};
|
||||
24
crates/upm-core/src/metadata/document.rs
Normal file
24
crates/upm-core/src/metadata/document.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
30
crates/upm-core/src/metadata/electron_builder.rs
Normal file
30
crates/upm-core/src/metadata/electron_builder.rs
Normal 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)
|
||||
}
|
||||
7
crates/upm-core/src/metadata/mod.rs
Normal file
7
crates/upm-core/src/metadata/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod document;
|
||||
mod electron_builder;
|
||||
mod parser;
|
||||
mod zsync;
|
||||
|
||||
pub use document::MetadataDocument;
|
||||
pub use parser::parse_document;
|
||||
22
crates/upm-core/src/metadata/parser.rs
Normal file
22
crates/upm-core/src/metadata/parser.rs
Normal 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 {}
|
||||
38
crates/upm-core/src/metadata/zsync.rs
Normal file
38
crates/upm-core/src/metadata/zsync.rs
Normal 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())
|
||||
}
|
||||
88
crates/upm-core/src/platform/capabilities.rs
Normal file
88
crates/upm-core/src/platform/capabilities.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
75
crates/upm-core/src/platform/distro.rs
Normal file
75
crates/upm-core/src/platform/distro.rs
Normal 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))
|
||||
}
|
||||
106
crates/upm-core/src/platform/mod.rs
Normal file
106
crates/upm-core/src/platform/mod.rs
Normal 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
|
||||
}
|
||||
2
crates/upm-core/src/registry/mod.rs
Normal file
2
crates/upm-core/src/registry/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod model;
|
||||
pub mod store;
|
||||
14
crates/upm-core/src/registry/model.rs
Normal file
14
crates/upm-core/src/registry/model.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
118
crates/upm-core/src/registry/store.rs
Normal file
118
crates/upm-core/src/registry/store.rs
Normal 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(®istry)?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
516
crates/upm-core/src/source/appimagehub.rs
Normal file
516
crates/upm-core/src/source/appimagehub.rs
Normal 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()
|
||||
}
|
||||
579
crates/upm-core/src/source/github.rs
Normal file
579
crates/upm-core/src/source/github.rs
Normal 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("-")
|
||||
}
|
||||
445
crates/upm-core/src/source/input.rs
Normal file
445
crates/upm-core/src/source/input.rs
Normal 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(':')
|
||||
}
|
||||
2
crates/upm-core/src/source/mod.rs
Normal file
2
crates/upm-core/src/source/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod github;
|
||||
pub mod input;
|
||||
93
crates/upm-core/src/update/channels.rs
Normal file
93
crates/upm-core/src/update/channels.rs
Normal 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
|
||||
}
|
||||
2
crates/upm-core/src/update/mod.rs
Normal file
2
crates/upm-core/src/update/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod channels;
|
||||
pub mod ranking;
|
||||
103
crates/upm-core/src/update/ranking.rs
Normal file
103
crates/upm-core/src/update/ranking.rs
Normal 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()
|
||||
}
|
||||
383
crates/upm-core/tests/adapter_contract.rs
Normal file
383
crates/upm-core/tests/adapter_contract.rs
Normal 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");
|
||||
}
|
||||
14
crates/upm-core/tests/adapter_smoke.rs
Normal file
14
crates/upm-core/tests/adapter_smoke.rs
Normal 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"));
|
||||
}
|
||||
139
crates/upm-core/tests/checksum_verification.rs
Normal file
139
crates/upm-core/tests/checksum_verification.rs
Normal 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
|
||||
}
|
||||
181
crates/upm-core/tests/download_pipeline.rs
Normal file
181
crates/upm-core/tests/download_pipeline.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
3
crates/upm-core/tests/fixtures/example.zsync
vendored
Normal file
3
crates/upm-core/tests/fixtures/example.zsync
vendored
Normal 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
|
||||
3
crates/upm-core/tests/fixtures/latest-linux.yml
vendored
Normal file
3
crates/upm-core/tests/fixtures/latest-linux.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version: 0.0.11
|
||||
path: T3-Code-0.0.11-x86_64.AppImage
|
||||
sha512: example-sha
|
||||
88
crates/upm-core/tests/github_add_flow.rs
Normal file
88
crates/upm-core/tests/github_add_flow.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
44
crates/upm-core/tests/github_source_discovery.rs
Normal file
44
crates/upm-core/tests/github_source_discovery.rs
Normal 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);
|
||||
}
|
||||
47
crates/upm-core/tests/identity_resolution.rs
Normal file
47
crates/upm-core/tests/identity_resolution.rs
Normal 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
|
||||
);
|
||||
}
|
||||
136
crates/upm-core/tests/install_failures.rs
Normal file
136
crates/upm-core/tests/install_failures.rs
Normal 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");
|
||||
}
|
||||
611
crates/upm-core/tests/install_integration.rs
Normal file
611
crates/upm-core/tests/install_integration.rs
Normal 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);
|
||||
}
|
||||
28
crates/upm-core/tests/install_paths.rs
Normal file
28
crates/upm-core/tests/install_paths.rs
Normal 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"));
|
||||
}
|
||||
33
crates/upm-core/tests/install_payload.rs
Normal file
33
crates/upm-core/tests/install_payload.rs
Normal 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);
|
||||
}
|
||||
49
crates/upm-core/tests/install_policy.rs
Normal file
49
crates/upm-core/tests/install_policy.rs
Normal 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);
|
||||
}
|
||||
8
crates/upm-core/tests/install_scope.rs
Normal file
8
crates/upm-core/tests/install_scope.rs
Normal 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);
|
||||
}
|
||||
11
crates/upm-core/tests/metadata_contract.rs
Normal file
11
crates/upm-core/tests/metadata_contract.rs
Normal 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());
|
||||
}
|
||||
15
crates/upm-core/tests/metadata_electron_builder.rs
Normal file
15
crates/upm-core/tests/metadata_electron_builder.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
12
crates/upm-core/tests/metadata_zsync.rs
Normal file
12
crates/upm-core/tests/metadata_zsync.rs
Normal 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());
|
||||
}
|
||||
52
crates/upm-core/tests/platform_detection.rs
Normal file
52
crates/upm-core/tests/platform_detection.rs
Normal 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(>k_update_icon_cache, "#!/bin/sh\n").unwrap();
|
||||
fs::set_permissions(&update_desktop_database, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::set_permissions(>k_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);
|
||||
}
|
||||
159
crates/upm-core/tests/provider_registry.rs
Normal file
159
crates/upm-core/tests/provider_registry.rs
Normal 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,
|
||||
®istry,
|
||||
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,
|
||||
®istry,
|
||||
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")
|
||||
);
|
||||
}
|
||||
419
crates/upm-core/tests/query_resolution.rs
Normal file
419
crates/upm-core/tests/query_resolution.rs
Normal 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);
|
||||
}
|
||||
288
crates/upm-core/tests/registry_roundtrip.rs
Normal file
288
crates/upm-core/tests/registry_roundtrip.rs
Normal 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(®istry).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(®istry).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(®istry).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");
|
||||
}
|
||||
196
crates/upm-core/tests/remove_flow.rs
Normal file
196
crates/upm-core/tests/remove_flow.rs
Normal 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,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
}
|
||||
212
crates/upm-core/tests/search_github.rs
Normal file
212
crates/upm-core/tests/search_github.rs
Normal 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"))
|
||||
}
|
||||
}
|
||||
303
crates/upm-core/tests/show_resolution.rs
Normal file
303
crates/upm-core/tests/show_resolution.rs
Normal 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 { .. }
|
||||
));
|
||||
}
|
||||
584
crates/upm-core/tests/update_planning.rs
Normal file
584
crates/upm-core/tests/update_planning.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue