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
}