feat: harden download and install security

This commit is contained in:
stoorps 2026-03-21 20:48:53 +00:00
parent f8ffb95376
commit af13e98eb3
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
33 changed files with 1517 additions and 46 deletions

View file

@ -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}")
}
}
}

View file

@ -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()

View file

@ -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
}
}

View file

@ -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),
},

View file

@ -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 {

View file

@ -94,6 +94,7 @@ pub enum ShowResultError {
matches: Vec<String>,
},
UnsupportedQuery,
InsecureHttpSource,
NoInstallableArtifact {
source: SourceSummary,
},

View file

@ -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,
}

View file

@ -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(())
}

View file

@ -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());
}

View file

@ -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,
}],
}),

View file

@ -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(),
}
}