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