1use std::{
2 fs::{self, File, OpenOptions},
3 io::Write,
4 path::{Path, PathBuf},
5 time::{SystemTime, UNIX_EPOCH},
6};
7
8use fs2::FileExt;
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use url::Url;
11
12use crate::fs_ops::replace_file;
13use crate::{
14 apply_directory_patch, default_platform, download_to_file, ensure_available_space,
15 normalize_managed_paths, read_url_to_string, sha256_bytes, validate_version_upgrade,
16 verify_directory_manifest_signature_with_keys, verify_file_tree, verify_sha256,
17 ApplyDirectoryPatchOptions, DirectoryUpdateManifest, DownloadEvent, Error, FileTreeManifest,
18 HttpHeader, Result,
19};
20
21const TRANSACTION_SCHEMA_VERSION: u32 = 1;
22const PREPARATION_DISK_RESERVE_BYTES: u64 = 64 * 1024 * 1024;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct PrepareDirectoryUpdateOptions {
27 pub manifest_url: String,
28 pub application_id: String,
29 pub install_root: PathBuf,
30 pub managed_paths: Vec<String>,
31 pub current_version: String,
32 pub expected_version: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub platform: Option<String>,
35 pub cache_dir: PathBuf,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub hpatchz_path: Option<PathBuf>,
38 #[serde(default)]
39 pub signature_public_keys: Vec<String>,
40 #[serde(default = "default_true")]
41 pub require_signature: bool,
42 #[serde(default)]
43 pub headers: Vec<HttpHeader>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub timeout_secs: Option<u64>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub enum PreparedDirectoryUpdateKind {
51 Prepared,
52 AlreadyPrepared,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub enum DirectoryTransactionState {
58 Preparing,
59 Ready,
60 SupervisorStarted,
61 Committing,
62 AwaitingHealth,
63 Committed,
64 RolledBack,
65 Failed,
66 HealthTimeout,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct DirectoryUpdateTransaction {
72 pub schema_version: u32,
73 pub transaction_id: String,
74 pub application_id: String,
75 pub platform: String,
76 pub current_version: String,
77 pub target_version: String,
78 pub install_root: PathBuf,
79 pub managed_paths: Vec<String>,
80 pub source_tree_sha256: String,
81 pub target_tree_sha256: String,
82 pub patch_sha256: String,
83 pub patch_size: u64,
84 pub full_size: u64,
85 pub state: DirectoryTransactionState,
86 pub created_at_ms: u64,
87 pub updated_at_ms: u64,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub failure_reason: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct PreparedDirectoryUpdate {
95 pub kind: PreparedDirectoryUpdateKind,
96 pub transaction: DirectoryUpdateTransaction,
97 pub transaction_path: PathBuf,
98 pub staging_path: PathBuf,
99 pub patch_path: PathBuf,
100 pub bytes_downloaded: u64,
101 pub saved_bytes: u64,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106struct PreparedPointer {
107 transaction_path: PathBuf,
108 target_version: String,
109 target_tree_sha256: String,
110}
111
112pub async fn load_directory_update_manifest(
113 manifest_url: &str,
114 headers: &[HttpHeader],
115 timeout_secs: Option<u64>,
116 signature_public_keys: &[String],
117 require_signature: bool,
118 expected_version: Option<&str>,
119) -> Result<DirectoryUpdateManifest> {
120 let manifest_text = read_url_to_string(manifest_url, headers, timeout_secs).await?;
121 let manifest: DirectoryUpdateManifest = serde_json::from_str(&manifest_text)?;
122 manifest.validate()?;
123 if signature_public_keys.is_empty() {
124 if require_signature {
125 return Err(Error::SignaturePublicKeyMissing);
126 }
127 } else {
128 verify_directory_manifest_signature_with_keys(&manifest, signature_public_keys)?;
129 }
130 if let Some(expected_version) = expected_version {
131 if manifest.version != expected_version {
132 return Err(Error::UnexpectedVersion {
133 expected: expected_version.to_string(),
134 actual: manifest.version,
135 });
136 }
137 }
138 if require_signature {
139 validate_production_transport(manifest_url, &manifest)?;
140 }
141 Ok(manifest)
142}
143
144pub async fn prepare_directory_update<F>(
145 options: PrepareDirectoryUpdateOptions,
146 mut on_event: F,
147) -> Result<PreparedDirectoryUpdate>
148where
149 F: FnMut(DownloadEvent),
150{
151 if options.application_id.trim().is_empty() {
152 return Err(Error::Message("application id is empty".to_string()));
153 }
154 if options.current_version.trim().is_empty() || options.expected_version.trim().is_empty() {
155 return Err(Error::Message(
156 "current and expected versions are required".to_string(),
157 ));
158 }
159 validate_version_upgrade(&options.current_version, &options.expected_version)?;
160 if !options.install_root.is_dir() {
161 return Err(Error::Message(format!(
162 "installation root is not a directory: {}",
163 options.install_root.display()
164 )));
165 }
166
167 let managed_paths = normalize_managed_paths(&options.managed_paths)?;
168 fs::create_dir_all(&options.cache_dir)
169 .map_err(|error| crate::error::io_path(&options.cache_dir, error))?;
170 let lock = open_update_lock(&options.cache_dir)?;
171 lock.lock_exclusive().map_err(Error::Io)?;
172
173 let platform = options.platform.clone().unwrap_or_else(default_platform);
174 let manifest = load_directory_update_manifest(
175 &options.manifest_url,
176 &options.headers,
177 options.timeout_secs,
178 &options.signature_public_keys,
179 options.require_signature,
180 Some(&options.expected_version),
181 )
182 .await?;
183 let release = manifest.platform(&platform)?.clone();
184 if release.managed_paths != managed_paths {
185 return Err(Error::Message(format!(
186 "managed path policy mismatch: manifest={:?}, client={managed_paths:?}",
187 release.managed_paths
188 )));
189 }
190 let delta = release
191 .delta_from(&options.current_version)
192 .cloned()
193 .ok_or_else(|| Error::NoMatchingDelta {
194 platform: platform.clone(),
195 current_version: options.current_version.clone(),
196 })?;
197
198 verify_tree_blocking(
199 options.install_root.clone(),
200 managed_paths.clone(),
201 delta.source.clone(),
202 )
203 .await?;
204
205 let transaction_id = transaction_id(
206 &options.application_id,
207 &platform,
208 &options.current_version,
209 &manifest.version,
210 &delta.source.tree_sha256,
211 &release.target.tree_sha256,
212 );
213 let transactions_root = options.cache_dir.join("transactions");
214 let transaction_root = transactions_root.join(&transaction_id);
215 let transaction_path = transaction_root.join("transaction.json");
216 let manifest_path = transaction_root.join("manifest.json");
217 let patch_path = transaction_root.join("patch.hdiff");
218 let staging_path = transaction_root.join("staged");
219 let result_path = transaction_root.join("result.json");
220
221 if transaction_path.is_file() {
222 let transaction: DirectoryUpdateTransaction = read_json(&transaction_path)?;
223 if result_path.is_file()
224 || matches!(
225 transaction.state,
226 DirectoryTransactionState::RolledBack
227 | DirectoryTransactionState::Failed
228 | DirectoryTransactionState::HealthTimeout
229 )
230 {
231 return Err(Error::Message(format!(
232 "previous directory update transaction failed: {}",
233 transaction.transaction_id
234 )));
235 }
236 if matches!(
237 transaction.state,
238 DirectoryTransactionState::Ready | DirectoryTransactionState::SupervisorStarted
239 ) && staging_path.is_dir()
240 {
241 verify_tree_blocking(
242 staging_path.clone(),
243 managed_paths.clone(),
244 release.target.clone(),
245 )
246 .await?;
247 return Ok(PreparedDirectoryUpdate {
248 kind: PreparedDirectoryUpdateKind::AlreadyPrepared,
249 bytes_downloaded: 0,
250 saved_bytes: release.full.size.saturating_sub(delta.patch.size),
251 transaction,
252 transaction_path,
253 staging_path,
254 patch_path,
255 });
256 }
257 }
258
259 prepare_transactions_root(&transactions_root)?;
260 let prepared_pointer = options.cache_dir.join("prepared.json");
261 if prepared_pointer.is_file() {
262 fs::remove_file(&prepared_pointer)
263 .map_err(|error| crate::error::io_path(&prepared_pointer, error))?;
264 }
265 let required_cache_bytes = release
266 .target
267 .total_size
268 .checked_add(delta.patch.size)
269 .and_then(|size| size.checked_add(PREPARATION_DISK_RESERVE_BYTES))
270 .ok_or_else(|| Error::Message("directory update disk requirement overflow".to_string()))?;
271 ensure_available_space(&options.cache_dir, required_cache_bytes)?;
272 fs::create_dir_all(&transaction_root)
273 .map_err(|error| crate::error::io_path(&transaction_root, error))?;
274 write_json_atomic(&manifest_path, &manifest)?;
275
276 let now = now_millis();
277 let mut transaction = DirectoryUpdateTransaction {
278 schema_version: TRANSACTION_SCHEMA_VERSION,
279 transaction_id,
280 application_id: options.application_id,
281 platform,
282 current_version: options.current_version,
283 target_version: manifest.version.clone(),
284 install_root: options.install_root.clone(),
285 managed_paths: managed_paths.clone(),
286 source_tree_sha256: delta.source.tree_sha256.clone(),
287 target_tree_sha256: release.target.tree_sha256.clone(),
288 patch_sha256: delta.patch.sha256.clone(),
289 patch_size: delta.patch.size,
290 full_size: release.full.size,
291 state: DirectoryTransactionState::Preparing,
292 created_at_ms: now,
293 updated_at_ms: now,
294 failure_reason: None,
295 };
296 write_json_atomic(&transaction_path, &transaction)?;
297
298 let patch_url = resolve_artifact_url(&options.manifest_url, &delta.patch.url)?;
299 let stats = download_to_file(
300 &patch_url,
301 &patch_path,
302 &options.headers,
303 options.timeout_secs,
304 Some(delta.patch.size),
305 &mut on_event,
306 )
307 .await?;
308 if stats.bytes_written != delta.patch.size {
309 return Err(Error::SizeMismatch {
310 path: patch_path,
311 expected: delta.patch.size,
312 actual: stats.bytes_written,
313 });
314 }
315 verify_sha256(&patch_path, &delta.patch.sha256)?;
316
317 if staging_path.exists() {
318 fs::remove_dir_all(&staging_path)
319 .map_err(|error| crate::error::io_path(&staging_path, error))?;
320 }
321 apply_directory_patch_blocking(
322 options.install_root,
323 patch_path.clone(),
324 staging_path.clone(),
325 managed_paths.clone(),
326 release.target.clone(),
327 options.hpatchz_path,
328 )
329 .await?;
330
331 transaction.state = DirectoryTransactionState::Ready;
332 transaction.updated_at_ms = now_millis();
333 write_json_atomic(&transaction_path, &transaction)?;
334 write_json_atomic(
335 &options.cache_dir.join("prepared.json"),
336 &PreparedPointer {
337 transaction_path: transaction_path.clone(),
338 target_version: manifest.version,
339 target_tree_sha256: release.target.tree_sha256,
340 },
341 )?;
342
343 Ok(PreparedDirectoryUpdate {
344 kind: PreparedDirectoryUpdateKind::Prepared,
345 bytes_downloaded: stats.bytes_written,
346 saved_bytes: release.full.size.saturating_sub(stats.bytes_written),
347 transaction,
348 transaction_path,
349 staging_path,
350 patch_path,
351 })
352}
353
354pub fn read_directory_transaction(path: impl AsRef<Path>) -> Result<DirectoryUpdateTransaction> {
355 let transaction: DirectoryUpdateTransaction = read_json(path.as_ref())?;
356 if transaction.schema_version != TRANSACTION_SCHEMA_VERSION {
357 return Err(Error::Message(format!(
358 "unsupported transaction schema version: {}",
359 transaction.schema_version
360 )));
361 }
362 Ok(transaction)
363}
364
365pub fn write_directory_transaction(
366 path: impl AsRef<Path>,
367 transaction: &DirectoryUpdateTransaction,
368) -> Result<()> {
369 write_json_atomic(path.as_ref(), transaction)
370}
371
372pub fn read_directory_manifest_file(path: impl AsRef<Path>) -> Result<DirectoryUpdateManifest> {
373 let manifest: DirectoryUpdateManifest = read_json(path.as_ref())?;
374 manifest.validate()?;
375 Ok(manifest)
376}
377
378pub fn write_json_file_atomic<T: Serialize>(path: impl AsRef<Path>, value: &T) -> Result<()> {
379 write_json_atomic(path.as_ref(), value)
380}
381
382async fn verify_tree_blocking(
383 root: PathBuf,
384 managed_paths: Vec<String>,
385 expected: FileTreeManifest,
386) -> Result<()> {
387 tokio::task::spawn_blocking(move || verify_file_tree(root, &managed_paths, &expected))
388 .await
389 .map_err(|error| Error::Message(format!("tree verification task failed: {error}")))??;
390 Ok(())
391}
392
393async fn apply_directory_patch_blocking(
394 old_path: PathBuf,
395 patch_path: PathBuf,
396 output_path: PathBuf,
397 managed_paths: Vec<String>,
398 expected_tree: FileTreeManifest,
399 hpatchz_path: Option<PathBuf>,
400) -> Result<()> {
401 tokio::task::spawn_blocking(move || {
402 apply_directory_patch(&ApplyDirectoryPatchOptions {
403 old_path,
404 patch_path,
405 output_path,
406 managed_paths,
407 expected_tree,
408 hpatchz_path,
409 cache_size: Some("64m".to_string()),
410 parallel_threads: Some(4),
411 verify_checksums: true,
412 })
413 })
414 .await
415 .map_err(|error| Error::Message(format!("directory patch task failed: {error}")))??;
416 Ok(())
417}
418
419fn open_update_lock(cache_dir: &Path) -> Result<File> {
420 OpenOptions::new()
421 .create(true)
422 .truncate(false)
423 .read(true)
424 .write(true)
425 .open(cache_dir.join("lock"))
426 .map_err(Error::Io)
427}
428
429fn prepare_transactions_root(path: &Path) -> Result<()> {
430 if !path.exists() {
431 return fs::create_dir_all(path).map_err(|error| crate::error::io_path(path, error));
432 }
433
434 for entry in fs::read_dir(path).map_err(|error| crate::error::io_path(path, error))? {
435 let entry = entry.map_err(|error| crate::error::io_path(path, error))?;
436 let entry_path = entry.path();
437 if !entry
438 .file_type()
439 .map_err(|error| crate::error::io_path(&entry_path, error))?
440 .is_dir()
441 {
442 return Err(Error::Message(format!(
443 "unexpected entry in directory update transaction store: {}",
444 entry_path.display()
445 )));
446 }
447
448 let transaction_path = entry_path.join("transaction.json");
449 if transaction_path.is_file() {
450 let transaction = read_directory_transaction(&transaction_path)?;
451 if matches!(
452 transaction.state,
453 DirectoryTransactionState::SupervisorStarted
454 | DirectoryTransactionState::Committing
455 | DirectoryTransactionState::AwaitingHealth
456 | DirectoryTransactionState::HealthTimeout
457 ) {
458 return Err(Error::Message(format!(
459 "another directory update transaction is active: {}",
460 transaction.transaction_id
461 )));
462 }
463 }
464
465 fs::remove_dir_all(&entry_path)
466 .map_err(|error| crate::error::io_path(&entry_path, error))?;
467 }
468 Ok(())
469}
470
471fn resolve_artifact_url(manifest_url: &str, artifact_url: &str) -> Result<String> {
472 if Url::parse(artifact_url).is_ok() {
473 return Ok(artifact_url.to_string());
474 }
475 if let Ok(base) = Url::parse(manifest_url) {
476 if matches!(base.scheme(), "http" | "https" | "file") {
477 return Ok(base.join(artifact_url)?.to_string());
478 }
479 }
480 let manifest_path = Path::new(manifest_url);
481 let parent = manifest_path.parent().unwrap_or_else(|| Path::new("."));
482 Ok(parent.join(artifact_url).to_string_lossy().to_string())
483}
484
485fn validate_production_transport(
486 manifest_url: &str,
487 manifest: &DirectoryUpdateManifest,
488) -> Result<()> {
489 require_https(manifest_url, "directory update manifest")?;
490 for release in manifest.platforms.values() {
491 let full_url = resolve_artifact_url(manifest_url, &release.full.url)?;
492 require_https(&full_url, "full updater artifact")?;
493 for delta in &release.deltas {
494 let patch_url = resolve_artifact_url(manifest_url, &delta.patch.url)?;
495 require_https(&patch_url, "directory patch artifact")?;
496 }
497 }
498 Ok(())
499}
500
501fn require_https(value: &str, label: &str) -> Result<()> {
502 let scheme = Url::parse(value)
503 .map(|url| url.scheme().to_string())
504 .unwrap_or_else(|_| "local-path".to_string());
505 if scheme == "https" {
506 return Ok(());
507 }
508 Err(Error::InsecureTransport {
509 label: label.to_string(),
510 scheme,
511 })
512}
513
514fn transaction_id(
515 application_id: &str,
516 platform: &str,
517 current_version: &str,
518 target_version: &str,
519 source_tree: &str,
520 target_tree: &str,
521) -> String {
522 let payload = format!(
523 "{application_id}\0{platform}\0{current_version}\0{target_version}\0{source_tree}\0{target_tree}"
524 );
525 let digest = sha256_bytes(payload.as_bytes());
526 format!(
527 "{}-to-{}-{}",
528 sanitize(current_version),
529 sanitize(target_version),
530 &digest[..16]
531 )
532}
533
534fn sanitize(value: &str) -> String {
535 value
536 .chars()
537 .map(|character| {
538 if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
539 character
540 } else {
541 '_'
542 }
543 })
544 .collect()
545}
546
547fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
548 Ok(serde_json::from_slice(
549 &fs::read(path).map_err(|error| crate::error::io_path(path, error))?,
550 )?)
551}
552
553fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
554 if let Some(parent) = path.parent() {
555 fs::create_dir_all(parent).map_err(|error| crate::error::io_path(parent, error))?;
556 }
557 let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
558 let bytes = serde_json::to_vec_pretty(value)?;
559 let mut file =
560 File::create(&temporary).map_err(|error| crate::error::io_path(&temporary, error))?;
561 file.write_all(&bytes)
562 .map_err(|error| crate::error::io_path(&temporary, error))?;
563 file.sync_all()
564 .map_err(|error| crate::error::io_path(&temporary, error))?;
565 replace_file(&temporary, path)
566}
567
568fn now_millis() -> u64 {
569 SystemTime::now()
570 .duration_since(UNIX_EPOCH)
571 .map(|duration| duration.as_millis() as u64)
572 .unwrap_or_default()
573}
574
575fn default_true() -> bool {
576 true
577}
578
579#[cfg(test)]
580mod tests {
581 use std::fs;
582
583 use tempfile::tempdir;
584
585 use super::{
586 prepare_transactions_root, require_https, transaction_id, write_directory_transaction,
587 DirectoryTransactionState, DirectoryUpdateTransaction,
588 };
589
590 #[test]
591 fn transaction_id_is_stable() {
592 let first = transaction_id("app", "windows-x86_64", "1.0.0", "1.1.0", "a", "b");
593 let second = transaction_id("app", "windows-x86_64", "1.0.0", "1.1.0", "a", "b");
594 assert_eq!(first, second);
595 assert!(first.starts_with("1.0.0-to-1.1.0-"));
596 }
597
598 #[test]
599 fn production_transport_requires_https() {
600 assert!(require_https("https://updates.example/latest.json", "manifest").is_ok());
601 assert!(require_https("http://updates.example/latest.json", "manifest").is_err());
602 assert!(require_https("C:\\updates\\latest.json", "manifest").is_err());
603 }
604
605 #[test]
606 fn active_transactions_are_never_pruned() {
607 let dir = tempdir().unwrap();
608 let transactions = dir.path().join("transactions");
609 let active = transactions.join("active");
610 fs::create_dir_all(&active).unwrap();
611 let transaction = DirectoryUpdateTransaction {
612 schema_version: 1,
613 transaction_id: "active".to_string(),
614 application_id: "test.app".to_string(),
615 platform: "windows-x86_64".to_string(),
616 current_version: "1.0.0".to_string(),
617 target_version: "1.1.0".to_string(),
618 install_root: dir.path().join("install"),
619 managed_paths: vec!["app.exe".to_string()],
620 source_tree_sha256: "a".repeat(64),
621 target_tree_sha256: "b".repeat(64),
622 patch_sha256: "c".repeat(64),
623 patch_size: 1,
624 full_size: 2,
625 state: DirectoryTransactionState::Committing,
626 created_at_ms: 1,
627 updated_at_ms: 1,
628 failure_reason: None,
629 };
630 write_directory_transaction(active.join("transaction.json"), &transaction).unwrap();
631
632 assert!(prepare_transactions_root(&transactions).is_err());
633 assert!(active.is_dir());
634 }
635}