Skip to main content

hdiff_update_core/
tool.rs

1#[cfg(windows)]
2use std::os::windows::process::CommandExt;
3use std::{
4    env,
5    ffi::OsString,
6    path::{Path, PathBuf},
7    process::Command,
8};
9
10use serde::{Deserialize, Serialize};
11
12use crate::{error::io_path, sha256_file, verify_sha256, Error, FileDigest, Result};
13
14#[cfg(windows)]
15// Prevent console-subsystem HDiffPatch tools from flashing in GUI applications.
16const CREATE_NO_WINDOW: u32 = 0x08000000;
17
18pub const DEFAULT_ALGORITHM: &str = "hdiffpatch-v5-window-zstd";
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct CreatePatchOptions {
23    pub old_path: PathBuf,
24    pub new_path: PathBuf,
25    pub patch_path: PathBuf,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub hdiffz_path: Option<PathBuf>,
28    #[serde(default)]
29    pub force: bool,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub step_size: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub old_window_size: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub compression: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub checksum: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub parallel_threads: Option<u16>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ApplyPatchOptions {
45    pub old_path: PathBuf,
46    pub patch_path: PathBuf,
47    pub output_path: PathBuf,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub hpatchz_path: Option<PathBuf>,
50    #[serde(default)]
51    pub force: bool,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub expected_sha256: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub cache_size: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub parallel_threads: Option<u16>,
58    #[serde(default = "default_true")]
59    pub verify_checksums: bool,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PatchCreateResult {
65    pub old: FileDigest,
66    pub new: FileDigest,
67    pub patch: FileDigest,
68    pub algorithm: String,
69    pub tool: PathBuf,
70    pub stdout: String,
71    pub stderr: String,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct PatchApplyResult {
77    pub old: FileDigest,
78    pub patch: FileDigest,
79    pub output: FileDigest,
80    pub tool: PathBuf,
81    pub stdout: String,
82    pub stderr: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct ToolOutput {
88    pub tool: PathBuf,
89    pub args: Vec<String>,
90    pub stdout: String,
91    pub stderr: String,
92}
93
94pub fn create_patch(options: &CreatePatchOptions) -> Result<PatchCreateResult> {
95    let hdiffz = resolve_tool_path(options.hdiffz_path.as_deref(), "HDIFFZ_PATH", "hdiffz.exe")?;
96    if let Some(parent) = options.patch_path.parent() {
97        std::fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
98    }
99
100    let output = match run_tool(&hdiffz, &create_window_diff_args(options)) {
101        Ok(output) => output,
102        Err(window_error) => {
103            if options.patch_path.is_file() {
104                let _ = std::fs::remove_file(&options.patch_path);
105            }
106            match run_tool(&hdiffz, &create_single_compressed_diff_args(options)) {
107                Ok(mut output) => {
108                    output.stderr = format!(
109                        "window diff fallback reason: {window_error}\n{}",
110                        output.stderr
111                    );
112                    output
113                }
114                Err(_) => return Err(window_error),
115            }
116        }
117    };
118
119    Ok(PatchCreateResult {
120        old: sha256_file(&options.old_path)?,
121        new: sha256_file(&options.new_path)?,
122        patch: sha256_file(&options.patch_path)?,
123        algorithm: DEFAULT_ALGORITHM.to_string(),
124        tool: hdiffz,
125        stdout: output.stdout,
126        stderr: output.stderr,
127    })
128}
129
130fn create_window_diff_args(options: &CreatePatchOptions) -> Vec<OsString> {
131    let mut args = Vec::<OsString>::new();
132    if options.force {
133        args.push("-f".into());
134    }
135    args.push(format!("-WD-{}", options.step_size.as_deref().unwrap_or("256k")).into());
136    args.push(format!("-w-{}", options.old_window_size.as_deref().unwrap_or("2m")).into());
137    args.push(format!("-c-{}", options.compression.as_deref().unwrap_or("zstd-20")).into());
138    args.push(format!("-C-{}", options.checksum.as_deref().unwrap_or("xxh128")).into());
139    push_common_create_args(&mut args, options);
140    args
141}
142
143fn create_single_compressed_diff_args(options: &CreatePatchOptions) -> Vec<OsString> {
144    let mut args = Vec::<OsString>::new();
145    if options.force {
146        args.push("-f".into());
147    }
148    args.push(format!("-SD-{}", options.step_size.as_deref().unwrap_or("256k")).into());
149    args.push(format!("-c-{}", options.compression.as_deref().unwrap_or("zstd-20")).into());
150    push_common_create_args(&mut args, options);
151    args
152}
153
154fn push_common_create_args(args: &mut Vec<OsString>, options: &CreatePatchOptions) {
155    if let Some(parallel_threads) = options.parallel_threads {
156        args.push(format!("-p-{parallel_threads}").into());
157    }
158    args.push(options.old_path.as_os_str().to_os_string());
159    args.push(options.new_path.as_os_str().to_os_string());
160    args.push(options.patch_path.as_os_str().to_os_string());
161}
162
163pub fn apply_patch(options: &ApplyPatchOptions) -> Result<PatchApplyResult> {
164    let hpatchz = resolve_tool_path(
165        options.hpatchz_path.as_deref(),
166        "HPATCHZ_PATH",
167        "hpatchz.exe",
168    )?;
169    if let Some(parent) = options.output_path.parent() {
170        std::fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
171    }
172
173    let mut args = Vec::<OsString>::new();
174    if options.force {
175        args.push("-f".into());
176    }
177    if options.verify_checksums {
178        args.push("-C-all".into());
179    }
180    args.push(format!("-s-{}", options.cache_size.as_deref().unwrap_or("64m")).into());
181    if let Some(parallel_threads) = options.parallel_threads {
182        args.push(format!("-p-{parallel_threads}").into());
183    }
184    args.push(options.old_path.as_os_str().to_os_string());
185    args.push(options.patch_path.as_os_str().to_os_string());
186    args.push(options.output_path.as_os_str().to_os_string());
187
188    let output = run_tool(&hpatchz, &args)?;
189    let output_digest = if let Some(expected) = &options.expected_sha256 {
190        verify_sha256(&options.output_path, expected)?
191    } else {
192        sha256_file(&options.output_path)?
193    };
194
195    Ok(PatchApplyResult {
196        old: sha256_file(&options.old_path)?,
197        patch: sha256_file(&options.patch_path)?,
198        output: output_digest,
199        tool: hpatchz,
200        stdout: output.stdout,
201        stderr: output.stderr,
202    })
203}
204
205pub fn inspect_diff(
206    diff_path: impl AsRef<Path>,
207    hpatchz_path: Option<&Path>,
208) -> Result<ToolOutput> {
209    let hpatchz = resolve_tool_path(hpatchz_path, "HPATCHZ_PATH", "hpatchz.exe")?;
210    let args = vec![
211        "-info".into(),
212        diff_path.as_ref().as_os_str().to_os_string(),
213    ];
214    let output = run_tool(&hpatchz, &args)?;
215    Ok(ToolOutput {
216        tool: hpatchz,
217        args: args.iter().map(os_to_string).collect(),
218        stdout: output.stdout,
219        stderr: output.stderr,
220    })
221}
222
223pub fn resolve_tool_path(
224    explicit: Option<&Path>,
225    env_var: &str,
226    default_name: &str,
227) -> Result<PathBuf> {
228    let mut candidates = Vec::<PathBuf>::new();
229    if let Some(explicit) = explicit {
230        candidates.push(explicit.to_path_buf());
231    }
232    if let Ok(from_env) = env::var(env_var) {
233        if !from_env.trim().is_empty() {
234            candidates.push(PathBuf::from(from_env));
235        }
236    }
237    if let Ok(current_exe) = env::current_exe() {
238        if let Some(parent) = current_exe.parent() {
239            candidates.push(parent.join(default_name));
240        }
241    }
242    if let Ok(current_dir) = env::current_dir() {
243        candidates.push(current_dir.join(default_name));
244    }
245    candidates.extend(path_candidates(default_name));
246
247    for candidate in candidates {
248        if candidate.is_file() {
249            return Ok(candidate);
250        }
251    }
252
253    Err(Error::ToolNotFound {
254        tool: default_name.to_string(),
255    })
256}
257
258struct CapturedOutput {
259    stdout: String,
260    stderr: String,
261}
262
263fn run_tool(program: &Path, args: &[OsString]) -> Result<CapturedOutput> {
264    let mut command = Command::new(program);
265    #[cfg(windows)]
266    command.creation_flags(CREATE_NO_WINDOW);
267
268    let output = command
269        .args(args)
270        .output()
271        .map_err(|error| io_path(program, error))?;
272    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
273    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
274
275    if !output.status.success() {
276        return Err(Error::ProcessFailed {
277            program: program.to_path_buf(),
278            args: args.iter().map(os_to_string).collect(),
279            status: output.status,
280            stdout,
281            stderr,
282        });
283    }
284
285    Ok(CapturedOutput { stdout, stderr })
286}
287
288fn path_candidates(default_name: &str) -> Vec<PathBuf> {
289    let Some(paths) = env::var_os("PATH") else {
290        return Vec::new();
291    };
292    env::split_paths(&paths)
293        .flat_map(|path| {
294            let direct = path.join(default_name);
295            #[cfg(windows)]
296            {
297                let mut names = vec![direct];
298                if !default_name.ends_with(".exe") {
299                    names.push(path.join(format!("{default_name}.exe")));
300                }
301                names
302            }
303            #[cfg(not(windows))]
304            {
305                vec![direct]
306            }
307        })
308        .collect()
309}
310
311fn os_to_string(value: &OsString) -> String {
312    value.to_string_lossy().to_string()
313}
314
315fn default_true() -> bool {
316    true
317}