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