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