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(¤t_exe, &helper_path)?;
329 let source_digest = sha256_file(¤t_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(¤t_exe, &helper_path)?;
642 if sha256_file(¤t_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, ¶meters) {
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(¤t)?;
1283 }
1284 if operation.is_directory {
1285 rename_with_retry(&backup, ¤t)?;
1286 } else {
1287 move_file_replace(&backup, ¤t)?;
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(¤t, &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, ¤t)
1393 })
1394 .map(|_| ())
1395 } else {
1396 copy_file_backup(¤t, &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, ¤t)
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(¤t)?;
1447 }
1448 if backup.exists() {
1449 if operation.is_directory {
1450 rename_with_retry(&backup, ¤t)?;
1451 } else {
1452 move_file_replace(&backup, ¤t)?;
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}