hdiff-update-core 0.1.2

Core library for signed HDiffPatch-based differential application updates.
Documentation
use std::{fs, path::PathBuf};

use hdiff_update_core::{
    apply_patch, create_patch, prepare_delta_update, sha256_file, ApplyPatchOptions, Artifact,
    CreatePatchOptions, DeltaArtifact, PlatformRelease, PrepareUpdateOptions, PreparedUpdateKind,
    UpdateManifest, DEFAULT_ALGORITHM,
};
use tempfile::tempdir;

#[test]
fn hdiff_tools_roundtrip_small_binary() {
    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 hdiffz.exe or hpatchz.exe is missing");
        return;
    }

    let dir = tempdir().expect("tempdir");
    let old = dir.path().join("old.bin");
    let new = dir.path().join("new.bin");
    let patch = dir.path().join("delta.hpatch");
    let output = dir.path().join("output.bin");

    let mut old_bytes = vec![0_u8; 1024 * 1024];
    let mut new_bytes = vec![0_u8; 1024 * 1024];
    for index in 0..old_bytes.len() {
        old_bytes[index] = (index % 251) as u8;
        new_bytes[index] = old_bytes[index];
    }
    for index in 100_000..105_000 {
        new_bytes[index] = ((index * 7) % 251) as u8;
    }
    fs::write(&old, old_bytes).expect("old");
    fs::write(&new, new_bytes).expect("new");

    let created = create_patch(&CreatePatchOptions {
        old_path: old.clone(),
        new_path: new.clone(),
        patch_path: patch.clone(),
        hdiffz_path: Some(hdiffz),
        force: true,
        step_size: Some("64k".to_string()),
        old_window_size: Some("512k".to_string()),
        compression: Some("zstd-20".to_string()),
        checksum: Some("xxh128".to_string()),
        parallel_threads: Some(2),
    })
    .expect("create patch");
    assert!(created.patch.size > 0);

    let expected = sha256_file(&new).expect("hash new").sha256;
    let applied = apply_patch(&ApplyPatchOptions {
        old_path: old,
        patch_path: patch,
        output_path: output,
        hpatchz_path: Some(hpatchz),
        force: true,
        expected_sha256: Some(expected.clone()),
        cache_size: Some("8m".to_string()),
        parallel_threads: Some(2),
        verify_checksums: true,
    })
    .expect("apply patch");

    assert_eq!(applied.output.sha256, expected);
    assert_eq!(
        fs::read(new).expect("read new"),
        fs::read(applied.output.path).expect("read out")
    );
}

#[tokio::test]
async fn prepare_delta_update_applies_matching_cached_installer() {
    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 prepare_delta_update roundtrip because hdiffz.exe or hpatchz.exe is missing"
        );
        return;
    }

    let dir = tempdir().expect("tempdir");
    let server = dir.path().join("server");
    let cache = dir.path().join("cache");
    fs::create_dir_all(&server).expect("server dir");
    let old = dir.path().join("xCodex_0.1.0_x64-setup.exe");
    let new = server.join("xCodex_0.2.0_x64-setup.exe");
    let patch = server.join("0.1.0_to_0.2.0.hpatch");
    let output = cache.join("xCodex_0.2.0_x64-setup.exe");

    let old_bytes = deterministic_bytes(1024 * 1024, 3);
    let mut new_bytes = old_bytes.clone();
    for index in 200_000..210_000 {
        new_bytes[index] = ((index * 17) % 251) as u8;
    }
    fs::write(&old, old_bytes).expect("old");
    fs::write(&new, new_bytes).expect("new");
    let patch_result = create_patch(&CreatePatchOptions {
        old_path: old.clone(),
        new_path: new.clone(),
        patch_path: patch.clone(),
        hdiffz_path: Some(hdiffz),
        force: true,
        step_size: Some("64k".to_string()),
        old_window_size: Some("512k".to_string()),
        compression: Some("zstd-20".to_string()),
        checksum: Some("xxh128".to_string()),
        parallel_threads: Some(2),
    })
    .expect("create patch");
    let new_digest = sha256_file(&new).expect("new digest");
    let manifest_path = server.join("latest.json");
    fs::write(
        &manifest_path,
        serde_json::to_vec_pretty(&manifest(
            &patch_result.old.sha256,
            &patch_result.patch.sha256,
            patch_result.patch.size,
            &new_digest.sha256,
            new_digest.size,
        ))
        .expect("manifest json"),
    )
    .expect("write manifest");

    let prepared = prepare_delta_update(
        PrepareUpdateOptions {
            manifest_url: manifest_path.to_string_lossy().to_string(),
            platform: Some("windows-x86_64".to_string()),
            current_version: Some("0.1.0".to_string()),
            expected_version: Some("0.2.0".to_string()),
            current_artifact_path: Some(old),
            output_path: Some(output.clone()),
            patch_path: Some(cache.join("patches").join("delta.hpatch")),
            cache_dir: Some(cache),
            hpatchz_path: Some(hpatchz),
            signature_public_key: None,
            require_signature: false,
            force: true,
            headers: Vec::new(),
            timeout_secs: None,
        },
        |_| {},
    )
    .await
    .expect("prepare delta");

    assert!(matches!(prepared.kind, PreparedUpdateKind::Delta));
    assert_eq!(prepared.artifact.sha256, new_digest.sha256);
    assert_eq!(
        fs::read(new).expect("read new"),
        fs::read(output).expect("read output")
    );
}

