hdiff-update-core 0.2.0

Core library for signed, transactional HDiffPatch directory updates.
Documentation
use std::{collections::BTreeMap, fs, path::PathBuf};

use hdiff_update_core::{
    apply_directory_patch, build_file_tree_manifest, create_directory_patch,
    prepare_directory_update, ApplyDirectoryPatchOptions, Artifact, CreateDirectoryPatchOptions,
    DirectoryDelta, DirectoryPlatformRelease, DirectoryUpdateManifest,
    PrepareDirectoryUpdateOptions, PreparedDirectoryUpdateKind, DIRECTORY_UPDATE_ALGORITHM,
    DIRECTORY_UPDATE_SCHEMA_VERSION,
};
use tempfile::tempdir;

#[test]
fn directory_patch_roundtrip_ignores_unmanaged_installer_files() {
    let workspace = workspace_root();
    let hdiffz = workspace.join("hdiffz.exe");
    let hpatchz = workspace.join("hpatchz.exe");
    if !hdiffz.is_file() || !hpatchz.is_file() {
        eprintln!("skipping HDiffPatch roundtrip because tools are missing");
        return;
    }

    let dir = tempdir().expect("tempdir");
    let old = dir.path().join("old");
    let new = dir.path().join("new");
    let replay = dir.path().join("replay");
    let patch = dir.path().join("payload.hdiff");
    let managed = vec![
        "app.exe".to_string(),
        "plugins".to_string(),
        "resources".to_string(),
    ];

    create_payload(&old, b"old-main", b"old-resource");
    create_payload(&new, b"new-main", b"new-resource");
    fs::write(old.join("uninstall.exe"), b"unmanaged").expect("unmanaged old file");

    let target = build_file_tree_manifest(&new, &managed).expect("target manifest");
    let created = create_directory_patch(&CreateDirectoryPatchOptions {
        old_path: old.clone(),
        new_path: new,
        patch_path: patch.clone(),
        managed_paths: managed.clone(),
        hdiffz_path: Some(hdiffz),
        force: true,
        step_size: Some("64k".to_string()),
        compression: Some("lzma2-9-8m".to_string()),
        checksum: Some("xxh128".to_string()),
        parallel_threads: Some(2),
    })
    .expect("create directory patch");
    assert!(created.patch.size > 0);

    let applied = apply_directory_patch(&ApplyDirectoryPatchOptions {
        old_path: old,
        patch_path: patch,
        output_path: replay,
        managed_paths: managed,
        expected_tree: target.clone(),
        hpatchz_path: Some(hpatchz),
        cache_size: Some("8m".to_string()),
        parallel_threads: Some(2),
        verify_checksums: true,
    })
    .expect("apply directory patch");
    assert_eq!(applied.output.tree_sha256, target.tree_sha256);
}

#[tokio::test]
async fn preparation_reuses_a_verified_ready_transaction() {
    let workspace = workspace_root();
    let hdiffz = workspace.join("hdiffz.exe");
    let hpatchz = workspace.join("hpatchz.exe");
    if !hdiffz.is_file() || !hpatchz.is_file() {
        eprintln!("skipping HDiffPatch preparation because tools are missing");
        return;
    }

    let dir = tempdir().expect("tempdir");
    let old = dir.path().join("installed");
    let new = dir.path().join("new");
    let server = dir.path().join("server");
    let cache = dir.path().join("cache");
    fs::create_dir_all(&server).expect("server");
    let patch = server.join("payload.hdiff");
    let managed = vec![
        "app.exe".to_string(),
        "plugins".to_string(),
        "resources".to_string(),
    ];
    create_payload(&old, b"old-main", b"old-resource");
    create_payload(&new, b"new-main", b"new-resource");
    fs::write(old.join("uninstall.exe"), b"unmanaged").expect("unmanaged");
    let source = build_file_tree_manifest(&old, &managed).expect("source");
    let target = build_file_tree_manifest(&new, &managed).expect("target");
    let patch_result = create_directory_patch(&CreateDirectoryPatchOptions {
        old_path: old.clone(),
        new_path: new,
        patch_path: patch.clone(),
        managed_paths: managed.clone(),
        hdiffz_path: Some(hdiffz),
        force: true,
        step_size: Some("64k".to_string()),
        compression: Some("lzma2-9-8m".to_string()),
        checksum: Some("xxh128".to_string()),
        parallel_threads: Some(2),
    })
    .expect("patch");
    let manifest_path = server.join("latest.json");
    fs::write(
        &manifest_path,
        serde_json::to_vec_pretty(&DirectoryUpdateManifest {
            schema_version: DIRECTORY_UPDATE_SCHEMA_VERSION,
            version: "0.2.0".to_string(),
            notes: None,
            published_at: None,
            signature: None,
            platforms: BTreeMap::from([(
                "windows-x86_64".to_string(),
                DirectoryPlatformRelease {
                    full: Artifact {
                        url: "full.exe".to_string(),
                        sha256: "a".repeat(64),
                        size: 1000,
                        signature: None,
                    },
                    managed_paths: managed.clone(),
                    target: target.clone(),
                    deltas: vec![DirectoryDelta {
                        from_version: "0.1.0".to_string(),
                        source,
                        patch: Artifact {
                            url: "payload.hdiff".to_string(),
                            sha256: patch_result.patch.sha256,
                            size: patch_result.patch.size,
                            signature: None,
                        },
                        algorithm: DIRECTORY_UPDATE_ALGORITHM.to_string(),
                    }],
                },
            )]),
        })
        .expect("manifest"),
    )
    .expect("write manifest");

    let options = PrepareDirectoryUpdateOptions {
        manifest_url: manifest_path.to_string_lossy().to_string(),
        application_id: "test.app".to_string(),
        install_root: old,
        managed_paths: managed,
        current_version: "0.1.0".to_string(),
        expected_version: "0.2.0".to_string(),
        platform: Some("windows-x86_64".to_string()),
        cache_dir: cache,
        hpatchz_path: Some(hpatchz),
        signature_public_keys: Vec::new(),
        require_signature: false,
        headers: Vec::new(),
        timeout_secs: None,
    };
    let first = prepare_directory_update(options.clone(), |_| {})
        .await
        .expect("first preparation");
    assert_eq!(first.kind, PreparedDirectoryUpdateKind::Prepared);
    let second = prepare_directory_update(options, |_| {})
        .await
        .expect("reuse preparation");
    assert_eq!(second.kind, PreparedDirectoryUpdateKind::AlreadyPrepared);
    assert_eq!(second.bytes_downloaded, 0);
    assert_eq!(
        first.transaction.transaction_id,
        second.transaction.transaction_id
    );
}

fn create_payload(root: &std::path::Path, main: &[u8], resource: &[u8]) {
    fs::create_dir_all(root.join("resources")).expect("resources");
    fs::create_dir_all(root.join("plugins")).expect("plugins");
    fs::write(root.join("app.exe"), main).expect("app");
    fs::write(root.join("resources/data.bin"), resource).expect("resource");
    fs::write(root.join("plugins/plugin.js"), b"same-plugin").expect("plugin");
}

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|path| path.parent())
        .expect("workspace root")
        .to_path_buf()
}