Skip to main content

hdiff_update_core/
directory_update.rs

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}