use std::{
fs,
path::{Path, PathBuf},
process::{Child, Command},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use fs2::FileExt;
use hdiff_update_core::{
copy_managed_tree, ensure_available_space, normalize_managed_paths, normalize_relative_path,
path_for_manifest_entry, prepare_directory_update, read_directory_manifest_file,
read_directory_transaction, sha256_file, verify_directory_manifest_signature_with_keys,
verify_file_tree, write_directory_transaction, write_json_file_atomic,
DirectoryTransactionState, DownloadEvent, Error as CoreError, HttpHeader,
PrepareDirectoryUpdateOptions, PreparedDirectoryUpdate, PreparedDirectoryUpdateKind,
};
use serde::{Deserialize, Serialize};
use tauri::{ipc::Channel, AppHandle, Manager, Runtime, State};
#[cfg(windows)]
use std::{
ffi::OsStr,
os::windows::{ffi::OsStrExt, process::CommandExt},
};
#[cfg(windows)]
use windows_sys::Win32::{
Foundation::{
CloseHandle, ERROR_CANCELLED, ERROR_ELEVATION_REQUIRED, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
Storage::FileSystem::{
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, SYNCHRONIZE,
},
System::Threading::{
GetExitCodeProcess, GetProcessId, OpenProcess, WaitForSingleObject,
PROCESS_QUERY_LIMITED_INFORMATION,
},
UI::{
Shell::{ShellExecuteExW, SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW},
WindowsAndMessaging::SW_SHOWNORMAL,
},
};
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const FILE_UPDATE_ROOT: &str = "file-updates";
const SUPERVISOR_ARGUMENT: &str = "--hdiff-update-supervisor";
const ELEVATED_ARGUMENT: &str = "--hdiff-update-elevated";
const HEALTH_FILE: &str = "health.ok";
const RESULT_FILE: &str = "result.json";
const ROLLBACK_REQUEST_FILE: &str = "rollback.request";
const LAUNCHED_PID_FILE: &str = "launched.pid";
const SUPERVISOR_PID_FILE: &str = "supervisor.pid";
const MANIFEST_FILE: &str = "manifest.json";
const STAGING_DIR: &str = "staged";
#[cfg(windows)]
const FILE_OPERATION_RETRY_SECS: u64 = 15;
#[cfg(windows)]
const COMMIT_DISK_RESERVE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct FileUpdateHelperConfig {
pub application_id: &'static str,
pub current_version: &'static str,
pub public_keys: &'static [&'static str],
pub updater_public_key: &'static str,
pub main_executable: &'static str,
pub managed_paths: &'static [&'static str],
pub hpatchz_relative_path: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileUpdateCommandError {
pub message: String,
}
impl<E> From<E> for FileUpdateCommandError
where
E: std::fmt::Display,
{
fn from(error: E) -> Self {
Self {
message: error.to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareFileUpdateOptions {
pub manifest_url: String,
pub current_version: String,
pub expected_version: String,
#[serde(default)]
pub headers: Vec<HttpHeader>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum PrepareFileUpdateResult {
Prepared {
update: PreparedDirectoryUpdate,
},
AlreadyPrepared {
update: PreparedDirectoryUpdate,
},
Fallback {
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchFileUpdateOptions {
pub transaction_path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchFileUpdateResult {
pub pid: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum HelperResultState {
AwaitingHealth,
Committed,
RolledBack,
Failed,
RecoveryFailed,
HealthTimeout,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct HelperResult {
state: HelperResultState,
updated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PreparedPointer {
transaction_path: PathBuf,
target_version: String,
target_tree_sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum JournalOperationState {
Pending,
BackedUp,
Installed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct JournalOperation {
path: String,
is_directory: bool,
state: JournalOperationState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CommitJournal {
transaction_id: String,
state: DirectoryTransactionState,
operations: Vec<JournalOperation>,
updated_at_ms: u64,
}
struct ValidatedContext {
transaction_path: PathBuf,
transaction_root: PathBuf,
transaction: hdiff_update_core::DirectoryUpdateTransaction,
source: hdiff_update_core::FileTreeManifest,
target: hdiff_update_core::FileTreeManifest,
staging_path: PathBuf,
result_path: PathBuf,
install_root: PathBuf,
managed_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FileUpdateHelperInvocation {
Supervisor {
transaction_path: PathBuf,
parent_pid: u32,
},
Elevated {
transaction_path: PathBuf,
},
}
#[tauri::command]
pub async fn prepare_file_update<R: Runtime>(
app: AppHandle<R>,
config: State<'_, FileUpdateHelperConfig>,
options: PrepareFileUpdateOptions,
on_event: Channel<DownloadEvent>,
) -> Result<PrepareFileUpdateResult, FileUpdateCommandError> {
if app.config().identifier != config.application_id {
return Err(FileUpdateCommandError::from(
"file update plugin application id does not match the Tauri identifier",
));
}
let public_keys = trusted_public_keys(&config);
if public_keys.is_empty() {
return Ok(PrepareFileUpdateResult::Fallback {
reason: "updaterNotConfigured".to_string(),
detail: Some(
"file update trusted public-key ring is not compiled into the application"
.to_string(),
),
});
}
let install_root = current_install_root()?;
let cache_dir = file_update_cache_root(&app)?;
let hpatchz_path =
path_for_manifest_entry(&app.path().resource_dir()?, config.hpatchz_relative_path)?;
let configured_paths = normalize_managed_paths(
&config
.managed_paths
.iter()
.map(|path| (*path).to_string())
.collect::<Vec<_>>(),
)?;
let hpatchz_path =
validate_bundled_patch_tool(&install_root, &hpatchz_path, &configured_paths)?;
let result = prepare_directory_update(
PrepareDirectoryUpdateOptions {
manifest_url: options.manifest_url,
application_id: config.application_id.to_string(),
install_root,
managed_paths: configured_paths,
current_version: options.current_version,
expected_version: options.expected_version,
platform: None,
cache_dir: cache_dir.clone(),
hpatchz_path: Some(hpatchz_path),
signature_public_keys: public_keys,
require_signature: true,
headers: options.headers,
timeout_secs: options.timeout_secs,
},
|event| {
let _ = on_event.send(event);
},
)
.await;
match result {
Ok(update) if update.kind == PreparedDirectoryUpdateKind::AlreadyPrepared => {
write_json_file_atomic(
cache_dir.join("prepared.json"),
&PreparedPointer {
transaction_path: update.transaction_path.clone(),
target_version: update.transaction.target_version.clone(),
target_tree_sha256: update.transaction.target_tree_sha256.clone(),
},
)?;
Ok(PrepareFileUpdateResult::AlreadyPrepared { update })
}
Ok(update) => Ok(PrepareFileUpdateResult::Prepared { update }),
Err(error) => Ok(PrepareFileUpdateResult::Fallback {
reason: fallback_reason(&error).to_string(),
detail: Some(fallback_detail(&error)),
}),
}
}
#[tauri::command]
pub async fn launch_file_update<R: Runtime>(
app: AppHandle<R>,
config: State<'_, FileUpdateHelperConfig>,
options: LaunchFileUpdateOptions,
) -> Result<LaunchFileUpdateResult, FileUpdateCommandError> {
let cache_root = file_update_cache_root(&app)?;
fs::create_dir_all(&cache_root)?;
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(cache_root.join("lock"))?;
lock.lock_exclusive()?;
let transaction_path = validate_transaction_path(&cache_root, &options.transaction_path)?;
let pid = launch_file_update_inner(&config, &cache_root, &transaction_path)?;
Ok(LaunchFileUpdateResult { pid })
}
fn launch_file_update_inner(
config: &FileUpdateHelperConfig,
cache_root: &Path,
transaction_path: &Path,
) -> Result<u32, FileUpdateCommandError> {
let transaction_path = validate_transaction_path(cache_root, transaction_path)?;
let mut transaction = read_directory_transaction(&transaction_path)?;
if transaction.application_id != config.application_id {
return Err(FileUpdateCommandError::from(
"file update transaction belongs to another application",
));
}
if transaction.state != DirectoryTransactionState::Ready {
return Err(FileUpdateCommandError::from(format!(
"file update transaction is not ready: {:?}",
transaction.state
)));
}
let context = validate_helper_context(config, &transaction_path)?;
verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.source,
)?;
let current_exe = std::env::current_exe()?;
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("transaction path has no parent"))?;
let helper_dir = transaction_root.join("helper");
fs::create_dir_all(&helper_dir)?;
let helper_path = helper_dir.join(
current_exe
.file_name()
.unwrap_or_else(|| OsStr::new("update-helper.exe")),
);
fs::copy(¤t_exe, &helper_path)?;
let source_digest = sha256_file(¤t_exe)?;
let helper_digest = sha256_file(&helper_path)?;
if source_digest.sha256 != helper_digest.sha256 {
return Err(FileUpdateCommandError::from(
"copied file update helper failed SHA-256 verification",
));
}
transaction.state = DirectoryTransactionState::SupervisorStarted;
transaction.updated_at_ms = now_millis();
write_directory_transaction(&transaction_path, &transaction)?;
let mut command = Command::new(&helper_path);
command
.arg(SUPERVISOR_ARGUMENT)
.arg(&transaction_path)
.arg(std::process::id().to_string());
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
transaction.state = DirectoryTransactionState::Ready;
transaction.updated_at_ms = now_millis();
write_directory_transaction(&transaction_path, &transaction)?;
return Err(FileUpdateCommandError::from(format!(
"failed to start file update supervisor {}: {error}",
helper_path.display()
)));
}
};
let pid = child.id();
if let Err(error) = write_supervisor_pid(transaction_root, pid) {
let _ = child.kill();
let _ = child.wait();
transaction.state = DirectoryTransactionState::Ready;
transaction.updated_at_ms = now_millis();
write_directory_transaction(&transaction_path, &transaction)?;
return Err(error);
}
Ok(pid)
}
pub fn maybe_run_file_update_helper(
config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
let args = std::env::args_os().collect::<Vec<_>>();
match parse_file_update_helper_invocation(&args)? {
Some(FileUpdateHelperInvocation::Supervisor {
transaction_path,
parent_pid,
}) => {
run_supervisor(config, &transaction_path, parent_pid)?;
Ok(true)
}
Some(FileUpdateHelperInvocation::Elevated { transaction_path }) => {
run_elevated_worker(config, &transaction_path)?;
Ok(true)
}
None => crate::full_update::maybe_run_full_update_helper(config, &args),
}
}
fn parse_file_update_helper_invocation(
args: &[std::ffi::OsString],
) -> Result<Option<FileUpdateHelperInvocation>, FileUpdateCommandError> {
let Some(mode) = args.get(1).and_then(|value| value.to_str()) else {
return Ok(None);
};
match mode {
SUPERVISOR_ARGUMENT => {
let transaction_path = args
.get(2)
.map(PathBuf::from)
.ok_or_else(|| FileUpdateCommandError::from("missing transaction path"))?;
let parent_pid = args
.get(3)
.and_then(|value| value.to_str())
.ok_or_else(|| FileUpdateCommandError::from("missing parent process id"))?
.parse::<u32>()
.map_err(FileUpdateCommandError::from)?;
Ok(Some(FileUpdateHelperInvocation::Supervisor {
transaction_path,
parent_pid,
}))
}
ELEVATED_ARGUMENT => {
let transaction_path = args
.get(2)
.map(PathBuf::from)
.ok_or_else(|| FileUpdateCommandError::from("missing transaction path"))?;
Ok(Some(FileUpdateHelperInvocation::Elevated {
transaction_path,
}))
}
_ => Ok(None),
}
}
pub fn apply_prepared_update_on_startup(
config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
if let Err(error) = cleanup_update_caches(config) {
log::warn!(
"failed to clean update caches before startup: {}",
error.message
);
}
match maybe_resume_interrupted_update(config) {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(error) => {
log::error!(
"failed to recover interrupted directory update; continuing startup: {}",
error.message
);
return Ok(false);
}
}
match maybe_apply_prepared_file_update(config) {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(error) => log::warn!(
"failed to apply prepared directory update; continuing startup: {}",
error.message
),
}
match crate::full_update::maybe_apply_prepared_full_update(config) {
Ok(action) => Ok(action),
Err(error) => {
log::warn!(
"failed to apply prepared full update; continuing startup: {}",
error.message
);
Ok(false)
}
}
}
pub fn confirm_file_update_startup<R: Runtime>(
app: &AppHandle<R>,
) -> Result<bool, FileUpdateCommandError> {
let cache_root = file_update_cache_root(app)?;
let transactions_root = cache_root.join("transactions");
if !transactions_root.is_dir() {
return Ok(false);
}
let current_version = app.package_info().version.to_string();
for entry in fs::read_dir(transactions_root)? {
let entry = entry?;
let transaction_path = entry.path().join("transaction.json");
if !transaction_path.is_file() {
continue;
}
let transaction = match read_directory_transaction(&transaction_path) {
Ok(transaction) => transaction,
Err(_) => continue,
};
if transaction.application_id != app.config().identifier
|| transaction.target_version != current_version
|| !matches!(
transaction.state,
DirectoryTransactionState::AwaitingHealth
| DirectoryTransactionState::HealthTimeout
)
{
continue;
}
fs::write(entry.path().join(HEALTH_FILE), b"ok")?;
return Ok(true);
}
Ok(false)
}
#[cfg(windows)]
fn maybe_apply_prepared_file_update(
config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
if trusted_public_keys(config).is_empty() {
return Ok(false);
}
let cache_root = match helper_cache_root(config) {
Ok(path) => path,
Err(_) => return Ok(false),
};
let pointer_path = cache_root.join("prepared.json");
if !pointer_path.is_file() {
return Ok(false);
}
let pointer = match serde_json::from_slice::<PreparedPointer>(&fs::read(&pointer_path)?) {
Ok(pointer) => pointer,
Err(_) => {
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(_) => {
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
let mut transaction = match read_directory_transaction(&transaction_path) {
Ok(transaction) => transaction,
Err(_) => {
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
};
if transaction.application_id != config.application_id
|| !versions_match(&transaction.current_version, config.current_version)
|| pointer.target_version != transaction.target_version
|| pointer.target_tree_sha256 != transaction.target_tree_sha256
{
invalidate_prepared_file_update(
&cache_root,
&transaction_path,
&mut transaction,
"prepared directory update does not match the running application",
)?;
return Ok(false);
}
if matches!(
transaction.state,
DirectoryTransactionState::Committed
| DirectoryTransactionState::RolledBack
| DirectoryTransactionState::Failed
) {
let _ = fs::remove_file(pointer_path);
return Ok(false);
}
if transaction.state != DirectoryTransactionState::Ready {
return Ok(false);
}
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(cache_root.join("lock"))?;
lock.lock_exclusive()?;
match launch_file_update_inner(config, &cache_root, &transaction_path) {
Ok(_) => Ok(true),
Err(error) => {
let mut transaction =
read_directory_transaction(&transaction_path).unwrap_or(transaction);
invalidate_prepared_file_update(
&cache_root,
&transaction_path,
&mut transaction,
&error.message,
)?;
Ok(false)
}
}
}
#[cfg(not(windows))]
fn maybe_apply_prepared_file_update(
_config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
Ok(false)
}
#[cfg(windows)]
fn maybe_resume_interrupted_update(
config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
if trusted_public_keys(config).is_empty() {
return Ok(false);
}
let cache_root = match helper_cache_root(config) {
Ok(path) => path,
Err(_) => return Ok(false),
};
let pointer_path = cache_root.join("prepared.json");
if !pointer_path.is_file() {
return Ok(false);
}
let pointer: PreparedPointer = match serde_json::from_slice(&fs::read(&pointer_path)?) {
Ok(pointer) => pointer,
Err(_) => {
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(_) => return Ok(false),
};
let transaction = read_directory_transaction(&transaction_path)?;
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("transaction path has no parent"))?;
let health_exists = transaction_root.join(HEALTH_FILE).is_file();
let result = read_helper_result(&transaction_root.join(RESULT_FILE)).ok();
if result.as_ref().is_some_and(|result| {
matches!(
result.state,
HelperResultState::Committed
| HelperResultState::RolledBack
| HelperResultState::Failed
)
}) {
return Ok(false);
}
let supervisor_running = read_supervisor_pid(transaction_root)
.map(process_is_running)
.unwrap_or(false);
match transaction.state {
DirectoryTransactionState::SupervisorStarted | DirectoryTransactionState::Committing => {
if supervisor_running {
return Ok(true);
}
spawn_recovery_supervisor(transaction_root, &transaction_path)?;
Ok(true)
}
DirectoryTransactionState::AwaitingHealth | DirectoryTransactionState::HealthTimeout => {
let context = validate_helper_context(config, &transaction_path)?;
let target_is_installed = verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.target,
)
.is_ok();
if supervisor_running {
return Ok(!target_is_installed);
}
if target_is_installed && !health_exists {
return Ok(false);
}
spawn_recovery_supervisor(transaction_root, &transaction_path)?;
Ok(true)
}
_ => Ok(false),
}
}
#[cfg(not(windows))]
fn maybe_resume_interrupted_update(
_config: &FileUpdateHelperConfig,
) -> Result<bool, FileUpdateCommandError> {
Ok(false)
}
#[cfg(windows)]
fn spawn_recovery_supervisor(
transaction_root: &Path,
transaction_path: &Path,
) -> Result<(), FileUpdateCommandError> {
let current_exe = std::env::current_exe()?;
let helper_dir = transaction_root.join("helper");
fs::create_dir_all(&helper_dir)?;
let helper_path = helper_dir.join(
current_exe
.file_name()
.unwrap_or_else(|| OsStr::new("update-helper.exe")),
);
fs::copy(¤t_exe, &helper_path)?;
if sha256_file(¤t_exe)?.sha256 != sha256_file(&helper_path)?.sha256 {
return Err(FileUpdateCommandError::from(
"recovery helper failed SHA-256 verification",
));
}
let mut command = Command::new(helper_path);
command
.arg(SUPERVISOR_ARGUMENT)
.arg(transaction_path)
.arg(std::process::id().to_string())
.creation_flags(CREATE_NO_WINDOW);
let mut child = command.spawn()?;
if let Err(error) = write_supervisor_pid(transaction_root, child.id()) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
Ok(())
}
#[cfg(windows)]
fn invalidate_prepared_file_update(
cache_root: &Path,
transaction_path: &Path,
transaction: &mut hdiff_update_core::DirectoryUpdateTransaction,
reason: &str,
) -> Result<(), FileUpdateCommandError> {
transaction.state = DirectoryTransactionState::Failed;
transaction.updated_at_ms = now_millis();
transaction.failure_reason = Some(reason.to_string());
write_directory_transaction(transaction_path, transaction)?;
let pointer_path = cache_root.join("prepared.json");
if pointer_path.is_file() {
fs::remove_file(pointer_path)?;
}
Ok(())
}
fn versions_match(left: &str, right: &str) -> bool {
left.trim().trim_start_matches('v') == right.trim().trim_start_matches('v')
}
fn cleanup_update_caches(config: &FileUpdateHelperConfig) -> Result<(), FileUpdateCommandError> {
let mut first_error = None;
for result in [
cleanup_legacy_roaming_cache(config),
cleanup_terminal_file_update_transactions(config),
crate::full_update::cleanup_full_update_cache(config),
] {
if let Err(error) = result {
log::warn!("update cache cleanup step failed: {}", error.message);
if first_error.is_none() {
first_error = Some(error);
}
}
}
first_error.map_or(Ok(()), Err)
}
fn cleanup_legacy_roaming_cache(
config: &FileUpdateHelperConfig,
) -> Result<(), FileUpdateCommandError> {
let Some(app_data) = std::env::var_os("APPDATA") else {
return Ok(());
};
let legacy_root = PathBuf::from(app_data)
.join(config.application_id)
.join("hdiff-update");
if !legacy_root.exists() {
return Ok(());
}
ensure_plain_directory(&legacy_root)?;
fs::remove_dir_all(legacy_root)?;
Ok(())
}
fn cleanup_terminal_file_update_transactions(
config: &FileUpdateHelperConfig,
) -> Result<(), FileUpdateCommandError> {
let cache_root = helper_cache_root(config)?;
let transactions_root = cache_root.join("transactions");
if !transactions_root.is_dir() {
return Ok(());
}
ensure_plain_directory(&transactions_root)?;
let pointer_path = cache_root.join("prepared.json");
let pointer_transaction = fs::read(&pointer_path)
.ok()
.and_then(|bytes| serde_json::from_slice::<PreparedPointer>(&bytes).ok())
.and_then(|pointer| pointer.transaction_path.canonicalize().ok());
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 canonical_transaction = transaction_path.canonicalize().ok();
let transaction = read_directory_transaction(&transaction_path).ok();
let removable = transaction.as_ref().is_some_and(|transaction| {
matches!(
transaction.state,
DirectoryTransactionState::Preparing
| DirectoryTransactionState::Committed
| DirectoryTransactionState::RolledBack
| DirectoryTransactionState::Failed
)
});
if !removable {
continue;
}
if file_transaction_has_running_process(&entry_path) {
continue;
}
fs::remove_dir_all(entry_path)?;
if pointer_transaction.is_some()
&& pointer_transaction == canonical_transaction
&& pointer_path.is_file()
{
fs::remove_file(&pointer_path)?;
}
}
Ok(())
}
#[cfg(windows)]
fn file_transaction_has_running_process(transaction_root: &Path) -> bool {
read_supervisor_pid(transaction_root).is_some_and(process_is_running)
|| read_launched_pid(transaction_root).is_some_and(process_is_running)
}
#[cfg(not(windows))]
fn file_transaction_has_running_process(_transaction_root: &Path) -> bool {
false
}
fn fallback_reason(error: &CoreError) -> &'static str {
match error {
CoreError::SignatureMissing
| CoreError::SignaturePublicKeyMissing
| CoreError::SignatureVerificationFailed
| CoreError::InvalidKeyMaterial { .. } => "manifestSignatureInvalid",
CoreError::UnexpectedVersion { .. } => "versionMismatch",
CoreError::InvalidVersion { .. } | CoreError::NonUpgradeVersion { .. } => "versionMismatch",
CoreError::MissingPlatform { .. } => "unsupportedPlatform",
CoreError::NoMatchingDelta { .. } => "noMatchingDelta",
CoreError::HashMismatch { .. }
| CoreError::SizeMismatch { .. }
| CoreError::DownloadLimitExceeded { .. } => "artifactVerificationFailed",
CoreError::InsufficientDiskSpace { .. } => "insufficientDiskSpace",
CoreError::Http(_)
| CoreError::UnexpectedHttpStatus { .. }
| CoreError::Url(_)
| CoreError::UnsupportedUrl(_) => "downloadFailed",
CoreError::InsecureTransport { .. } => "insecureTransport",
CoreError::ProcessFailed { .. } => "patchFailed",
CoreError::UnsupportedAlgorithm(_) => "unsupportedAlgorithm",
CoreError::Message(message) if message.contains("managed tree mismatch") => {
"sourceTreeMismatch"
}
CoreError::Message(message) if message.contains("previous directory update") => {
"previousCommitFailed"
}
_ => "directoryUpdateFailed",
}
}
fn fallback_detail(error: &CoreError) -> String {
match error {
CoreError::Http(_) | CoreError::UnexpectedHttpStatus { .. } => {
"HTTPS update request failed".to_string()
}
CoreError::Url(_) | CoreError::UnsupportedUrl(_) => {
"update endpoint URL is invalid".to_string()
}
CoreError::InsecureTransport { .. } => {
"production directory updates require HTTPS".to_string()
}
CoreError::InsufficientDiskSpace {
required,
available,
..
} => format!(
"not enough free disk space: required {required} bytes, available {available} bytes"
),
_ => error.to_string(),
}
}
fn trusted_public_keys(config: &FileUpdateHelperConfig) -> Vec<String> {
config
.public_keys
.iter()
.map(|key| key.trim())
.filter(|key| !key.is_empty())
.map(str::to_string)
.collect()
}
fn file_update_cache_root<R: Runtime>(
app: &AppHandle<R>,
) -> Result<PathBuf, FileUpdateCommandError> {
Ok(app
.path()
.app_local_data_dir()?
.join("hdiff-update")
.join(FILE_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(FILE_UPDATE_ROOT))
}
fn current_install_root() -> Result<PathBuf, FileUpdateCommandError> {
let executable = std::env::current_exe()?;
executable
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| FileUpdateCommandError::from("current executable has no parent directory"))
}
fn validate_bundled_patch_tool(
install_root: &Path,
tool_path: &Path,
managed_paths: &[String],
) -> Result<PathBuf, FileUpdateCommandError> {
let install_root = install_root.canonicalize()?;
let tool_path = tool_path.canonicalize()?;
let metadata = fs::symlink_metadata(&tool_path)?;
if !metadata.is_file() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"bundled patch tool is missing or is a reparse point: {}",
tool_path.display()
)));
}
let relative = tool_path.strip_prefix(&install_root).map_err(|_| {
FileUpdateCommandError::from("bundled patch tool is outside the installation root")
})?;
let relative = relative
.to_str()
.ok_or_else(|| FileUpdateCommandError::from("bundled patch tool path is not UTF-8"))?
.replace('\\', "/");
let relative = normalize_relative_path(&relative)?;
if !managed_paths
.iter()
.any(|managed| relative == *managed || relative.starts_with(&format!("{managed}/")))
{
return Err(FileUpdateCommandError::from(format!(
"bundled patch tool is outside the managed update tree: {relative}"
)));
}
Ok(tool_path)
}
fn validate_transaction_path(
cache_root: &Path,
transaction_path: &Path,
) -> Result<PathBuf, FileUpdateCommandError> {
let expected_root = cache_root.join("transactions");
let transaction_path = transaction_path.canonicalize()?;
let expected_root = expected_root.canonicalize()?;
if !transaction_path.starts_with(&expected_root)
|| transaction_path.file_name() != Some(OsStr::new("transaction.json"))
{
return Err(FileUpdateCommandError::from(format!(
"transaction path is outside the managed cache: {}",
transaction_path.display()
)));
}
Ok(transaction_path)
}
fn validate_helper_context(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
) -> Result<ValidatedContext, FileUpdateCommandError> {
let public_keys = trusted_public_keys(config);
if public_keys.is_empty() {
return Err(FileUpdateCommandError::from(
"file update helper trusted public-key ring is not compiled into the application",
));
}
let transaction_path = transaction_path.canonicalize()?;
let expected_transactions_root = helper_cache_root(config)?
.join("transactions")
.canonicalize()?;
if !transaction_path.starts_with(&expected_transactions_root)
|| transaction_path.file_name() != Some(OsStr::new("transaction.json"))
{
return Err(FileUpdateCommandError::from(format!(
"file update transaction is outside the application cache: {}",
transaction_path.display()
)));
}
let transaction_root = transaction_path
.parent()
.ok_or_else(|| FileUpdateCommandError::from("transaction path has no parent"))?
.to_path_buf();
ensure_plain_directory(&transaction_root)?;
let transaction = read_directory_transaction(&transaction_path)?;
if transaction.application_id != config.application_id {
return Err(FileUpdateCommandError::from(
"file update helper application id mismatch",
));
}
let configured_paths = normalize_managed_paths(
&config
.managed_paths
.iter()
.map(|path| (*path).to_string())
.collect::<Vec<_>>(),
)?;
if transaction.managed_paths != configured_paths {
return Err(FileUpdateCommandError::from(
"file update helper managed path policy mismatch",
));
}
if !configured_paths
.iter()
.any(|path| path.eq_ignore_ascii_case(config.main_executable))
{
return Err(FileUpdateCommandError::from(
"main executable is not part of the managed path policy",
));
}
let manifest_path = transaction_root.join(MANIFEST_FILE);
let manifest = read_directory_manifest_file(&manifest_path)?;
verify_directory_manifest_signature_with_keys(&manifest, &public_keys)?;
if manifest.version != transaction.target_version {
return Err(FileUpdateCommandError::from(
"transaction target version does not match signed manifest",
));
}
let release = manifest.platform(&transaction.platform)?.clone();
if release.managed_paths != configured_paths {
return Err(FileUpdateCommandError::from(
"signed manifest managed paths do not match helper policy",
));
}
let delta = release
.delta_from(&transaction.current_version)
.ok_or_else(|| FileUpdateCommandError::from("signed manifest has no transaction delta"))?;
if delta.source.tree_sha256 != transaction.source_tree_sha256
|| release.target.tree_sha256 != transaction.target_tree_sha256
|| delta.patch.sha256 != transaction.patch_sha256
|| delta.patch.size != transaction.patch_size
{
return Err(FileUpdateCommandError::from(
"transaction metadata does not match signed manifest",
));
}
let install_root = transaction.install_root.canonicalize()?;
ensure_plain_directory(&install_root)?;
let main_executable = install_root.join(config.main_executable);
if !main_executable.is_file() {
return Err(FileUpdateCommandError::from(format!(
"installed main executable is missing: {}",
main_executable.display()
)));
}
let staging_path = transaction_root.join(STAGING_DIR);
ensure_plain_directory(&staging_path)?;
verify_file_tree(&staging_path, &configured_paths, &release.target)?;
Ok(ValidatedContext {
transaction_path,
transaction_root: transaction_root.clone(),
transaction,
source: delta.source.clone(),
target: release.target,
staging_path,
result_path: transaction_root.join(RESULT_FILE),
install_root,
managed_paths: configured_paths,
})
}
#[cfg(windows)]
fn run_supervisor(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
parent_pid: u32,
) -> Result<(), FileUpdateCommandError> {
let mut context = validate_helper_context(config, transaction_path)?;
write_supervisor_pid(&context.transaction_root, std::process::id())?;
wait_for_process_exit(parent_pid, Duration::from_secs(120))?;
let helper = std::env::current_exe()?;
let parameters = windows_command_line(&[
ELEVATED_ARGUMENT.to_string(),
context.transaction_path.to_string_lossy().to_string(),
]);
let elevated = match shell_execute_elevated(&helper, ¶meters) {
Ok(process) => process,
Err(error) => {
context.transaction.state = DirectoryTransactionState::Failed;
context.transaction.updated_at_ms = now_millis();
context.transaction.failure_reason = Some(error.message.clone());
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(
&context.result_path,
HelperResultState::Failed,
Some(error.message.clone()),
)?;
let _ = cleanup_transaction_payload(&context);
launch_installed_application(config, &context.install_root)?;
return Ok(());
}
};
let mut launched = None::<Child>;
let mut launched_once = false;
let mut launch_failed = false;
let mut rollback_requested = false;
loop {
let wait = unsafe { WaitForSingleObject(elevated, 200) };
let helper_result = read_helper_result(&context.result_path).ok();
if launched.is_none()
&& !launch_failed
&& helper_result
.as_ref()
.is_some_and(|result| result.state == HelperResultState::AwaitingHealth)
{
match launch_installed_application(config, &context.install_root) {
Ok(mut child) => {
if let Err(error) = write_launched_pid(&context.transaction_root, child.id()) {
let _ = child.kill();
let _ = child.wait();
request_rollback(
&context.transaction_root,
&format!(
"failed to record updated application process: {}",
error.message
),
)?;
launch_failed = true;
rollback_requested = true;
} else {
launched_once = true;
launched = Some(child);
}
}
Err(error) => {
request_rollback(&context.transaction_root, &error.message)?;
launch_failed = true;
rollback_requested = true;
}
}
}
if let Some(child) = launched.as_mut() {
if child.try_wait()?.is_some()
&& !context.transaction_root.join(HEALTH_FILE).is_file()
&& !rollback_requested
{
request_rollback(
&context.transaction_root,
"updated application exited before confirming startup",
)?;
rollback_requested = true;
}
}
if wait == WAIT_OBJECT_0 {
break;
}
if wait != WAIT_TIMEOUT {
unsafe {
CloseHandle(elevated);
}
return Err(FileUpdateCommandError::from(format!(
"waiting for elevated update worker failed with code {wait}"
)));
}
}
let mut exit_code = 0_u32;
unsafe {
GetExitCodeProcess(elevated, &mut exit_code);
CloseHandle(elevated);
}
let final_result = read_helper_result(&context.result_path).ok();
let child_running = launched
.as_mut()
.map(|child| child.try_wait().map(|status| status.is_none()))
.transpose()?
.unwrap_or(false);
let should_restart = match final_result.as_ref().map(|result| result.state) {
Some(HelperResultState::Committed) => !launched_once,
Some(HelperResultState::AwaitingHealth | HelperResultState::HealthTimeout) => {
!child_running
}
Some(HelperResultState::RolledBack | HelperResultState::Failed) | None => !child_running,
Some(HelperResultState::RecoveryFailed) => false,
};
if should_restart {
let _ = launch_installed_application(config, &context.install_root)?;
}
if exit_code != 0 {
return Err(FileUpdateCommandError::from(format!(
"elevated file update worker exited with code {exit_code}"
)));
}
Ok(())
}
#[cfg(not(windows))]
fn run_supervisor(
_config: &FileUpdateHelperConfig,
_transaction_path: &Path,
_parent_pid: u32,
) -> Result<(), FileUpdateCommandError> {
Err(FileUpdateCommandError::from(
"file update supervisor is only implemented on Windows",
))
}
#[cfg(windows)]
fn run_elevated_worker(
config: &FileUpdateHelperConfig,
transaction_path: &Path,
) -> Result<(), FileUpdateCommandError> {
let mut context = validate_helper_context(config, transaction_path)?;
match context.transaction.state {
DirectoryTransactionState::Committing => {
let error =
FileUpdateCommandError::from("interrupted directory update was rolled back");
finish_rollback(&mut context, &error.message)?;
return Err(error);
}
DirectoryTransactionState::AwaitingHealth | DirectoryTransactionState::HealthTimeout => {
if context.transaction_root.join(HEALTH_FILE).is_file() {
verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.target,
)?;
let protected_root = protected_transaction_root(&context);
if protected_root.exists() {
ensure_plain_directory(&protected_root)?;
fs::remove_dir_all(&protected_root)?;
}
context.transaction.state = DirectoryTransactionState::Committed;
context.transaction.updated_at_ms = now_millis();
context.transaction.failure_reason = None;
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(&context.result_path, HelperResultState::Committed, None)?;
let _ = cleanup_transaction_payload(&context);
return Ok(());
}
let error = FileUpdateCommandError::from(
"updated application did not confirm startup; rolling back",
);
finish_rollback(&mut context, &error.message)?;
return Err(error);
}
_ => {}
}
context.transaction.state = DirectoryTransactionState::Committing;
context.transaction.updated_at_ms = now_millis();
write_directory_transaction(&context.transaction_path, &context.transaction)?;
let commit_result = commit_update(config, &mut context);
match commit_result {
Ok(protected_root) => {
context.transaction.state = DirectoryTransactionState::AwaitingHealth;
context.transaction.updated_at_ms = now_millis();
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(
&context.result_path,
HelperResultState::AwaitingHealth,
None,
)?;
let health_path = context.transaction_root.join(HEALTH_FILE);
let rollback_path = context.transaction_root.join(ROLLBACK_REQUEST_FILE);
let deadline = std::time::Instant::now() + Duration::from_secs(120);
while std::time::Instant::now() < deadline {
if health_path.is_file() {
verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.target,
)?;
if protected_root.exists() {
ensure_plain_directory(&protected_root)?;
fs::remove_dir_all(&protected_root)?;
}
context.transaction.state = DirectoryTransactionState::Committed;
context.transaction.updated_at_ms = now_millis();
context.transaction.failure_reason = None;
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(&context.result_path, HelperResultState::Committed, None)?;
let _ = cleanup_transaction_payload(&context);
return Ok(());
}
if rollback_path.is_file() {
let reason = fs::read_to_string(&rollback_path)
.unwrap_or_else(|_| "updated application failed to start".to_string());
let error = FileUpdateCommandError::from(reason.trim().to_string());
finish_rollback(&mut context, &error.message)?;
return Err(error);
}
thread::sleep(Duration::from_millis(250));
}
let launched_process_running = read_launched_pid(&context.transaction_root)
.map(process_is_running)
.unwrap_or(false);
if !launched_process_running {
let error = FileUpdateCommandError::from(
"updated application did not stay running; rolling back",
);
finish_rollback(&mut context, &error.message)?;
return Err(error);
}
context.transaction.state = DirectoryTransactionState::HealthTimeout;
context.transaction.updated_at_ms = now_millis();
context.transaction.failure_reason =
Some("updated application did not confirm startup within 120 seconds".to_string());
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(
&context.result_path,
HelperResultState::HealthTimeout,
context.transaction.failure_reason.clone(),
)?;
Ok(())
}
Err(error) => {
finish_rollback(&mut context, &error.message)?;
Err(error)
}
}
}
#[cfg(windows)]
fn finish_rollback(
context: &mut ValidatedContext,
reason: &str,
) -> Result<(), FileUpdateCommandError> {
match recover_interrupted_commit(context) {
Ok(()) => {
context.transaction.state = DirectoryTransactionState::RolledBack;
context.transaction.updated_at_ms = now_millis();
context.transaction.failure_reason = Some(reason.to_string());
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(
&context.result_path,
HelperResultState::RolledBack,
Some(reason.to_string()),
)?;
let _ = cleanup_transaction_payload(context);
Ok(())
}
Err(recovery_error) => {
let message = format!(
"{reason}; rollback recovery failed: {}",
recovery_error.message
);
context.transaction.state = DirectoryTransactionState::Committing;
context.transaction.updated_at_ms = now_millis();
context.transaction.failure_reason = Some(message.clone());
write_directory_transaction(&context.transaction_path, &context.transaction)?;
write_helper_result(
&context.result_path,
HelperResultState::RecoveryFailed,
Some(message),
)?;
Err(recovery_error)
}
}
}
#[cfg(not(windows))]
fn run_elevated_worker(
_config: &FileUpdateHelperConfig,
_transaction_path: &Path,
) -> Result<(), FileUpdateCommandError> {
Err(FileUpdateCommandError::from(
"elevated file update worker is only implemented on Windows",
))
}
#[cfg(windows)]
fn protected_transaction_root(context: &ValidatedContext) -> PathBuf {
context
.install_root
.join(".hdiff-update")
.join(&context.transaction.transaction_id)
}
#[cfg(windows)]
fn recover_interrupted_commit(context: &ValidatedContext) -> Result<(), FileUpdateCommandError> {
let protected_root = protected_transaction_root(context);
if !protected_root.exists() {
return Ok(());
}
ensure_plain_directory(&protected_root)?;
let journal_path = protected_root.join("journal.json");
if !journal_path.is_file() {
fs::remove_dir_all(&protected_root)?;
return Ok(());
}
let journal: CommitJournal = serde_json::from_slice(&fs::read(&journal_path)?)?;
if journal.transaction_id != context.transaction.transaction_id {
return Err(FileUpdateCommandError::from(
"protected update journal belongs to another transaction",
));
}
let backup_root = protected_root.join("backup");
for operation in journal.operations.iter().rev() {
let current = context.install_root.join(&operation.path);
let backup = backup_root.join(&operation.path);
if operation.state == JournalOperationState::Pending && !operation.is_directory {
if !current.is_file() {
return Err(FileUpdateCommandError::from(format!(
"cannot recover file backup because the current file is missing: {}",
operation.path
)));
}
if backup.exists() {
remove_managed_entry(&backup)?;
}
continue;
}
if backup.exists() {
if current.exists() {
remove_managed_entry(¤t)?;
}
if operation.is_directory {
rename_with_retry(&backup, ¤t)?;
} else {
move_file_replace(&backup, ¤t)?;
}
} else if !current.exists() {
return Err(FileUpdateCommandError::from(format!(
"cannot recover managed path because both current and backup are missing: {}",
operation.path
)));
}
}
verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.source,
)?;
fs::remove_dir_all(&protected_root)?;
Ok(())
}
#[cfg(windows)]
fn commit_update(
config: &FileUpdateHelperConfig,
context: &mut ValidatedContext,
) -> Result<PathBuf, FileUpdateCommandError> {
verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.source,
)?;
verify_file_tree(
&context.staging_path,
&context.managed_paths,
&context.target,
)?;
let file_backup_bytes = context
.source
.files
.iter()
.filter(|file| context.managed_paths.contains(&file.path))
.try_fold(0_u64, |total, file| total.checked_add(file.size))
.ok_or_else(|| FileUpdateCommandError::from("commit disk requirement overflow"))?;
let required_commit_bytes = context
.target
.total_size
.checked_add(file_backup_bytes)
.and_then(|size| size.checked_add(COMMIT_DISK_RESERVE_BYTES))
.ok_or_else(|| FileUpdateCommandError::from("commit disk requirement overflow"))?;
ensure_available_space(&context.install_root, required_commit_bytes)?;
let update_root = context.install_root.join(".hdiff-update");
if update_root.exists() {
ensure_plain_directory(&update_root)?;
} else {
fs::create_dir(&update_root)?;
ensure_plain_directory(&update_root)?;
}
let protected_root = protected_transaction_root(context);
if protected_root.exists() {
ensure_plain_directory(&protected_root)?;
fs::remove_dir_all(&protected_root)?;
}
let new_root = protected_root.join("new");
let backup_root = protected_root.join("backup");
fs::create_dir_all(&backup_root)?;
copy_managed_tree(&context.staging_path, &new_root, &context.managed_paths)?;
verify_file_tree(&new_root, &context.managed_paths, &context.target)?;
let journal_path = protected_root.join("journal.json");
let mut operations = context
.managed_paths
.iter()
.map(|path| JournalOperation {
path: path.clone(),
is_directory: context.install_root.join(path).is_dir(),
state: JournalOperationState::Pending,
})
.collect::<Vec<_>>();
operations.sort_by_key(|operation| {
(
operation.path.eq_ignore_ascii_case(config.main_executable),
operation.path.to_ascii_lowercase(),
)
});
let mut journal = CommitJournal {
transaction_id: context.transaction.transaction_id.clone(),
state: DirectoryTransactionState::Committing,
operations,
updated_at_ms: now_millis(),
};
write_json_file_atomic(&journal_path, &journal)?;
for index in 0..journal.operations.len() {
let operation = journal.operations[index].clone();
let current = context.install_root.join(&operation.path);
let replacement = new_root.join(&operation.path);
let backup = backup_root.join(&operation.path);
if let Some(parent) = backup.parent() {
fs::create_dir_all(parent)?;
}
let result = if operation.is_directory {
rename_with_retry(¤t, &backup)
.and_then(|_| {
journal.operations[index].state = JournalOperationState::BackedUp;
journal.updated_at_ms = now_millis();
write_json_file_atomic(&journal_path, &journal).map_err(core_error_to_io)?;
rename_with_retry(&replacement, ¤t)
})
.map(|_| ())
} else {
copy_file_backup(¤t, &backup).and_then(|_| {
journal.operations[index].state = JournalOperationState::BackedUp;
journal.updated_at_ms = now_millis();
write_json_file_atomic(&journal_path, &journal).map_err(core_error_to_io)?;
move_file_replace(&replacement, ¤t)
})
};
if let Err(error) = result {
let commit_error = FileUpdateCommandError::from(format!(
"failed to commit managed path {}: {error}",
operation.path
));
rollback_operations(context, &backup_root, &journal)?;
return Err(commit_error);
}
journal.operations[index].state = JournalOperationState::Installed;
journal.updated_at_ms = now_millis();
write_json_file_atomic(&journal_path, &journal)?;
}
if let Err(error) = verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.target,
) {
rollback_operations(context, &backup_root, &journal)?;
return Err(FileUpdateCommandError::from(format!(
"installed target verification failed: {error}"
)));
}
journal.state = DirectoryTransactionState::AwaitingHealth;
journal.updated_at_ms = now_millis();
write_json_file_atomic(&journal_path, &journal)?;
Ok(protected_root)
}
#[cfg(windows)]
fn rollback_operations(
context: &ValidatedContext,
backup_root: &Path,
journal: &CommitJournal,
) -> Result<(), FileUpdateCommandError> {
for operation in journal.operations.iter().rev() {
if operation.state == JournalOperationState::Pending {
continue;
}
let current = context.install_root.join(&operation.path);
let backup = backup_root.join(&operation.path);
if operation.state == JournalOperationState::Installed && current.exists() {
remove_managed_entry(¤t)?;
}
if backup.exists() {
if operation.is_directory {
rename_with_retry(&backup, ¤t)?;
} else {
move_file_replace(&backup, ¤t)?;
}
}
}
verify_file_tree(
&context.install_root,
&context.managed_paths,
&context.source,
)?;
Ok(())
}
#[cfg(windows)]
fn remove_managed_entry(path: &Path) -> Result<(), FileUpdateCommandError> {
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(FileUpdateCommandError::from(format!(
"refusing to remove managed reparse point: {}",
path.display()
)));
}
if metadata.is_dir() {
retry_file_operation(|| fs::remove_dir_all(path))?;
} else if metadata.is_file() {
retry_file_operation(|| fs::remove_file(path))?;
} else {
return Err(FileUpdateCommandError::from(format!(
"unsupported managed entry type: {}",
path.display()
)));
}
Ok(())
}
#[cfg(windows)]
fn move_file_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
let source = wide_null(source.as_os_str())?;
let destination = wide_null(destination.as_os_str())?;
retry_file_operation(|| {
let result = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if result == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
})
}
#[cfg(windows)]
fn rename_with_retry(source: &Path, destination: &Path) -> std::io::Result<()> {
retry_file_operation(|| fs::rename(source, destination))
}
#[cfg(windows)]
fn retry_file_operation<T>(
mut operation: impl FnMut() -> std::io::Result<T>,
) -> std::io::Result<T> {
let deadline = std::time::Instant::now() + Duration::from_secs(FILE_OPERATION_RETRY_SECS);
loop {
match operation() {
Ok(value) => return Ok(value),
Err(error)
if is_retryable_file_error(&error) && std::time::Instant::now() < deadline =>
{
thread::sleep(Duration::from_millis(200));
}
Err(error) => return Err(error),
}
}
}
#[cfg(windows)]
fn is_retryable_file_error(error: &std::io::Error) -> bool {
matches!(error.raw_os_error(), Some(5 | 32 | 33))
}
#[cfg(windows)]
fn copy_file_backup(source: &Path, destination: &Path) -> std::io::Result<()> {
let temporary = destination.with_extension(format!("backup-part-{}", std::process::id()));
if temporary.exists() {
fs::remove_file(&temporary)?;
}
retry_file_operation(|| fs::copy(source, &temporary))?;
fs::OpenOptions::new()
.write(true)
.open(&temporary)?
.sync_all()?;
if let Err(error) = move_file_replace(&temporary, destination) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
Ok(())
}
#[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!(
"application process {pid} did not exit within {} seconds",
timeout.as_secs()
)))
} else {
Err(FileUpdateCommandError::from(format!(
"waiting for application process {pid} failed with code {result}"
)))
}
}
#[cfg(windows)]
fn shell_execute_elevated(
executable: &Path,
parameters: &str,
) -> Result<HANDLE, FileUpdateCommandError> {
let verb = wide_null(OsStr::new("runas"))?;
let executable = wide_null(executable.as_os_str())?;
let parameters = wide_null(OsStr::new(parameters))?;
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.as_ptr(),
lpParameters: parameters.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 file update worker: {error}"
)));
}
if execute_info.hProcess.is_null() || unsafe { GetProcessId(execute_info.hProcess) } == 0 {
if !execute_info.hProcess.is_null() {
unsafe {
CloseHandle(execute_info.hProcess);
}
}
return Err(FileUpdateCommandError::from(
"elevated file update worker started without a process handle",
));
}
Ok(execute_info.hProcess)
}
#[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 request_rollback(transaction_root: &Path, reason: &str) -> Result<(), FileUpdateCommandError> {
fs::write(
transaction_root.join(ROLLBACK_REQUEST_FILE),
reason.trim().as_bytes(),
)?;
Ok(())
}
#[cfg(windows)]
fn write_launched_pid(transaction_root: &Path, pid: u32) -> Result<(), FileUpdateCommandError> {
fs::write(
transaction_root.join(LAUNCHED_PID_FILE),
pid.to_string().as_bytes(),
)?;
Ok(())
}
#[cfg(windows)]
fn write_supervisor_pid(transaction_root: &Path, pid: u32) -> Result<(), FileUpdateCommandError> {
fs::write(
transaction_root.join(SUPERVISOR_PID_FILE),
pid.to_string().as_bytes(),
)?;
Ok(())
}
#[cfg(windows)]
fn read_supervisor_pid(transaction_root: &Path) -> Option<u32> {
fs::read_to_string(transaction_root.join(SUPERVISOR_PID_FILE))
.ok()?
.trim()
.parse()
.ok()
}
#[cfg(windows)]
fn read_launched_pid(transaction_root: &Path) -> Option<u32> {
fs::read_to_string(transaction_root.join(LAUNCHED_PID_FILE))
.ok()?
.trim()
.parse()
.ok()
}
#[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(windows)]
fn cleanup_transaction_payload(context: &ValidatedContext) -> Result<(), FileUpdateCommandError> {
if context.staging_path.exists() {
ensure_plain_directory(&context.staging_path)?;
fs::remove_dir_all(&context.staging_path)?;
}
let patch_path = context.transaction_root.join("patch.hdiff");
if patch_path.is_file() {
fs::remove_file(patch_path)?;
}
let rollback_path = context.transaction_root.join(ROLLBACK_REQUEST_FILE);
if rollback_path.is_file() {
fs::remove_file(rollback_path)?;
}
let supervisor_pid_path = context.transaction_root.join(SUPERVISOR_PID_FILE);
if supervisor_pid_path.is_file() {
fs::remove_file(supervisor_pid_path)?;
}
let cache_root = context
.transaction_root
.parent()
.and_then(Path::parent)
.ok_or_else(|| FileUpdateCommandError::from("transaction cache root is missing"))?;
let pointer_path = cache_root.join("prepared.json");
if pointer_path.is_file() {
let pointer = serde_json::from_slice::<PreparedPointer>(&fs::read(&pointer_path)?);
if pointer.ok().is_some_and(|pointer| {
pointer.transaction_path.canonicalize().ok() == Some(context.transaction_path.clone())
}) {
fs::remove_file(pointer_path)?;
}
}
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(())
}
#[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 write_helper_result(
path: &Path,
state: HelperResultState,
message: Option<String>,
) -> Result<(), FileUpdateCommandError> {
write_json_file_atomic(
path,
&HelperResult {
state,
updated_at_ms: now_millis(),
message,
},
)
.map_err(FileUpdateCommandError::from)
}
fn read_helper_result(path: &Path) -> Result<HelperResult, FileUpdateCommandError> {
Ok(serde_json::from_slice(&fs::read(path)?)?)
}
fn core_error_to_io(error: CoreError) -> std::io::Error {
std::io::Error::other(error.to_string())
}
#[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
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
#[cfg(windows)]
use std::fs;
#[cfg(windows)]
use hdiff_update_core::{
build_file_tree_manifest, verify_file_tree, DirectoryTransactionState,
DirectoryUpdateTransaction,
};
#[cfg(windows)]
use tempfile::tempdir;
use super::{
parse_file_update_helper_invocation, FileUpdateHelperInvocation, SUPERVISOR_ARGUMENT,
};
#[cfg(windows)]
use super::{
commit_update, quote_windows_argument, recover_interrupted_commit, windows_command_line,
CommitJournal, FileUpdateHelperConfig, JournalOperation, JournalOperationState,
ValidatedContext,
};
#[test]
fn normal_and_embedded_process_arguments_do_not_dispatch_update_helpers() {
assert!(
parse_file_update_helper_invocation(&[OsString::from("app.exe")])
.unwrap()
.is_none()
);
assert!(parse_file_update_helper_invocation(&[
OsString::from("app.exe"),
OsString::from("--embedded-workspace-session-broker"),
])
.unwrap()
.is_none());
assert_eq!(
parse_file_update_helper_invocation(&[
OsString::from("app.exe"),
OsString::from(SUPERVISOR_ARGUMENT),
OsString::from("C:\\cache\\transaction.json"),
OsString::from("42"),
])
.unwrap(),
Some(FileUpdateHelperInvocation::Supervisor {
transaction_path: "C:\\cache\\transaction.json".into(),
parent_pid: 42,
})
);
}
#[cfg(windows)]
#[test]
fn quotes_helper_arguments_for_shell_execute() {
assert_eq!(quote_windows_argument("plain"), "plain");
assert_eq!(
windows_command_line(&[
"--hdiff-update-elevated".to_string(),
"C:\\Program Data\\transaction.json".to_string(),
]),
"--hdiff-update-elevated \"C:\\Program Data\\transaction.json\""
);
}
#[cfg(windows)]
#[test]
fn commit_replaces_managed_payload_and_keeps_unmanaged_files() {
let dir = tempdir().unwrap();
let install = dir.path().join("install");
let transaction_root = dir.path().join("transaction");
let staging = transaction_root.join("staged");
fs::create_dir_all(install.join("resources")).unwrap();
fs::create_dir_all(install.join("plugins")).unwrap();
fs::create_dir_all(staging.join("resources")).unwrap();
fs::create_dir_all(staging.join("plugins")).unwrap();
fs::write(install.join("app.exe"), b"old-app").unwrap();
fs::write(install.join("resources/data.bin"), b"old-resource").unwrap();
fs::write(install.join("plugins/plugin.js"), b"same").unwrap();
fs::write(install.join("uninstall.exe"), b"unmanaged").unwrap();
fs::write(staging.join("app.exe"), b"new-app").unwrap();
fs::write(staging.join("resources/data.bin"), b"new-resource").unwrap();
fs::write(staging.join("plugins/plugin.js"), b"same").unwrap();
let managed = vec![
"app.exe".to_string(),
"plugins".to_string(),
"resources".to_string(),
];
let source = build_file_tree_manifest(&install, &managed).unwrap();
let target = build_file_tree_manifest(&staging, &managed).unwrap();
let transaction = DirectoryUpdateTransaction {
schema_version: 1,
transaction_id: "test-transaction".to_string(),
application_id: "test.app".to_string(),
platform: "windows-x86_64".to_string(),
current_version: "0.1.0".to_string(),
target_version: "0.2.0".to_string(),
install_root: install.clone(),
managed_paths: managed.clone(),
source_tree_sha256: source.tree_sha256.clone(),
target_tree_sha256: target.tree_sha256.clone(),
patch_sha256: "a".repeat(64),
patch_size: 1,
full_size: 100,
state: DirectoryTransactionState::Ready,
created_at_ms: 1,
updated_at_ms: 1,
failure_reason: None,
};
let transaction_path = transaction_root.join("transaction.json");
let mut context = ValidatedContext {
transaction_path,
transaction_root: transaction_root.clone(),
transaction,
source,
target: target.clone(),
staging_path: staging,
result_path: transaction_root.join("result.json"),
install_root: install.clone(),
managed_paths: managed.clone(),
};
let config = FileUpdateHelperConfig {
application_id: "test.app",
current_version: "1.0.0",
public_keys: &["unused"],
updater_public_key: "",
main_executable: "app.exe",
managed_paths: &["app.exe", "plugins", "resources"],
hpatchz_relative_path: "bin/hpatchz.exe",
};
let protected = commit_update(&config, &mut context).unwrap();
verify_file_tree(&install, &managed, &target).unwrap();
assert_eq!(
fs::read(install.join("uninstall.exe")).unwrap(),
b"unmanaged"
);
assert!(protected.join("backup/app.exe").is_file());
}
#[cfg(windows)]
#[test]
fn interrupted_pending_file_backup_never_replaces_the_intact_source() {
let dir = tempdir().unwrap();
let install = dir.path().join("install");
let transaction_root = dir.path().join("transaction");
let staging = transaction_root.join("staged");
fs::create_dir_all(&install).unwrap();
fs::create_dir_all(&staging).unwrap();
fs::write(install.join("app.exe"), b"old-app-complete").unwrap();
fs::write(staging.join("app.exe"), b"new-app").unwrap();
let managed = vec!["app.exe".to_string()];
let source = build_file_tree_manifest(&install, &managed).unwrap();
let target = build_file_tree_manifest(&staging, &managed).unwrap();
let transaction = DirectoryUpdateTransaction {
schema_version: 1,
transaction_id: "test-interrupted-backup".to_string(),
application_id: "test.app".to_string(),
platform: "windows-x86_64".to_string(),
current_version: "0.1.0".to_string(),
target_version: "0.2.0".to_string(),
install_root: install.clone(),
managed_paths: managed.clone(),
source_tree_sha256: source.tree_sha256.clone(),
target_tree_sha256: target.tree_sha256.clone(),
patch_sha256: "a".repeat(64),
patch_size: 1,
full_size: 100,
state: DirectoryTransactionState::Committing,
created_at_ms: 1,
updated_at_ms: 1,
failure_reason: None,
};
let context = ValidatedContext {
transaction_path: transaction_root.join("transaction.json"),
transaction_root: transaction_root.clone(),
transaction,
source,
target,
staging_path: staging,
result_path: transaction_root.join("result.json"),
install_root: install.clone(),
managed_paths: managed,
};
let protected = install
.join(".hdiff-update")
.join("test-interrupted-backup");
fs::create_dir_all(protected.join("backup")).unwrap();
fs::write(protected.join("backup/app.exe"), b"partial").unwrap();
let journal = CommitJournal {
transaction_id: "test-interrupted-backup".to_string(),
state: DirectoryTransactionState::Committing,
operations: vec![JournalOperation {
path: "app.exe".to_string(),
is_directory: false,
state: JournalOperationState::Pending,
}],
updated_at_ms: 1,
};
fs::write(
protected.join("journal.json"),
serde_json::to_vec_pretty(&journal).unwrap(),
)
.unwrap();
recover_interrupted_commit(&context).unwrap();
assert_eq!(
fs::read(install.join("app.exe")).unwrap(),
b"old-app-complete"
);
assert!(!protected.exists());
}
}