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}