返回 CodeWhale
package_digest.rs
根目录 / crates / tui / src / skills / package_digest.rs
1 //! Bounded package content digest shared by audit and mutation.
2 //!
3 //! Kept separate so `install` can write metadata v2 without depending on the
4 //! audit module (which itself depends on install marker constants).
5
6 use std::collections::HashSet;
7 use std::fs;
8 use std::io::Read;
9 use std::path::{Path, PathBuf};
10
11 use sha2::{Digest, Sha256};
12
13 use super::install::{
14 DEFAULT_MAX_SIZE_BYTES, INSTALLED_FROM_MARKER, TRUSTED_MARKER, is_reserved_root_metadata,
15 };
16
17 pub const PACKAGE_DIGEST_MAX_BYTES: u64 = DEFAULT_MAX_SIZE_BYTES;
18 pub const PACKAGE_DIGEST_MAX_FILES: usize = 256;
19 pub const PACKAGE_DIGEST_MAX_DEPTH: usize = 8;
20
21 #[derive(Debug, Clone, PartialEq, Eq)]
22 pub enum PackageDigestError {
23 Unreadable,
24 SymlinkPresent,
25 EscapedRoot,
26 Cycle,
27 Oversized,
28 TooManyFiles,
29 TooDeep,
30 }
31
32 impl std::fmt::Display for PackageDigestError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.write_str(match self {
35 Self::Unreadable => "unreadable package file",
36 Self::SymlinkPresent => "symlink present in package",
37 Self::EscapedRoot => "path escaped package root",
38 Self::Cycle => "symlink/directory cycle",
39 Self::Oversized => "package exceeded size limit",
40 Self::TooManyFiles => "package exceeded file limit",
41 Self::TooDeep => "package exceeded depth limit",
42 })
43 }
44 }
45
46 impl std::error::Error for PackageDigestError {}
47
48 /// SHA-256 hex of the normalized package manifest (relative path + len + bytes).
49 pub fn compute_package_digest(package_dir: &Path) -> Result<String, PackageDigestError> {
50 let canonical_package =
51 fs::canonicalize(package_dir).map_err(|_| PackageDigestError::Unreadable)?;
52
53 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
54 let mut total_bytes: u64 = 0;
55 let mut visited = HashSet::new();
56
57 walk(
58 package_dir,
59 &canonical_package,
60 0,
61 &mut visited,
62 &mut files,
63 &mut total_bytes,
64 )?;
65
66 files.sort_by(|a, b| a.0.cmp(&b.0));
67 let mut hasher = Sha256::new();
68 for (rel, bytes) in &files {
69 hasher.update(rel.as_bytes());
70 hasher.update(b"\0");
71 hasher.update((bytes.len() as u64).to_le_bytes());
72 hasher.update(bytes);
73 }
74 Ok(hex_digest(hasher.finalize()))
75 }
76
77 fn walk(
78 dir: &Path,
79 package_root: &Path,
80 depth: usize,
81 visited: &mut HashSet<PathBuf>,
82 files: &mut Vec<(String, Vec<u8>)>,
83 total_bytes: &mut u64,
84 ) -> Result<(), PackageDigestError> {
85 if depth > PACKAGE_DIGEST_MAX_DEPTH {
86 return Err(PackageDigestError::TooDeep);
87 }
88 let meta = fs::symlink_metadata(dir).map_err(|_| PackageDigestError::Unreadable)?;
89 if meta.file_type().is_symlink() {
90 return Err(PackageDigestError::SymlinkPresent);
91 }
92 let canonical = fs::canonicalize(dir).map_err(|_| PackageDigestError::Unreadable)?;
93 if !canonical.starts_with(package_root) {
94 return Err(PackageDigestError::EscapedRoot);
95 }
96 if !visited.insert(canonical.clone()) {
97 return Err(PackageDigestError::Cycle);
98 }
99
100 let entries = fs::read_dir(&canonical).map_err(|_| PackageDigestError::Unreadable)?;
101 for entry in entries {
102 let entry = entry.map_err(|_| PackageDigestError::Unreadable)?;
103 let path = entry.path();
104 let name = path
105 .file_name()
106 .and_then(|s| s.to_str())
107 .ok_or(PackageDigestError::Unreadable)?;
108
109 let meta = fs::symlink_metadata(&path).map_err(|_| PackageDigestError::Unreadable)?;
110 if meta.file_type().is_symlink() {
111 return Err(PackageDigestError::SymlinkPresent);
112 }
113
114 // Only local bookkeeping at the package root is outside the receipt.
115 // Hidden files and backup/temp names can contain executable payloads.
116 if depth == 0 && is_reserved_root_metadata(Path::new(name)) {
117 if meta.is_file()
118 && [
119 INSTALLED_FROM_MARKER,
120 TRUSTED_MARKER,
121 ".system-installed-version",
122 ]
123 .contains(&name)
124 {
125 continue;
126 }
127 // A variant may be an ordinary payload on a case-sensitive disk
128 // or alias the receipt itself on another disk. Refuse ambiguity:
129 // the owner must rename it before the package can be trusted.
130 return Err(PackageDigestError::Unreadable);
131 }
132
133 if meta.is_dir() {
134 walk(&path, package_root, depth + 1, visited, files, total_bytes)?;
135 continue;
136 }
137 if !meta.is_file() {
138 return Err(PackageDigestError::Unreadable);
139 }
140 if files.len() >= PACKAGE_DIGEST_MAX_FILES {
141 return Err(PackageDigestError::TooManyFiles);
142 }
143 let remaining = PACKAGE_DIGEST_MAX_BYTES.saturating_sub(*total_bytes);
144 if meta.len() > remaining {
145 return Err(PackageDigestError::Oversized);
146 }
147 let mut file = fs::File::open(&path).map_err(|_| PackageDigestError::Unreadable)?;
148 let bytes = read_package_file(&mut file, remaining)?;
149 *total_bytes += bytes.len() as u64;
150 let rel = path
151 .strip_prefix(package_root)
152 .map_err(|_| PackageDigestError::EscapedRoot)?
153 .components()
154 .map(|component| {
155 component
156 .as_os_str()
157 .to_str()
158 .ok_or(PackageDigestError::Unreadable)
159 })
160 .collect::<Result<Vec<_>, _>>()?
161 .join("/");
162 files.push((rel, bytes));
163 }
164 Ok(())
165 }
166
167 fn read_package_file(file: &mut fs::File, remaining: u64) -> Result<Vec<u8>, PackageDigestError> {
168 // Metadata is only an early check: a package file may grow before reading.
169 let mut bytes = Vec::new();
170 file.take(remaining + 1)
171 .read_to_end(&mut bytes)
172 .map_err(|_| PackageDigestError::Unreadable)?;
173 if bytes.len() as u64 > remaining {
174 return Err(PackageDigestError::Oversized);
175 }
176 Ok(bytes)
177 }
178
179 fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
180 let bytes = bytes.as_ref();
181 let mut out = String::with_capacity(bytes.len() * 2);
182 for byte in bytes {
183 use std::fmt::Write as _;
184 let _ = write!(&mut out, "{byte:02x}");
185 }
186 out
187 }
188
189 #[cfg(test)]
190 mod tests {
191 use super::*;
192
193 #[test]
194 fn relative_roots_keep_nested_paths_and_match_canonical_roots() {
195 let cwd = std::env::current_dir().unwrap();
196 let tmp = tempfile::tempdir_in(&cwd).unwrap();
197 let relative = tmp.path().strip_prefix(&cwd).unwrap();
198 assert!(!relative.is_absolute());
199 fs::create_dir(relative.join("before")).unwrap();
200 fs::create_dir(relative.join("after")).unwrap();
201 fs::write(relative.join("before/payload.txt"), b"same bytes").unwrap();
202 let canonical = fs::canonicalize(relative).unwrap();
203
204 let before = compute_package_digest(relative).unwrap();
205 assert_eq!(before, compute_package_digest(&canonical).unwrap());
206 fs::rename(
207 relative.join("before/payload.txt"),
208 relative.join("after/payload.txt"),
209 )
210 .unwrap();
211 let after = compute_package_digest(relative).unwrap();
212 assert_ne!(before, after);
213 assert_eq!(after, compute_package_digest(&canonical).unwrap());
214 }
215
216 #[cfg(unix)]
217 #[test]
218 fn aliased_ancestors_keep_nested_paths_but_symlink_package_roots_are_refused() {
219 use std::os::unix::fs::symlink;
220
221 let tmp = tempfile::tempdir().unwrap();
222 let parent = tmp.path().join("real");
223 let package = parent.join("package");
224 fs::create_dir_all(package.join("before")).unwrap();
225 fs::create_dir(package.join("after")).unwrap();
226 fs::write(package.join("before/payload.txt"), b"same bytes").unwrap();
227 let alias = tmp.path().join("alias");
228 symlink(&parent, &alias).unwrap();
229 let aliased_package = alias.join("package");
230 let canonical = fs::canonicalize(&package).unwrap();
231
232 let before = compute_package_digest(&aliased_package).unwrap();
233 assert_eq!(before, compute_package_digest(&canonical).unwrap());
234 fs::rename(
235 package.join("before/payload.txt"),
236 package.join("after/payload.txt"),
237 )
238 .unwrap();
239 let after = compute_package_digest(&aliased_package).unwrap();
240 assert_ne!(before, after);
241 assert_eq!(after, compute_package_digest(&canonical).unwrap());
242
243 let root_link = tmp.path().join("package-link");
244 symlink(&package, &root_link).unwrap();
245 assert_eq!(
246 compute_package_digest(&root_link),
247 Err(PackageDigestError::SymlinkPresent)
248 );
249 }
250
251 #[cfg(unix)]
252 #[test]
253 fn literal_backslash_filename_differs_from_nested_path() {
254 let tmp = tempfile::tempdir().unwrap();
255 let package = fs::canonicalize(tmp.path()).unwrap();
256 fs::create_dir(package.join("a")).unwrap();
257 let literal = package.join(r"a\b");
258 fs::write(&literal, b"same bytes").unwrap();
259 let before = compute_package_digest(&package).unwrap();
260
261 fs::rename(literal, package.join("a/b")).unwrap();
262 assert_ne!(before, compute_package_digest(&package).unwrap());
263 }
264
265 #[test]
266 fn case_variant_metadata_fails_closed_instead_of_omitting_payload_or_hashing_itself() {
267 let tmp = tempfile::tempdir().unwrap();
268 fs::write(tmp.path().join(".TRUSTED"), b"payload or aliased receipt").unwrap();
269 assert_eq!(
270 compute_package_digest(tmp.path()),
271 Err(PackageDigestError::Unreadable)
272 );
273 }
274
275 #[test]
276 fn reserved_metadata_directories_cannot_hide_payload() {
277 let tmp = tempfile::tempdir().unwrap();
278 for marker in [
279 INSTALLED_FROM_MARKER,
280 TRUSTED_MARKER,
281 ".system-installed-version",
282 ] {
283 let path = tmp.path().join(marker);
284 fs::create_dir(&path).unwrap();
285 fs::write(path.join("payload"), b"must not be omitted").unwrap();
286 assert_eq!(
287 compute_package_digest(tmp.path()),
288 Err(PackageDigestError::Unreadable)
289 );
290 fs::remove_dir_all(path).unwrap();
291 }
292 }
293
294 // Linux permits non-UTF-8 filenames; macOS APFS rejects the fixture itself.
295 #[cfg(target_os = "linux")]
296 #[test]
297 fn non_utf8_payload_names_fail_closed() {
298 use std::os::unix::ffi::OsStringExt as _;
299 let tmp = tempfile::tempdir().unwrap();
300 let name = std::ffi::OsString::from_vec(b"payload-\xff".to_vec());
301 fs::write(tmp.path().join(name), b"payload").unwrap();
302 assert_eq!(
303 compute_package_digest(tmp.path()),
304 Err(PackageDigestError::Unreadable)
305 );
306 }
307
308 #[cfg(unix)]
309 #[test]
310 fn non_regular_package_entries_fail_closed() {
311 let tmp = tempfile::tempdir().unwrap();
312 let _socket = std::os::unix::net::UnixListener::bind(tmp.path().join("socket")).unwrap();
313 assert_eq!(
314 compute_package_digest(tmp.path()),
315 Err(PackageDigestError::Unreadable)
316 );
317 }
318
319 #[test]
320 fn bounded_read_rejects_growth_after_metadata_without_reading_the_whole_file() {
321 use std::io::Seek as _;
322 let tmp = tempfile::tempdir().unwrap();
323 let path = tmp.path().join("growing");
324 fs::write(&path, b"ok").unwrap();
325 let allowed = fs::metadata(&path).unwrap().len();
326 fs::write(&path, b"grew beyond the remaining budget").unwrap();
327 let mut file = fs::File::open(path).unwrap();
328 assert_eq!(
329 read_package_file(&mut file, allowed),
330 Err(PackageDigestError::Oversized)
331 );
332 assert_eq!(file.stream_position().unwrap(), allowed + 1);
333 }
334
335 #[test]
336 fn bounded_read_accepts_exact_remaining_bytes() {
337 // A named file in a temp dir, like the neighbouring test: Windows CI
338 // denies the anonymous `tempfile()` under the hermetic test home.
339 let tmp = tempfile::tempdir().unwrap();
340 let path = tmp.path().join("exact");
341 fs::write(&path, b"exact").unwrap();
342 let mut file = fs::File::open(path).unwrap();
343 assert_eq!(read_package_file(&mut file, 5).unwrap(), b"exact");
344 }
345 }
346
346 lines RUST