Skip to main content

tauri_plugin_hdiff_update/
file_update.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4    process::{Child, Command},
5    thread,
6    time::{Duration, SystemTime, UNIX_EPOCH},
7};
8
9use fs2::FileExt;
10use hdiff_update_core::{
11    copy_managed_tree, ensure_available_space, normalize_managed_paths, normalize_relative_path,
12    path_for_manifest_entry, prepare_directory_update, read_directory_manifest_file,
13    read_directory_transaction, sha256_file, verify_directory_manifest_signature_with_keys,
14    verify_file_tree, write_directory_transaction, write_json_file_atomic,
15    DirectoryTransactionState, DownloadEvent, Error as CoreError, HttpHeader,
16    PrepareDirectoryUpdateOptions, PreparedDirectoryUpdate, PreparedDirectoryUpdateKind,
17};
18use serde::{Deserialize, Serialize};
19use tauri::{ipc::Channel, AppHandle, Manager, Runtime, State};
20
21#[cfg(windows)]
22use std::{
23    ffi::OsStr,
24    os::windows::{ffi::OsStrExt, process::CommandExt},
25};
26#[cfg(windows)]
27use windows_sys::Win32::{
28    Foundation::{
29        CloseHandle, ERROR_CANCELLED, ERROR_ELEVATION_REQUIRED, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT,
30    },
31    Storage::FileSystem::{
32        MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, SYNCHRONIZE,
33    },
34    System::Threading::{
35        GetExitCodeProcess, GetProcessId, OpenProcess, WaitForSingleObject,
36        PROCESS_QUERY_LIMITED_INFORMATION,
37    },
38    UI::{
39        Shell::{ShellExecuteExW, SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW},
40        WindowsAndMessaging::SW_SHOWNORMAL,
41    },
42};
43
44#[cfg(windows)]
45const CREATE_NO_WINDOW: u32 = 0x0800_0000;
46const FILE_UPDATE_ROOT: &str = "file-updates";
47const SUPERVISOR_ARGUMENT: &str = "--hdiff-update-supervisor";
48const ELEVATED_ARGUMENT: &str = "--hdiff-update-elevated";
49const HEALTH_FILE: &str = "health.ok";
50const RESULT_FILE: &str = "result.json";
51const ROLLBACK_REQUEST_FILE: &str = "rollback.request";
52const LAUNCHED_PID_FILE: &str = "launched.pid";
53const MANIFEST_FILE: &str = "manifest.json";
54const STAGING_DIR: &str = "staged";
55#[cfg(windows)]
56const FILE_OPERATION_RETRY_SECS: u64 = 15;
57#[cfg(windows)]
58const COMMIT_DISK_RESERVE_BYTES: u64 = 64 * 1024 * 1024;
59
60#[derive(Debug, Clone, Copy)]
61pub struct FileUpdateHelperConfig {
62    pub application_id: &'static str,
63    pub public_keys: &'static [&'static str],
64    pub main_executable: &'static str,
65    pub managed_paths: &'static [&'static str],
66    pub hpatchz_relative_path: &'static str,
67}
68
69#[derive(Debug, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct FileUpdateCommandError {
72    pub message: String,
73}
74
75impl<E> From<E> for FileUpdateCommandError
76where
77    E: std::fmt::Display,
78{
79    fn from(error: E) -> Self {
80        Self {
81            message: error.to_string(),
82        }
83    }
84}
85
86#[derive(Debug, Clone, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct PrepareFileUpdateOptions {
89    pub manifest_url: String,
90    pub current_version: String,
91    pub expected_version: String,
92    #[serde(default)]
93    pub headers: Vec<HttpHeader>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub timeout_secs: Option<u64>,
96}
97
98#[derive(Debug, Clone, Serialize)]
99#[serde(tag = "status", rename_all = "camelCase")]
100pub enum PrepareFileUpdateResult {
101    Prepared {
102        update: PreparedDirectoryUpdate,
103    },
104    AlreadyPrepared {
105        update: PreparedDirectoryUpdate,
106    },
107    Fallback {
108        reason: String,
109        #[serde(default, skip_serializing_if = "Option::is_none")]
110        detail: Option<String>,
111    },
112}
113
114#[derive(Debug, Clone, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct LaunchFileUpdateOptions {
117    pub transaction_path: PathBuf,
118}
119
120#[derive(Debug, Clone, Serialize)]
121#[serde(rename_all = "camelCase")]
122pub struct LaunchFileUpdateResult {
123    pub pid: u32,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128enum HelperResultState {
129    AwaitingHealth,
130    Committed,
131    RolledBack,
132    Failed,
133    RecoveryFailed,
134    HealthTimeout,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139struct HelperResult {
140    state: HelperResultState,
141    updated_at_ms: u64,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    message: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148struct PreparedPointer {
149    transaction_path: PathBuf,
150    target_version: String,
151    target_tree_sha256: String,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156enum JournalOperationState {
157    Pending,
158    BackedUp,
159    Installed,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164struct JournalOperation {
165    path: String,
166    is_directory: bool,
167    state: JournalOperationState,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172struct CommitJournal {
173    transaction_id: String,
174    state: DirectoryTransactionState,
175    operations: Vec<JournalOperation>,
176    updated_at_ms: u64,
177}
178
179struct ValidatedContext {
180    transaction_path: PathBuf,
181    transaction_root: PathBuf,
182    transaction: hdiff_update_core::DirectoryUpdateTransaction,
183    source: hdiff_update_core::FileTreeManifest,
184    target: hdiff_update_core::FileTreeManifest,
185    staging_path: PathBuf,
186    result_path: PathBuf,
187    install_root: PathBuf,
188    managed_paths: Vec<String>,
189}
190
191#[tauri::command]
192pub async fn prepare_file_update<R: Runtime>(
193    app: AppHandle<R>,
194    config: State<'_, FileUpdateHelperConfig>,
195    options: PrepareFileUpdateOptions,
196    on_event: Channel<DownloadEvent>,
197) -> Result<PrepareFileUpdateResult, FileUpdateCommandError> {
198    if app.config().identifier != config.application_id {
199        return Err(FileUpdateCommandError::from(
200            "file update plugin application id does not match the Tauri identifier",
201        ));
202    }
203    let public_keys = trusted_public_keys(&config);
204    if public_keys.is_empty() {
205        return Ok(PrepareFileUpdateResult::Fallback {
206            reason: "updaterNotConfigured".to_string(),
207            detail: Some(
208                "file update trusted public-key ring is not compiled into the application"
209                    .to_string(),
210            ),
211        });
212    }
213    let install_root = current_install_root()?;
214    let cache_dir = file_update_cache_root(&app)?;
215    let hpatchz_path =
216        path_for_manifest_entry(&app.path().resource_dir()?, config.hpatchz_relative_path)?;
217    let configured_paths = normalize_managed_paths(
218        &config
219            .managed_paths
220            .iter()
221            .map(|path| (*path).to_string())
222            .collect::<Vec<_>>(),
223    )?;
224    let hpatchz_path =
225        validate_bundled_patch_tool(&install_root, &hpatchz_path, &configured_paths)?;
226
227    let result = prepare_directory_update(
228        PrepareDirectoryUpdateOptions {
229            manifest_url: options.manifest_url,
230            application_id: config.application_id.to_string(),
231            install_root,
232            managed_paths: configured_paths,
233            current_version: options.current_version,
234            expected_version: options.expected_version,
235            platform: None,
236            cache_dir,
237            hpatchz_path: Some(hpatchz_path),
238            signature_public_keys: public_keys,
239            require_signature: true,
240            headers: options.headers,
241            timeout_secs: options.timeout_secs,
242        },
243        |event| {
244            let _ = on_event.send(event);
245        },
246    )
247    .await;
248
249    match result {
250        Ok(update) if update.kind == PreparedDirectoryUpdateKind::AlreadyPrepared => {
251            Ok(PrepareFileUpdateResult::AlreadyPrepared { update })
252        }
253        Ok(update) => Ok(PrepareFileUpdateResult::Prepared { update }),
254        Err(error) => Ok(PrepareFileUpdateResult::Fallback {
255            reason: fallback_reason(&error).to_string(),
256            detail: Some(fallback_detail(&error)),
257        }),
258    }
259}
260
261#[tauri::command]
262pub async fn launch_file_update<R: Runtime>(
263    app: AppHandle<R>,
264    options: LaunchFileUpdateOptions,
265) -> Result<LaunchFileUpdateResult, FileUpdateCommandError> {
266    let cache_root = file_update_cache_root(&app)?;
267    fs::create_dir_all(&cache_root)?;
268    let lock = fs::OpenOptions::new()
269        .create(true)
270        .truncate(false)
271        .read(true)
272        .write(true)
273        .open(cache_root.join("lock"))?;
274    lock.lock_exclusive()?;
275    let transaction_path = validate_transaction_path(&cache_root, &options.transaction_path)?;
276    let mut transaction = read_directory_transaction(&transaction_path)?;
277    if transaction.application_id != app.config().identifier {
278        return Err(FileUpdateCommandError::from(
279            "file update transaction belongs to another application",
280        ));
281    }
282    if transaction.state != DirectoryTransactionState::Ready {
283        return Err(FileUpdateCommandError::from(format!(
284            "file update transaction is not ready: {:?}",
285            transaction.state
286        )));
287    }
288
289    let current_exe = std::env::current_exe()?;
290    let transaction_root = transaction_path
291        .parent()
292        .ok_or_else(|| FileUpdateCommandError::from("transaction path has no parent"))?;
293    let helper_dir = transaction_root.join("helper");
294    fs::create_dir_all(&helper_dir)?;
295    let helper_path = helper_dir.join(
296        current_exe
297            .file_name()
298            .unwrap_or_else(|| OsStr::new("update-helper.exe")),
299    );
300    fs::copy(&current_exe, &helper_path)?;
301    let source_digest = sha256_file(&current_exe)?;
302    let helper_digest = sha256_file(&helper_path)?;
303    if source_digest.sha256 != helper_digest.sha256 {
304        return Err(FileUpdateCommandError::from(
305            "copied file update helper failed SHA-256 verification",
306        ));
307    }
308
309    transaction.state = DirectoryTransactionState::SupervisorStarted;
310    transaction.updated_at_ms = now_millis();
311    write_directory_transaction(&transaction_path, &transaction)?;
312
313    let mut command = Command::new(&helper_path);
314    command
315        .arg(SUPERVISOR_ARGUMENT)
316        .arg(&transaction_path)
317        .arg(std::process::id().to_string());
318    #[cfg(windows)]
319    command.creation_flags(CREATE_NO_WINDOW);
320    let child = match command.spawn() {
321        Ok(child) => child,
322        Err(error) => {
323            transaction.state = DirectoryTransactionState::Ready;
324            transaction.updated_at_ms = now_millis();
325            write_directory_transaction(&transaction_path, &transaction)?;
326            return Err(FileUpdateCommandError::from(format!(
327                "failed to start file update supervisor {}: {error}",
328                helper_path.display()
329            )));
330        }
331    };
332    Ok(LaunchFileUpdateResult { pid: child.id() })
333}
334
335pub fn maybe_run_file_update_helper(
336    config: &FileUpdateHelperConfig,
337) -> Result<bool, FileUpdateCommandError> {
338    let args = std::env::args_os().collect::<Vec<_>>();
339    let Some(mode) = args.get(1).and_then(|value| value.to_str()) else {
340        return maybe_resume_interrupted_update(config);
341    };
342
343    match mode {
344        SUPERVISOR_ARGUMENT => {
345            let transaction_path = args
346                .get(2)
347                .map(PathBuf::from)
348                .ok_or_else(|| FileUpdateCommandError::from("missing transaction path"))?;
349            let parent_pid = args
350                .get(3)
351                .and_then(|value| value.to_str())
352                .ok_or_else(|| FileUpdateCommandError::from("missing parent process id"))?
353                .parse::<u32>()
354                .map_err(FileUpdateCommandError::from)?;
355            run_supervisor(config, &transaction_path, parent_pid)?;
356            Ok(true)
357        }
358        ELEVATED_ARGUMENT => {
359            let transaction_path = args
360                .get(2)
361                .map(PathBuf::from)
362                .ok_or_else(|| FileUpdateCommandError::from("missing transaction path"))?;
363            run_elevated_worker(config, &transaction_path)?;
364            Ok(true)
365        }
366        _ => maybe_resume_interrupted_update(config),
367    }
368}
369
370pub fn confirm_file_update_startup<R: Runtime>(
371    app: &AppHandle<R>,
372) -> Result<bool, FileUpdateCommandError> {
373    let cache_root = file_update_cache_root(app)?;
374    let transactions_root = cache_root.join("transactions");
375    if !transactions_root.is_dir() {
376        return Ok(false);
377    }
378    let current_version = app.package_info().version.to_string();
379    for entry in fs::read_dir(transactions_root)? {
380        let entry = entry?;
381        let transaction_path = entry.path().join("transaction.json");
382        if !transaction_path.is_file() {
383            continue;
384        }
385        let transaction = match read_directory_transaction(&transaction_path) {
386            Ok(transaction) => transaction,
387            Err(_) => continue,
388        };
389        if transaction.application_id != app.config().identifier
390            || transaction.target_version != current_version
391            || !matches!(
392                transaction.state,
393                DirectoryTransactionState::AwaitingHealth
394                    | DirectoryTransactionState::HealthTimeout
395            )
396        {
397            continue;
398        }
399        fs::write(entry.path().join(HEALTH_FILE), b"ok")?;
400        return Ok(true);
401    }
402    Ok(false)
403}
404
405#[cfg(windows)]
406fn maybe_resume_interrupted_update(
407    config: &FileUpdateHelperConfig,
408) -> Result<bool, FileUpdateCommandError> {
409    if trusted_public_keys(config).is_empty() {
410        return Ok(false);
411    }
412    let cache_root = match helper_cache_root(config) {
413        Ok(path) => path,
414        Err(_) => return Ok(false),
415    };
416    let pointer_path = cache_root.join("prepared.json");
417    if !pointer_path.is_file() {
418        return Ok(false);
419    }
420    let pointer: PreparedPointer = serde_json::from_slice(&fs::read(pointer_path)?)?;
421    let transaction_path = match validate_transaction_path(&cache_root, &pointer.transaction_path) {
422        Ok(path) => path,
423        Err(_) => return Ok(false),
424    };
425    let transaction = read_directory_transaction(&transaction_path)?;
426    let transaction_root = transaction_path
427        .parent()
428        .ok_or_else(|| FileUpdateCommandError::from("transaction path has no parent"))?;
429    let health_exists = transaction_root.join(HEALTH_FILE).is_file();
430    let result = read_helper_result(&transaction_root.join(RESULT_FILE)).ok();
431    if result.as_ref().is_some_and(|result| {
432        matches!(
433            result.state,
434            HelperResultState::Committed
435                | HelperResultState::RolledBack
436                | HelperResultState::Failed
437        )
438    }) {
439        return Ok(false);
440    }
441
442    let launched_process_running = read_launched_pid(transaction_root)
443        .map(process_is_running)
444        .unwrap_or(false);
445    let should_resume = match transaction.state {
446        DirectoryTransactionState::Committing => true,
447        DirectoryTransactionState::AwaitingHealth | DirectoryTransactionState::HealthTimeout => {
448            health_exists || !launched_process_running
449        }
450        DirectoryTransactionState::SupervisorStarted => result.is_none(),
451        _ => false,
452    };
453    if !should_resume {
454        return Ok(false);
455    }
456
457    let current_exe = std::env::current_exe()?;
458    let helper_dir = transaction_root.join("helper");
459    fs::create_dir_all(&helper_dir)?;
460    let helper_path = helper_dir.join(
461        current_exe
462            .file_name()
463            .unwrap_or_else(|| OsStr::new("update-helper.exe")),
464    );
465    fs::copy(&current_exe, &helper_path)?;
466    if sha256_file(&current_exe)?.sha256 != sha256_file(&helper_path)?.sha256 {
467        return Err(FileUpdateCommandError::from(
468            "recovery helper failed SHA-256 verification",
469        ));
470    }
471    let mut command = Command::new(helper_path);
472    command
473        .arg(SUPERVISOR_ARGUMENT)
474        .arg(transaction_path)
475        .arg(std::process::id().to_string())
476        .creation_flags(CREATE_NO_WINDOW);
477    command.spawn()?;
478    Ok(true)
479}
480
481#[cfg(not(windows))]
482fn maybe_resume_interrupted_update(
483    _config: &FileUpdateHelperConfig,
484) -> Result<bool, FileUpdateCommandError> {
485    Ok(false)
486}
487
488fn fallback_reason(error: &CoreError) -> &'static str {
489    match error {
490        CoreError::SignatureMissing
491        | CoreError::SignaturePublicKeyMissing
492        | CoreError::SignatureVerificationFailed
493        | CoreError::InvalidKeyMaterial { .. } => "manifestSignatureInvalid",
494        CoreError::UnexpectedVersion { .. } => "versionMismatch",
495        CoreError::InvalidVersion { .. } | CoreError::NonUpgradeVersion { .. } => "versionMismatch",
496        CoreError::MissingPlatform { .. } => "unsupportedPlatform",
497        CoreError::NoMatchingDelta { .. } => "noMatchingDelta",
498        CoreError::HashMismatch { .. }
499        | CoreError::SizeMismatch { .. }
500        | CoreError::DownloadLimitExceeded { .. } => "artifactVerificationFailed",
501        CoreError::InsufficientDiskSpace { .. } => "insufficientDiskSpace",
502        CoreError::Http(_)
503        | CoreError::UnexpectedHttpStatus { .. }
504        | CoreError::Url(_)
505        | CoreError::UnsupportedUrl(_) => "downloadFailed",
506        CoreError::InsecureTransport { .. } => "insecureTransport",
507        CoreError::ProcessFailed { .. } => "patchFailed",
508        CoreError::UnsupportedAlgorithm(_) => "unsupportedAlgorithm",
509        CoreError::Message(message) if message.contains("managed tree mismatch") => {
510            "sourceTreeMismatch"
511        }
512        CoreError::Message(message) if message.contains("previous directory update") => {
513            "previousCommitFailed"
514        }
515        _ => "directoryUpdateFailed",
516    }
517}
518
519fn fallback_detail(error: &CoreError) -> String {
520    match error {
521        CoreError::Http(_) | CoreError::UnexpectedHttpStatus { .. } => {
522            "HTTPS update request failed".to_string()
523        }
524        CoreError::Url(_) | CoreError::UnsupportedUrl(_) => {
525            "update endpoint URL is invalid".to_string()
526        }
527        CoreError::InsecureTransport { .. } => {
528            "production directory updates require HTTPS".to_string()
529        }
530        CoreError::InsufficientDiskSpace {
531            required,
532            available,
533            ..
534        } => format!(
535            "not enough free disk space: required {required} bytes, available {available} bytes"
536        ),
537        _ => error.to_string(),
538    }
539}
540
541fn trusted_public_keys(config: &FileUpdateHelperConfig) -> Vec<String> {
542    config
543        .public_keys
544        .iter()
545        .map(|key| key.trim())
546        .filter(|key| !key.is_empty())
547        .map(str::to_string)
548        .collect()
549}
550
551fn file_update_cache_root<R: Runtime>(
552    app: &AppHandle<R>,
553) -> Result<PathBuf, FileUpdateCommandError> {
554    Ok(app
555        .path()
556        .app_data_dir()?
557        .join("hdiff-update")
558        .join(FILE_UPDATE_ROOT))
559}
560
561fn helper_cache_root(config: &FileUpdateHelperConfig) -> Result<PathBuf, FileUpdateCommandError> {
562    let app_data = std::env::var_os("APPDATA")
563        .ok_or_else(|| FileUpdateCommandError::from("APPDATA is not available"))?;
564    Ok(PathBuf::from(app_data)
565        .join(config.application_id)
566        .join("hdiff-update")
567        .join(FILE_UPDATE_ROOT))
568}
569
570fn current_install_root() -> Result<PathBuf, FileUpdateCommandError> {
571    let executable = std::env::current_exe()?;
572    executable
573        .parent()
574        .map(Path::to_path_buf)
575        .ok_or_else(|| FileUpdateCommandError::from("current executable has no parent directory"))
576}
577
578fn validate_bundled_patch_tool(
579    install_root: &Path,
580    tool_path: &Path,
581    managed_paths: &[String],
582) -> Result<PathBuf, FileUpdateCommandError> {
583    let install_root = install_root.canonicalize()?;
584    let tool_path = tool_path.canonicalize()?;
585    let metadata = fs::symlink_metadata(&tool_path)?;
586    if !metadata.is_file() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
587        return Err(FileUpdateCommandError::from(format!(
588            "bundled patch tool is missing or is a reparse point: {}",
589            tool_path.display()
590        )));
591    }
592    let relative = tool_path.strip_prefix(&install_root).map_err(|_| {
593        FileUpdateCommandError::from("bundled patch tool is outside the installation root")
594    })?;
595    let relative = relative
596        .to_str()
597        .ok_or_else(|| FileUpdateCommandError::from("bundled patch tool path is not UTF-8"))?
598        .replace('\\', "/");
599    let relative = normalize_relative_path(&relative)?;
600    if !managed_paths
601        .iter()
602        .any(|managed| relative == *managed || relative.starts_with(&format!("{managed}/")))
603    {
604        return Err(FileUpdateCommandError::from(format!(
605            "bundled patch tool is outside the managed update tree: {relative}"
606        )));
607    }
608    Ok(tool_path)
609}
610
611fn validate_transaction_path(
612    cache_root: &Path,
613    transaction_path: &Path,
614) -> Result<PathBuf, FileUpdateCommandError> {
615    let expected_root = cache_root.join("transactions");
616    let transaction_path = transaction_path.canonicalize()?;
617    let expected_root = expected_root.canonicalize()?;
618    if !transaction_path.starts_with(&expected_root)
619        || transaction_path.file_name() != Some(OsStr::new("transaction.json"))
620    {
621        return Err(FileUpdateCommandError::from(format!(
622            "transaction path is outside the managed cache: {}",
623            transaction_path.display()
624        )));
625    }
626    Ok(transaction_path)
627}
628
629fn validate_helper_context(
630    config: &FileUpdateHelperConfig,
631    transaction_path: &Path,
632) -> Result<ValidatedContext, FileUpdateCommandError> {
633    let public_keys = trusted_public_keys(config);
634    if public_keys.is_empty() {
635        return Err(FileUpdateCommandError::from(
636            "file update helper trusted public-key ring is not compiled into the application",
637        ));
638    }
639    let transaction_path = transaction_path.canonicalize()?;
640    let expected_transactions_root = helper_cache_root(config)?
641        .join("transactions")
642        .canonicalize()?;
643    if !transaction_path.starts_with(&expected_transactions_root)
644        || transaction_path.file_name() != Some(OsStr::new("transaction.json"))
645    {
646        return Err(FileUpdateCommandError::from(format!(
647            "file update transaction is outside the application cache: {}",
648            transaction_path.display()
649        )));
650    }
651    let transaction_root = transaction_path
652        .parent()
653        .ok_or_else(|| FileUpdateCommandError::from("transaction path has no parent"))?
654        .to_path_buf();
655    ensure_plain_directory(&transaction_root)?;
656    let transaction = read_directory_transaction(&transaction_path)?;
657    if transaction.application_id != config.application_id {
658        return Err(FileUpdateCommandError::from(
659            "file update helper application id mismatch",
660        ));
661    }
662    let configured_paths = normalize_managed_paths(
663        &config
664            .managed_paths
665            .iter()
666            .map(|path| (*path).to_string())
667            .collect::<Vec<_>>(),
668    )?;
669    if transaction.managed_paths != configured_paths {
670        return Err(FileUpdateCommandError::from(
671            "file update helper managed path policy mismatch",
672        ));
673    }
674    if !configured_paths
675        .iter()
676        .any(|path| path.eq_ignore_ascii_case(config.main_executable))
677    {
678        return Err(FileUpdateCommandError::from(
679            "main executable is not part of the managed path policy",
680        ));
681    }
682
683    let manifest_path = transaction_root.join(MANIFEST_FILE);
684    let manifest = read_directory_manifest_file(&manifest_path)?;
685    verify_directory_manifest_signature_with_keys(&manifest, &public_keys)?;
686    if manifest.version != transaction.target_version {
687        return Err(FileUpdateCommandError::from(
688            "transaction target version does not match signed manifest",
689        ));
690    }
691    let release = manifest.platform(&transaction.platform)?.clone();
692    if release.managed_paths != configured_paths {
693        return Err(FileUpdateCommandError::from(
694            "signed manifest managed paths do not match helper policy",
695        ));
696    }
697    let delta = release
698        .delta_from(&transaction.current_version)
699        .ok_or_else(|| FileUpdateCommandError::from("signed manifest has no transaction delta"))?;
700    if delta.source.tree_sha256 != transaction.source_tree_sha256
701        || release.target.tree_sha256 != transaction.target_tree_sha256
702        || delta.patch.sha256 != transaction.patch_sha256
703        || delta.patch.size != transaction.patch_size
704    {
705        return Err(FileUpdateCommandError::from(
706            "transaction metadata does not match signed manifest",
707        ));
708    }
709
710    let install_root = transaction.install_root.canonicalize()?;
711    ensure_plain_directory(&install_root)?;
712    let main_executable = install_root.join(config.main_executable);
713    if !main_executable.is_file() {
714        return Err(FileUpdateCommandError::from(format!(
715            "installed main executable is missing: {}",
716            main_executable.display()
717        )));
718    }
719    let staging_path = transaction_root.join(STAGING_DIR);
720    ensure_plain_directory(&staging_path)?;
721    verify_file_tree(&staging_path, &configured_paths, &release.target)?;
722
723    Ok(ValidatedContext {
724        transaction_path,
725        transaction_root: transaction_root.clone(),
726        transaction,
727        source: delta.source.clone(),
728        target: release.target,
729        staging_path,
730        result_path: transaction_root.join(RESULT_FILE),
731        install_root,
732        managed_paths: configured_paths,
733    })
734}
735
736#[cfg(windows)]
737fn run_supervisor(
738    config: &FileUpdateHelperConfig,
739    transaction_path: &Path,
740    parent_pid: u32,
741) -> Result<(), FileUpdateCommandError> {
742    let mut context = validate_helper_context(config, transaction_path)?;
743    wait_for_process_exit(parent_pid, Duration::from_secs(120))?;
744
745    let helper = std::env::current_exe()?;
746    let parameters = windows_command_line(&[
747        ELEVATED_ARGUMENT.to_string(),
748        context.transaction_path.to_string_lossy().to_string(),
749    ]);
750    let elevated = match shell_execute_elevated(&helper, &parameters) {
751        Ok(process) => process,
752        Err(error) => {
753            context.transaction.state = DirectoryTransactionState::Failed;
754            context.transaction.updated_at_ms = now_millis();
755            context.transaction.failure_reason = Some(error.message.clone());
756            write_directory_transaction(&context.transaction_path, &context.transaction)?;
757            write_helper_result(
758                &context.result_path,
759                HelperResultState::Failed,
760                Some(error.message.clone()),
761            )?;
762            let _ = cleanup_transaction_payload(&context);
763            launch_installed_application(config, &context.install_root)?;
764            return Ok(());
765        }
766    };
767
768    let mut launched = None::<Child>;
769    let mut launched_once = false;
770    let mut launch_failed = false;
771    let mut rollback_requested = false;
772    loop {
773        let wait = unsafe { WaitForSingleObject(elevated, 200) };
774        let helper_result = read_helper_result(&context.result_path).ok();
775        if launched.is_none()
776            && !launch_failed
777            && helper_result
778                .as_ref()
779                .is_some_and(|result| result.state == HelperResultState::AwaitingHealth)
780        {
781            match launch_installed_application(config, &context.install_root) {
782                Ok(mut child) => {
783                    if let Err(error) = write_launched_pid(&context.transaction_root, child.id()) {
784                        let _ = child.kill();
785                        let _ = child.wait();
786                        request_rollback(
787                            &context.transaction_root,
788                            &format!(
789                                "failed to record updated application process: {}",
790                                error.message
791                            ),
792                        )?;
793                        launch_failed = true;
794                        rollback_requested = true;
795                    } else {
796                        launched_once = true;
797                        launched = Some(child);
798                    }
799                }
800                Err(error) => {
801                    request_rollback(&context.transaction_root, &error.message)?;
802                    launch_failed = true;
803                    rollback_requested = true;
804                }
805            }
806        }
807        if let Some(child) = launched.as_mut() {
808            if child.try_wait()?.is_some()
809                && !context.transaction_root.join(HEALTH_FILE).is_file()
810                && !rollback_requested
811            {
812                request_rollback(
813                    &context.transaction_root,
814                    "updated application exited before confirming startup",
815                )?;
816                rollback_requested = true;
817            }
818        }
819        if wait == WAIT_OBJECT_0 {
820            break;
821        }
822        if wait != WAIT_TIMEOUT {
823            unsafe {
824                CloseHandle(elevated);
825            }
826            return Err(FileUpdateCommandError::from(format!(
827                "waiting for elevated update worker failed with code {wait}"
828            )));
829        }
830    }
831
832    let mut exit_code = 0_u32;
833    unsafe {
834        GetExitCodeProcess(elevated, &mut exit_code);
835        CloseHandle(elevated);
836    }
837    let final_result = read_helper_result(&context.result_path).ok();
838    let child_running = launched
839        .as_mut()
840        .map(|child| child.try_wait().map(|status| status.is_none()))
841        .transpose()?
842        .unwrap_or(false);
843    let should_restart = match final_result.as_ref().map(|result| result.state) {
844        Some(HelperResultState::Committed) => !launched_once,
845        Some(HelperResultState::AwaitingHealth | HelperResultState::HealthTimeout) => {
846            !child_running
847        }
848        Some(HelperResultState::RolledBack | HelperResultState::Failed) | None => !child_running,
849        Some(HelperResultState::RecoveryFailed) => false,
850    };
851    if should_restart {
852        let _ = launch_installed_application(config, &context.install_root)?;
853    }
854    if exit_code != 0 {
855        return Err(FileUpdateCommandError::from(format!(
856            "elevated file update worker exited with code {exit_code}"
857        )));
858    }
859    Ok(())
860}
861
862#[cfg(not(windows))]
863fn run_supervisor(
864    _config: &FileUpdateHelperConfig,
865    _transaction_path: &Path,
866    _parent_pid: u32,
867) -> Result<(), FileUpdateCommandError> {
868    Err(FileUpdateCommandError::from(
869        "file update supervisor is only implemented on Windows",
870    ))
871}
872
873#[cfg(windows)]
874fn run_elevated_worker(
875    config: &FileUpdateHelperConfig,
876    transaction_path: &Path,
877) -> Result<(), FileUpdateCommandError> {
878    let mut context = validate_helper_context(config, transaction_path)?;
879    match context.transaction.state {
880        DirectoryTransactionState::Committing => {
881            let error =
882                FileUpdateCommandError::from("interrupted directory update was rolled back");
883            finish_rollback(&mut context, &error.message)?;
884            return Err(error);
885        }
886        DirectoryTransactionState::AwaitingHealth | DirectoryTransactionState::HealthTimeout => {
887            if context.transaction_root.join(HEALTH_FILE).is_file() {
888                verify_file_tree(
889                    &context.install_root,
890                    &context.managed_paths,
891                    &context.target,
892                )?;
893                let protected_root = protected_transaction_root(&context);
894                if protected_root.exists() {
895                    ensure_plain_directory(&protected_root)?;
896                    fs::remove_dir_all(&protected_root)?;
897                }
898                context.transaction.state = DirectoryTransactionState::Committed;
899                context.transaction.updated_at_ms = now_millis();
900                context.transaction.failure_reason = None;
901                write_directory_transaction(&context.transaction_path, &context.transaction)?;
902                write_helper_result(&context.result_path, HelperResultState::Committed, None)?;
903                let _ = cleanup_transaction_payload(&context);
904                return Ok(());
905            }
906
907            let error = FileUpdateCommandError::from(
908                "updated application did not confirm startup; rolling back",
909            );
910            finish_rollback(&mut context, &error.message)?;
911            return Err(error);
912        }
913        _ => {}
914    }
915    context.transaction.state = DirectoryTransactionState::Committing;
916    context.transaction.updated_at_ms = now_millis();
917    write_directory_transaction(&context.transaction_path, &context.transaction)?;
918
919    let commit_result = commit_update(config, &mut context);
920    match commit_result {
921        Ok(protected_root) => {
922            context.transaction.state = DirectoryTransactionState::AwaitingHealth;
923            context.transaction.updated_at_ms = now_millis();
924            write_directory_transaction(&context.transaction_path, &context.transaction)?;
925            write_helper_result(
926                &context.result_path,
927                HelperResultState::AwaitingHealth,
928                None,
929            )?;
930
931            let health_path = context.transaction_root.join(HEALTH_FILE);
932            let rollback_path = context.transaction_root.join(ROLLBACK_REQUEST_FILE);
933            let deadline = std::time::Instant::now() + Duration::from_secs(120);
934            while std::time::Instant::now() < deadline {
935                if health_path.is_file() {
936                    verify_file_tree(
937                        &context.install_root,
938                        &context.managed_paths,
939                        &context.target,
940                    )?;
941                    if protected_root.exists() {
942                        ensure_plain_directory(&protected_root)?;
943                        fs::remove_dir_all(&protected_root)?;
944                    }
945                    context.transaction.state = DirectoryTransactionState::Committed;
946                    context.transaction.updated_at_ms = now_millis();
947                    context.transaction.failure_reason = None;
948                    write_directory_transaction(&context.transaction_path, &context.transaction)?;
949                    write_helper_result(&context.result_path, HelperResultState::Committed, None)?;
950                    let _ = cleanup_transaction_payload(&context);
951                    return Ok(());
952                }
953                if rollback_path.is_file() {
954                    let reason = fs::read_to_string(&rollback_path)
955                        .unwrap_or_else(|_| "updated application failed to start".to_string());
956                    let error = FileUpdateCommandError::from(reason.trim().to_string());
957                    finish_rollback(&mut context, &error.message)?;
958                    return Err(error);
959                }
960                thread::sleep(Duration::from_millis(250));
961            }
962
963            let launched_process_running = read_launched_pid(&context.transaction_root)
964                .map(process_is_running)
965                .unwrap_or(false);
966            if !launched_process_running {
967                let error = FileUpdateCommandError::from(
968                    "updated application did not stay running; rolling back",
969                );
970                finish_rollback(&mut context, &error.message)?;
971                return Err(error);
972            }
973
974            context.transaction.state = DirectoryTransactionState::HealthTimeout;
975            context.transaction.updated_at_ms = now_millis();
976            context.transaction.failure_reason =
977                Some("updated application did not confirm startup within 120 seconds".to_string());
978            write_directory_transaction(&context.transaction_path, &context.transaction)?;
979            write_helper_result(
980                &context.result_path,
981                HelperResultState::HealthTimeout,
982                context.transaction.failure_reason.clone(),
983            )?;
984            Ok(())
985        }
986        Err(error) => {
987            finish_rollback(&mut context, &error.message)?;
988            Err(error)
989        }
990    }
991}
992
993#[cfg(windows)]
994fn finish_rollback(
995    context: &mut ValidatedContext,
996    reason: &str,
997) -> Result<(), FileUpdateCommandError> {
998    match recover_interrupted_commit(context) {
999        Ok(()) => {
1000            context.transaction.state = DirectoryTransactionState::RolledBack;
1001            context.transaction.updated_at_ms = now_millis();
1002            context.transaction.failure_reason = Some(reason.to_string());
1003            write_directory_transaction(&context.transaction_path, &context.transaction)?;
1004            write_helper_result(
1005                &context.result_path,
1006                HelperResultState::RolledBack,
1007                Some(reason.to_string()),
1008            )?;
1009            let _ = cleanup_transaction_payload(context);
1010            Ok(())
1011        }
1012        Err(recovery_error) => {
1013            let message = format!(
1014                "{reason}; rollback recovery failed: {}",
1015                recovery_error.message
1016            );
1017            context.transaction.state = DirectoryTransactionState::Committing;
1018            context.transaction.updated_at_ms = now_millis();
1019            context.transaction.failure_reason = Some(message.clone());
1020            write_directory_transaction(&context.transaction_path, &context.transaction)?;
1021            write_helper_result(
1022                &context.result_path,
1023                HelperResultState::RecoveryFailed,
1024                Some(message),
1025            )?;
1026            Err(recovery_error)
1027        }
1028    }
1029}
1030
1031#[cfg(not(windows))]
1032fn run_elevated_worker(
1033    _config: &FileUpdateHelperConfig,
1034    _transaction_path: &Path,
1035) -> Result<(), FileUpdateCommandError> {
1036    Err(FileUpdateCommandError::from(
1037        "elevated file update worker is only implemented on Windows",
1038    ))
1039}
1040
1041#[cfg(windows)]
1042fn protected_transaction_root(context: &ValidatedContext) -> PathBuf {
1043    context
1044        .install_root
1045        .join(".hdiff-update")
1046        .join(&context.transaction.transaction_id)
1047}
1048
1049#[cfg(windows)]
1050fn recover_interrupted_commit(context: &ValidatedContext) -> Result<(), FileUpdateCommandError> {
1051    let protected_root = protected_transaction_root(context);
1052    if !protected_root.exists() {
1053        return Ok(());
1054    }
1055    ensure_plain_directory(&protected_root)?;
1056    let journal_path = protected_root.join("journal.json");
1057    if !journal_path.is_file() {
1058        fs::remove_dir_all(&protected_root)?;
1059        return Ok(());
1060    }
1061    let journal: CommitJournal = serde_json::from_slice(&fs::read(&journal_path)?)?;
1062    if journal.transaction_id != context.transaction.transaction_id {
1063        return Err(FileUpdateCommandError::from(
1064            "protected update journal belongs to another transaction",
1065        ));
1066    }
1067    let backup_root = protected_root.join("backup");
1068    for operation in journal.operations.iter().rev() {
1069        let current = context.install_root.join(&operation.path);
1070        let backup = backup_root.join(&operation.path);
1071        if operation.state == JournalOperationState::Pending && !operation.is_directory {
1072            if !current.is_file() {
1073                return Err(FileUpdateCommandError::from(format!(
1074                    "cannot recover file backup because the current file is missing: {}",
1075                    operation.path
1076                )));
1077            }
1078            if backup.exists() {
1079                remove_managed_entry(&backup)?;
1080            }
1081            continue;
1082        }
1083        if backup.exists() {
1084            if current.exists() {
1085                remove_managed_entry(&current)?;
1086            }
1087            if operation.is_directory {
1088                rename_with_retry(&backup, &current)?;
1089            } else {
1090                move_file_replace(&backup, &current)?;
1091            }
1092        } else if !current.exists() {
1093            return Err(FileUpdateCommandError::from(format!(
1094                "cannot recover managed path because both current and backup are missing: {}",
1095                operation.path
1096            )));
1097        }
1098    }
1099    verify_file_tree(
1100        &context.install_root,
1101        &context.managed_paths,
1102        &context.source,
1103    )?;
1104    fs::remove_dir_all(&protected_root)?;
1105    Ok(())
1106}
1107
1108#[cfg(windows)]
1109fn commit_update(
1110    config: &FileUpdateHelperConfig,
1111    context: &mut ValidatedContext,
1112) -> Result<PathBuf, FileUpdateCommandError> {
1113    verify_file_tree(
1114        &context.install_root,
1115        &context.managed_paths,
1116        &context.source,
1117    )?;
1118    verify_file_tree(
1119        &context.staging_path,
1120        &context.managed_paths,
1121        &context.target,
1122    )?;
1123    let file_backup_bytes = context
1124        .source
1125        .files
1126        .iter()
1127        .filter(|file| context.managed_paths.contains(&file.path))
1128        .try_fold(0_u64, |total, file| total.checked_add(file.size))
1129        .ok_or_else(|| FileUpdateCommandError::from("commit disk requirement overflow"))?;
1130    let required_commit_bytes = context
1131        .target
1132        .total_size
1133        .checked_add(file_backup_bytes)
1134        .and_then(|size| size.checked_add(COMMIT_DISK_RESERVE_BYTES))
1135        .ok_or_else(|| FileUpdateCommandError::from("commit disk requirement overflow"))?;
1136    ensure_available_space(&context.install_root, required_commit_bytes)?;
1137
1138    let update_root = context.install_root.join(".hdiff-update");
1139    if update_root.exists() {
1140        ensure_plain_directory(&update_root)?;
1141    } else {
1142        fs::create_dir(&update_root)?;
1143        ensure_plain_directory(&update_root)?;
1144    }
1145    let protected_root = protected_transaction_root(context);
1146    if protected_root.exists() {
1147        ensure_plain_directory(&protected_root)?;
1148        fs::remove_dir_all(&protected_root)?;
1149    }
1150    let new_root = protected_root.join("new");
1151    let backup_root = protected_root.join("backup");
1152    fs::create_dir_all(&backup_root)?;
1153    copy_managed_tree(&context.staging_path, &new_root, &context.managed_paths)?;
1154    verify_file_tree(&new_root, &context.managed_paths, &context.target)?;
1155
1156    let journal_path = protected_root.join("journal.json");
1157    let mut operations = context
1158        .managed_paths
1159        .iter()
1160        .map(|path| JournalOperation {
1161            path: path.clone(),
1162            is_directory: context.install_root.join(path).is_dir(),
1163            state: JournalOperationState::Pending,
1164        })
1165        .collect::<Vec<_>>();
1166    operations.sort_by_key(|operation| {
1167        (
1168            operation.path.eq_ignore_ascii_case(config.main_executable),
1169            operation.path.to_ascii_lowercase(),
1170        )
1171    });
1172    let mut journal = CommitJournal {
1173        transaction_id: context.transaction.transaction_id.clone(),
1174        state: DirectoryTransactionState::Committing,
1175        operations,
1176        updated_at_ms: now_millis(),
1177    };
1178    write_json_file_atomic(&journal_path, &journal)?;
1179
1180    for index in 0..journal.operations.len() {
1181        let operation = journal.operations[index].clone();
1182        let current = context.install_root.join(&operation.path);
1183        let replacement = new_root.join(&operation.path);
1184        let backup = backup_root.join(&operation.path);
1185        if let Some(parent) = backup.parent() {
1186            fs::create_dir_all(parent)?;
1187        }
1188
1189        let result = if operation.is_directory {
1190            rename_with_retry(&current, &backup)
1191                .and_then(|_| {
1192                    journal.operations[index].state = JournalOperationState::BackedUp;
1193                    journal.updated_at_ms = now_millis();
1194                    write_json_file_atomic(&journal_path, &journal).map_err(core_error_to_io)?;
1195                    rename_with_retry(&replacement, &current)
1196                })
1197                .map(|_| ())
1198        } else {
1199            copy_file_backup(&current, &backup).and_then(|_| {
1200                journal.operations[index].state = JournalOperationState::BackedUp;
1201                journal.updated_at_ms = now_millis();
1202                write_json_file_atomic(&journal_path, &journal).map_err(core_error_to_io)?;
1203                move_file_replace(&replacement, &current)
1204            })
1205        };
1206
1207        if let Err(error) = result {
1208            let commit_error = FileUpdateCommandError::from(format!(
1209                "failed to commit managed path {}: {error}",
1210                operation.path
1211            ));
1212            rollback_operations(context, &backup_root, &journal)?;
1213            return Err(commit_error);
1214        }
1215        journal.operations[index].state = JournalOperationState::Installed;
1216        journal.updated_at_ms = now_millis();
1217        write_json_file_atomic(&journal_path, &journal)?;
1218    }
1219
1220    if let Err(error) = verify_file_tree(
1221        &context.install_root,
1222        &context.managed_paths,
1223        &context.target,
1224    ) {
1225        rollback_operations(context, &backup_root, &journal)?;
1226        return Err(FileUpdateCommandError::from(format!(
1227            "installed target verification failed: {error}"
1228        )));
1229    }
1230    journal.state = DirectoryTransactionState::AwaitingHealth;
1231    journal.updated_at_ms = now_millis();
1232    write_json_file_atomic(&journal_path, &journal)?;
1233    Ok(protected_root)
1234}
1235
1236#[cfg(windows)]
1237fn rollback_operations(
1238    context: &ValidatedContext,
1239    backup_root: &Path,
1240    journal: &CommitJournal,
1241) -> Result<(), FileUpdateCommandError> {
1242    for operation in journal.operations.iter().rev() {
1243        if operation.state == JournalOperationState::Pending {
1244            continue;
1245        }
1246        let current = context.install_root.join(&operation.path);
1247        let backup = backup_root.join(&operation.path);
1248        if operation.state == JournalOperationState::Installed && current.exists() {
1249            remove_managed_entry(&current)?;
1250        }
1251        if backup.exists() {
1252            if operation.is_directory {
1253                rename_with_retry(&backup, &current)?;
1254            } else {
1255                move_file_replace(&backup, &current)?;
1256            }
1257        }
1258    }
1259    verify_file_tree(
1260        &context.install_root,
1261        &context.managed_paths,
1262        &context.source,
1263    )?;
1264    Ok(())
1265}
1266
1267#[cfg(windows)]
1268fn remove_managed_entry(path: &Path) -> Result<(), FileUpdateCommandError> {
1269    let metadata = fs::symlink_metadata(path)?;
1270    if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
1271        return Err(FileUpdateCommandError::from(format!(
1272            "refusing to remove managed reparse point: {}",
1273            path.display()
1274        )));
1275    }
1276    if metadata.is_dir() {
1277        retry_file_operation(|| fs::remove_dir_all(path))?;
1278    } else if metadata.is_file() {
1279        retry_file_operation(|| fs::remove_file(path))?;
1280    } else {
1281        return Err(FileUpdateCommandError::from(format!(
1282            "unsupported managed entry type: {}",
1283            path.display()
1284        )));
1285    }
1286    Ok(())
1287}
1288
1289#[cfg(windows)]
1290fn move_file_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
1291    let source = wide_null(source.as_os_str())?;
1292    let destination = wide_null(destination.as_os_str())?;
1293    retry_file_operation(|| {
1294        let result = unsafe {
1295            MoveFileExW(
1296                source.as_ptr(),
1297                destination.as_ptr(),
1298                MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
1299            )
1300        };
1301        if result == 0 {
1302            Err(std::io::Error::last_os_error())
1303        } else {
1304            Ok(())
1305        }
1306    })
1307}
1308
1309#[cfg(windows)]
1310fn rename_with_retry(source: &Path, destination: &Path) -> std::io::Result<()> {
1311    retry_file_operation(|| fs::rename(source, destination))
1312}
1313
1314#[cfg(windows)]
1315fn retry_file_operation<T>(
1316    mut operation: impl FnMut() -> std::io::Result<T>,
1317) -> std::io::Result<T> {
1318    let deadline = std::time::Instant::now() + Duration::from_secs(FILE_OPERATION_RETRY_SECS);
1319    loop {
1320        match operation() {
1321            Ok(value) => return Ok(value),
1322            Err(error)
1323                if is_retryable_file_error(&error) && std::time::Instant::now() < deadline =>
1324            {
1325                thread::sleep(Duration::from_millis(200));
1326            }
1327            Err(error) => return Err(error),
1328        }
1329    }
1330}
1331
1332#[cfg(windows)]
1333fn is_retryable_file_error(error: &std::io::Error) -> bool {
1334    matches!(error.raw_os_error(), Some(5 | 32 | 33))
1335}
1336
1337#[cfg(windows)]
1338fn copy_file_backup(source: &Path, destination: &Path) -> std::io::Result<()> {
1339    let temporary = destination.with_extension(format!("backup-part-{}", std::process::id()));
1340    if temporary.exists() {
1341        fs::remove_file(&temporary)?;
1342    }
1343    retry_file_operation(|| fs::copy(source, &temporary))?;
1344    fs::OpenOptions::new()
1345        .write(true)
1346        .open(&temporary)?
1347        .sync_all()?;
1348    if let Err(error) = move_file_replace(&temporary, destination) {
1349        let _ = fs::remove_file(&temporary);
1350        return Err(error);
1351    }
1352    Ok(())
1353}
1354
1355#[cfg(windows)]
1356fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), FileUpdateCommandError> {
1357    let process = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1358    if process.is_null() {
1359        return Ok(());
1360    }
1361    let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32;
1362    let result = unsafe { WaitForSingleObject(process, timeout_ms) };
1363    unsafe {
1364        CloseHandle(process);
1365    }
1366    if result == WAIT_OBJECT_0 {
1367        Ok(())
1368    } else if result == WAIT_TIMEOUT {
1369        Err(FileUpdateCommandError::from(format!(
1370            "application process {pid} did not exit within {} seconds",
1371            timeout.as_secs()
1372        )))
1373    } else {
1374        Err(FileUpdateCommandError::from(format!(
1375            "waiting for application process {pid} failed with code {result}"
1376        )))
1377    }
1378}
1379
1380#[cfg(windows)]
1381fn shell_execute_elevated(
1382    executable: &Path,
1383    parameters: &str,
1384) -> Result<HANDLE, FileUpdateCommandError> {
1385    let verb = wide_null(OsStr::new("runas"))?;
1386    let executable = wide_null(executable.as_os_str())?;
1387    let parameters = wide_null(OsStr::new(parameters))?;
1388    let mut execute_info = SHELLEXECUTEINFOW {
1389        cbSize: std::mem::size_of::<SHELLEXECUTEINFOW>() as u32,
1390        fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
1391        lpVerb: verb.as_ptr(),
1392        lpFile: executable.as_ptr(),
1393        lpParameters: parameters.as_ptr(),
1394        nShow: SW_SHOWNORMAL,
1395        ..Default::default()
1396    };
1397    if unsafe { ShellExecuteExW(&mut execute_info) } == 0 {
1398        let error = std::io::Error::last_os_error();
1399        if error.raw_os_error() == Some(ERROR_CANCELLED as i32) {
1400            return Err(FileUpdateCommandError::from(
1401                "administrator permission was declined",
1402            ));
1403        }
1404        if error.raw_os_error() == Some(ERROR_ELEVATION_REQUIRED as i32) {
1405            return Err(FileUpdateCommandError::from(
1406                "administrator permission is required",
1407            ));
1408        }
1409        return Err(FileUpdateCommandError::from(format!(
1410            "failed to start elevated file update worker: {error}"
1411        )));
1412    }
1413    if execute_info.hProcess.is_null() || unsafe { GetProcessId(execute_info.hProcess) } == 0 {
1414        if !execute_info.hProcess.is_null() {
1415            unsafe {
1416                CloseHandle(execute_info.hProcess);
1417            }
1418        }
1419        return Err(FileUpdateCommandError::from(
1420            "elevated file update worker started without a process handle",
1421        ));
1422    }
1423    Ok(execute_info.hProcess)
1424}
1425
1426#[cfg(windows)]
1427fn launch_installed_application(
1428    config: &FileUpdateHelperConfig,
1429    install_root: &Path,
1430) -> Result<Child, FileUpdateCommandError> {
1431    let executable = install_root.join(config.main_executable);
1432    let mut command = Command::new(&executable);
1433    command
1434        .current_dir(install_root)
1435        .creation_flags(CREATE_NO_WINDOW);
1436    command.spawn().map_err(|error| {
1437        FileUpdateCommandError::from(format!(
1438            "failed to restart installed application {}: {error}",
1439            executable.display()
1440        ))
1441    })
1442}
1443
1444#[cfg(windows)]
1445fn request_rollback(transaction_root: &Path, reason: &str) -> Result<(), FileUpdateCommandError> {
1446    fs::write(
1447        transaction_root.join(ROLLBACK_REQUEST_FILE),
1448        reason.trim().as_bytes(),
1449    )?;
1450    Ok(())
1451}
1452
1453#[cfg(windows)]
1454fn write_launched_pid(transaction_root: &Path, pid: u32) -> Result<(), FileUpdateCommandError> {
1455    fs::write(
1456        transaction_root.join(LAUNCHED_PID_FILE),
1457        pid.to_string().as_bytes(),
1458    )?;
1459    Ok(())
1460}
1461
1462#[cfg(windows)]
1463fn read_launched_pid(transaction_root: &Path) -> Option<u32> {
1464    fs::read_to_string(transaction_root.join(LAUNCHED_PID_FILE))
1465        .ok()?
1466        .trim()
1467        .parse()
1468        .ok()
1469}
1470
1471#[cfg(windows)]
1472fn process_is_running(pid: u32) -> bool {
1473    let process = unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
1474    if process.is_null() {
1475        return false;
1476    }
1477    let wait = unsafe { WaitForSingleObject(process, 0) };
1478    unsafe {
1479        CloseHandle(process);
1480    }
1481    wait == WAIT_TIMEOUT
1482}
1483
1484#[cfg(windows)]
1485fn cleanup_transaction_payload(context: &ValidatedContext) -> Result<(), FileUpdateCommandError> {
1486    if context.staging_path.exists() {
1487        ensure_plain_directory(&context.staging_path)?;
1488        fs::remove_dir_all(&context.staging_path)?;
1489    }
1490    let patch_path = context.transaction_root.join("patch.hdiff");
1491    if patch_path.is_file() {
1492        fs::remove_file(patch_path)?;
1493    }
1494    let rollback_path = context.transaction_root.join(ROLLBACK_REQUEST_FILE);
1495    if rollback_path.is_file() {
1496        fs::remove_file(rollback_path)?;
1497    }
1498
1499    let cache_root = context
1500        .transaction_root
1501        .parent()
1502        .and_then(Path::parent)
1503        .ok_or_else(|| FileUpdateCommandError::from("transaction cache root is missing"))?;
1504    let pointer_path = cache_root.join("prepared.json");
1505    if pointer_path.is_file() {
1506        let pointer = serde_json::from_slice::<PreparedPointer>(&fs::read(&pointer_path)?);
1507        if pointer.ok().is_some_and(|pointer| {
1508            pointer.transaction_path.canonicalize().ok() == Some(context.transaction_path.clone())
1509        }) {
1510            fs::remove_file(pointer_path)?;
1511        }
1512    }
1513    Ok(())
1514}
1515
1516fn ensure_plain_directory(path: &Path) -> Result<(), FileUpdateCommandError> {
1517    let metadata = fs::symlink_metadata(path)?;
1518    if !metadata.is_dir() || metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
1519        return Err(FileUpdateCommandError::from(format!(
1520            "directory is missing or is a reparse point: {}",
1521            path.display()
1522        )));
1523    }
1524    Ok(())
1525}
1526
1527#[cfg(windows)]
1528fn is_reparse_point(metadata: &fs::Metadata) -> bool {
1529    use std::os::windows::fs::MetadataExt;
1530    metadata.file_attributes() & 0x0000_0400 != 0
1531}
1532
1533#[cfg(not(windows))]
1534fn is_reparse_point(_metadata: &fs::Metadata) -> bool {
1535    false
1536}
1537
1538fn write_helper_result(
1539    path: &Path,
1540    state: HelperResultState,
1541    message: Option<String>,
1542) -> Result<(), FileUpdateCommandError> {
1543    write_json_file_atomic(
1544        path,
1545        &HelperResult {
1546            state,
1547            updated_at_ms: now_millis(),
1548            message,
1549        },
1550    )
1551    .map_err(FileUpdateCommandError::from)
1552}
1553
1554fn read_helper_result(path: &Path) -> Result<HelperResult, FileUpdateCommandError> {
1555    Ok(serde_json::from_slice(&fs::read(path)?)?)
1556}
1557
1558fn core_error_to_io(error: CoreError) -> std::io::Error {
1559    std::io::Error::other(error.to_string())
1560}
1561
1562#[cfg(windows)]
1563fn wide_null(value: &OsStr) -> std::io::Result<Vec<u16>> {
1564    let mut wide = Vec::new();
1565    for unit in value.encode_wide() {
1566        if unit == 0 {
1567            return Err(std::io::Error::new(
1568                std::io::ErrorKind::InvalidInput,
1569                "value contains an embedded null character",
1570            ));
1571        }
1572        wide.push(unit);
1573    }
1574    wide.push(0);
1575    Ok(wide)
1576}
1577
1578#[cfg(windows)]
1579fn windows_command_line(args: &[String]) -> String {
1580    args.iter()
1581        .map(|argument| quote_windows_argument(argument))
1582        .collect::<Vec<_>>()
1583        .join(" ")
1584}
1585
1586#[cfg(windows)]
1587fn quote_windows_argument(argument: &str) -> String {
1588    if !argument.is_empty()
1589        && !argument
1590            .chars()
1591            .any(|character| matches!(character, ' ' | '\t' | '"'))
1592    {
1593        return argument.to_owned();
1594    }
1595    let mut quoted = String::with_capacity(argument.len() + 2);
1596    quoted.push('"');
1597    let mut backslashes = 0usize;
1598    for character in argument.chars() {
1599        match character {
1600            '\\' => backslashes += 1,
1601            '"' => {
1602                quoted.extend(std::iter::repeat('\\').take(backslashes * 2 + 1));
1603                quoted.push('"');
1604                backslashes = 0;
1605            }
1606            _ => {
1607                quoted.extend(std::iter::repeat('\\').take(backslashes));
1608                quoted.push(character);
1609                backslashes = 0;
1610            }
1611        }
1612    }
1613    quoted.extend(std::iter::repeat('\\').take(backslashes * 2));
1614    quoted.push('"');
1615    quoted
1616}
1617
1618fn now_millis() -> u64 {
1619    SystemTime::now()
1620        .duration_since(UNIX_EPOCH)
1621        .map(|duration| duration.as_millis() as u64)
1622        .unwrap_or_default()
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627    #[cfg(windows)]
1628    use std::fs;
1629
1630    #[cfg(windows)]
1631    use hdiff_update_core::{
1632        build_file_tree_manifest, verify_file_tree, DirectoryTransactionState,
1633        DirectoryUpdateTransaction,
1634    };
1635    #[cfg(windows)]
1636    use tempfile::tempdir;
1637
1638    #[cfg(windows)]
1639    use super::{
1640        commit_update, quote_windows_argument, recover_interrupted_commit, windows_command_line,
1641        CommitJournal, FileUpdateHelperConfig, JournalOperation, JournalOperationState,
1642        ValidatedContext,
1643    };
1644
1645    #[cfg(windows)]
1646    #[test]
1647    fn quotes_helper_arguments_for_shell_execute() {
1648        assert_eq!(quote_windows_argument("plain"), "plain");
1649        assert_eq!(
1650            windows_command_line(&[
1651                "--hdiff-update-elevated".to_string(),
1652                "C:\\Program Data\\transaction.json".to_string(),
1653            ]),
1654            "--hdiff-update-elevated \"C:\\Program Data\\transaction.json\""
1655        );
1656    }
1657
1658    #[cfg(windows)]
1659    #[test]
1660    fn commit_replaces_managed_payload_and_keeps_unmanaged_files() {
1661        let dir = tempdir().unwrap();
1662        let install = dir.path().join("install");
1663        let transaction_root = dir.path().join("transaction");
1664        let staging = transaction_root.join("staged");
1665        fs::create_dir_all(install.join("resources")).unwrap();
1666        fs::create_dir_all(install.join("plugins")).unwrap();
1667        fs::create_dir_all(staging.join("resources")).unwrap();
1668        fs::create_dir_all(staging.join("plugins")).unwrap();
1669        fs::write(install.join("app.exe"), b"old-app").unwrap();
1670        fs::write(install.join("resources/data.bin"), b"old-resource").unwrap();
1671        fs::write(install.join("plugins/plugin.js"), b"same").unwrap();
1672        fs::write(install.join("uninstall.exe"), b"unmanaged").unwrap();
1673        fs::write(staging.join("app.exe"), b"new-app").unwrap();
1674        fs::write(staging.join("resources/data.bin"), b"new-resource").unwrap();
1675        fs::write(staging.join("plugins/plugin.js"), b"same").unwrap();
1676
1677        let managed = vec![
1678            "app.exe".to_string(),
1679            "plugins".to_string(),
1680            "resources".to_string(),
1681        ];
1682        let source = build_file_tree_manifest(&install, &managed).unwrap();
1683        let target = build_file_tree_manifest(&staging, &managed).unwrap();
1684        let transaction = DirectoryUpdateTransaction {
1685            schema_version: 1,
1686            transaction_id: "test-transaction".to_string(),
1687            application_id: "test.app".to_string(),
1688            platform: "windows-x86_64".to_string(),
1689            current_version: "0.1.0".to_string(),
1690            target_version: "0.2.0".to_string(),
1691            install_root: install.clone(),
1692            managed_paths: managed.clone(),
1693            source_tree_sha256: source.tree_sha256.clone(),
1694            target_tree_sha256: target.tree_sha256.clone(),
1695            patch_sha256: "a".repeat(64),
1696            patch_size: 1,
1697            full_size: 100,
1698            state: DirectoryTransactionState::Ready,
1699            created_at_ms: 1,
1700            updated_at_ms: 1,
1701            failure_reason: None,
1702        };
1703        let transaction_path = transaction_root.join("transaction.json");
1704        let mut context = ValidatedContext {
1705            transaction_path,
1706            transaction_root: transaction_root.clone(),
1707            transaction,
1708            source,
1709            target: target.clone(),
1710            staging_path: staging,
1711            result_path: transaction_root.join("result.json"),
1712            install_root: install.clone(),
1713            managed_paths: managed.clone(),
1714        };
1715        let config = FileUpdateHelperConfig {
1716            application_id: "test.app",
1717            public_keys: &["unused"],
1718            main_executable: "app.exe",
1719            managed_paths: &["app.exe", "plugins", "resources"],
1720            hpatchz_relative_path: "bin/hpatchz.exe",
1721        };
1722
1723        let protected = commit_update(&config, &mut context).unwrap();
1724        verify_file_tree(&install, &managed, &target).unwrap();
1725        assert_eq!(
1726            fs::read(install.join("uninstall.exe")).unwrap(),
1727            b"unmanaged"
1728        );
1729        assert!(protected.join("backup/app.exe").is_file());
1730    }
1731
1732    #[cfg(windows)]
1733    #[test]
1734    fn interrupted_pending_file_backup_never_replaces_the_intact_source() {
1735        let dir = tempdir().unwrap();
1736        let install = dir.path().join("install");
1737        let transaction_root = dir.path().join("transaction");
1738        let staging = transaction_root.join("staged");
1739        fs::create_dir_all(&install).unwrap();
1740        fs::create_dir_all(&staging).unwrap();
1741        fs::write(install.join("app.exe"), b"old-app-complete").unwrap();
1742        fs::write(staging.join("app.exe"), b"new-app").unwrap();
1743
1744        let managed = vec!["app.exe".to_string()];
1745        let source = build_file_tree_manifest(&install, &managed).unwrap();
1746        let target = build_file_tree_manifest(&staging, &managed).unwrap();
1747        let transaction = DirectoryUpdateTransaction {
1748            schema_version: 1,
1749            transaction_id: "test-interrupted-backup".to_string(),
1750            application_id: "test.app".to_string(),
1751            platform: "windows-x86_64".to_string(),
1752            current_version: "0.1.0".to_string(),
1753            target_version: "0.2.0".to_string(),
1754            install_root: install.clone(),
1755            managed_paths: managed.clone(),
1756            source_tree_sha256: source.tree_sha256.clone(),
1757            target_tree_sha256: target.tree_sha256.clone(),
1758            patch_sha256: "a".repeat(64),
1759            patch_size: 1,
1760            full_size: 100,
1761            state: DirectoryTransactionState::Committing,
1762            created_at_ms: 1,
1763            updated_at_ms: 1,
1764            failure_reason: None,
1765        };
1766        let context = ValidatedContext {
1767            transaction_path: transaction_root.join("transaction.json"),
1768            transaction_root: transaction_root.clone(),
1769            transaction,
1770            source,
1771            target,
1772            staging_path: staging,
1773            result_path: transaction_root.join("result.json"),
1774            install_root: install.clone(),
1775            managed_paths: managed,
1776        };
1777        let protected = install
1778            .join(".hdiff-update")
1779            .join("test-interrupted-backup");
1780        fs::create_dir_all(protected.join("backup")).unwrap();
1781        fs::write(protected.join("backup/app.exe"), b"partial").unwrap();
1782        let journal = CommitJournal {
1783            transaction_id: "test-interrupted-backup".to_string(),
1784            state: DirectoryTransactionState::Committing,
1785            operations: vec![JournalOperation {
1786                path: "app.exe".to_string(),
1787                is_directory: false,
1788                state: JournalOperationState::Pending,
1789            }],
1790            updated_at_ms: 1,
1791        };
1792        fs::write(
1793            protected.join("journal.json"),
1794            serde_json::to_vec_pretty(&journal).unwrap(),
1795        )
1796        .unwrap();
1797
1798        recover_interrupted_commit(&context).unwrap();
1799
1800        assert_eq!(
1801            fs::read(install.join("app.exe")).unwrap(),
1802            b"old-app-complete"
1803        );
1804        assert!(!protected.exists());
1805    }
1806}