Skip to main content

harn_modules/package_execution/
content_hash.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use sha2::{Digest, Sha256};
7use unicode_normalization::UnicodeNormalization;
8
9use super::{PackageExecutionError, CACHE_METADATA_FILE, CONTENT_HASH_FILE};
10
11pub const CANONICAL_CONTENT_HASH_PREFIX: &str = "sha256-v2:";
12const ARCHIVE_CONTENT_HASH_PREFIX: &str = "sha256:";
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15enum PackageContentHashAlgorithm {
16    CanonicalV2,
17    ArchiveV1,
18}
19
20impl PackageContentHashAlgorithm {
21    fn parse(hash: &str) -> Result<Self, PackageExecutionError> {
22        let (algorithm, hex) = if let Some(hex) = hash.strip_prefix(CANONICAL_CONTENT_HASH_PREFIX) {
23            (Self::CanonicalV2, hex)
24        } else if let Some(hex) = hash.strip_prefix(ARCHIVE_CONTENT_HASH_PREFIX) {
25            (Self::ArchiveV1, hex)
26        } else {
27            return Err(PackageExecutionError::Invalid(format!(
28                "package content hash must use sha256-v2:<64 hex> or archive sha256:<64 hex>, got {hash}"
29            )));
30        };
31        if !is_sha256_hex(hex) {
32            return Err(PackageExecutionError::Invalid(format!(
33                "package content hash must use sha256-v2:<64 hex> or archive sha256:<64 hex>, got {hash}"
34            )));
35        }
36        Ok(algorithm)
37    }
38}
39
40pub fn compute_package_content_hash(dir: &Path) -> Result<String, PackageExecutionError> {
41    compute_canonical_package_content_hash_capturing(dir, None).map(|(hash, _)| hash)
42}
43
44pub fn compute_archive_content_hash(dir: &Path) -> Result<String, PackageExecutionError> {
45    compute_archive_content_hash_capturing(dir, None).map(|(hash, _)| hash)
46}
47
48pub fn is_canonical_package_content_hash(hash: &str) -> bool {
49    hash.strip_prefix(CANONICAL_CONTENT_HASH_PREFIX)
50        .is_some_and(is_sha256_hex)
51}
52
53pub fn verify_package_content_hash(
54    dir: &Path,
55    expected: &str,
56) -> Result<String, PackageExecutionError> {
57    compute_package_content_hash_capturing(dir, None, expected).map(|(hash, _)| hash)
58}
59
60pub(super) fn compute_package_content_hash_capturing(
61    dir: &Path,
62    capture: Option<&Path>,
63    expected: &str,
64) -> Result<(String, Option<Vec<u8>>), PackageExecutionError> {
65    match PackageContentHashAlgorithm::parse(expected)? {
66        PackageContentHashAlgorithm::CanonicalV2 => {
67            compute_canonical_package_content_hash_capturing(dir, capture)
68        }
69        PackageContentHashAlgorithm::ArchiveV1 => {
70            compute_archive_content_hash_capturing(dir, capture)
71        }
72    }
73}
74
75fn compute_archive_content_hash_capturing(
76    dir: &Path,
77    capture: Option<&Path>,
78) -> Result<(String, Option<Vec<u8>>), PackageExecutionError> {
79    let mut files = Vec::new();
80    collect_hashable_files(dir, dir, &mut files)?;
81    files.sort();
82    let mut hasher = Sha256::new();
83    let mut captured = None;
84    for relative in files {
85        let normalized = normalized_package_relative_path(&relative);
86        let path = dir.join(&relative);
87        let contents = read_regular_file(&path)?;
88        hasher.update(normalized.as_bytes());
89        hasher.update([0]);
90        hasher.update(hex::encode(Sha256::digest(&contents)).as_bytes());
91        if capture == Some(relative.as_path()) {
92            captured = Some(contents);
93        }
94    }
95    Ok((
96        format!(
97            "{ARCHIVE_CONTENT_HASH_PREFIX}{}",
98            hex::encode(hasher.finalize())
99        ),
100        captured,
101    ))
102}
103
104fn compute_canonical_package_content_hash_capturing(
105    dir: &Path,
106    capture: Option<&Path>,
107) -> Result<(String, Option<Vec<u8>>), PackageExecutionError> {
108    let mut paths = Vec::new();
109    collect_hashable_files(dir, dir, &mut paths)?;
110    let mut files = paths
111        .into_iter()
112        .map(|relative| {
113            canonical_package_relative_path(&relative).map(|normalized| (normalized, relative))
114        })
115        .collect::<Result<Vec<_>, _>>()?;
116    files.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes()));
117    for adjacent in files.windows(2) {
118        if adjacent[0].0 == adjacent[1].0 {
119            return Err(PackageExecutionError::Invalid(format!(
120                "package paths {} and {} have the same canonical identity '{}'",
121                adjacent[0].1.display(),
122                adjacent[1].1.display(),
123                adjacent[0].0
124            )));
125        }
126    }
127
128    let mut hasher = Sha256::new();
129    hasher.update(b"harn-package-content-v2\0");
130    let mut captured = None;
131    for (normalized_path, relative) in files {
132        let path = dir.join(&relative);
133        let contents = read_regular_file(&path)?;
134        let canonical_contents = canonical_file_contents(&contents);
135        hash_framed(&mut hasher, normalized_path.as_bytes());
136        hash_framed(&mut hasher, &Sha256::digest(canonical_contents.as_ref()));
137        if capture == Some(relative.as_path()) {
138            captured = Some(contents);
139        }
140    }
141    Ok((
142        format!(
143            "{CANONICAL_CONTENT_HASH_PREFIX}{}",
144            hex::encode(hasher.finalize())
145        ),
146        captured,
147    ))
148}
149
150fn hash_framed(hasher: &mut Sha256, bytes: &[u8]) {
151    hasher.update((bytes.len() as u64).to_be_bytes());
152    hasher.update(bytes);
153}
154
155fn canonical_file_contents(contents: &[u8]) -> Cow<'_, [u8]> {
156    if contents.contains(&0) || std::str::from_utf8(contents).is_err() {
157        return Cow::Borrowed(contents);
158    }
159    if !contents.contains(&b'\r') {
160        return Cow::Borrowed(contents);
161    }
162    let mut normalized = Vec::with_capacity(contents.len());
163    let mut index = 0;
164    while index < contents.len() {
165        if contents[index] == b'\r' {
166            normalized.push(b'\n');
167            index += usize::from(contents.get(index + 1) == Some(&b'\n')) + 1;
168        } else {
169            normalized.push(contents[index]);
170            index += 1;
171        }
172    }
173    Cow::Owned(normalized)
174}
175
176fn collect_hashable_files(
177    root: &Path,
178    cursor: &Path,
179    out: &mut Vec<PathBuf>,
180) -> Result<(), PackageExecutionError> {
181    let entries = fs::read_dir(cursor).map_err(|error| {
182        PackageExecutionError::io("read directory", cursor.to_path_buf(), error)
183    })?;
184    for entry in entries {
185        let entry = entry.map_err(|error| {
186            PackageExecutionError::io("read directory entry", cursor.to_path_buf(), error)
187        })?;
188        let path = entry.path();
189        let file_type = entry
190            .file_type()
191            .map_err(|error| PackageExecutionError::io("stat", path.clone(), error))?;
192        let name = entry.file_name();
193        if excluded_package_name(&name) {
194            continue;
195        }
196        if file_type.is_symlink() {
197            return Err(PackageExecutionError::Invalid(format!(
198                "package content contains unsupported symlink: {}",
199                path.display()
200            )));
201        }
202        if file_type.is_dir() {
203            collect_hashable_files(root, &path, out)?;
204        } else if file_type.is_file() {
205            let relative = path.strip_prefix(root).map_err(|error| {
206                PackageExecutionError::Invalid(format!(
207                    "failed to relativize {}: {error}",
208                    path.display()
209                ))
210            })?;
211            out.push(relative.to_path_buf());
212        }
213    }
214    Ok(())
215}
216
217fn read_regular_file(path: &Path) -> Result<Vec<u8>, PackageExecutionError> {
218    let metadata = fs::symlink_metadata(path)
219        .map_err(|error| PackageExecutionError::io("stat", path.to_path_buf(), error))?;
220    if !metadata.file_type().is_file() {
221        return Err(PackageExecutionError::Invalid(format!(
222            "package content is not a regular file: {}",
223            path.display()
224        )));
225    }
226    fs::read(path).map_err(|error| PackageExecutionError::io("read", path.to_path_buf(), error))
227}
228
229pub(super) fn excluded_package_name(name: &OsStr) -> bool {
230    name == OsStr::new(".git")
231        || name == OsStr::new(".gitignore")
232        || name == OsStr::new("CLAUDE.md")
233        || is_package_cache_metadata_name(name)
234}
235
236/// Reserved cache markers and their incomplete atomic-write siblings are not
237/// package content. Use the same classification when materializing packages.
238pub fn is_package_cache_metadata_name(name: &OsStr) -> bool {
239    if name == OsStr::new(CONTENT_HASH_FILE) || name == OsStr::new(CACHE_METADATA_FILE) {
240        return true;
241    }
242    let Some(name) = name.to_str() else {
243        return false;
244    };
245    // The atomic writer keeps a bounded marker stem and a compact UUID.
246    // Package tests derive these names from the real atomic writer.
247    name.strip_prefix("..harn-package-ca.")
248        .or_else(|| name.strip_prefix("..harn-content-ha."))
249        .and_then(|suffix| suffix.strip_suffix(".tmp"))
250        .is_some_and(|id| id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit()))
251}
252
253pub fn normalized_package_relative_path(path: &Path) -> String {
254    path.components()
255        .map(|component| component.as_os_str().to_string_lossy())
256        .collect::<Vec<_>>()
257        .join("/")
258}
259
260fn canonical_package_relative_path(path: &Path) -> Result<String, PackageExecutionError> {
261    let mut components = Vec::new();
262    for component in path.components() {
263        let Component::Normal(value) = component else {
264            return Err(PackageExecutionError::Invalid(format!(
265                "package content path is not relative and normalized: {}",
266                path.display()
267            )));
268        };
269        let value = value.to_str().ok_or_else(|| {
270            PackageExecutionError::Invalid(format!(
271                "package content path is not valid UTF-8: {}",
272                path.display()
273            ))
274        })?;
275        components.push(value.nfc().collect::<String>());
276    }
277    Ok(components.join("/"))
278}
279
280pub(super) fn validate_content_hash(hash: &str) -> Result<(), PackageExecutionError> {
281    PackageContentHashAlgorithm::parse(hash).map(|_| ())
282}
283
284fn is_sha256_hex(hex: &str) -> bool {
285    hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
286}