use std::{
collections::HashMap,
ffi::OsStr,
fs,
io::Read,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use base64::Engine;
use fs2::FileExt;
use hdiff_update_core::{
default_platform, download_to_file, sha256_file, write_json_file_atomic, DownloadEvent,
HttpHeader,
};
use minisign_verify::{PublicKey, Signature};
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{ipc::Channel, AppHandle, Manager, Runtime, State};
use url::Url;
use crate::file_update::{FileUpdateCommandError, FileUpdateHelperConfig};
#[cfg(windows)]
use std::{
ffi::{c_void, OsString},
os::windows::{ffi::OsStrExt, process::CommandExt},
process::{Child, Command},
thread,
time::Duration,
};
#[cfg(windows)]
use windows_sys::Win32::{
Foundation::{
CloseHandle, LocalFree, ERROR_ALREADY_EXISTS, ERROR_CANCELLED, ERROR_ELEVATION_REQUIRED,
HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
Security::{
Authorization::{ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1},
SetFileSecurityW, DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION,
OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
SECURITY_ATTRIBUTES,
},
Storage::FileSystem::{CreateDirectoryW, SYNCHRONIZE},
System::Threading::{
GetExitCodeProcess, GetProcessId, OpenProcess, WaitForSingleObject,
PROCESS_QUERY_LIMITED_INFORMATION,
},
UI::{
Shell::{ShellExecuteExW, SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW},
WindowsAndMessaging::SW_SHOWNORMAL,
},
};
const FULL_UPDATE_ROOT: &str = "full-updates";
const TRANSACTION_SCHEMA_VERSION: u32 = 2;
const PROTECTED_PLAN_SCHEMA_VERSION: u32 = 1;
const MAX_INSTALLER_BYTES: u64 = 1024 * 1024 * 1024;
const MAX_AUTOMATIC_LAUNCH_ATTEMPTS: u32 = 2;
const ACTIVE_LAUNCH_GRACE_MS: u64 = 15_000;
const PROTECTED_PREPARED_GRACE_MS: u64 = 2 * 60 * 1000;
const PROTECTED_INSTALLING_GRACE_MS: u64 = 32 * 60 * 1000;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[cfg(windows)]
const FULL_SUPERVISOR_ARGUMENT: &str = "--hdiff-full-update-supervisor";
#[cfg(windows)]
const FULL_ELEVATED_BOOTSTRAP_ARGUMENT: &str = "--hdiff-full-update-elevated-bootstrap";
#[cfg(windows)]
const FULL_ELEVATED_WORKER_ARGUMENT: &str = "--hdiff-full-update-elevated-worker";
#[cfg(windows)]
const FULL_CLEANUP_ARGUMENT: &str = "--hdiff-full-update-cleanup";
#[cfg(windows)]
const CLEANUP_FULL: &str = "full";
#[cfg(windows)]
const CLEANUP_HELPER: &str = "helper";
#[cfg(windows)]
const PROTECTED_PLAN_FILE: &str = "plan.json";
#[cfg(windows)]
const PROTECTED_RESULT_FILE: &str = "result.json";
#[cfg(windows)]
const PROTECTED_INSTALLER_FILE: &str = "installer.exe";
#[cfg(windows)]
const PROTECTED_HELPER_FILE: &str = "update-worker.exe";
#[cfg(windows)]
const PARENT_EXIT_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[cfg(windows)]
const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(2 * 60);
#[cfg(windows)]
const INSTALLER_TIMEOUT: Duration = Duration::from_secs(30 * 60);
#[cfg(windows)]
const PROTECTED_RESULT_TIMEOUT: Duration = Duration::from_secs(31 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FullUpdateState {
Preparing,
Ready,
SupervisorStarted,
Elevating,
Installing,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FullUpdateTransaction {
pub schema_version: u32,
pub transaction_id: String,
pub application_id: String,
pub current_version: String,
pub target_version: String,
pub download_url: String,
pub install_root: PathBuf,
pub installer_path: PathBuf,
pub installer_sha256: String,
pub installer_size: u64,
pub updater_signature: String,
pub source_executable_sha256: String,
pub state: FullUpdateState,
pub launch_attempts: u32,
pub automatic_launch_blocked: bool,
pub last_launch_automatic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supervisor_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installer_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launched_at_ms: Option<u64>,
pub created_at_ms: u64,
pub updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FullPreparedPointer {
transaction_path: PathBuf,
target_version: String,
installer_sha256: String,
download_url: String,
updater_signature: String,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PreparedFullUpdateKind {
Prepared,
AlreadyPrepared,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreparedFullUpdate {
pub kind: PreparedFullUpdateKind,
pub transaction: FullUpdateTransaction,
pub transaction_path: PathBuf,
pub installer_path: PathBuf,
pub bytes_downloaded: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareFullUpdateOptions {
pub current_version: String,
pub expected_version: String,
pub raw_json: serde_json::Value,
#[serde(default)]
pub headers: Vec<HttpHeader>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
struct TauriUpdateArtifact {
url: String,
signature: String,
}
#[derive(Debug, Clone, Deserialize)]
struct TauriUpdateManifest {
#[serde(alias = "name")]
version: String,
#[serde(default)]
platforms: Option<HashMap<String, TauriUpdateArtifact>>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
signature: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchFullUpdateOptions {
pub transaction_path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchFullUpdateResult {
pub pid: u32,
}
#[cfg(windows)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProtectedFullUpdatePlan {
schema_version: u32,
transaction_id: String,
application_id: String,
current_version: String,
target_version: String,
install_root: PathBuf,
installed_executable_path: PathBuf,
protected_installer_path: PathBuf,
protected_helper_path: PathBuf,
installer_sha256: String,
installer_size: u64,
updater_signature: String,
source_executable_sha256: String,
bootstrap_pid: u32,
created_at_ms: u64,
}
#[cfg(windows)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum ProtectedResultState {
Prepared,
Installing,
Completed,
Failed,
}
#[cfg(windows)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProtectedFullUpdateResult {
state: ProtectedResultState,
#[serde(default, skip_serializing_if = "Option::is_none")]
worker_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
installer_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
installer_exit_code: Option<i32>,
updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[cfg(windows)]
#[derive(Debug, Clone, PartialEq, Eq)]
enum FullHelperInvocation {
Supervisor {
transaction_path: PathBuf,
parent_pid: u32,
},
ElevatedBootstrap {
transaction_path: PathBuf,
},
ElevatedWorker {
plan_path: PathBuf,
},
Cleanup {
transaction_path: PathBuf,
supervisor_pid: u32,
full: bool,
},
}
#[tauri::command]
pub async fn prepare_full_update<R: Runtime>(
app: AppHandle<R>,
config: State<'_, FileUpdateHelperConfig>,
options: PrepareFullUpdateOptions,
on_event: Channel<DownloadEvent>,
) -> Result<PreparedFullUpdate, FileUpdateCommandError> {
if config.updater_public_key.trim().is_empty() {
return Err(FileUpdateCommandError::from(
"full updater public key is not compiled into the application",
));
}
if app.config().identifier != config.application_id {
return Err(FileUpdateCommandError::from(
"full update plugin application id does not match the Tauri identifier",
));
}
validate_application_id(config.application_id)?;
validate_version_upgrade(&options.current_version, &options.expected_version)?;
if !same_version(&options.current_version, config.current_version)
|| !same_version(
&app.package_info().version.to_string(),
config.current_version,
)
{
return Err(FileUpdateCommandError::from(format!(
"full update source version mismatch: updater={}, application={}, configured={}",
options.current_version,
app.package_info().version,
config.current_version
)));
}
let manifest: TauriUpdateManifest = serde_json::from_value(options.raw_json)?;
if !same_version(&manifest.version, &options.expected_version) {
return Err(FileUpdateCommandError::from(format!(
"full update manifest version mismatch: expected {}, got {}",
options.expected_version, manifest.version
)));
}
let artifact = resolve_tauri_update_artifact(manifest)?;
let download_url = Url::parse(&artifact.url)?;
if download_url.scheme() != "https" {
return Err(FileUpdateCommandError::from(
"full update installer must use HTTPS",
));
}
let cache_root = full_update_cache_root(&app)?;
fs::create_dir_all(&cache_root)?;
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
if let Some(prepared) = load_reusable_update(
&cache_root,
&config,
&options.expected_version,
download_url.as_str(),
&artifact.signature,
)? {
return Ok(prepared);
}
reset_full_update_cache(&cache_root, &config)?;
let transaction_id = full_transaction_id(
config.application_id,
&options.current_version,
&options.expected_version,
download_url.as_str(),
&artifact.signature,
);
let transaction_root = cache_root.join("transactions").join(&transaction_id);
fs::create_dir_all(&transaction_root)?;
ensure_plain_directory(&transaction_root)?;
let transaction_path = transaction_root.join("transaction.json");
let installer_path = transaction_root.join("installer.exe");
let current_executable = std::env::current_exe()?.canonicalize()?;
let install_root = current_executable
.parent()
.ok_or_else(|| FileUpdateCommandError::from("current executable has no parent"))?
.to_path_buf();
validate_installed_executable(&config, &install_root, ¤t_executable)?;
let source_executable_sha256 = sha256_file(¤t_executable)?.sha256;
let now = now_millis();
let mut transaction = FullUpdateTransaction {
schema_version: TRANSACTION_SCHEMA_VERSION,
transaction_id,
application_id: config.application_id.to_string(),
current_version: options.current_version.clone(),
target_version: options.expected_version.clone(),
download_url: download_url.as_str().to_string(),
install_root,
installer_path: installer_path.clone(),
installer_sha256: String::new(),
installer_size: 0,
updater_signature: artifact.signature.clone(),
source_executable_sha256,
state: FullUpdateState::Preparing,
launch_attempts: 0,
automatic_launch_blocked: false,
last_launch_automatic: false,
supervisor_pid: None,
bootstrap_pid: None,
worker_pid: None,
installer_pid: None,
launched_at_ms: None,
created_at_ms: now,
updated_at_ms: now,
failure_reason: None,
};
write_json_file_atomic(&transaction_path, &transaction)?;
let stats = download_to_file(
download_url.as_str(),
&installer_path,
&options.headers,
options.timeout_secs,
Some(MAX_INSTALLER_BYTES),
|event| {
let _ = on_event.send(event);
},
)
.await?;
ensure_plain_file(&installer_path)?;
ensure_windows_installer(&installer_path)?;
verify_updater_signature(
&installer_path,
config.updater_public_key,
&artifact.signature,
)?;
let digest = sha256_file(&installer_path)?;
transaction.installer_sha256 = digest.sha256.clone();
transaction.installer_size = digest.size;
transaction.state = FullUpdateState::Ready;
transaction.updated_at_ms = now_millis();
write_json_file_atomic(&transaction_path, &transaction)?;
write_json_file_atomic(
cache_root.join("prepared.json"),
&pointer_for(&transaction_path, &transaction),
)?;
Ok(PreparedFullUpdate {
kind: PreparedFullUpdateKind::Prepared,
transaction,
transaction_path,
installer_path,
bytes_downloaded: stats.bytes_written,
})
}
#[tauri::command]
pub async fn launch_full_update<R: Runtime>(
app: AppHandle<R>,
config: State<'_, FileUpdateHelperConfig>,
options: LaunchFullUpdateOptions,
) -> Result<LaunchFullUpdateResult, FileUpdateCommandError> {
let cache_root = full_update_cache_root(&app)?;
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
let transaction_path = validate_transaction_path(&cache_root, &options.transaction_path)?;
let pid = launch_full_update_inner(&config, &cache_root, &transaction_path, false)?;
Ok(LaunchFullUpdateResult { pid })
}
fn resolve_tauri_update_artifact(
manifest: TauriUpdateManifest,
) -> Result<TauriUpdateArtifact, FileUpdateCommandError> {
if let Some(platforms) = manifest.platforms {
let platform = default_platform();
return platforms.get(&platform).cloned().ok_or_else(|| {
FileUpdateCommandError::from(format!(
"full update manifest has no artifact for {platform}"
))
});
}
match (manifest.url, manifest.signature) {
(Some(url), Some(signature)) => Ok(TauriUpdateArtifact { url, signature }),
_ => Err(FileUpdateCommandError::from(
"full update manifest does not contain an installer URL and signature",
)),
}
}
pub(crate) fn maybe_apply_prepared_full_update(
config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
let cache_root = helper_cache_root(config)?;
let pointer_path = cache_root.join("prepared.json");
if !pointer_path.is_file() {
return Ok(false);
}
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
let pointer = match read_json::<FullPreparedPointer>(&pointer_path) {
Ok(pointer) => pointer,
Err(error) => {
log::warn!(
"discarding unreadable full update pointer: {}",
error.message
);
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
let transaction_path = match validate_transaction_path(&cache_root, &pointer.transaction_path) {
Ok(path) => path,
Err(error) => {
log::warn!("discarding invalid full update pointer: {}", error.message);
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
let mut transaction = match read_transaction(&transaction_path) {
Ok(transaction) => transaction,
Err(error) => {
log::warn!(
"discarding invalid full update transaction: {}",
error.message
);
let _ = remove_transaction_and_pointer(&cache_root, &transaction_path);
return Ok(false);
}
};
if same_version(config.current_version, &transaction.target_version) {
let _ =
cleanup_transaction_if_inactive(config, &cache_root, &transaction_path, &transaction);
return Ok(false);
}
if let Err(error) = validate_transaction_identity(
config,
&cache_root,
&transaction_path,
&pointer,
&transaction,
) {
transaction.state = FullUpdateState::Failed;
transaction.updated_at_ms = now_millis();
transaction.failure_reason = Some(error.message.clone());
let _ = write_json_file_atomic(&transaction_path, &transaction);
let _ =
cleanup_transaction_if_inactive(config, &cache_root, &transaction_path, &transaction);
return Ok(false);
}
#[cfg(windows)]
{
if let Some(result) = read_protected_result(config, &transaction.transaction_id) {
if result.state == ProtectedResultState::Failed {
restore_ready_after_launch_failure(
&transaction_path,
&mut transaction,
result
.message
.as_deref()
.unwrap_or("protected full update worker failed"),
)?;
cleanup_protected_transaction_root(config, &transaction.transaction_id);
return Ok(false);
}
if result.state == ProtectedResultState::Completed {
restore_ready_after_launch_failure(
&transaction_path,
&mut transaction,
"full update installer completed without replacing the running version",
)?;
cleanup_protected_transaction_root(config, &transaction.transaction_id);
return Ok(false);
}
}
}
match transaction.state {
FullUpdateState::Ready => {
if transaction.automatic_launch_blocked {
return Ok(false);
}
}
FullUpdateState::SupervisorStarted
| FullUpdateState::Elevating
| FullUpdateState::Installing => {
if transaction_or_protected_has_running_process(config, &transaction)
|| transaction.launched_at_ms.is_some_and(|started| {
now_millis().saturating_sub(started) <= ACTIVE_LAUNCH_GRACE_MS
})
{
return Ok(true);
}
if transaction.launch_attempts >= MAX_AUTOMATIC_LAUNCH_ATTEMPTS {
restore_ready_after_launch_failure(
&transaction_path,
&mut transaction,
"full update helper stopped before installation completed",
)?;
return Ok(false);
}
transaction.state = FullUpdateState::Ready;
transaction.supervisor_pid = None;
transaction.bootstrap_pid = None;
transaction.worker_pid = None;
transaction.installer_pid = None;
transaction.updated_at_ms = now_millis();
write_json_file_atomic(&transaction_path, &transaction)?;
}
FullUpdateState::Completed | FullUpdateState::Failed => {
let _ = cleanup_transaction_if_inactive(
config,
&cache_root,
&transaction_path,
&transaction,
);
return Ok(false);
}
FullUpdateState::Preparing => {
transaction.state = FullUpdateState::Failed;
transaction.updated_at_ms = now_millis();
transaction.failure_reason =
Some("full update preparation was interrupted".to_string());
write_json_file_atomic(&transaction_path, &transaction)?;
let _ = cleanup_transaction_if_inactive(
config,
&cache_root,
&transaction_path,
&transaction,
);
return Ok(false);
}
}
match launch_full_update_inner(config, &cache_root, &transaction_path, true) {
Ok(_) => Ok(true),
Err(error) => {
let mut transaction = read_transaction(&transaction_path).unwrap_or(transaction);
restore_ready_after_launch_failure(
&transaction_path,
&mut transaction,
&error.message,
)?;
Ok(false)
}
}
}
#[cfg(windows)]
pub(crate) fn maybe_run_full_update_helper(
config: &FileUpdateHelperConfig,
args: &[OsString],
) -> Result<bool, FileUpdateCommandError> {
let Some(invocation) = parse_full_helper_invocation(args)? else {
return Ok(false);
};
match invocation {
FullHelperInvocation::Supervisor {
transaction_path,
parent_pid,
} => run_full_supervisor(config, &transaction_path, parent_pid)?,
FullHelperInvocation::ElevatedBootstrap { transaction_path } => {
run_full_elevated_bootstrap(config, &transaction_path)?
}
FullHelperInvocation::ElevatedWorker { plan_path } => {
run_full_elevated_worker(config, &plan_path)?
}
FullHelperInvocation::Cleanup {
transaction_path,
supervisor_pid,
full,
} => run_full_cleanup(config, &transaction_path, supervisor_pid, full)?,
}
Ok(true)
}
#[cfg(not(windows))]
pub(crate) fn maybe_run_full_update_helper(
_config: &FileUpdateHelperConfig,
_args: &[std::ffi::OsString],
) -> Result<bool, FileUpdateCommandError> {
Ok(false)
}
fn launch_full_update_inner(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
automatic: bool,
) -> Result<u32, FileUpdateCommandError> {
let pointer = read_json::<FullPreparedPointer>(&cache_root.join("prepared.json"))?;
let mut transaction =
validate_ready_transaction(config, cache_root, transaction_path, &pointer)?;
if transaction.state != FullUpdateState::Ready {
return Err(FileUpdateCommandError::from(format!(
"full update transaction is not ready: {:?}",
transaction.state
)));
}
validate_current_executable(config, &transaction)?;
#[cfg(windows)]
{
let helper_path = copy_supervisor_helper(&transaction)?;
transaction.state = FullUpdateState::SupervisorStarted;
transaction.launch_attempts = transaction.launch_attempts.saturating_add(1);
transaction.automatic_launch_blocked = false;
transaction.last_launch_automatic = automatic;
transaction.supervisor_pid = None;
transaction.bootstrap_pid = None;
transaction.worker_pid = None;
transaction.installer_pid = None;
transaction.launched_at_ms = Some(now_millis());
transaction.updated_at_ms = now_millis();
transaction.failure_reason = None;
if let Err(error) = write_json_file_atomic(transaction_path, &transaction) {
if let Some(helper_root) = helper_path.parent() {
let _ = fs::remove_dir_all(helper_root);
}
return Err(FileUpdateCommandError::from(error));
}
let mut command = Command::new(&helper_path);
command
.arg(FULL_SUPERVISOR_ARGUMENT)
.arg(transaction_path)
.arg(std::process::id().to_string())
.current_dir(
helper_path.parent().ok_or_else(|| {
FileUpdateCommandError::from("full update helper has no parent")
})?,
)
.creation_flags(CREATE_NO_WINDOW);
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
if let Some(helper_root) = helper_path.parent() {
let _ = fs::remove_dir_all(helper_root);
}
restore_ready_after_launch_failure(
transaction_path,
&mut transaction,
&format!(
"failed to start full update supervisor {}: {error}",
helper_path.display()
),
)?;
return Err(FileUpdateCommandError::from(format!(
"failed to start full update supervisor {}: {error}",
helper_path.display()
)));
}
};
let pid = child.id();
transaction.supervisor_pid = Some(pid);
transaction.updated_at_ms = now_millis();
if let Err(error) = write_json_file_atomic(transaction_path, &transaction) {
let _ = child.kill();
let _ = child.wait();
if let Some(helper_root) = helper_path.parent() {
let _ = fs::remove_dir_all(helper_root);
}
restore_ready_after_launch_failure(
transaction_path,
&mut transaction,
"failed to persist full update supervisor process id",
)?;
return Err(FileUpdateCommandError::from(error));
}
Ok(pid)
}
#[cfg(not(windows))]
{
let _ = (automatic, transaction);
Err(FileUpdateCommandError::from(
"persistent full updates are only implemented on Windows",
))
}
}
fn load_reusable_update(
cache_root: &Path,
config: &FileUpdateHelperConfig,
target_version: &str,
download_url: &str,
updater_signature: &str,
) -> Result<Option<PreparedFullUpdate>, FileUpdateCommandError> {
let pointer_path = cache_root.join("prepared.json");
if !pointer_path.is_file() {
return Ok(None);
}
let pointer = match read_json::<FullPreparedPointer>(&pointer_path) {
Ok(pointer) => pointer,
Err(_) => return Ok(None),
};
if !release_identity_matches(
&pointer.target_version,
&pointer.download_url,
&pointer.updater_signature,
target_version,
download_url,
updater_signature,
) {
return Ok(None);
}
let transaction_path = match validate_transaction_path(cache_root, &pointer.transaction_path) {
Ok(path) => path,
Err(_) => return Ok(None),
};
let transaction =
match validate_ready_transaction(config, cache_root, &transaction_path, &pointer) {
Ok(transaction) => transaction,
Err(_) => return Ok(None),
};
if transaction.state != FullUpdateState::Ready
|| !release_identity_matches(
&transaction.target_version,
&transaction.download_url,
&transaction.updater_signature,
target_version,
download_url,
updater_signature,
)
{
return Ok(None);
}
validate_current_executable(config, &transaction)?;
Ok(Some(PreparedFullUpdate {
kind: PreparedFullUpdateKind::AlreadyPrepared,
installer_path: transaction.installer_path.clone(),
transaction,
transaction_path,
bytes_downloaded: 0,
}))
}
fn validate_ready_transaction(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
pointer: &FullPreparedPointer,
) -> Result<FullUpdateTransaction, FileUpdateCommandError> {
let transaction_path = validate_transaction_path(cache_root, transaction_path)?;
let mut transaction = read_transaction(&transaction_path)?;
validate_transaction_identity(config, cache_root, &transaction_path, pointer, &transaction)?;
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?;
let installer_path = transaction.installer_path.canonicalize()?;
if !installer_path.starts_with(transaction_root)
|| installer_path.file_name() != Some(OsStr::new("installer.exe"))
{
return Err(FileUpdateCommandError::from(
"full update installer is outside its transaction",
));
}
ensure_plain_file(&installer_path)?;
let digest = sha256_file(&installer_path)?;
if digest.size != transaction.installer_size || digest.sha256 != transaction.installer_sha256 {
return Err(FileUpdateCommandError::from(
"full update installer hash or size does not match its transaction",
));
}
ensure_windows_installer(&installer_path)?;
verify_updater_signature(
&installer_path,
config.updater_public_key,
&transaction.updater_signature,
)?;
transaction.installer_path = installer_path;
Ok(transaction)
}
fn validate_transaction_identity(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
pointer: &FullPreparedPointer,
transaction: &FullUpdateTransaction,
) -> Result<(), FileUpdateCommandError> {
validate_application_id(config.application_id)?;
if transaction.application_id != config.application_id {
return Err(FileUpdateCommandError::from(
"full update transaction belongs to another application",
));
}
if !same_version(&transaction.current_version, config.current_version) {
return Err(FileUpdateCommandError::from(
"full update transaction source version does not match the application",
));
}
validate_version_upgrade(&transaction.current_version, &transaction.target_version)?;
let canonical_transaction = validate_transaction_path(cache_root, transaction_path)?;
if pointer.transaction_path.canonicalize()? != canonical_transaction
|| pointer.target_version != transaction.target_version
|| pointer.installer_sha256 != transaction.installer_sha256
|| pointer.download_url != transaction.download_url
|| pointer.updater_signature != transaction.updater_signature
{
return Err(FileUpdateCommandError::from(
"full update prepared pointer does not match its transaction",
));
}
let expected_id = full_transaction_id(
&transaction.application_id,
&transaction.current_version,
&transaction.target_version,
&transaction.download_url,
&transaction.updater_signature,
);
if transaction.transaction_id != expected_id {
return Err(FileUpdateCommandError::from(
"full update transaction identity is invalid",
));
}
let transaction_root = canonical_transaction
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?;
if transaction_root.file_name() != Some(OsStr::new(&transaction.transaction_id)) {
return Err(FileUpdateCommandError::from(
"full update transaction directory does not match its identity",
));
}
let install_root = transaction.install_root.canonicalize()?;
if install_root != transaction.install_root {
return Err(FileUpdateCommandError::from(
"full update installation root is not canonical",
));
}
let installed_executable = install_root.join(config.main_executable).canonicalize()?;
validate_installed_executable(config, &install_root, &installed_executable)?;
Ok(())
}
fn validate_current_executable(
config: &FileUpdateHelperConfig,
transaction: &FullUpdateTransaction,
) -> Result<(), FileUpdateCommandError> {
let current_executable = std::env::current_exe()?.canonicalize()?;
let expected_executable = transaction
.install_root
.join(config.main_executable)
.canonicalize()?;
if current_executable != expected_executable {
return Err(FileUpdateCommandError::from(
"full update launch must originate from the installed application executable",
));
}
let digest = sha256_file(¤t_executable)?;
if digest.sha256 != transaction.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"running executable does not match the prepared full update source",
));
}
Ok(())
}
fn validate_installed_executable(
config: &FileUpdateHelperConfig,
install_root: &Path,
executable: &Path,
) -> Result<(), FileUpdateCommandError> {
ensure_plain_directory(install_root)?;
validate_machine_install_root(install_root)?;
ensure_plain_file(executable)?;
if executable.parent() != Some(install_root)
|| !windows_file_name_eq(executable, config.main_executable)
{
return Err(FileUpdateCommandError::from(
"full update executable is outside the configured installation root",
));
}
Ok(())
}
#[cfg(windows)]
fn validate_machine_install_root(install_root: &Path) -> Result<(), FileUpdateCommandError> {
let inside_program_files = ["PROGRAMFILES", "PROGRAMFILES(X86)"]
.into_iter()
.filter_map(std::env::var_os)
.filter_map(|path| PathBuf::from(path).canonicalize().ok())
.any(|root| install_root.starts_with(root));
if !inside_program_files {
return Err(FileUpdateCommandError::from(
"persistent full updates require a per-machine Program Files installation",
));
}
Ok(())
}
#[cfg(not(windows))]
fn validate_machine_install_root(_install_root: &Path) -> Result<(), FileUpdateCommandError> {
Ok(())
}
fn read_transaction(path: &Path) -> Result<FullUpdateTransaction, FileUpdateCommandError> {
let transaction = read_json::<FullUpdateTransaction>(path)?;
if transaction.schema_version != TRANSACTION_SCHEMA_VERSION {
return Err(FileUpdateCommandError::from(format!(
"unsupported full update transaction schema version: {}",
transaction.schema_version
)));
}
Ok(transaction)
}
fn reset_full_update_cache(
cache_root: &Path,
config: &FileUpdateHelperConfig,
) -> Result<(), FileUpdateCommandError> {
let transactions_root = cache_root.join("transactions");
if transactions_root.exists() {
ensure_plain_directory(&transactions_root)?;
for entry in fs::read_dir(&transactions_root)? {
let entry = entry?;
let entry_path = entry.path();
ensure_plain_directory(&entry_path)?;
let transaction_path = entry_path.join("transaction.json");
if let Ok(transaction) = read_transaction(&transaction_path) {
if transaction_or_protected_has_running_process(config, &transaction)
|| (matches!(
transaction.state,
FullUpdateState::SupervisorStarted
| FullUpdateState::Elevating
| FullUpdateState::Installing
) && transaction.launched_at_ms.is_some_and(|started| {
now_millis().saturating_sub(started) <= ACTIVE_LAUNCH_GRACE_MS
}))
{
return Err(FileUpdateCommandError::from(
"a full update installation is already running",
));
}
}
fs::remove_dir_all(entry_path)?;
}
} else {
fs::create_dir_all(&transactions_root)?;
}
let pointer_path = cache_root.join("prepared.json");
if pointer_path.is_file() {
fs::remove_file(pointer_path)?;
}
Ok(())
}
fn restore_ready_after_launch_failure(
transaction_path: &Path,
transaction: &mut FullUpdateTransaction,
reason: &str,
) -> Result<(), FileUpdateCommandError> {
transaction.state = FullUpdateState::Ready;
transaction.automatic_launch_blocked = true;
transaction.bootstrap_pid = None;
transaction.worker_pid = None;
transaction.installer_pid = None;
transaction.updated_at_ms = now_millis();
transaction.failure_reason = Some(reason.to_string());
write_json_file_atomic(transaction_path, transaction)?;
Ok(())
}
pub(crate) fn cleanup_full_update_cache(
config: &FileUpdateHelperConfig,
) -> Result<(), FileUpdateCommandError> {
let cache_root = helper_cache_root(config)?;
if cache_root.exists() {
ensure_plain_directory(&cache_root)?;
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
let transactions_root = cache_root.join("transactions");
if transactions_root.is_dir() {
ensure_plain_directory(&transactions_root)?;
for entry in fs::read_dir(&transactions_root)? {
let entry = entry?;
let entry_path = entry.path();
ensure_plain_directory(&entry_path)?;
let transaction_path = entry_path.join("transaction.json");
let Ok(transaction) = read_transaction(&transaction_path) else {
continue;
};
if is_locally_collectable(transaction.state)
&& !transaction_or_protected_has_running_process(config, &transaction)
{
let _ = remove_transaction_and_pointer(&cache_root, &transaction_path);
}
}
}
}
cleanup_protected_terminal_roots(config);
Ok(())
}
fn cleanup_transaction_if_inactive(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
transaction: &FullUpdateTransaction,
) -> Result<bool, FileUpdateCommandError> {
if transaction_or_protected_has_running_process(config, transaction) {
return Ok(false);
}
remove_transaction_and_pointer(cache_root, transaction_path)?;
Ok(true)
}
fn remove_transaction_and_pointer(
cache_root: &Path,
transaction_path: &Path,
) -> Result<(), FileUpdateCommandError> {
let transaction_path = validate_transaction_path(cache_root, transaction_path)?;
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?;
let pointer_path = cache_root.join("prepared.json");
let pointer_matches = read_json::<FullPreparedPointer>(&pointer_path)
.ok()
.and_then(|pointer| pointer.transaction_path.canonicalize().ok())
.is_some_and(|path| path == transaction_path);
ensure_plain_directory(transaction_root)?;
fs::remove_dir_all(transaction_root)?;
if pointer_matches && pointer_path.is_file() {
fs::remove_file(pointer_path)?;
}
Ok(())
}
fn full_update_cache_root<R: Runtime>(
app: &AppHandle<R>,
) -> Result<PathBuf, FileUpdateCommandError> {
Ok(app
.path()
.app_local_data_dir()?
.join("hdiff-update")
.join(FULL_UPDATE_ROOT))
}
fn helper_cache_root(config: &FileUpdateHelperConfig) -> Result<PathBuf, FileUpdateCommandError> {
let local_app_data = std::env::var_os("LOCALAPPDATA")
.ok_or_else(|| FileUpdateCommandError::from("LOCALAPPDATA is not available"))?;
Ok(PathBuf::from(local_app_data)
.join(config.application_id)
.join("hdiff-update")
.join(FULL_UPDATE_ROOT))
}
fn open_lock(cache_root: &Path) -> Result<fs::File, FileUpdateCommandError> {
fs::create_dir_all(cache_root)?;
Ok(fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(cache_root.join("lock"))?)
}
fn validate_transaction_path(
cache_root: &Path,
transaction_path: &Path,
) -> Result<PathBuf, FileUpdateCommandError> {
let expected_root = cache_root.join("transactions").canonicalize()?;
let transaction_path = transaction_path.canonicalize()?;
if !transaction_path.starts_with(expected_root)
|| transaction_path.file_name() != Some(OsStr::new("transaction.json"))
{
return Err(FileUpdateCommandError::from(format!(
"full update transaction is outside the managed cache: {}",
transaction_path.display()
)));
}
Ok(transaction_path)
}
fn pointer_for(
transaction_path: &Path,
transaction: &FullUpdateTransaction,
) -> FullPreparedPointer {
FullPreparedPointer {
transaction_path: transaction_path.to_path_buf(),
target_version: transaction.target_version.clone(),
installer_sha256: transaction.installer_sha256.clone(),
download_url: transaction.download_url.clone(),
updater_signature: transaction.updater_signature.clone(),
}
}
fn verify_updater_signature(
installer_path: &Path,
encoded_public_key: &str,
encoded_signature: &str,
) -> Result<(), FileUpdateCommandError> {
let public_key_text = decode_base64_utf8(encoded_public_key, "updater public key")?;
let signature_text = decode_base64_utf8(encoded_signature, "updater signature")?;
let public_key = PublicKey::decode(&public_key_text)?;
let signature = Signature::decode(&signature_text)?;
let mut verifier = public_key.verify_stream(&signature)?;
let mut installer = fs::File::open(installer_path)?;
let mut buffer = vec![0_u8; 1024 * 1024];
loop {
let read = installer.read(&mut buffer)?;
if read == 0 {
break;
}
verifier.update(&buffer[..read]);
}
verifier.finalize()?;
Ok(())
}
fn decode_base64_utf8(value: &str, label: &str) -> Result<String, FileUpdateCommandError> {
let decoded = base64::engine::general_purpose::STANDARD
.decode(value.trim())
.map_err(|error| FileUpdateCommandError::from(format!("invalid {label}: {error}")))?;
String::from_utf8(decoded)
.map_err(|error| FileUpdateCommandError::from(format!("invalid {label}: {error}")))
}
fn ensure_windows_installer(path: &Path) -> Result<(), FileUpdateCommandError> {
let mut file = fs::File::open(path)?;
let mut magic = [0_u8; 2];
file.read_exact(&mut magic)?;
if magic != *b"MZ" {
return Err(FileUpdateCommandError::from(
"full update artifact is not a Windows executable",
));
}
Ok(())
}
fn ensure_plain_directory(path: &Path) -> Result<(), FileUpdateCommandError> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"directory is missing or is a reparse point: {}",
path.display()
)));
}
Ok(())
}
fn ensure_plain_file(path: &Path) -> Result<(), FileUpdateCommandError> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_file() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"file is missing or is a reparse point: {}",
path.display()
)));
}
Ok(())
}
#[cfg(windows)]
fn is_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x0000_0400 != 0
}
#[cfg(not(windows))]
fn is_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
fn validate_application_id(application_id: &str) -> Result<(), FileUpdateCommandError> {
if application_id.is_empty()
|| !application_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
{
return Err(FileUpdateCommandError::from(
"full update application id contains unsupported path characters",
));
}
Ok(())
}
fn validate_version_upgrade(current: &str, target: &str) -> Result<(), FileUpdateCommandError> {
let current = parse_version(current)?;
let target = parse_version(target)?;
if target <= current {
return Err(FileUpdateCommandError::from(
"full update target version is not newer than the current version",
));
}
Ok(())
}
fn same_version(left: &str, right: &str) -> bool {
match (parse_version(left), parse_version(right)) {
(Ok(left), Ok(right)) => left == right,
_ => left.trim() == right.trim(),
}
}
fn release_identity_matches(
stored_version: &str,
stored_url: &str,
stored_signature: &str,
requested_version: &str,
requested_url: &str,
requested_signature: &str,
) -> bool {
same_version(stored_version, requested_version)
&& stored_url == requested_url
&& stored_signature == requested_signature
}
fn is_locally_collectable(state: FullUpdateState) -> bool {
matches!(
state,
FullUpdateState::Preparing | FullUpdateState::Completed | FullUpdateState::Failed
)
}
fn parse_version(value: &str) -> Result<Version, FileUpdateCommandError> {
Version::parse(value.trim().trim_start_matches('v')).map_err(FileUpdateCommandError::from)
}
fn full_transaction_id(
application_id: &str,
current_version: &str,
target_version: &str,
download_url: &str,
updater_signature: &str,
) -> String {
let mut hasher = Sha256::new();
for value in [
application_id,
current_version,
target_version,
download_url,
updater_signature,
] {
hasher.update(value.as_bytes());
hasher.update([0]);
}
hex::encode(hasher.finalize())
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, FileUpdateCommandError> {
Ok(serde_json::from_slice(&fs::read(path)?)?)
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64
}
fn windows_file_name_eq(path: &Path, expected: &str) -> bool {
path.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name.eq_ignore_ascii_case(expected))
}
fn transaction_has_running_process(transaction: &FullUpdateTransaction) -> bool {
[
transaction.supervisor_pid,
transaction.bootstrap_pid,
transaction.worker_pid,
transaction.installer_pid,
]
.into_iter()
.flatten()
.any(process_is_running)
}
fn transaction_or_protected_has_running_process(
config: &FileUpdateHelperConfig,
transaction: &FullUpdateTransaction,
) -> bool {
transaction_has_running_process(transaction)
|| protected_transaction_has_running_process(config, &transaction.transaction_id)
}
#[cfg(windows)]
fn protected_transaction_has_running_process(
config: &FileUpdateHelperConfig,
transaction_id: &str,
) -> bool {
let Some(result) = read_protected_result(config, transaction_id) else {
return false;
};
(result.state == ProtectedResultState::Prepared
&& now_millis().saturating_sub(result.updated_at_ms) <= PROTECTED_PREPARED_GRACE_MS)
|| (result.state == ProtectedResultState::Installing
&& now_millis().saturating_sub(result.updated_at_ms) <= PROTECTED_INSTALLING_GRACE_MS)
|| result.worker_pid.is_some_and(process_is_running)
|| result.installer_pid.is_some_and(process_is_running)
}
#[cfg(windows)]
fn read_protected_result(
config: &FileUpdateHelperConfig,
transaction_id: &str,
) -> Option<ProtectedFullUpdateResult> {
let root = protected_transaction_root(config, transaction_id).ok()?;
read_json::<ProtectedFullUpdateResult>(&root.join(PROTECTED_RESULT_FILE)).ok()
}
#[cfg(not(windows))]
fn protected_transaction_has_running_process(
_config: &FileUpdateHelperConfig,
_transaction_id: &str,
) -> bool {
false
}
#[cfg(windows)]
fn parse_full_helper_invocation(
args: &[OsString],
) -> Result<Option<FullHelperInvocation>, FileUpdateCommandError> {
let Some(mode) = args.get(1).and_then(|value| value.to_str()) else {
return Ok(None);
};
let invocation = match mode {
FULL_SUPERVISOR_ARGUMENT => FullHelperInvocation::Supervisor {
transaction_path: required_path_argument(args, 2, "transaction path")?,
parent_pid: required_pid_argument(args, 3, "parent process id")?,
},
FULL_ELEVATED_BOOTSTRAP_ARGUMENT => FullHelperInvocation::ElevatedBootstrap {
transaction_path: required_path_argument(args, 2, "transaction path")?,
},
FULL_ELEVATED_WORKER_ARGUMENT => FullHelperInvocation::ElevatedWorker {
plan_path: required_path_argument(args, 2, "protected update plan path")?,
},
FULL_CLEANUP_ARGUMENT => {
let kind = args
.get(4)
.and_then(|value| value.to_str())
.ok_or_else(|| FileUpdateCommandError::from("missing cleanup kind"))?;
let full = match kind {
CLEANUP_FULL => true,
CLEANUP_HELPER => false,
_ => {
return Err(FileUpdateCommandError::from(
"unsupported full update cleanup kind",
))
}
};
FullHelperInvocation::Cleanup {
transaction_path: required_path_argument(args, 2, "transaction path")?,
supervisor_pid: required_pid_argument(args, 3, "supervisor process id")?,
full,
}
}
_ => return Ok(None),
};
Ok(Some(invocation))
}
#[cfg(windows)]
fn required_path_argument(
args: &[OsString],
index: usize,
label: &str,
) -> Result<PathBuf, FileUpdateCommandError> {
args.get(index)
.map(PathBuf::from)
.ok_or_else(|| FileUpdateCommandError::from(format!("missing {label}")))
}
#[cfg(windows)]
fn required_pid_argument(
args: &[OsString],
index: usize,
label: &str,
) -> Result<u32, FileUpdateCommandError> {
args.get(index)
.and_then(|value| value.to_str())
.ok_or_else(|| FileUpdateCommandError::from(format!("missing {label}")))?
.parse::<u32>()
.map_err(FileUpdateCommandError::from)
}
#[cfg(windows)]
fn copy_supervisor_helper(
transaction: &FullUpdateTransaction,
) -> Result<PathBuf, FileUpdateCommandError> {
let current_executable = std::env::current_exe()?.canonicalize()?;
let installer_path = transaction.installer_path.canonicalize()?;
let transaction_root = installer_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?;
let helper_root = transaction_root.join("helper");
if helper_root.exists() {
ensure_plain_directory(&helper_root)?;
fs::remove_dir_all(&helper_root)?;
}
fs::create_dir_all(&helper_root)?;
ensure_plain_directory(&helper_root)?;
let helper_path = helper_root.join(
current_executable
.file_name()
.unwrap_or_else(|| OsStr::new("update-supervisor.exe")),
);
copy_file_synced(¤t_executable, &helper_path)?;
let helper_digest = sha256_file(&helper_path)?;
if helper_digest.sha256 != transaction.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"copied full update supervisor failed SHA-256 verification",
));
}
Ok(helper_path)
}
#[cfg(windows)]
fn run_full_supervisor(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
parent_pid: u32,
) -> Result<(), FileUpdateCommandError> {
let cache_root = helper_cache_root(config)?;
let transaction_path = validate_transaction_path(&cache_root, transaction_path)?;
let pointer = read_json::<FullPreparedPointer>(&cache_root.join("prepared.json"))?;
let transaction =
validate_ready_or_active_transaction(config, &cache_root, &transaction_path, &pointer)?;
let own_digest = sha256_file(std::env::current_exe()?)?;
if own_digest.sha256 != transaction.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"full update supervisor executable hash does not match the transaction",
));
}
wait_for_process_exit(parent_pid, PARENT_EXIT_TIMEOUT)?;
let mut transaction =
validate_ready_or_active_transaction(config, &cache_root, &transaction_path, &pointer)?;
let installed_executable = transaction
.install_root
.join(config.main_executable)
.canonicalize()?;
let installed_digest = sha256_file(&installed_executable)?;
if installed_digest.sha256 != transaction.source_executable_sha256 {
return recover_supervisor_failure(
config,
&transaction_path,
&mut transaction,
"installed executable changed after full update preparation",
);
}
transaction.state = FullUpdateState::Elevating;
transaction.updated_at_ms = now_millis();
write_json_file_atomic(&transaction_path, &transaction)?;
let parameters = windows_command_line(&[
FULL_ELEVATED_BOOTSTRAP_ARGUMENT.to_string(),
transaction_path.to_string_lossy().into_owned(),
]);
let elevated = match shell_execute_elevated(&installed_executable, ¶meters) {
Ok(process) => process,
Err(error) => {
return recover_supervisor_failure(
config,
&transaction_path,
&mut transaction,
&error.message,
)
}
};
transaction.bootstrap_pid = Some(elevated.pid);
transaction.updated_at_ms = now_millis();
write_json_file_atomic(&transaction_path, &transaction)?;
let bootstrap_exit = wait_process_handle(elevated.handle, BOOTSTRAP_TIMEOUT)?;
if bootstrap_exit != 0 {
return recover_supervisor_failure(
config,
&transaction_path,
&mut transaction,
&format!("elevated full update bootstrap exited with code {bootstrap_exit}"),
);
}
match wait_for_protected_result(
config,
&transaction.transaction_id,
PROTECTED_RESULT_TIMEOUT,
) {
Ok(result) if result.state == ProtectedResultState::Completed => {
if let Ok(mut latest) = read_transaction(&transaction_path) {
latest.state = FullUpdateState::Completed;
latest.updated_at_ms = now_millis();
latest.failure_reason = None;
let _ = write_json_file_atomic(&transaction_path, &latest);
}
if let Err(error) = spawn_cleanup_helper(
config,
&transaction.install_root,
&transaction_path,
std::process::id(),
true,
) {
log::warn!(
"full update completed but deferred cache cleanup could not start: {}",
error.message
);
}
Ok(())
}
Ok(result) => recover_supervisor_failure(
config,
&transaction_path,
&mut transaction,
result
.message
.as_deref()
.unwrap_or("full update installer failed"),
),
Err(error) => {
recover_supervisor_failure(config, &transaction_path, &mut transaction, &error.message)
}
}
}
#[cfg(windows)]
fn validate_ready_or_active_transaction(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
pointer: &FullPreparedPointer,
) -> Result<FullUpdateTransaction, FileUpdateCommandError> {
let transaction = validate_ready_transaction(config, cache_root, transaction_path, pointer)?;
if !matches!(
transaction.state,
FullUpdateState::Ready
| FullUpdateState::SupervisorStarted
| FullUpdateState::Elevating
| FullUpdateState::Installing
) {
return Err(FileUpdateCommandError::from(format!(
"full update transaction is not launchable: {:?}",
transaction.state
)));
}
Ok(transaction)
}
#[cfg(windows)]
fn recover_supervisor_failure(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
transaction: &mut FullUpdateTransaction,
reason: &str,
) -> Result<(), FileUpdateCommandError> {
if protected_transaction_has_running_process(config, &transaction.transaction_id) {
return Err(FileUpdateCommandError::from(format!(
"{reason}; protected full update process is still running"
)));
}
let latest = read_transaction(transaction_path).unwrap_or_else(|_| transaction.clone());
*transaction = latest;
restore_ready_after_launch_failure(transaction_path, transaction, reason)?;
cleanup_protected_transaction_root(config, &transaction.transaction_id);
if let Err(error) = spawn_cleanup_helper(
config,
&transaction.install_root,
transaction_path,
std::process::id(),
false,
) {
log::warn!(
"failed to start deferred full update helper cleanup: {}",
error.message
);
}
launch_installed_application(config, &transaction.install_root)?;
Ok(())
}
#[cfg(windows)]
fn run_full_elevated_bootstrap(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
) -> Result<(), FileUpdateCommandError> {
let cache_root = helper_cache_root(config)?;
let transaction_path = validate_transaction_path(&cache_root, transaction_path)?;
let pointer = read_json::<FullPreparedPointer>(&cache_root.join("prepared.json"))?;
let transaction =
validate_ready_or_active_transaction(config, &cache_root, &transaction_path, &pointer)?;
validate_current_executable(config, &transaction)?;
let protected_root = prepare_protected_transaction_root(config, &transaction.transaction_id)?;
let mut cleanup_guard = ProtectedRootCleanupGuard::new(protected_root.clone());
let protected_installer = protected_root.join(PROTECTED_INSTALLER_FILE);
let protected_helper = protected_root.join(PROTECTED_HELPER_FILE);
copy_file_synced(&transaction.installer_path, &protected_installer)?;
copy_file_synced(&std::env::current_exe()?, &protected_helper)?;
verify_protected_copy(
&protected_installer,
transaction.installer_size,
&transaction.installer_sha256,
config.updater_public_key,
&transaction.updater_signature,
)?;
let helper_digest = sha256_file(&protected_helper)?;
if helper_digest.sha256 != transaction.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"protected full update worker failed SHA-256 verification",
));
}
let installed_executable = transaction
.install_root
.join(config.main_executable)
.canonicalize()?;
let plan = ProtectedFullUpdatePlan {
schema_version: PROTECTED_PLAN_SCHEMA_VERSION,
transaction_id: transaction.transaction_id.clone(),
application_id: transaction.application_id.clone(),
current_version: transaction.current_version.clone(),
target_version: transaction.target_version.clone(),
install_root: transaction.install_root.clone(),
installed_executable_path: installed_executable,
protected_installer_path: protected_installer,
protected_helper_path: protected_helper.clone(),
installer_sha256: transaction.installer_sha256.clone(),
installer_size: transaction.installer_size,
updater_signature: transaction.updater_signature.clone(),
source_executable_sha256: transaction.source_executable_sha256.clone(),
bootstrap_pid: std::process::id(),
created_at_ms: now_millis(),
};
let plan_path = protected_root.join(PROTECTED_PLAN_FILE);
write_json_file_atomic(&plan_path, &plan)?;
let result_path = protected_root.join(PROTECTED_RESULT_FILE);
write_protected_result(
&result_path,
ProtectedResultState::Prepared,
None,
None,
None,
None,
)?;
let mut command = Command::new(&protected_helper);
command
.arg(FULL_ELEVATED_WORKER_ARGUMENT)
.arg(&plan_path)
.current_dir(&protected_root)
.creation_flags(CREATE_NO_WINDOW);
let mut child = command.spawn().map_err(|error| {
FileUpdateCommandError::from(format!(
"failed to start protected full update worker {}: {error}",
protected_helper.display()
))
})?;
let worker_pid = child.id();
if let Err(error) = write_protected_result(
&result_path,
ProtectedResultState::Prepared,
Some(worker_pid),
None,
None,
None,
) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
drop(child);
cleanup_guard.disarm();
Ok(())
}
#[cfg(windows)]
fn run_full_elevated_worker(
config: &FileUpdateHelperConfig,
plan_path: &Path,
) -> Result<(), FileUpdateCommandError> {
let plan = validate_protected_plan(config, plan_path)?;
let protected_root = plan_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("protected update plan has no parent"))?;
let result_path = protected_root.join(PROTECTED_RESULT_FILE);
let result = run_full_elevated_worker_inner(config, &plan, protected_root, &result_path);
if let Err(error) = &result {
record_protected_failure(&plan, &result_path, &error.message);
}
result
}
#[cfg(windows)]
fn run_full_elevated_worker_inner(
config: &FileUpdateHelperConfig,
plan: &ProtectedFullUpdatePlan,
protected_root: &Path,
result_path: &Path,
) -> Result<(), FileUpdateCommandError> {
wait_for_process_exit(plan.bootstrap_pid, BOOTSTRAP_TIMEOUT)?;
let own_digest = sha256_file(std::env::current_exe()?)?;
if own_digest.sha256 != plan.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"protected full update worker hash does not match the source application",
));
}
verify_protected_copy(
&plan.protected_installer_path,
plan.installer_size,
&plan.installer_sha256,
config.updater_public_key,
&plan.updater_signature,
)?;
let installed_digest = sha256_file(&plan.installed_executable_path)?;
if installed_digest.sha256 != plan.source_executable_sha256 {
return Err(FileUpdateCommandError::from(
"installed executable changed before the full installer started",
));
}
write_protected_result(
result_path,
ProtectedResultState::Installing,
Some(std::process::id()),
None,
None,
None,
)?;
let mut installer = Command::new(&plan.protected_installer_path);
installer
.args(["/P", "/R", "/UPDATE", "/ARGS"])
.current_dir(protected_root)
.creation_flags(CREATE_NO_WINDOW);
let mut installer = installer.spawn().map_err(|error| {
FileUpdateCommandError::from(format!(
"failed to start protected full update installer {}: {error}",
plan.protected_installer_path.display()
))
})?;
let installer_pid = installer.id();
write_protected_result(
result_path,
ProtectedResultState::Installing,
Some(std::process::id()),
Some(installer_pid),
None,
None,
)?;
let status = match wait_child_timeout(&mut installer, INSTALLER_TIMEOUT) {
Ok(status) => status,
Err(error) => {
let _ = installer.kill();
let _ = installer.wait();
return Err(error);
}
};
if !status.success() {
return Err(FileUpdateCommandError::from(format!(
"full update installer exited with code {}",
status.code().unwrap_or(-1)
)));
}
write_protected_result(
result_path,
ProtectedResultState::Completed,
Some(std::process::id()),
Some(installer_pid),
status.code(),
None,
)?;
let _ = fs::remove_file(&plan.protected_installer_path);
make_protected_root_user_cleanable(protected_root)?;
Ok(())
}
#[cfg(windows)]
fn record_protected_failure(plan: &ProtectedFullUpdatePlan, result_path: &Path, reason: &str) {
let _ = write_protected_result(
result_path,
ProtectedResultState::Failed,
Some(std::process::id()),
None,
None,
Some(reason.to_string()),
);
let _ = fs::remove_file(&plan.protected_installer_path);
if let Some(root) = result_path.parent() {
let _ = make_protected_root_user_cleanable(root);
}
}
#[cfg(windows)]
fn run_full_cleanup(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
supervisor_pid: u32,
full: bool,
) -> Result<(), FileUpdateCommandError> {
wait_for_process_exit(supervisor_pid, BOOTSTRAP_TIMEOUT)?;
let cache_root = helper_cache_root(config)?;
let lock = open_lock(&cache_root)?;
lock.lock_exclusive()?;
let transaction_path = validate_transaction_path(&cache_root, transaction_path)?;
let transaction = read_transaction(&transaction_path)?;
if transaction.application_id != config.application_id {
return Err(FileUpdateCommandError::from(
"full update cleanup transaction belongs to another application",
));
}
if full {
if !same_version(config.current_version, &transaction.target_version)
|| transaction.state != FullUpdateState::Completed
{
return Err(FileUpdateCommandError::from(
"full update cleanup requires the installed target version",
));
}
cleanup_protected_transaction_root(config, &transaction.transaction_id);
remove_transaction_and_pointer(&cache_root, &transaction_path)?;
} else {
let helper_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("full update transaction has no parent"))?
.join("helper");
if helper_root.exists() {
ensure_plain_directory(&helper_root)?;
fs::remove_dir_all(helper_root)?;
}
cleanup_protected_transaction_root(config, &transaction.transaction_id);
}
Ok(())
}
#[cfg(windows)]
fn validate_protected_plan(
config: &FileUpdateHelperConfig,
plan_path: &Path,
) -> Result<ProtectedFullUpdatePlan, FileUpdateCommandError> {
ensure_plain_file(plan_path)?;
let plan = read_json::<ProtectedFullUpdatePlan>(plan_path)?;
if plan.schema_version != PROTECTED_PLAN_SCHEMA_VERSION
|| plan.application_id != config.application_id
{
return Err(FileUpdateCommandError::from(
"protected full update plan identity is invalid",
));
}
validate_version_upgrade(&plan.current_version, &plan.target_version)?;
let expected_root = protected_transaction_root(config, &plan.transaction_id)?;
let canonical_root = expected_root.canonicalize()?;
let canonical_plan = plan_path.canonicalize()?;
if canonical_plan.parent() != Some(canonical_root.as_path())
|| canonical_plan.file_name() != Some(OsStr::new(PROTECTED_PLAN_FILE))
{
return Err(FileUpdateCommandError::from(
"protected full update plan is outside the administrator cache",
));
}
let protected_installer = plan.protected_installer_path.canonicalize()?;
let protected_helper = plan.protected_helper_path.canonicalize()?;
if protected_installer.parent() != Some(canonical_root.as_path())
|| protected_installer.file_name() != Some(OsStr::new(PROTECTED_INSTALLER_FILE))
|| protected_helper.parent() != Some(canonical_root.as_path())
|| protected_helper.file_name() != Some(OsStr::new(PROTECTED_HELPER_FILE))
{
return Err(FileUpdateCommandError::from(
"protected full update payload is outside the administrator cache",
));
}
if std::env::current_exe()?.canonicalize()? != protected_helper {
return Err(FileUpdateCommandError::from(
"protected full update worker mode must run from the administrator cache",
));
}
let install_root = plan.install_root.canonicalize()?;
let installed_executable = plan.installed_executable_path.canonicalize()?;
validate_installed_executable(config, &install_root, &installed_executable)?;
Ok(plan)
}
#[cfg(windows)]
fn verify_protected_copy(
installer_path: &Path,
expected_size: u64,
expected_sha256: &str,
updater_public_key: &str,
updater_signature: &str,
) -> Result<(), FileUpdateCommandError> {
ensure_plain_file(installer_path)?;
ensure_windows_installer(installer_path)?;
let digest = sha256_file(installer_path)?;
if digest.size != expected_size || digest.sha256 != expected_sha256 {
return Err(FileUpdateCommandError::from(
"protected full update installer hash or size verification failed",
));
}
verify_updater_signature(installer_path, updater_public_key, updater_signature)?;
Ok(())
}
#[cfg(windows)]
fn write_protected_result(
result_path: &Path,
state: ProtectedResultState,
worker_pid: Option<u32>,
installer_pid: Option<u32>,
installer_exit_code: Option<i32>,
message: Option<String>,
) -> Result<(), FileUpdateCommandError> {
write_json_file_atomic(
result_path,
&ProtectedFullUpdateResult {
state,
worker_pid,
installer_pid,
installer_exit_code,
updated_at_ms: now_millis(),
message,
},
)?;
Ok(())
}
#[cfg(windows)]
fn wait_for_protected_result(
config: &FileUpdateHelperConfig,
transaction_id: &str,
timeout: Duration,
) -> Result<ProtectedFullUpdateResult, FileUpdateCommandError> {
let result_path =
protected_transaction_root(config, transaction_id)?.join(PROTECTED_RESULT_FILE);
let deadline = std::time::Instant::now() + timeout;
loop {
if let Ok(result) = read_json::<ProtectedFullUpdateResult>(&result_path) {
if matches!(
result.state,
ProtectedResultState::Completed | ProtectedResultState::Failed
) {
wait_for_terminal_protected_processes(&result, BOOTSTRAP_TIMEOUT)?;
return Ok(result);
}
}
if std::time::Instant::now() >= deadline {
return Err(FileUpdateCommandError::from(
"timed out waiting for the full update installer",
));
}
thread::sleep(Duration::from_millis(250));
}
}
#[cfg(windows)]
fn wait_for_terminal_protected_processes(
result: &ProtectedFullUpdateResult,
timeout: Duration,
) -> Result<(), FileUpdateCommandError> {
let deadline = std::time::Instant::now() + timeout;
for pid in [result.installer_pid, result.worker_pid]
.into_iter()
.flatten()
{
if !process_is_running(pid) {
continue;
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return Err(FileUpdateCommandError::from(
"timed out waiting for terminal full update processes",
));
}
wait_for_process_exit(pid, remaining)?;
}
Ok(())
}
#[cfg(windows)]
fn protected_transaction_root(
config: &FileUpdateHelperConfig,
transaction_id: &str,
) -> Result<PathBuf, FileUpdateCommandError> {
validate_application_id(config.application_id)?;
if transaction_id.len() != 64 || !transaction_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(FileUpdateCommandError::from(
"full update transaction id is not a SHA-256 digest",
));
}
let program_data = std::env::var_os("PROGRAMDATA")
.ok_or_else(|| FileUpdateCommandError::from("PROGRAMDATA is not available"))?;
Ok(PathBuf::from(program_data)
.join(config.application_id)
.join("hdiff-update")
.join(FULL_UPDATE_ROOT)
.join("transactions")
.join(transaction_id))
}
#[cfg(windows)]
fn prepare_protected_transaction_root(
config: &FileUpdateHelperConfig,
transaction_id: &str,
) -> Result<PathBuf, FileUpdateCommandError> {
let transaction_root = protected_transaction_root(config, transaction_id)?;
let program_data = PathBuf::from(
std::env::var_os("PROGRAMDATA")
.ok_or_else(|| FileUpdateCommandError::from("PROGRAMDATA is not available"))?,
);
let descriptor = SecurityDescriptor::from_sddl(
"O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)",
)?;
let relative = transaction_root.strip_prefix(&program_data).map_err(|_| {
FileUpdateCommandError::from("protected full update root is outside PROGRAMDATA")
})?;
let mut current = program_data;
let component_count = relative.components().count();
for (index, component) in relative.components().enumerate() {
current.push(component.as_os_str());
if index + 1 == component_count && current.exists() {
remove_path_without_following(¤t)?;
}
create_or_secure_directory(¤t, &descriptor)?;
}
Ok(transaction_root)
}
#[cfg(windows)]
fn cleanup_protected_terminal_roots(config: &FileUpdateHelperConfig) {
let Ok(program_data) = std::env::var("PROGRAMDATA") else {
return;
};
let transactions_root = PathBuf::from(program_data)
.join(config.application_id)
.join("hdiff-update")
.join(FULL_UPDATE_ROOT)
.join("transactions");
let Ok(entries) = fs::read_dir(&transactions_root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
continue;
};
if !metadata.is_dir() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
continue;
}
let result = read_json::<ProtectedFullUpdateResult>(&path.join(PROTECTED_RESULT_FILE));
let Ok(result) = result else {
continue;
};
if !matches!(
result.state,
ProtectedResultState::Completed | ProtectedResultState::Failed
) || result.worker_pid.is_some_and(process_is_running)
|| result.installer_pid.is_some_and(process_is_running)
{
continue;
}
let _ = fs::remove_dir_all(path);
}
}
#[cfg(not(windows))]
fn cleanup_protected_terminal_roots(_config: &FileUpdateHelperConfig) {}
#[cfg(windows)]
fn cleanup_protected_transaction_root(config: &FileUpdateHelperConfig, transaction_id: &str) {
let Ok(root) = protected_transaction_root(config, transaction_id) else {
return;
};
if let Ok(result) = read_json::<ProtectedFullUpdateResult>(&root.join(PROTECTED_RESULT_FILE)) {
if result.worker_pid.is_some_and(process_is_running)
|| result.installer_pid.is_some_and(process_is_running)
{
return;
}
}
let _ = fs::remove_dir_all(root);
}
#[cfg(windows)]
fn make_protected_root_user_cleanable(root: &Path) -> Result<(), FileUpdateCommandError> {
let descriptor = SecurityDescriptor::from_sddl(
"O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;BU)",
)?;
apply_security_descriptor(root, &descriptor)
}
#[cfg(windows)]
struct ProtectedRootCleanupGuard {
path: PathBuf,
armed: bool,
}
#[cfg(windows)]
impl ProtectedRootCleanupGuard {
fn new(path: PathBuf) -> Self {
Self { path, armed: true }
}
fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(windows)]
impl Drop for ProtectedRootCleanupGuard {
fn drop(&mut self) {
if self.armed && self.path.exists() {
let _ = remove_path_without_following(&self.path);
}
}
}
#[cfg(windows)]
struct SecurityDescriptor(PSECURITY_DESCRIPTOR);
#[cfg(windows)]
impl SecurityDescriptor {
fn from_sddl(sddl: &str) -> Result<Self, FileUpdateCommandError> {
let sddl = wide_null(OsStr::new(sddl))?;
let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl.as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
std::ptr::null_mut(),
)
} == 0
{
return Err(FileUpdateCommandError::from(format!(
"failed to create protected directory security descriptor: {}",
std::io::Error::last_os_error()
)));
}
Ok(Self(descriptor))
}
}
#[cfg(windows)]
impl Drop for SecurityDescriptor {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
LocalFree(self.0.cast::<c_void>());
}
}
}
}
#[cfg(windows)]
fn create_or_secure_directory(
path: &Path,
descriptor: &SecurityDescriptor,
) -> Result<(), FileUpdateCommandError> {
if path.exists() {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
remove_path_without_following(path)?;
} else if !metadata.is_dir() {
return Err(FileUpdateCommandError::from(format!(
"protected update path is not a directory: {}",
path.display()
)));
}
}
if !path.exists() {
let wide_path = wide_null(path.as_os_str())?;
let attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
if unsafe { CreateDirectoryW(wide_path.as_ptr(), &attributes) } == 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() != Some(ERROR_ALREADY_EXISTS as i32) {
return Err(FileUpdateCommandError::from(format!(
"failed to create protected update directory {}: {error}",
path.display()
)));
}
}
}
ensure_plain_directory(path)?;
apply_security_descriptor(path, descriptor)
}
#[cfg(windows)]
fn apply_security_descriptor(
path: &Path,
descriptor: &SecurityDescriptor,
) -> Result<(), FileUpdateCommandError> {
let path = wide_null(path.as_os_str())?;
let information = OWNER_SECURITY_INFORMATION
| GROUP_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION;
if unsafe { SetFileSecurityW(path.as_ptr(), information, descriptor.0) } == 0 {
return Err(FileUpdateCommandError::from(format!(
"failed to secure protected update directory: {}",
std::io::Error::last_os_error()
)));
}
Ok(())
}
#[cfg(windows)]
fn remove_path_without_following(path: &Path) -> Result<(), FileUpdateCommandError> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
if metadata.is_dir() {
fs::remove_dir(path)?;
} else {
fs::remove_file(path)?;
}
return Ok(());
}
if metadata.is_dir() {
ensure_plain_tree(path)?;
fs::remove_dir_all(path)?;
} else if metadata.is_file() {
fs::remove_file(path)?;
} else {
return Err(FileUpdateCommandError::from(format!(
"unsupported protected update path type: {}",
path.display()
)));
}
Ok(())
}
#[cfg(windows)]
fn ensure_plain_tree(root: &Path) -> Result<(), FileUpdateCommandError> {
ensure_plain_directory(root)?;
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"protected update tree contains a reparse point: {}",
path.display()
)));
}
if metadata.is_dir() {
ensure_plain_tree(&path)?;
} else if !metadata.is_file() {
return Err(FileUpdateCommandError::from(format!(
"protected update tree contains an unsupported entry: {}",
path.display()
)));
}
}
Ok(())
}
#[cfg(windows)]
fn copy_file_synced(source: &Path, destination: &Path) -> Result<(), FileUpdateCommandError> {
ensure_plain_file(source)?;
if destination.exists() {
ensure_plain_file(destination)?;
fs::remove_file(destination)?;
}
fs::copy(source, destination)?;
fs::OpenOptions::new()
.write(true)
.open(destination)?
.sync_all()?;
ensure_plain_file(destination)?;
Ok(())
}
#[cfg(windows)]
struct ElevatedProcess {
handle: HANDLE,
pid: u32,
}
#[cfg(windows)]
fn shell_execute_elevated(
executable: &Path,
parameters: &str,
) -> Result<ElevatedProcess, FileUpdateCommandError> {
let verb = wide_null(OsStr::new("runas"))?;
let executable_wide = wide_null(executable.as_os_str())?;
let parameters = wide_null(OsStr::new(parameters))?;
let directory = executable
.parent()
.map(|path| wide_null(path.as_os_str()))
.transpose()?;
let mut execute_info = SHELLEXECUTEINFOW {
cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
lpVerb: verb.as_ptr(),
lpFile: executable_wide.as_ptr(),
lpParameters: parameters.as_ptr(),
lpDirectory: directory
.as_ref()
.map_or(std::ptr::null(), |value| value.as_ptr()),
nShow: SW_SHOWNORMAL,
..Default::default()
};
if unsafe { ShellExecuteExW(&mut execute_info) } == 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(ERROR_CANCELLED as i32) {
return Err(FileUpdateCommandError::from(
"administrator permission was declined",
));
}
if error.raw_os_error() == Some(ERROR_ELEVATION_REQUIRED as i32) {
return Err(FileUpdateCommandError::from(
"administrator permission is required",
));
}
return Err(FileUpdateCommandError::from(format!(
"failed to start elevated full update bootstrap: {error}"
)));
}
if execute_info.hProcess.is_null() {
return Err(FileUpdateCommandError::from(
"elevated full update bootstrap started without a process handle",
));
}
let pid = unsafe { GetProcessId(execute_info.hProcess) };
if pid == 0 {
unsafe {
CloseHandle(execute_info.hProcess);
}
return Err(FileUpdateCommandError::from(
"elevated full update bootstrap started without a process id",
));
}
Ok(ElevatedProcess {
handle: execute_info.hProcess,
pid,
})
}
#[cfg(windows)]
fn wait_process_handle(handle: HANDLE, timeout: Duration) -> Result<u32, FileUpdateCommandError> {
let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32;
let wait = unsafe { WaitForSingleObject(handle, timeout_ms) };
if wait != WAIT_OBJECT_0 {
unsafe {
CloseHandle(handle);
}
if wait == WAIT_TIMEOUT {
return Err(FileUpdateCommandError::from(
"timed out waiting for elevated full update bootstrap",
));
}
return Err(FileUpdateCommandError::from(format!(
"waiting for elevated full update bootstrap failed with code {wait}"
)));
}
let mut exit_code = 0_u32;
if unsafe { GetExitCodeProcess(handle, &mut exit_code) } == 0 {
let error = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(FileUpdateCommandError::from(format!(
"failed to read elevated full update bootstrap exit code: {error}"
)));
}
unsafe {
CloseHandle(handle);
}
Ok(exit_code)
}
#[cfg(windows)]
fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), FileUpdateCommandError> {
let process = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return Ok(());
}
let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32;
let result = unsafe { WaitForSingleObject(process, timeout_ms) };
unsafe {
CloseHandle(process);
}
if result == WAIT_OBJECT_0 {
Ok(())
} else if result == WAIT_TIMEOUT {
Err(FileUpdateCommandError::from(format!(
"process {pid} did not exit within {} seconds",
timeout.as_secs()
)))
} else {
Err(FileUpdateCommandError::from(format!(
"waiting for process {pid} failed with code {result}"
)))
}
}
#[cfg(windows)]
fn wait_child_timeout(
child: &mut Child,
timeout: Duration,
) -> Result<std::process::ExitStatus, FileUpdateCommandError> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok(status);
}
if std::time::Instant::now() >= deadline {
return Err(FileUpdateCommandError::from(
"full update installer did not exit before the timeout",
));
}
thread::sleep(Duration::from_millis(250));
}
}
#[cfg(windows)]
fn launch_installed_application(
config: &FileUpdateHelperConfig,
install_root: &Path,
) -> Result<Child, FileUpdateCommandError> {
let executable = install_root.join(config.main_executable);
let mut command = Command::new(&executable);
command
.current_dir(install_root)
.creation_flags(CREATE_NO_WINDOW);
command.spawn().map_err(|error| {
FileUpdateCommandError::from(format!(
"failed to restart installed application {}: {error}",
executable.display()
))
})
}
#[cfg(windows)]
fn spawn_cleanup_helper(
config: &FileUpdateHelperConfig,
install_root: &Path,
transaction_path: &Path,
supervisor_pid: u32,
full: bool,
) -> Result<u32, FileUpdateCommandError> {
let executable = install_root.join(config.main_executable);
let mut command = Command::new(&executable);
command
.arg(FULL_CLEANUP_ARGUMENT)
.arg(transaction_path)
.arg(supervisor_pid.to_string())
.arg(if full { CLEANUP_FULL } else { CLEANUP_HELPER })
.current_dir(install_root)
.creation_flags(CREATE_NO_WINDOW);
let child = command.spawn().map_err(|error| {
FileUpdateCommandError::from(format!(
"failed to start full update cleanup helper {}: {error}",
executable.display()
))
})?;
let pid = child.id();
drop(child);
Ok(pid)
}
#[cfg(windows)]
fn process_is_running(pid: u32) -> bool {
let process = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return false;
}
let wait = unsafe { WaitForSingleObject(process, 0) };
unsafe {
CloseHandle(process);
}
wait == WAIT_TIMEOUT
}
#[cfg(not(windows))]
fn process_is_running(_pid: u32) -> bool {
false
}
#[cfg(windows)]
fn wide_null(value: &OsStr) -> std::io::Result<Vec<u16>> {
let mut wide = Vec::new();
for unit in value.encode_wide() {
if unit == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"value contains an embedded null character",
));
}
wide.push(unit);
}
wide.push(0);
Ok(wide)
}
#[cfg(windows)]
fn windows_command_line(args: &[String]) -> String {
args.iter()
.map(|argument| quote_windows_argument(argument))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(windows)]
fn quote_windows_argument(argument: &str) -> String {
if !argument.is_empty()
&& !argument
.chars()
.any(|character| matches!(character, ' ' | '\t' | '"'))
{
return argument.to_owned();
}
let mut quoted = String::with_capacity(argument.len() + 2);
quoted.push('"');
let mut backslashes = 0usize;
for character in argument.chars() {
match character {
'\\' => backslashes += 1,
'"' => {
quoted.extend(std::iter::repeat('\\').take(backslashes * 2 + 1));
quoted.push('"');
backslashes = 0;
}
_ => {
quoted.extend(std::iter::repeat('\\').take(backslashes));
quoted.push(character);
backslashes = 0;
}
}
}
quoted.extend(std::iter::repeat('\\').take(backslashes * 2));
quoted.push('"');
quoted
}
#[cfg(test)]
mod tests {
use std::fs;
use base64::Engine;
use super::{
full_transaction_id, is_locally_collectable, release_identity_matches,
resolve_tauri_update_artifact, same_version, validate_application_id,
validate_version_upgrade, verify_updater_signature, FullUpdateState, TauriUpdateManifest,
};
#[test]
fn full_transaction_id_binds_url_and_signature() {
let first = full_transaction_id(
"app",
"1.0.0",
"1.1.0",
"https://example/app.exe",
"signature-a",
);
let same = full_transaction_id(
"app",
"1.0.0",
"1.1.0",
"https://example/app.exe",
"signature-a",
);
let different_url = full_transaction_id(
"app",
"1.0.0",
"1.1.0",
"https://example/republished.exe",
"signature-a",
);
let different_signature = full_transaction_id(
"app",
"1.0.0",
"1.1.0",
"https://example/app.exe",
"signature-b",
);
assert_eq!(first, same);
assert_ne!(first, different_url);
assert_ne!(first, different_signature);
assert_eq!(first.len(), 64);
}
#[test]
fn reusable_full_update_requires_exact_version_url_and_signature() {
assert!(release_identity_matches(
"v1.1.0",
"https://example.test/app.exe",
"signature-a",
"1.1.0",
"https://example.test/app.exe",
"signature-a",
));
assert!(!release_identity_matches(
"1.1.0",
"https://example.test/app.exe",
"signature-a",
"1.1.0",
"https://example.test/app.exe?republished=1",
"signature-a",
));
assert!(!release_identity_matches(
"1.1.0",
"https://example.test/app.exe",
"signature-a",
"1.1.0",
"https://example.test/app.exe",
"signature-b",
));
}
#[test]
fn cache_collection_keeps_ready_and_active_transactions() {
assert!(is_locally_collectable(FullUpdateState::Preparing));
assert!(is_locally_collectable(FullUpdateState::Completed));
assert!(is_locally_collectable(FullUpdateState::Failed));
assert!(!is_locally_collectable(FullUpdateState::Ready));
assert!(!is_locally_collectable(FullUpdateState::SupervisorStarted));
assert!(!is_locally_collectable(FullUpdateState::Elevating));
assert!(!is_locally_collectable(FullUpdateState::Installing));
}
#[test]
fn version_comparison_accepts_v_prefix_and_rejects_non_upgrade() {
assert!(same_version("v1.2.3", "1.2.3"));
assert!(validate_version_upgrade("1.2.3", "1.2.4").is_ok());
assert!(validate_version_upgrade("1.2.3", "1.2.3").is_err());
assert!(validate_version_upgrade("1.2.3", "1.2.2").is_err());
}
#[test]
fn application_id_is_safe_as_a_program_data_component() {
assert!(validate_application_id("ai.xcodex.citizenl").is_ok());
assert!(validate_application_id("../other").is_err());
assert!(validate_application_id("app\\other").is_err());
assert!(validate_application_id("").is_err());
}
#[test]
fn resolves_dynamic_tauri_update_artifact() {
let manifest: TauriUpdateManifest = serde_json::from_value(serde_json::json!({
"version": "1.2.4",
"url": "https://example.test/app.exe",
"signature": "signature"
}))
.unwrap();
let artifact = resolve_tauri_update_artifact(manifest).unwrap();
assert_eq!(artifact.url, "https://example.test/app.exe");
assert_eq!(artifact.signature, "signature");
}
#[test]
fn verifies_tauri_wrapped_prehashed_minisign_signature_from_disk() {
let directory = tempfile::tempdir().unwrap();
let installer = directory.path().join("installer.exe");
fs::write(&installer, b"test").unwrap();
let public_key = "untrusted comment: minisign public key E7620F1842B4E81F\nRWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
let signature = "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg==";
let public_key = base64::engine::general_purpose::STANDARD.encode(public_key);
let signature = base64::engine::general_purpose::STANDARD.encode(signature);
verify_updater_signature(&installer, &public_key, &signature).unwrap();
}
#[cfg(windows)]
#[test]
fn helper_dispatch_requires_an_explicit_full_update_mode() {
use std::ffi::OsString;
use super::{parse_full_helper_invocation, FullHelperInvocation, FULL_SUPERVISOR_ARGUMENT};
assert!(parse_full_helper_invocation(&[OsString::from("app.exe")])
.unwrap()
.is_none());
assert!(parse_full_helper_invocation(&[
OsString::from("app.exe"),
OsString::from("--embedded-terminal-broker"),
])
.unwrap()
.is_none());
let invocation = parse_full_helper_invocation(&[
OsString::from("app.exe"),
OsString::from(FULL_SUPERVISOR_ARGUMENT),
OsString::from("C:\\cache\\transaction.json"),
OsString::from("42"),
])
.unwrap();
assert_eq!(
invocation,
Some(FullHelperInvocation::Supervisor {
transaction_path: "C:\\cache\\transaction.json".into(),
parent_pid: 42,
})
);
}
#[cfg(windows)]
#[test]
fn quotes_elevated_bootstrap_arguments() {
use super::{windows_command_line, FULL_ELEVATED_BOOTSTRAP_ARGUMENT};
assert_eq!(
windows_command_line(&[
FULL_ELEVATED_BOOTSTRAP_ARGUMENT.to_string(),
"C:\\Program Data\\transaction.json".to_string(),
]),
"--hdiff-full-update-elevated-bootstrap \"C:\\Program Data\\transaction.json\""
);
}
}