harn_modules/package_execution/
content_hash.rs1use 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
236pub 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 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}