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 mut args = Vec::<OsString>::new();
95 if options.force {
96 args.push("-f".into());
97 }
98 args.push(format!("-WD-{}", options.step_size.as_deref().unwrap_or("256k")).into());
99 args.push(format!("-w-{}", options.old_window_size.as_deref().unwrap_or("2m")).into());
100 args.push(format!("-c-{}", options.compression.as_deref().unwrap_or("zstd-20")).into());
101 args.push(format!("-C-{}", options.checksum.as_deref().unwrap_or("xxh128")).into());
102 if let Some(parallel_threads) = options.parallel_threads {
103 args.push(format!("-p-{parallel_threads}").into());
104 }
105 args.push(options.old_path.as_os_str().to_os_string());
106 args.push(options.new_path.as_os_str().to_os_string());
107 args.push(options.patch_path.as_os_str().to_os_string());
108
109 let output = run_tool(&hdiffz, &args)?;
110
111 Ok(PatchCreateResult {
112 old: sha256_file(&options.old_path)?,
113 new: sha256_file(&options.new_path)?,
114 patch: sha256_file(&options.patch_path)?,
115 algorithm: DEFAULT_ALGORITHM.to_string(),
116 tool: hdiffz,
117 stdout: output.stdout,
118 stderr: output.stderr,
119 })
120}
121
122pub fn apply_patch(options: &ApplyPatchOptions) -> Result<PatchApplyResult> {
123 let hpatchz = resolve_tool_path(
124 options.hpatchz_path.as_deref(),
125 "HPATCHZ_PATH",
126 "hpatchz.exe",
127 )?;
128 if let Some(parent) = options.output_path.parent() {
129 std::fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
130 }
131
132 let mut args = Vec::<OsString>::new();
133 if options.force {
134 args.push("-f".into());
135 }
136 if options.verify_checksums {
137 args.push("-C-all".into());
138 }
139 args.push(format!("-s-{}", options.cache_size.as_deref().unwrap_or("64m")).into());
140 if let Some(parallel_threads) = options.parallel_threads {
141 args.push(format!("-p-{parallel_threads}").into());
142 }
143 args.push(options.old_path.as_os_str().to_os_string());
144 args.push(options.patch_path.as_os_str().to_os_string());
145 args.push(options.output_path.as_os_str().to_os_string());
146
147 let output = run_tool(&hpatchz, &args)?;
148 let output_digest = if let Some(expected) = &options.expected_sha256 {
149 verify_sha256(&options.output_path, expected)?
150 } else {
151 sha256_file(&options.output_path)?
152 };
153
154 Ok(PatchApplyResult {
155 old: sha256_file(&options.old_path)?,
156 patch: sha256_file(&options.patch_path)?,
157 output: output_digest,
158 tool: hpatchz,
159 stdout: output.stdout,
160 stderr: output.stderr,
161 })
162}
163
164pub fn inspect_diff(
165 diff_path: impl AsRef<Path>,
166 hpatchz_path: Option<&Path>,
167) -> Result<ToolOutput> {
168 let hpatchz = resolve_tool_path(hpatchz_path, "HPATCHZ_PATH", "hpatchz.exe")?;
169 let args = vec![
170 "-info".into(),
171 diff_path.as_ref().as_os_str().to_os_string(),
172 ];
173 let output = run_tool(&hpatchz, &args)?;
174 Ok(ToolOutput {
175 tool: hpatchz,
176 args: args.iter().map(os_to_string).collect(),
177 stdout: output.stdout,
178 stderr: output.stderr,
179 })
180}
181
182pub fn resolve_tool_path(
183 explicit: Option<&Path>,
184 env_var: &str,
185 default_name: &str,
186) -> Result<PathBuf> {
187 let mut candidates = Vec::<PathBuf>::new();
188 if let Some(explicit) = explicit {
189 candidates.push(explicit.to_path_buf());
190 }
191 if let Ok(from_env) = env::var(env_var) {
192 if !from_env.trim().is_empty() {
193 candidates.push(PathBuf::from(from_env));
194 }
195 }
196 if let Ok(current_exe) = env::current_exe() {
197 if let Some(parent) = current_exe.parent() {
198 candidates.push(parent.join(default_name));
199 }
200 }
201 if let Ok(current_dir) = env::current_dir() {
202 candidates.push(current_dir.join(default_name));
203 }
204 candidates.extend(path_candidates(default_name));
205
206 for candidate in candidates {
207 if candidate.is_file() {
208 return Ok(candidate);
209 }
210 }
211
212 Err(Error::ToolNotFound {
213 tool: default_name.to_string(),
214 })
215}
216
217struct CapturedOutput {
218 stdout: String,
219 stderr: String,
220}
221
222fn run_tool(program: &Path, args: &[OsString]) -> Result<CapturedOutput> {
223 let output = Command::new(program)
224 .args(args)
225 .output()
226 .map_err(|error| io_path(program, error))?;
227 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
228 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
229
230 if !output.status.success() {
231 return Err(Error::ProcessFailed {
232 program: program.to_path_buf(),
233 args: args.iter().map(os_to_string).collect(),
234 status: output.status,
235 stdout,
236 stderr,
237 });
238 }
239
240 Ok(CapturedOutput { stdout, stderr })
241}
242
243fn path_candidates(default_name: &str) -> Vec<PathBuf> {
244 let Some(paths) = env::var_os("PATH") else {
245 return Vec::new();
246 };
247 env::split_paths(&paths)
248 .flat_map(|path| {
249 let direct = path.join(default_name);
250 #[cfg(windows)]
251 {
252 let mut names = vec![direct];
253 if !default_name.ends_with(".exe") {
254 names.push(path.join(format!("{default_name}.exe")));
255 }
256 names
257 }
258 #[cfg(not(windows))]
259 {
260 vec![direct]
261 }
262 })
263 .collect()
264}
265
266fn os_to_string(value: &OsString) -> String {
267 value.to_string_lossy().to_string()
268}
269
270fn default_true() -> bool {
271 true
272}