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