use std::{
collections::BTreeSet,
fs,
path::{Component, Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::{error::io_path, sha256_file, Error, Result};
const TREE_DIGEST_DOMAIN: &[u8] = b"hdiff-update-tree-v1\0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeFile {
pub path: String,
pub sha256: String,
pub size: u64,
#[serde(default)]
pub executable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeManifest {
pub tree_sha256: String,
pub total_size: u64,
pub files: Vec<TreeFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TreeVerification {
pub tree_sha256: String,
pub total_size: u64,
pub file_count: usize,
}
pub fn normalize_managed_paths(paths: &[String]) -> Result<Vec<String>> {
if paths.is_empty() {
return Err(Error::Message(
"at least one managed path is required".to_string(),
));
}
let mut normalized = Vec::with_capacity(paths.len());
let mut exact = BTreeSet::new();
let mut folded = BTreeSet::new();
for path in paths {
let value = normalize_relative_path(path)?;
if value.contains('/') {
return Err(Error::Message(format!(
"managed path must be a single top-level entry: {path}"
)));
}
if !exact.insert(value.clone()) || !folded.insert(value.to_lowercase()) {
return Err(Error::Message(format!("duplicate managed path: {path}")));
}
normalized.push(value);
}
normalized.sort_by_key(|value| value.to_lowercase());
Ok(normalized)
}
pub fn normalize_relative_path(path: &str) -> Result<String> {
let value = path.trim();
if value.is_empty()
|| value.starts_with('/')
|| value.starts_with('\\')
|| value.contains('\\')
|| value.contains(':')
|| value
.chars()
.any(|character| character == '\0' || character.is_control())
{
return Err(Error::Message(format!("unsafe relative path: {path}")));
}
let parsed = Path::new(value);
if parsed.is_absolute()
|| parsed
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(Error::Message(format!("unsafe relative path: {path}")));
}
let segments = value.split('/').collect::<Vec<_>>();
if segments.iter().any(|segment| {
segment.is_empty()
|| *segment == "."
|| *segment == ".."
|| segment.ends_with('.')
|| segment.ends_with(' ')
|| segment
.chars()
.any(|character| matches!(character, '<' | '>' | '"' | '|' | '?' | '*'))
|| is_windows_reserved_segment(segment)
}) {
return Err(Error::Message(format!("unsafe relative path: {path}")));
}
Ok(segments.join("/"))
}
pub fn build_file_tree_manifest(
root: impl AsRef<Path>,
managed_paths: &[String],
) -> Result<FileTreeManifest> {
let root = root.as_ref();
let managed_paths = normalize_managed_paths(managed_paths)?;
let mut files = Vec::new();
for managed_path in managed_paths {
let absolute = root.join(&managed_path);
let metadata = safe_symlink_metadata(&absolute)?;
if metadata.is_file() {
files.push(tree_file(root, &absolute, &managed_path, &metadata)?);
} else if metadata.is_dir() {
collect_directory_files(root, &absolute, &managed_path, &mut files)?;
} else {
return Err(Error::Message(format!(
"managed path is neither a file nor a directory: {}",
absolute.display()
)));
}
}
files.sort_by(|left, right| left.path.cmp(&right.path));
validate_file_paths(&files)?;
let total_size = files.iter().try_fold(0_u64, |total, file| {
total
.checked_add(file.size)
.ok_or_else(|| Error::Message("managed tree size overflow".to_string()))
})?;
let tree_sha256 = calculate_tree_sha256(&files)?;
Ok(FileTreeManifest {
tree_sha256,
total_size,
files,
})
}
pub fn validate_file_tree_manifest(
manifest: &FileTreeManifest,
managed_paths: &[String],
) -> Result<()> {
let managed_paths = normalize_managed_paths(managed_paths)?;
validate_file_paths(&manifest.files)?;
for file in &manifest.files {
if !managed_paths
.iter()
.any(|managed| file.path == *managed || file.path.starts_with(&format!("{managed}/")))
{
return Err(Error::Message(format!(
"manifest file is outside managed paths: {}",
file.path
)));
}
if file.sha256.len() != 64
|| hex::decode(&file.sha256).map_or(true, |bytes| bytes.len() != 32)
{
return Err(Error::Message(format!(
"invalid SHA-256 for manifest file: {}",
file.path
)));
}
}
let total_size = manifest.files.iter().try_fold(0_u64, |total, file| {
total
.checked_add(file.size)
.ok_or_else(|| Error::Message("managed tree size overflow".to_string()))
})?;
if total_size != manifest.total_size {
return Err(Error::Message(format!(
"tree total size mismatch: expected {}, calculated {total_size}",
manifest.total_size
)));
}
let calculated = calculate_tree_sha256(&manifest.files)?;
if !calculated.eq_ignore_ascii_case(&manifest.tree_sha256) {
return Err(Error::Message(format!(
"tree digest mismatch: expected {}, calculated {calculated}",
manifest.tree_sha256
)));
}
Ok(())
}
pub fn verify_file_tree(
root: impl AsRef<Path>,
managed_paths: &[String],
expected: &FileTreeManifest,
) -> Result<TreeVerification> {
validate_file_tree_manifest(expected, managed_paths)?;
let actual = build_file_tree_manifest(root, managed_paths)?;
if actual.files != expected.files {
let detail = first_tree_difference(expected, &actual);
return Err(Error::Message(format!("managed tree mismatch: {detail}")));
}
if actual.total_size != expected.total_size
|| !actual
.tree_sha256
.eq_ignore_ascii_case(&expected.tree_sha256)
{
return Err(Error::Message(format!(
"managed tree digest mismatch: expected {}, got {}",
expected.tree_sha256, actual.tree_sha256
)));
}
Ok(TreeVerification {
tree_sha256: actual.tree_sha256,
total_size: actual.total_size,
file_count: actual.files.len(),
})
}
pub fn copy_managed_tree(
source_root: impl AsRef<Path>,
destination_root: impl AsRef<Path>,
managed_paths: &[String],
) -> Result<()> {
let source_root = source_root.as_ref();
let destination_root = destination_root.as_ref();
let managed_paths = normalize_managed_paths(managed_paths)?;
fs::create_dir_all(destination_root).map_err(|error| io_path(destination_root, error))?;
for managed_path in managed_paths {
let source = source_root.join(&managed_path);
let destination = destination_root.join(&managed_path);
let metadata = safe_symlink_metadata(&source)?;
if metadata.is_file() {
copy_file(&source, &destination, &metadata)?;
} else if metadata.is_dir() {
copy_directory(&source, &destination)?;
} else {
return Err(Error::Message(format!(
"managed path is neither a file nor a directory: {}",
source.display()
)));
}
}
Ok(())
}
pub fn path_for_manifest_entry(root: &Path, relative_path: &str) -> Result<PathBuf> {
let normalized = normalize_relative_path(relative_path)?;
Ok(normalized
.split('/')
.fold(root.to_path_buf(), |path, segment| path.join(segment)))
}
fn collect_directory_files(
root: &Path,
directory: &Path,
relative_directory: &str,
output: &mut Vec<TreeFile>,
) -> Result<()> {
let mut entries = fs::read_dir(directory)
.map_err(|error| io_path(directory, error))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| io_path(directory, error))?;
entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_lowercase());
for entry in entries {
let name = entry.file_name().into_string().map_err(|_| {
Error::Message(format!(
"managed tree path is not valid UTF-8: {}",
entry.path().display()
))
})?;
let relative = normalize_relative_path(&format!("{relative_directory}/{name}"))?;
let path = entry.path();
let metadata = safe_symlink_metadata(&path)?;
if metadata.is_dir() {
collect_directory_files(root, &path, &relative, output)?;
} else if metadata.is_file() {
output.push(tree_file(root, &path, &relative, &metadata)?);
} else {
return Err(Error::Message(format!(
"unsupported managed tree entry: {}",
path.display()
)));
}
}
Ok(())
}
fn tree_file(
_root: &Path,
absolute: &Path,
relative: &str,
metadata: &fs::Metadata,
) -> Result<TreeFile> {
let digest = sha256_file(absolute)?;
Ok(TreeFile {
path: normalize_relative_path(relative)?,
sha256: digest.sha256,
size: digest.size,
executable: is_executable(absolute, metadata),
})
}
fn calculate_tree_sha256(files: &[TreeFile]) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(TREE_DIGEST_DOMAIN);
for file in files {
let path = file.path.as_bytes();
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path);
hasher.update(file.size.to_le_bytes());
let digest = hex::decode(&file.sha256).map_err(|error| {
Error::Message(format!("invalid SHA-256 for {}: {error}", file.path))
})?;
if digest.len() != 32 {
return Err(Error::Message(format!(
"invalid SHA-256 length for {}",
file.path
)));
}
hasher.update(&digest);
hasher.update([u8::from(file.executable)]);
}
Ok(hex::encode(hasher.finalize()))
}
fn validate_file_paths(files: &[TreeFile]) -> Result<()> {
let mut exact = BTreeSet::new();
let mut folded = BTreeSet::new();
let mut previous = None::<&str>;
for file in files {
let normalized = normalize_relative_path(&file.path)?;
if normalized != file.path {
return Err(Error::Message(format!(
"manifest path is not canonical: {}",
file.path
)));
}
if !exact.insert(file.path.clone()) || !folded.insert(file.path.to_lowercase()) {
return Err(Error::Message(format!(
"duplicate manifest path: {}",
file.path
)));
}
if let Some(previous) = previous {
if previous > file.path.as_str() {
return Err(Error::Message(
"manifest files must be sorted by path".to_string(),
));
}
}
previous = Some(&file.path);
}
Ok(())
}
fn first_tree_difference(expected: &FileTreeManifest, actual: &FileTreeManifest) -> String {
for (expected_file, actual_file) in expected.files.iter().zip(&actual.files) {
if expected_file != actual_file {
return format!(
"expected {} ({} bytes, {}), got {} ({} bytes, {})",
expected_file.path,
expected_file.size,
expected_file.sha256,
actual_file.path,
actual_file.size,
actual_file.sha256
);
}
}
format!(
"expected {} files, got {} files",
expected.files.len(),
actual.files.len()
)
}
fn copy_directory(source: &Path, destination: &Path) -> Result<()> {
let metadata = safe_symlink_metadata(source)?;
fs::create_dir_all(destination).map_err(|error| io_path(destination, error))?;
fs::set_permissions(destination, metadata.permissions())
.map_err(|error| io_path(destination, error))?;
let mut entries = fs::read_dir(source)
.map_err(|error| io_path(source, error))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|error| io_path(source, error))?;
entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_lowercase());
for entry in entries {
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let metadata = safe_symlink_metadata(&source_path)?;
if metadata.is_dir() {
copy_directory(&source_path, &destination_path)?;
} else if metadata.is_file() {
copy_file(&source_path, &destination_path, &metadata)?;
} else {
return Err(Error::Message(format!(
"unsupported managed tree entry: {}",
source_path.display()
)));
}
}
Ok(())
}
fn copy_file(source: &Path, destination: &Path, metadata: &fs::Metadata) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|error| io_path(parent, error))?;
}
fs::copy(source, destination).map_err(|error| io_path(destination, error))?;
fs::set_permissions(destination, metadata.permissions())
.map_err(|error| io_path(destination, error))?;
Ok(())
}
fn safe_symlink_metadata(path: &Path) -> Result<fs::Metadata> {
let metadata = fs::symlink_metadata(path).map_err(|error| io_path(path, error))?;
if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
return Err(Error::Message(format!(
"managed path contains a symlink or reparse point: {}",
path.display()
)));
}
Ok(metadata)
}
#[cfg(windows)]
fn is_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn is_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn is_executable(_path: &Path, metadata: &fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(path: &Path, _metadata: &fs::Metadata) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
}
fn is_windows_reserved_segment(segment: &str) -> bool {
let stem = segment
.split('.')
.next()
.unwrap_or(segment)
.trim_end_matches([' ', '.'])
.to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem.len() == 4
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.as_bytes()[3].is_ascii_digit()
&& stem.as_bytes()[3] != b'0')
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::{
build_file_tree_manifest, copy_managed_tree, normalize_relative_path, verify_file_tree,
};
#[test]
fn rejects_unsafe_relative_paths() {
for path in ["", "../x", "a/../b", "C:/x", "a\\b", "a./", "CON", "a?.exe"] {
assert!(normalize_relative_path(path).is_err(), "{path}");
}
assert_eq!(
normalize_relative_path("resources/a.exe").unwrap(),
"resources/a.exe"
);
}
#[test]
fn tree_manifest_is_stable_and_strict() {
let source = tempdir().unwrap();
fs::write(source.path().join("app.exe"), b"app").unwrap();
fs::create_dir(source.path().join("resources")).unwrap();
fs::write(source.path().join("resources/a.txt"), b"a").unwrap();
let managed = vec!["app.exe".to_string(), "resources".to_string()];
let manifest = build_file_tree_manifest(source.path(), &managed).unwrap();
verify_file_tree(source.path(), &managed, &manifest).unwrap();
fs::write(source.path().join("resources/extra.txt"), b"extra").unwrap();
assert!(verify_file_tree(source.path(), &managed, &manifest).is_err());
}
#[test]
fn copies_only_managed_paths() {
let source = tempdir().unwrap();
let destination = tempdir().unwrap();
fs::write(source.path().join("app.exe"), b"app").unwrap();
fs::write(source.path().join("ignored.bin"), b"ignored").unwrap();
fs::create_dir(source.path().join("plugins")).unwrap();
fs::write(source.path().join("plugins/a.js"), b"a").unwrap();
let managed = vec!["app.exe".to_string(), "plugins".to_string()];
copy_managed_tree(source.path(), destination.path(), &managed).unwrap();
assert!(destination.path().join("app.exe").is_file());
assert!(destination.path().join("plugins/a.js").is_file());
assert!(!destination.path().join("ignored.bin").exists());
}
}