hdiff-update-core 0.1.1

Core library for signed HDiffPatch-based differential application updates.
Documentation
use std::{
    env,
    ffi::OsString,
    path::{Path, PathBuf},
    process::Command,
};

use serde::{Deserialize, Serialize};

use crate::{error::io_path, sha256_file, verify_sha256, Error, FileDigest, Result};

pub const DEFAULT_ALGORITHM: &str = "hdiffpatch-v5-window-zstd";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePatchOptions {
    pub old_path: PathBuf,
    pub new_path: PathBuf,
    pub patch_path: PathBuf,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hdiffz_path: Option<PathBuf>,
    #[serde(default)]
    pub force: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub step_size: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub old_window_size: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compression: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checksum: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parallel_threads: Option<u16>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyPatchOptions {
    pub old_path: PathBuf,
    pub patch_path: PathBuf,
    pub output_path: PathBuf,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hpatchz_path: Option<PathBuf>,
    #[serde(default)]
    pub force: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expected_sha256: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_size: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parallel_threads: Option<u16>,
    #[serde(default = "default_true")]
    pub verify_checksums: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchCreateResult {
    pub old: FileDigest,
    pub new: FileDigest,
    pub patch: FileDigest,
    pub algorithm: String,
    pub tool: PathBuf,
    pub stdout: String,
    pub stderr: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchApplyResult {
    pub old: FileDigest,
    pub patch: FileDigest,
    pub output: FileDigest,
    pub tool: PathBuf,
    pub stdout: String,
    pub stderr: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolOutput {
    pub tool: PathBuf,
    pub args: Vec<String>,
    pub stdout: String,
    pub stderr: String,
}

pub fn create_patch(options: &CreatePatchOptions) -> Result<PatchCreateResult> {
    let hdiffz = resolve_tool_path(options.hdiffz_path.as_deref(), "HDIFFZ_PATH", "hdiffz.exe")?;
    if let Some(parent) = options.patch_path.parent() {
        std::fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
    }

    let mut args = Vec::<OsString>::new();
    if options.force {
        args.push("-f".into());
    }
    args.push(format!("-WD-{}", options.step_size.as_deref().unwrap_or("256k")).into());
    args.push(format!("-w-{}", options.old_window_size.as_deref().unwrap_or("2m")).into());
    args.push(format!("-c-{}", options.compression.as_deref().unwrap_or("zstd-20")).into());
    args.push(format!("-C-{}", options.checksum.as_deref().unwrap_or("xxh128")).into());
    if let Some(parallel_threads) = options.parallel_threads {
        args.push(format!("-p-{parallel_threads}").into());
    }
    args.push(options.old_path.as_os_str().to_os_string());
    args.push(options.new_path.as_os_str().to_os_string());
    args.push(options.patch_path.as_os_str().to_os_string());

    let output = run_tool(&hdiffz, &args)?;

    Ok(PatchCreateResult {
        old: sha256_file(&options.old_path)?,
        new: sha256_file(&options.new_path)?,
        patch: sha256_file(&options.patch_path)?,
        algorithm: DEFAULT_ALGORITHM.to_string(),
        tool: hdiffz,
        stdout: output.stdout,
        stderr: output.stderr,
    })
}

pub fn apply_patch(options: &ApplyPatchOptions) -> Result<PatchApplyResult> {
    let hpatchz = resolve_tool_path(
        options.hpatchz_path.as_deref(),
        "HPATCHZ_PATH",
        "hpatchz.exe",
    )?;
    if let Some(parent) = options.output_path.parent() {
        std::fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
    }

    let mut args = Vec::<OsString>::new();
    if options.force {
        args.push("-f".into());
    }
    if options.verify_checksums {
        args.push("-C-all".into());
    }
    args.push(format!("-s-{}", options.cache_size.as_deref().unwrap_or("64m")).into());
    if let Some(parallel_threads) = options.parallel_threads {
        args.push(format!("-p-{parallel_threads}").into());
    }
    args.push(options.old_path.as_os_str().to_os_string());
    args.push(options.patch_path.as_os_str().to_os_string());
    args.push(options.output_path.as_os_str().to_os_string());

    let output = run_tool(&hpatchz, &args)?;
    let output_digest = if let Some(expected) = &options.expected_sha256 {
        verify_sha256(&options.output_path, expected)?
    } else {
        sha256_file(&options.output_path)?
    };

    Ok(PatchApplyResult {
        old: sha256_file(&options.old_path)?,
        patch: sha256_file(&options.patch_path)?,
        output: output_digest,
        tool: hpatchz,
        stdout: output.stdout,
        stderr: output.stderr,
    })
}

pub fn inspect_diff(
    diff_path: impl AsRef<Path>,
    hpatchz_path: Option<&Path>,
) -> Result<ToolOutput> {
    let hpatchz = resolve_tool_path(hpatchz_path, "HPATCHZ_PATH", "hpatchz.exe")?;
    let args = vec![
        "-info".into(),
        diff_path.as_ref().as_os_str().to_os_string(),
    ];
    let output = run_tool(&hpatchz, &args)?;
    Ok(ToolOutput {
        tool: hpatchz,
        args: args.iter().map(os_to_string).collect(),
        stdout: output.stdout,
        stderr: output.stderr,
    })
}

pub fn resolve_tool_path(
    explicit: Option<&Path>,
    env_var: &str,
    default_name: &str,
) -> Result<PathBuf> {
    let mut candidates = Vec::<PathBuf>::new();
    if let Some(explicit) = explicit {
        candidates.push(explicit.to_path_buf());
    }
    if let Ok(from_env) = env::var(env_var) {
        if !from_env.trim().is_empty() {
            candidates.push(PathBuf::from(from_env));
        }
    }
    if let Ok(current_exe) = env::current_exe() {
        if let Some(parent) = current_exe.parent() {
            candidates.push(parent.join(default_name));
        }
    }
    if let Ok(current_dir) = env::current_dir() {
        candidates.push(current_dir.join(default_name));
    }
    candidates.extend(path_candidates(default_name));

    for candidate in candidates {
        if candidate.is_file() {
            return Ok(candidate);
        }
    }

    Err(Error::ToolNotFound {
        tool: default_name.to_string(),
    })
}

struct CapturedOutput {
    stdout: String,
    stderr: String,
}

fn run_tool(program: &Path, args: &[OsString]) -> Result<CapturedOutput> {
    let output = Command::new(program)
        .args(args)
        .output()
        .map_err(|error| io_path(program, error))?;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();

    if !output.status.success() {
        return Err(Error::ProcessFailed {
            program: program.to_path_buf(),
            args: args.iter().map(os_to_string).collect(),
            status: output.status,
            stdout,
            stderr,
        });
    }

    Ok(CapturedOutput { stdout, stderr })
}

fn path_candidates(default_name: &str) -> Vec<PathBuf> {
    let Some(paths) = env::var_os("PATH") else {
        return Vec::new();
    };
    env::split_paths(&paths)
        .flat_map(|path| {
            let direct = path.join(default_name);
            #[cfg(windows)]
            {
                let mut names = vec![direct];
                if !default_name.ends_with(".exe") {
                    names.push(path.join(format!("{default_name}.exe")));
                }
                names
            }
            #[cfg(not(windows))]
            {
                vec![direct]
            }
        })
        .collect()
}

fn os_to_string(value: &OsString) -> String {
    value.to_string_lossy().to_string()
}

fn default_true() -> bool {
    true
}