Skip to main content

harn_modules/package_execution/
content_hash.rs

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