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)]
15const 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}