返回 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::path::{Path, PathBuf};
9
10 use sha2::{Digest, Sha256};
11
12 use super::install::{DEFAULT_MAX_SIZE_BYTES, INSTALLED_FROM_MARKER, TRUSTED_MARKER};
13
14 pub const PACKAGE_DIGEST_MAX_BYTES: u64 = DEFAULT_MAX_SIZE_BYTES;
15 pub const PACKAGE_DIGEST_MAX_FILES: usize = 256;
16 pub const PACKAGE_DIGEST_MAX_DEPTH: usize = 8;
17
18 #[derive(Debug, Clone, PartialEq, Eq)]
19 pub enum PackageDigestError {
20 Unreadable,
21 SymlinkPresent,
22 EscapedRoot,
23 Cycle,
24 Oversized,
25 TooManyFiles,
26 TooDeep,
27 }
28
29 impl std::fmt::Display for PackageDigestError {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.write_str(match self {
32 Self::Unreadable => "unreadable package file",
33 Self::SymlinkPresent => "symlink present in package",
34 Self::EscapedRoot => "path escaped package root",
35 Self::Cycle => "symlink/directory cycle",
36 Self::Oversized => "package exceeded size limit",
37 Self::TooManyFiles => "package exceeded file limit",
38 Self::TooDeep => "package exceeded depth limit",
39 })
40 }
41 }
42
43 impl std::error::Error for PackageDigestError {}
44
45 /// SHA-256 hex of the normalized package manifest (relative path + len + bytes).
46 pub fn compute_package_digest(package_dir: &Path) -> Result<String, PackageDigestError> {
47 let canonical_package =
48 fs::canonicalize(package_dir).map_err(|_| PackageDigestError::Unreadable)?;
49
50 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
51 let mut total_bytes: u64 = 0;
52 let mut visited = HashSet::new();
53
54 walk(
55 package_dir,
56 &canonical_package,
57 0,
58 &mut visited,
59 &mut files,
60 &mut total_bytes,
61 )?;
62
63 files.sort_by(|a, b| a.0.cmp(&b.0));
64 let mut hasher = Sha256::new();
65 for (rel, bytes) in &files {
66 hasher.update(rel.as_bytes());
67 hasher.update(b"\0");
68 hasher.update((bytes.len() as u64).to_le_bytes());
69 hasher.update(bytes);
70 }
71 Ok(hex_digest(hasher.finalize()))
72 }
73
74 fn walk(
75 dir: &Path,
76 package_root: &Path,
77 depth: usize,
78 visited: &mut HashSet<PathBuf>,
79 files: &mut Vec<(String, Vec<u8>)>,
80 total_bytes: &mut u64,
81 ) -> Result<(), PackageDigestError> {
82 if depth > PACKAGE_DIGEST_MAX_DEPTH {
83 return Err(PackageDigestError::TooDeep);
84 }
85 let meta = fs::symlink_metadata(dir).map_err(|_| PackageDigestError::Unreadable)?;
86 if meta.file_type().is_symlink() {
87 return Err(PackageDigestError::SymlinkPresent);
88 }
89 let canonical = fs::canonicalize(dir).map_err(|_| PackageDigestError::Unreadable)?;
90 if !canonical.starts_with(package_root) {
91 return Err(PackageDigestError::EscapedRoot);
92 }
93 if !visited.insert(canonical) {
94 return Err(PackageDigestError::Cycle);
95 }
96
97 let entries = fs::read_dir(dir).map_err(|_| PackageDigestError::Unreadable)?;
98 for entry in entries.flatten() {
99 let path = entry.path();
100 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
101 continue;
102 };
103
104 let meta = fs::symlink_metadata(&path).map_err(|_| PackageDigestError::Unreadable)?;
105 if meta.file_type().is_symlink() {
106 return Err(PackageDigestError::SymlinkPresent);
107 }
108
109 if name == INSTALLED_FROM_MARKER
110 || name == TRUSTED_MARKER
111 || name == ".system-installed-version"
112 || name.ends_with(".bak")
113 || name.ends_with(".tmp")
114 || name.starts_with('.')
115 {
116 continue;
117 }
118
119 if meta.is_dir() {
120 walk(&path, package_root, depth + 1, visited, files, total_bytes)?;
121 continue;
122 }
123 if !meta.is_file() {
124 continue;
125 }
126 if files.len() >= PACKAGE_DIGEST_MAX_FILES {
127 return Err(PackageDigestError::TooManyFiles);
128 }
129 let len = meta.len();
130 if *total_bytes + len > PACKAGE_DIGEST_MAX_BYTES {
131 return Err(PackageDigestError::Oversized);
132 }
133 let bytes = fs::read(&path).map_err(|_| PackageDigestError::Unreadable)?;
134 *total_bytes += bytes.len() as u64;
135 let rel = path
136 .strip_prefix(package_root)
137 .map(|p| p.to_string_lossy().replace('\\', "/"))
138 .unwrap_or_else(|_| name.to_string());
139 files.push((rel, bytes));
140 }
141 Ok(())
142 }
143
144 fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
145 let bytes = bytes.as_ref();
146 let mut out = String::with_capacity(bytes.len() * 2);
147 for byte in bytes {
148 use std::fmt::Write as _;
149 let _ = write!(&mut out, "{byte:02x}");
150 }
151 out
152 }
153
153 lines RUST