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