Skip to main content

hdiff_update_core/
tool.rs

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