#[tokio::test]
async fn prepare_delta_update_does_not_fall_back_to_full_download() {
    let dir = tempdir().expect("tempdir");
    let server = dir.path().join("server");
    fs::create_dir_all(&server).expect("server dir");
    let current = dir.path().join("current-installer.exe");
    let output = dir.path().join("should-not-exist.exe");
    fs::write(&current, b"not the declared source installer").expect("current");
    fs::write(server.join("xCodex_0.2.0_x64-setup.exe"), b"full installer").expect("full");
    let manifest_path = server.join("latest.json");
    fs::write(
        &manifest_path,
        serde_json::to_vec_pretty(&manifest(
            "0000000000000000000000000000000000000000000000000000000000000000",
            "1111111111111111111111111111111111111111111111111111111111111111",
            1,
            "2222222222222222222222222222222222222222222222222222222222222222",
            14,
        ))
        .expect("manifest json"),
    )
    .expect("write manifest");

    let error = prepare_delta_update(
        PrepareUpdateOptions {
            manifest_url: manifest_path.to_string_lossy().to_string(),
            platform: Some("windows-x86_64".to_string()),
            current_version: Some("0.1.0".to_string()),
            expected_version: Some("0.2.0".to_string()),
            current_artifact_path: Some(current),
            output_path: Some(output.clone()),
            patch_path: Some(dir.path().join("delta.hpatch")),
            cache_dir: Some(dir.path().join("cache")),
            hpatchz_path: None,
            signature_public_key: None,
            require_signature: false,
            force: true,
            headers: Vec::new(),
            timeout_secs: None,
        },
        |_| {},
    )
    .await
    .expect_err("delta-only prepare should reject missing source hash");

    assert!(
        matches!(error, hdiff_update_core::Error::NoMatchingArtifact { .. }),
        "{error:?}"
    );
    assert!(!output.exists());
}

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

fn deterministic_bytes(size: usize, salt: u8) -> Vec<u8> {
    (0..size)
        .map(|index| ((index * 31 + salt as usize) % 251) as u8)
        .collect()
}

fn manifest(
    old_sha256: &str,
    patch_sha256: &str,
    patch_size: u64,
    new_sha256: &str,
    new_size: u64,
) -> UpdateManifest {
    UpdateManifest {
        version: "0.2.0".to_string(),
        notes: None,
        published_at: None,
        signature: None,
        platforms: [(
            "windows-x86_64".to_string(),
            PlatformRelease {
                full: Artifact {
                    url: "xCodex_0.2.0_x64-setup.exe".to_string(),
                    sha256: new_sha256.to_string(),
                    size: new_size,
                    signature: None,
                },
                deltas: vec![DeltaArtifact {
                    from_version: Some("0.1.0".to_string()),
                    from_sha256: old_sha256.to_string(),
                    url: "0.1.0_to_0.2.0.hpatch".to_string(),
                    sha256: patch_sha256.to_string(),
                    size: patch_size,
                    target_sha256: Some(new_sha256.to_string()),
                    target_size: Some(new_size),
                    algorithm: DEFAULT_ALGORITHM.to_string(),
                }],
            },
        )]
        .into(),
    }
}