feat: harden download and install security
This commit is contained in:
parent
f8ffb95376
commit
af13e98eb3
33 changed files with 1517 additions and 46 deletions
|
|
@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||
[dependencies]
|
||||
base64.workspace = true
|
||||
fs2.workspace = true
|
||||
md5.workspace = true
|
||||
quick-xml.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::adapters::traits::{
|
|||
use crate::app::query::resolve_query;
|
||||
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
|
||||
use crate::source::appimagehub::{
|
||||
AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
|
||||
AppImageHubError, AppImageHubTransport, resolve_appimagehub_item, resolve_appimagehub_item_with,
|
||||
};
|
||||
|
||||
pub struct AppImageHubAdapter;
|
||||
|
|
@ -20,7 +20,7 @@ impl AppImageHubAdapter {
|
|||
}
|
||||
|
||||
let resolved = resolve_appimagehub_item_with(source, transport)
|
||||
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?;
|
||||
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?;
|
||||
|
||||
match resolved {
|
||||
Some(item) => Ok(AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
|
|
@ -64,7 +64,7 @@ impl SourceAdapter for AppImageHubAdapter {
|
|||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
match resolve_appimagehub_item(source)
|
||||
.map_err(|error| AdapterError::ResolutionFailed(format!("{error:?}")))?
|
||||
.map_err(|error| AdapterError::ResolutionFailed(render_appimagehub_error(&error)))?
|
||||
{
|
||||
Some(item) => Ok(AdapterResolution {
|
||||
source: item.source,
|
||||
|
|
@ -87,3 +87,19 @@ impl SourceAdapter for AppImageHubAdapter {
|
|||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,20 @@ use crate::update::ranking::{rank_channels, select_artifact, to_preference};
|
|||
|
||||
const FIXTURE_MODE_ENV: &str = "AIM_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(query, transport.as_ref(), &mut reporter)
|
||||
build_add_plan_with_reporter_and_policy(
|
||||
query,
|
||||
transport.as_ref(),
|
||||
&mut reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
|
||||
|
|
@ -45,19 +55,39 @@ pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
|
|||
transport: &T,
|
||||
) -> Result<AddPlan, BuildAddPlanError> {
|
||||
let mut reporter = NoopReporter;
|
||||
build_add_plan_with_reporter(query, transport, &mut reporter)
|
||||
build_add_plan_with_reporter_and_policy(
|
||||
query,
|
||||
transport,
|
||||
&mut reporter,
|
||||
AddSecurityPolicy::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(
|
||||
query,
|
||||
transport,
|
||||
reporter,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
}
|
||||
|
||||
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> {
|
||||
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();
|
||||
|
|
@ -154,6 +184,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
|
||||
|
|
@ -197,6 +228,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolved_item.version.clone(),
|
||||
arch: resolved_item.download.arch.clone(),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: resolved_item.download.md5sum.clone(),
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -230,6 +262,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "exact-input".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -270,6 +303,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: resolution.release.version.clone(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -300,6 +334,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
|
|||
version: "unresolved".to_owned(),
|
||||
arch: None,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
selection_reason: "heuristic-match".to_owned(),
|
||||
};
|
||||
let strategy = UpdateStrategy {
|
||||
|
|
@ -464,6 +499,7 @@ pub fn install_app_with_reporter(
|
|||
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(),
|
||||
|
|
@ -548,6 +584,9 @@ pub struct InstalledApp {
|
|||
#[derive(Debug)]
|
||||
pub enum BuildAddPlanError {
|
||||
Query(ResolveQueryError),
|
||||
InsecureHttpSource {
|
||||
locator: String,
|
||||
},
|
||||
Adapter(&'static str, crate::adapters::traits::AdapterError),
|
||||
GitHubDiscovery(GitHubDiscoveryError),
|
||||
NoInstallableArtifact {
|
||||
|
|
@ -571,6 +610,19 @@ pub enum InstallAppError {
|
|||
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,
|
||||
|
|
@ -685,12 +737,32 @@ fn is_retryable_download_error(error: &InstallAppError) -> bool {
|
|||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ pub fn resolve_identity(
|
|||
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 stable_id = normalize_identifier(explicit_id)?;
|
||||
let display_name = explicit_name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| explicit_id.to_owned());
|
||||
.map(sanitize_display_name)
|
||||
.unwrap_or_else(|| sanitize_display_name(explicit_id));
|
||||
|
||||
return Ok(AppIdentity {
|
||||
stable_id,
|
||||
|
|
@ -29,8 +29,8 @@ pub fn resolve_identity(
|
|||
|
||||
if let Some(explicit_name) = explicit_name.filter(|value| !value.trim().is_empty()) {
|
||||
return Ok(AppIdentity {
|
||||
stable_id: normalize_identifier(explicit_name),
|
||||
display_name: explicit_name.to_owned(),
|
||||
stable_id: normalize_identifier(explicit_name)?,
|
||||
display_name: sanitize_display_name(explicit_name),
|
||||
confidence: IdentityConfidence::NeedsConfirmation,
|
||||
});
|
||||
}
|
||||
|
|
@ -41,8 +41,8 @@ pub fn resolve_identity(
|
|||
{
|
||||
let display_name = repo.split('/').next_back().unwrap_or(&repo).to_owned();
|
||||
return Ok(AppIdentity {
|
||||
stable_id: normalize_identifier(&repo),
|
||||
display_name,
|
||||
stable_id: normalize_identifier(&repo)?,
|
||||
display_name: sanitize_display_name(&display_name),
|
||||
confidence: IdentityConfidence::Confident,
|
||||
});
|
||||
}
|
||||
|
|
@ -51,8 +51,8 @@ pub fn resolve_identity(
|
|||
&& fallback == IdentityFallback::AllowRawUrl
|
||||
{
|
||||
return Ok(AppIdentity {
|
||||
stable_id: normalize_url_identifier(source_url),
|
||||
display_name: source_url.to_owned(),
|
||||
stable_id: normalize_url_identifier(source_url)?,
|
||||
display_name: sanitize_display_name(source_url),
|
||||
confidence: IdentityConfidence::RawUrlFallback,
|
||||
});
|
||||
}
|
||||
|
|
@ -63,10 +63,11 @@ pub fn resolve_identity(
|
|||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum ResolveIdentityError {
|
||||
Unresolved,
|
||||
InvalidStableId,
|
||||
}
|
||||
|
||||
fn normalize_identifier(value: &str) -> String {
|
||||
value
|
||||
fn normalize_identifier(value: &str) -> Result<String, ResolveIdentityError> {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
|
|
@ -76,15 +77,41 @@ fn normalize_identifier(value: &str) -> String {
|
|||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_owned()
|
||||
.to_owned();
|
||||
|
||||
if normalized.is_empty() || normalized.contains("..") {
|
||||
return Err(ResolveIdentityError::InvalidStableId);
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_url_identifier(url: &str) -> String {
|
||||
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://");
|
||||
|
||||
format!("url-{}", normalize_identifier(trimmed))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ 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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::add::{build_add_plan, install_app_with_reporter};
|
||||
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,
|
||||
};
|
||||
|
|
@ -23,13 +25,32 @@ pub fn execute_updates(
|
|||
install_home: &Path,
|
||||
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
|
||||
let mut reporter = NoopReporter;
|
||||
execute_updates_with_reporter(apps, install_home, &mut reporter)
|
||||
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,
|
||||
|
|
@ -43,7 +64,7 @@ pub fn execute_updates_with_reporter(
|
|||
kind: OperationKind::UpdateItem,
|
||||
label: app.stable_id.clone(),
|
||||
});
|
||||
match execute_update(app, install_home, reporter) {
|
||||
match execute_update(app, install_home, reporter, policy) {
|
||||
Ok(updated) => {
|
||||
let warnings = updated
|
||||
.warnings
|
||||
|
|
@ -166,6 +187,7 @@ 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,
|
||||
|
|
@ -184,14 +206,17 @@ fn execute_update(
|
|||
.as_ref()
|
||||
.map(|install| install.scope)
|
||||
.unwrap_or(InstallScope::User);
|
||||
let plan = build_add_plan(&query).map_err(|error| {
|
||||
let reason = format!("failed to build update plan: {error:?}");
|
||||
reporter.report(&OperationEvent::Failed {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
reason
|
||||
})?;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ pub enum ShowResultError {
|
|||
matches: Vec<String>,
|
||||
},
|
||||
UnsupportedQuery,
|
||||
InsecureHttpSource,
|
||||
NoInstallableArtifact {
|
||||
source: SourceSummary,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ pub struct ArtifactCandidate {
|
|||
pub version: String,
|
||||
pub arch: Option<String>,
|
||||
pub trusted_checksum: Option<String>,
|
||||
pub weak_checksum_md5: Option<String>,
|
||||
pub selection_reason: String,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub enum PayloadInstallError {
|
|||
InvalidArtifact,
|
||||
ChecksumMismatch,
|
||||
InvalidTrustedChecksum,
|
||||
InvalidWeakChecksum,
|
||||
WeakChecksumMismatch,
|
||||
Io(io::Error),
|
||||
DesktopIntegration(io::Error),
|
||||
}
|
||||
|
|
@ -46,6 +48,13 @@ impl fmt::Display for PayloadInstallError {
|
|||
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}")
|
||||
|
|
@ -74,6 +83,7 @@ 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,
|
||||
}
|
||||
|
|
@ -124,6 +134,7 @@ 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)?;
|
||||
|
||||
|
|
@ -197,3 +208,30 @@ fn verify_trusted_checksum(
|
|||
|
||||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -14,7 +15,11 @@ pub fn refresh_integration(
|
|||
helpers.update_desktop_database_path.as_ref(),
|
||||
desktop_entry_path.and_then(Path::parent),
|
||||
) {
|
||||
if let Err(error) = Command::new(helper).arg(path).status() {
|
||||
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 {
|
||||
|
|
@ -27,7 +32,16 @@ pub fn refresh_integration(
|
|||
helpers.gtk_update_icon_cache_path.as_ref(),
|
||||
icon_path.map(icon_theme_root),
|
||||
) {
|
||||
if let Err(error) = Command::new(helper).args(["-f", "-t"]).arg(path).status() {
|
||||
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 {
|
||||
|
|
@ -46,3 +60,43 @@ fn icon_theme_root(icon_path: &Path) -> PathBuf {
|
|||
|
||||
icon_path.parent().unwrap_or(icon_path).to_path_buf()
|
||||
}
|
||||
|
||||
fn audit_helper(helper: &Path, args: &[&Path]) {
|
||||
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
let rendered_args = args
|
||||
.iter()
|
||||
.map(|arg| arg.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
eprintln!(
|
||||
"[aim] helper exec: {}{}{}",
|
||||
helper.display(),
|
||||
if rendered_args.is_empty() { "" } else { " " },
|
||||
rendered_args
|
||||
);
|
||||
}
|
||||
|
||||
fn audit_helper_status(helper: &Path, code: Option<i32>) {
|
||||
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
match code {
|
||||
Some(code) => eprintln!("[aim] helper exit: {} code={code}", helper.display()),
|
||||
None => eprintln!(
|
||||
"[aim] helper exit: {} terminated by signal",
|
||||
helper.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_helper_failure(helper: &Path, error: &str) {
|
||||
if env::var("AIM_DEBUG_EXTERNAL_HELPERS").ok().as_deref() != Some("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("[aim] helper failure: {} error={error}", helper.display());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ pub fn resolve_appimagehub_item_with<T: AppImageHubTransport + ?Sized>(
|
|||
return Ok(None);
|
||||
};
|
||||
|
||||
validate_download_url(&download.url)?;
|
||||
|
||||
Ok(Some(ResolvedAppImageHubItem {
|
||||
source: source.clone(),
|
||||
title: item.name.clone(),
|
||||
|
|
@ -190,6 +192,7 @@ impl AppImageHubTransport for FixtureAppImageHubTransport {
|
|||
#[derive(Debug)]
|
||||
pub enum AppImageHubError {
|
||||
FixtureItemMissing(String),
|
||||
InsecureDownloadUrl(String),
|
||||
Parse(quick_xml::DeError),
|
||||
Transport(reqwest::Error),
|
||||
UnsupportedSource(String),
|
||||
|
|
@ -300,6 +303,14 @@ fn content_to_item(content: OcsContent) -> AppImageHubItem {
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
|
@ -413,6 +424,12 @@ fn resolved_version(item: &AppImageHubItem, download: &AppImageHubDownload) -> S
|
|||
}
|
||||
|
||||
fn fixture_item(id: &str) -> Option<AppImageHubItem> {
|
||||
let insecure_http = env::var("AIM_APPIMAGEHUB_FIXTURE_INSECURE_HTTP")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some("1");
|
||||
let bad_md5 = env::var("AIM_APPIMAGEHUB_FIXTURE_BAD_MD5").ok().as_deref() == Some("1");
|
||||
|
||||
match id {
|
||||
"2338455" => Some(AppImageHubItem {
|
||||
id: "2338455".to_owned(),
|
||||
|
|
@ -427,12 +444,20 @@ fn fixture_item(id: &str) -> Option<AppImageHubItem> {
|
|||
"release-stable".to_owned(),
|
||||
],
|
||||
downloads: vec![AppImageHubDownload {
|
||||
url: "https://files06.pling.com/api/files/download/firefox-x86-64.AppImage"
|
||||
.to_owned(),
|
||||
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("1befdc026535be03a6001f33b11ef91d".to_owned()),
|
||||
md5sum: Some(if bad_md5 {
|
||||
"00000000000000000000000000000000".to_owned()
|
||||
} else {
|
||||
"2a685cf45213d5a2a243273fa68dafa6".to_owned()
|
||||
}),
|
||||
version: None,
|
||||
}],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ pub fn select_artifact(
|
|||
.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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ fn install_succeeds_with_valid_trusted_checksum() {
|
|||
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(),
|
||||
})
|
||||
|
|
@ -39,6 +40,7 @@ fn install_succeeds_without_trusted_checksum() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
@ -57,6 +59,7 @@ fn install_fails_before_commit_when_trusted_checksum_mismatches() {
|
|||
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(),
|
||||
})
|
||||
|
|
@ -77,6 +80,7 @@ fn malformed_trusted_checksum_fails_before_commit() {
|
|||
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(),
|
||||
})
|
||||
|
|
@ -87,6 +91,46 @@ fn malformed_trusted_checksum_fails_before_commit() {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ fn install_commits_from_staged_payload_path() {
|
|||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,3 +29,19 @@ fn explicit_id_is_treated_as_confident() {
|
|||
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,
|
||||
aim_core::app::identity::ResolveIdentityError::InvalidStableId
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fn integration_failure_removes_new_payload_and_generated_files() {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
|
|||
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("aim-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
@ -83,6 +84,7 @@ fn install_executes_refresh_helpers_when_available() {
|
|||
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("aim-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
@ -124,6 +126,7 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
|
|||
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("aim-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
@ -240,6 +243,46 @@ fn install_app_reports_operation_stages_in_order() {
|
|||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_app_sanitizes_desktop_entry_display_names() {
|
||||
let root = tempdir().unwrap();
|
||||
let mut reporter = Vec::new();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_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();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
use aim_core::app::progress::{OperationEvent, OperationStage};
|
||||
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
|
||||
use aim_core::app::add::AddSecurityPolicy;
|
||||
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
|
||||
use aim_core::app::update::{
|
||||
build_update_plan, execute_updates, execute_updates_with_reporter,
|
||||
execute_updates_with_reporter_and_policy,
|
||||
};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||
|
|
@ -311,6 +315,103 @@ fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin
|
|||
);
|
||||
}
|
||||
|
||||
#[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("AIM_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,
|
||||
aim_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("AIM_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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue