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