Skip to main content

hdiff_update_core/
tree.rs

1use std::{
2    collections::BTreeSet,
3    fs,
4    path::{Component, Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10use crate::{error::io_path, sha256_file, Error, Result};
11
12const TREE_DIGEST_DOMAIN: &[u8] = b"hdiff-update-tree-v1\0";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct TreeFile {
17    pub path: String,
18    pub sha256: String,
19    pub size: u64,
20    #[serde(default)]
21    pub executable: bool,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct FileTreeManifest {
27    pub tree_sha256: String,
28    pub total_size: u64,
29    pub files: Vec<TreeFile>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct TreeVerification {
35    pub tree_sha256: String,
36    pub total_size: u64,
37    pub file_count: usize,
38}
39
40pub fn normalize_managed_paths(paths: &[String]) -> Result<Vec<String>> {
41    if paths.is_empty() {
42        return Err(Error::Message(
43            "at least one managed path is required".to_string(),
44        ));
45    }
46
47    let mut normalized = Vec::with_capacity(paths.len());
48    let mut exact = BTreeSet::new();
49    let mut folded = BTreeSet::new();
50    for path in paths {
51        let value = normalize_relative_path(path)?;
52        if value.contains('/') {
53            return Err(Error::Message(format!(
54                "managed path must be a single top-level entry: {path}"
55            )));
56        }
57        if !exact.insert(value.clone()) || !folded.insert(value.to_lowercase()) {
58            return Err(Error::Message(format!("duplicate managed path: {path}")));
59        }
60        normalized.push(value);
61    }
62    normalized.sort_by_key(|value| value.to_lowercase());
63    Ok(normalized)
64}
65
66pub fn normalize_relative_path(path: &str) -> Result<String> {
67    let value = path.trim();
68    if value.is_empty()
69        || value.starts_with('/')
70        || value.starts_with('\\')
71        || value.contains('\\')
72        || value.contains(':')
73        || value
74            .chars()
75            .any(|character| character == '\0' || character.is_control())
76    {
77        return Err(Error::Message(format!("unsafe relative path: {path}")));
78    }
79
80    let parsed = Path::new(value);
81    if parsed.is_absolute()
82        || parsed
83            .components()
84            .any(|component| !matches!(component, Component::Normal(_)))
85    {
86        return Err(Error::Message(format!("unsafe relative path: {path}")));
87    }
88
89    let segments = value.split('/').collect::<Vec<_>>();
90    if segments.iter().any(|segment| {
91        segment.is_empty()
92            || *segment == "."
93            || *segment == ".."
94            || segment.ends_with('.')
95            || segment.ends_with(' ')
96            || segment
97                .chars()
98                .any(|character| matches!(character, '<' | '>' | '"' | '|' | '?' | '*'))
99            || is_windows_reserved_segment(segment)
100    }) {
101        return Err(Error::Message(format!("unsafe relative path: {path}")));
102    }
103
104    Ok(segments.join("/"))
105}
106
107pub fn build_file_tree_manifest(
108    root: impl AsRef<Path>,
109    managed_paths: &[String],
110) -> Result<FileTreeManifest> {
111    let root = root.as_ref();
112    let managed_paths = normalize_managed_paths(managed_paths)?;
113    let mut files = Vec::new();
114
115    for managed_path in managed_paths {
116        let absolute = root.join(&managed_path);
117        let metadata = safe_symlink_metadata(&absolute)?;
118        if metadata.is_file() {
119            files.push(tree_file(root, &absolute, &managed_path, &metadata)?);
120        } else if metadata.is_dir() {
121            collect_directory_files(root, &absolute, &managed_path, &mut files)?;
122        } else {
123            return Err(Error::Message(format!(
124                "managed path is neither a file nor a directory: {}",
125                absolute.display()
126            )));
127        }
128    }
129
130    files.sort_by(|left, right| left.path.cmp(&right.path));
131    validate_file_paths(&files)?;
132    let total_size = files.iter().try_fold(0_u64, |total, file| {
133        total
134            .checked_add(file.size)
135            .ok_or_else(|| Error::Message("managed tree size overflow".to_string()))
136    })?;
137    let tree_sha256 = calculate_tree_sha256(&files)?;
138
139    Ok(FileTreeManifest {
140        tree_sha256,
141        total_size,
142        files,
143    })
144}
145
146pub fn validate_file_tree_manifest(
147    manifest: &FileTreeManifest,
148    managed_paths: &[String],
149) -> Result<()> {
150    let managed_paths = normalize_managed_paths(managed_paths)?;
151    validate_file_paths(&manifest.files)?;
152
153    for file in &manifest.files {
154        if !managed_paths
155            .iter()
156            .any(|managed| file.path == *managed || file.path.starts_with(&format!("{managed}/")))
157        {
158            return Err(Error::Message(format!(
159                "manifest file is outside managed paths: {}",
160                file.path
161            )));
162        }
163        if file.sha256.len() != 64
164            || hex::decode(&file.sha256).map_or(true, |bytes| bytes.len() != 32)
165        {
166            return Err(Error::Message(format!(
167                "invalid SHA-256 for manifest file: {}",
168                file.path
169            )));
170        }
171    }
172
173    let total_size = manifest.files.iter().try_fold(0_u64, |total, file| {
174        total
175            .checked_add(file.size)
176            .ok_or_else(|| Error::Message("managed tree size overflow".to_string()))
177    })?;
178    if total_size != manifest.total_size {
179        return Err(Error::Message(format!(
180            "tree total size mismatch: expected {}, calculated {total_size}",
181            manifest.total_size
182        )));
183    }
184    let calculated = calculate_tree_sha256(&manifest.files)?;
185    if !calculated.eq_ignore_ascii_case(&manifest.tree_sha256) {
186        return Err(Error::Message(format!(
187            "tree digest mismatch: expected {}, calculated {calculated}",
188            manifest.tree_sha256
189        )));
190    }
191    Ok(())
192}
193
194pub fn verify_file_tree(
195    root: impl AsRef<Path>,
196    managed_paths: &[String],
197    expected: &FileTreeManifest,
198) -> Result<TreeVerification> {
199    validate_file_tree_manifest(expected, managed_paths)?;
200    let actual = build_file_tree_manifest(root, managed_paths)?;
201    if actual.files != expected.files {
202        let detail = first_tree_difference(expected, &actual);
203        return Err(Error::Message(format!("managed tree mismatch: {detail}")));
204    }
205    if actual.total_size != expected.total_size
206        || !actual
207            .tree_sha256
208            .eq_ignore_ascii_case(&expected.tree_sha256)
209    {
210        return Err(Error::Message(format!(
211            "managed tree digest mismatch: expected {}, got {}",
212            expected.tree_sha256, actual.tree_sha256
213        )));
214    }
215    Ok(TreeVerification {
216        tree_sha256: actual.tree_sha256,
217        total_size: actual.total_size,
218        file_count: actual.files.len(),
219    })
220}
221
222pub fn copy_managed_tree(
223    source_root: impl AsRef<Path>,
224    destination_root: impl AsRef<Path>,
225    managed_paths: &[String],
226) -> Result<()> {
227    let source_root = source_root.as_ref();
228    let destination_root = destination_root.as_ref();
229    let managed_paths = normalize_managed_paths(managed_paths)?;
230    fs::create_dir_all(destination_root).map_err(|error| io_path(destination_root, error))?;
231
232    for managed_path in managed_paths {
233        let source = source_root.join(&managed_path);
234        let destination = destination_root.join(&managed_path);
235        let metadata = safe_symlink_metadata(&source)?;
236        if metadata.is_file() {
237            copy_file(&source, &destination, &metadata)?;
238        } else if metadata.is_dir() {
239            copy_directory(&source, &destination)?;
240        } else {
241            return Err(Error::Message(format!(
242                "managed path is neither a file nor a directory: {}",
243                source.display()
244            )));
245        }
246    }
247    Ok(())
248}
249
250pub fn path_for_manifest_entry(root: &Path, relative_path: &str) -> Result<PathBuf> {
251    let normalized = normalize_relative_path(relative_path)?;
252    Ok(normalized
253        .split('/')
254        .fold(root.to_path_buf(), |path, segment| path.join(segment)))
255}
256
257fn collect_directory_files(
258    root: &Path,
259    directory: &Path,
260    relative_directory: &str,
261    output: &mut Vec<TreeFile>,
262) -> Result<()> {
263    let mut entries = fs::read_dir(directory)
264        .map_err(|error| io_path(directory, error))?
265        .collect::<std::result::Result<Vec<_>, _>>()
266        .map_err(|error| io_path(directory, error))?;
267    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_lowercase());
268
269    for entry in entries {
270        let name = entry.file_name().into_string().map_err(|_| {
271            Error::Message(format!(
272                "managed tree path is not valid UTF-8: {}",
273                entry.path().display()
274            ))
275        })?;
276        let relative = normalize_relative_path(&format!("{relative_directory}/{name}"))?;
277        let path = entry.path();
278        let metadata = safe_symlink_metadata(&path)?;
279        if metadata.is_dir() {
280            collect_directory_files(root, &path, &relative, output)?;
281        } else if metadata.is_file() {
282            output.push(tree_file(root, &path, &relative, &metadata)?);
283        } else {
284            return Err(Error::Message(format!(
285                "unsupported managed tree entry: {}",
286                path.display()
287            )));
288        }
289    }
290    Ok(())
291}
292
293fn tree_file(
294    _root: &Path,
295    absolute: &Path,
296    relative: &str,
297    metadata: &fs::Metadata,
298) -> Result<TreeFile> {
299    let digest = sha256_file(absolute)?;
300    Ok(TreeFile {
301        path: normalize_relative_path(relative)?,
302        sha256: digest.sha256,
303        size: digest.size,
304        executable: is_executable(absolute, metadata),
305    })
306}
307
308fn calculate_tree_sha256(files: &[TreeFile]) -> Result<String> {
309    let mut hasher = Sha256::new();
310    hasher.update(TREE_DIGEST_DOMAIN);
311    for file in files {
312        let path = file.path.as_bytes();
313        hasher.update((path.len() as u64).to_le_bytes());
314        hasher.update(path);
315        hasher.update(file.size.to_le_bytes());
316        let digest = hex::decode(&file.sha256).map_err(|error| {
317            Error::Message(format!("invalid SHA-256 for {}: {error}", file.path))
318        })?;
319        if digest.len() != 32 {
320            return Err(Error::Message(format!(
321                "invalid SHA-256 length for {}",
322                file.path
323            )));
324        }
325        hasher.update(&digest);
326        hasher.update([u8::from(file.executable)]);
327    }
328    Ok(hex::encode(hasher.finalize()))
329}
330
331fn validate_file_paths(files: &[TreeFile]) -> Result<()> {
332    let mut exact = BTreeSet::new();
333    let mut folded = BTreeSet::new();
334    let mut previous = None::<&str>;
335    for file in files {
336        let normalized = normalize_relative_path(&file.path)?;
337        if normalized != file.path {
338            return Err(Error::Message(format!(
339                "manifest path is not canonical: {}",
340                file.path
341            )));
342        }
343        if !exact.insert(file.path.clone()) || !folded.insert(file.path.to_lowercase()) {
344            return Err(Error::Message(format!(
345                "duplicate manifest path: {}",
346                file.path
347            )));
348        }
349        if let Some(previous) = previous {
350            if previous > file.path.as_str() {
351                return Err(Error::Message(
352                    "manifest files must be sorted by path".to_string(),
353                ));
354            }
355        }
356        previous = Some(&file.path);
357    }
358    Ok(())
359}
360
361fn first_tree_difference(expected: &FileTreeManifest, actual: &FileTreeManifest) -> String {
362    for (expected_file, actual_file) in expected.files.iter().zip(&actual.files) {
363        if expected_file != actual_file {
364            return format!(
365                "expected {} ({} bytes, {}), got {} ({} bytes, {})",
366                expected_file.path,
367                expected_file.size,
368                expected_file.sha256,
369                actual_file.path,
370                actual_file.size,
371                actual_file.sha256
372            );
373        }
374    }
375    format!(
376        "expected {} files, got {} files",
377        expected.files.len(),
378        actual.files.len()
379    )
380}
381
382fn copy_directory(source: &Path, destination: &Path) -> Result<()> {
383    let metadata = safe_symlink_metadata(source)?;
384    fs::create_dir_all(destination).map_err(|error| io_path(destination, error))?;
385    fs::set_permissions(destination, metadata.permissions())
386        .map_err(|error| io_path(destination, error))?;
387    let mut entries = fs::read_dir(source)
388        .map_err(|error| io_path(source, error))?
389        .collect::<std::result::Result<Vec<_>, _>>()
390        .map_err(|error| io_path(source, error))?;
391    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_lowercase());
392    for entry in entries {
393        let source_path = entry.path();
394        let destination_path = destination.join(entry.file_name());
395        let metadata = safe_symlink_metadata(&source_path)?;
396        if metadata.is_dir() {
397            copy_directory(&source_path, &destination_path)?;
398        } else if metadata.is_file() {
399            copy_file(&source_path, &destination_path, &metadata)?;
400        } else {
401            return Err(Error::Message(format!(
402                "unsupported managed tree entry: {}",
403                source_path.display()
404            )));
405        }
406    }
407    Ok(())
408}
409
410fn copy_file(source: &Path, destination: &Path, metadata: &fs::Metadata) -> Result<()> {
411    if let Some(parent) = destination.parent() {
412        fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
413    }
414    fs::copy(source, destination).map_err(|error| io_path(destination, error))?;
415    fs::set_permissions(destination, metadata.permissions())
416        .map_err(|error| io_path(destination, error))?;
417    Ok(())
418}
419
420fn safe_symlink_metadata(path: &Path) -> Result<fs::Metadata> {
421    let metadata = fs::symlink_metadata(path).map_err(|error| io_path(path, error))?;
422    if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
423        return Err(Error::Message(format!(
424            "managed path contains a symlink or reparse point: {}",
425            path.display()
426        )));
427    }
428    Ok(metadata)
429}
430
431#[cfg(windows)]
432fn is_reparse_point(metadata: &fs::Metadata) -> bool {
433    use std::os::windows::fs::MetadataExt;
434    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
435    metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
436}
437
438#[cfg(not(windows))]
439fn is_reparse_point(_metadata: &fs::Metadata) -> bool {
440    false
441}
442
443#[cfg(unix)]
444fn is_executable(_path: &Path, metadata: &fs::Metadata) -> bool {
445    use std::os::unix::fs::PermissionsExt;
446    metadata.permissions().mode() & 0o111 != 0
447}
448
449#[cfg(not(unix))]
450fn is_executable(path: &Path, _metadata: &fs::Metadata) -> bool {
451    path.extension()
452        .and_then(|extension| extension.to_str())
453        .is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
454}
455
456fn is_windows_reserved_segment(segment: &str) -> bool {
457    let stem = segment
458        .split('.')
459        .next()
460        .unwrap_or(segment)
461        .trim_end_matches([' ', '.'])
462        .to_ascii_uppercase();
463    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
464        || (stem.len() == 4
465            && (stem.starts_with("COM") || stem.starts_with("LPT"))
466            && stem.as_bytes()[3].is_ascii_digit()
467            && stem.as_bytes()[3] != b'0')
468}
469
470#[cfg(test)]
471mod tests {
472    use std::fs;
473
474    use tempfile::tempdir;
475
476    use super::{
477        build_file_tree_manifest, copy_managed_tree, normalize_relative_path, verify_file_tree,
478    };
479
480    #[test]
481    fn rejects_unsafe_relative_paths() {
482        for path in ["", "../x", "a/../b", "C:/x", "a\\b", "a./", "CON", "a?.exe"] {
483            assert!(normalize_relative_path(path).is_err(), "{path}");
484        }
485        assert_eq!(
486            normalize_relative_path("resources/a.exe").unwrap(),
487            "resources/a.exe"
488        );
489    }
490
491    #[test]
492    fn tree_manifest_is_stable_and_strict() {
493        let source = tempdir().unwrap();
494        fs::write(source.path().join("app.exe"), b"app").unwrap();
495        fs::create_dir(source.path().join("resources")).unwrap();
496        fs::write(source.path().join("resources/a.txt"), b"a").unwrap();
497        let managed = vec!["app.exe".to_string(), "resources".to_string()];
498        let manifest = build_file_tree_manifest(source.path(), &managed).unwrap();
499        verify_file_tree(source.path(), &managed, &manifest).unwrap();
500
501        fs::write(source.path().join("resources/extra.txt"), b"extra").unwrap();
502        assert!(verify_file_tree(source.path(), &managed, &manifest).is_err());
503    }
504
505    #[test]
506    fn copies_only_managed_paths() {
507        let source = tempdir().unwrap();
508        let destination = tempdir().unwrap();
509        fs::write(source.path().join("app.exe"), b"app").unwrap();
510        fs::write(source.path().join("ignored.bin"), b"ignored").unwrap();
511        fs::create_dir(source.path().join("plugins")).unwrap();
512        fs::write(source.path().join("plugins/a.js"), b"a").unwrap();
513        let managed = vec!["app.exe".to_string(), "plugins".to_string()];
514
515        copy_managed_tree(source.path(), destination.path(), &managed).unwrap();
516        assert!(destination.path().join("app.exe").is_file());
517        assert!(destination.path().join("plugins/a.js").is_file());
518        assert!(!destination.path().join("ignored.bin").exists());
519    }
520}