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