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