hdiff-update-core 0.2.0

Core library for signed, transactional HDiffPatch directory updates.
Documentation
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::{
    env,
    ffi::OsString,
    path::{Path, PathBuf},
    process::Command,
};

use serde::{Deserialize, Serialize};

use crate::{
    build_file_tree_manifest, error::io_path, sha256_file, verify_file_tree, Error, FileDigest,
    FileTreeManifest, Result, TreeVerification,
};

#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;

pub const DEFAULT_DIRECTORY_ALGORITHM: &str = "hdiffpatch-v5-dir-single-lzma2";

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectoryPatchOptions {
    pub old_path: PathBuf,
    pub new_path: PathBuf,
    pub patch_path: PathBuf,
    pub managed_paths: Vec<String>,
    #[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 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 ApplyDirectoryPatchOptions {
    pub old_path: PathBuf,
    pub patch_path: PathBuf,
    pub output_path: PathBuf,
    pub managed_paths: Vec<String>,
    pub expected_tree: FileTreeManifest,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hpatchz_path: Option<PathBuf>,
    #[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 DirectoryPatchCreateResult {
    pub old: FileTreeManifest,
    pub new: FileTreeManifest,
    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 DirectoryPatchApplyResult {
    pub patch: FileDigest,
    pub output: TreeVerification,
    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_directory_patch(
    options: &CreateDirectoryPatchOptions,
) -> Result<DirectoryPatchCreateResult> {
    if !options.old_path.is_dir() || !options.new_path.is_dir() {
        return Err(Error::Message(
            "directory patch inputs must both be directories".to_string(),
        ));
    }
    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("-m-0".into());
    args.push("-block-0".into());
    args.push(format!("-SD-{}", options.step_size.as_deref().unwrap_or("256k")).into());
    args.push(
        format!(
            "-c-{}",
            options.compression.as_deref().unwrap_or("lzma2-9-64m")
        )
        .into(),
    );
    args.push(format!("-C-{}", options.checksum.as_deref().unwrap_or("xxh128")).into());
    args.push(format!("-p-{}", options.parallel_threads.unwrap_or(4)).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(DirectoryPatchCreateResult {
        old: build_file_tree_manifest(&options.old_path, &options.managed_paths)?,
        new: build_file_tree_manifest(&options.new_path, &options.managed_paths)?,
        patch: sha256_file(&options.patch_path)?,
        algorithm: DEFAULT_DIRECTORY_ALGORITHM.to_string(),
        tool: hdiffz,
        stdout: output.stdout,
        stderr: output.stderr,
    })
}

pub fn apply_directory_patch(
    options: &ApplyDirectoryPatchOptions,
) -> Result<DirectoryPatchApplyResult> {
    if !options.old_path.is_dir() {
        return Err(Error::Message(format!(
            "directory patch source is not a directory: {}",
            options.old_path.display()
        )));
    }
    if options.output_path.exists() {
        return Err(Error::Message(format!(
            "directory patch output must not already exist: {}",
            options.output_path.display()
        )));
    }
    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.verify_checksums {
        args.push("-C-all".into());
    }
    args.push(format!("-s-{}", options.cache_size.as_deref().unwrap_or("64m")).into());
    args.push(format!("-p-{}", options.parallel_threads.unwrap_or(4)).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 verification = verify_file_tree(
        &options.output_path,
        &options.managed_paths,
        &options.expected_tree,
    )?;
    Ok(DirectoryPatchApplyResult {
        patch: sha256_file(&options.patch_path)?,
        output: verification,
        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 mut command = Command::new(program);
    #[cfg(windows)]
    command.creation_flags(CREATE_NO_WINDOW);
    let output = command
        .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
}