1use crate::app::config::AppLockPackage;
2use crate::app::generation::ValidationResult;
3use crate::app::policy::AppProfile;
4use sha2::{Digest, Sha512};
5use std::path::{Path, PathBuf};
6
7const STORE_CHECK_PREFIX: &str = "store-policy:";
8
9pub fn verify_local_store_packages(
10 instance_dir: Option<&Path>,
11 packages: &[AppLockPackage],
12 profile: AppProfile,
13) -> Vec<ValidationResult> {
14 packages
15 .iter()
16 .map(|package| verify_local_store_package(instance_dir, package, profile))
17 .collect()
18}
19
20pub fn is_store_check(result: &ValidationResult) -> bool {
21 result.check.starts_with(STORE_CHECK_PREFIX)
22}
23
24fn verify_local_store_package(
25 instance_dir: Option<&Path>,
26 package: &AppLockPackage,
27 profile: AppProfile,
28) -> ValidationResult {
29 let identity = package.identity();
30 let check = format!("{STORE_CHECK_PREFIX}{identity}");
31 let metadata_present = !package.store_path.trim().is_empty();
32
33 if !metadata_present {
34 return ValidationResult {
35 check,
36 passed: true,
37 message: format!(
38 "Warning: package '{}' has no explicit local store path metadata in the generation lock; offline store verification was skipped for backward compatibility under profile '{}'",
39 identity,
40 profile.as_str()
41 ),
42 };
43 }
44
45 let store_path = package.store_path.trim();
46
47 let resolved_path = resolve_store_path(instance_dir, store_path);
48 let resolved_display = resolved_path.display().to_string();
49 if !resolved_path.exists() {
50 return policy_result(
51 profile,
52 check,
53 format!(
54 "Package '{}' requires local store object '{}' at '{}' but the path is missing",
55 identity, package.store_object_id, resolved_display
56 ),
57 format!(
58 "Warning: package '{}' requires local store object '{}' at '{}' but the path is missing; developer profile surfaced the offline store verification warning without failing activation",
59 identity, package.store_object_id, resolved_display
60 ),
61 );
62 }
63
64 let expected_digest = expected_store_sha512(package);
65 match expected_digest {
66 Some(expected) => match compute_store_sha512(&resolved_path) {
67 Ok(actual) if actual == expected => ValidationResult {
68 check,
69 passed: true,
70 message: format!(
71 "Package '{}' local store path '{}' exists and matches expected store digest '{}{}'",
72 identity,
73 resolved_display,
74 if package.store_object_id.starts_with("store:sha512:") {
75 "store:sha512:"
76 } else {
77 ""
78 },
79 expected
80 ),
81 },
82 Ok(actual) => policy_result(
83 profile,
84 check,
85 format!(
86 "Package '{}' local store path '{}' exists but its computed sha512 '{}' does not match expected '{}'",
87 identity, resolved_display, actual, expected
88 ),
89 format!(
90 "Warning: package '{}' local store path '{}' exists but its computed sha512 '{}' does not match expected '{}'; developer profile surfaced the mismatch without failing activation",
91 identity, resolved_display, actual, expected
92 ),
93 ),
94 Err(error) => policy_result(
95 profile,
96 check,
97 format!(
98 "Package '{}' local store path '{}' exists but sha512 verification failed: {}",
99 identity, resolved_display, error
100 ),
101 format!(
102 "Warning: package '{}' local store path '{}' exists but sha512 verification failed: {}; developer profile surfaced the verification issue without failing activation",
103 identity, resolved_display, error
104 ),
105 ),
106 },
107 None => ValidationResult {
108 check,
109 passed: true,
110 message: format!(
111 "Package '{}' local store path '{}' exists; no comparable sha512 store digest metadata was present, so offline verification recorded existence only",
112 identity, resolved_display
113 ),
114 },
115 }
116}
117
118fn policy_result(
119 profile: AppProfile,
120 check: String,
121 strict_message: String,
122 developer_message: String,
123) -> ValidationResult {
124 match profile {
125 AppProfile::Developer => ValidationResult {
126 check,
127 passed: true,
128 message: developer_message,
129 },
130 AppProfile::Safe | AppProfile::Trusted => ValidationResult {
131 check,
132 passed: false,
133 message: strict_message,
134 },
135 }
136}
137
138fn expected_store_sha512(package: &AppLockPackage) -> Option<String> {
139 if let Some(value) = package.store_object_id.trim().strip_prefix("store:sha512:") {
140 if is_valid_sha512_hex(value) {
141 return Some(value.to_string());
142 }
143 }
144
145 let sha512 = package.sha512.trim();
146 if is_valid_sha512_hex(sha512) {
147 return Some(sha512.to_string());
148 }
149
150 None
151}
152
153fn is_valid_sha512_hex(value: &str) -> bool {
154 value.len() == 128 && value.chars().all(|c| c.is_ascii_hexdigit())
155}
156
157fn resolve_store_path(instance_dir: Option<&Path>, store_path: &str) -> PathBuf {
158 let store_path = Path::new(store_path);
159 if store_path.is_absolute() || instance_dir.is_none() {
160 return store_path.to_path_buf();
161 }
162
163 let instance_dir = instance_dir.expect("checked above");
164 let mut candidates = vec![instance_dir.join(store_path)];
165 if let Some(weft_dir) = instance_dir.parent() {
166 candidates.push(weft_dir.join(store_path));
167 if let Some(repo_root) = weft_dir.parent() {
168 candidates.push(repo_root.join(store_path));
169 }
170 }
171
172 candidates
173 .iter()
174 .find(|candidate| candidate.exists())
175 .cloned()
176 .unwrap_or_else(|| {
177 if store_path
178 .components()
179 .next()
180 .is_some_and(|component| component.as_os_str() == ".weft")
181 {
182 if let Some(repo_root) = instance_dir.parent().and_then(Path::parent) {
183 return repo_root.join(store_path);
184 }
185 }
186
187 instance_dir.join(store_path)
188 })
189}
190
191fn compute_store_sha512(path: &Path) -> Result<String, std::io::Error> {
192 let mut hasher = Sha512::new();
193
194 fn update_dir(hasher: &mut Sha512, root: &Path, path: &Path) -> Result<(), std::io::Error> {
195 let mut entries = std::fs::read_dir(path)?
196 .filter_map(|entry| entry.ok())
197 .map(|entry| entry.path())
198 .collect::<Vec<_>>();
199 entries.sort();
200
201 for entry in entries {
202 let relative = entry
203 .strip_prefix(root)
204 .unwrap_or(&entry)
205 .to_string_lossy()
206 .replace('\\', "/");
207 if entry.is_dir() {
208 hasher.update(relative.as_bytes());
209 update_dir(hasher, root, &entry)?;
210 } else if entry.is_file() {
211 hasher.update(relative.as_bytes());
212 hasher.update(std::fs::read(&entry)?);
213 }
214 }
215
216 Ok(())
217 }
218
219 if path.is_file() {
220 hasher.update(std::fs::read(path)?);
221 } else if path.is_dir() {
222 update_dir(&mut hasher, path, path)?;
223 }
224
225 Ok(format!("{:x}", hasher.finalize()))
226}
227
228#[cfg(test)]
229mod tests {
230 use super::verify_local_store_packages;
231 use crate::app::{AppLockEvidence, AppLockPackage, AppProfile};
232 use sha2::{Digest, Sha512};
233 use tempfile::tempdir;
234
235 fn store_package(
236 name: &str,
237 store_object_id: &str,
238 store_path: &str,
239 sha512: &str,
240 ) -> AppLockPackage {
241 AppLockPackage {
242 name: name.into(),
243 version: "0.1.0".into(),
244 store_object_id: store_object_id.into(),
245 store_path: store_path.into(),
246 sha512: sha512.into(),
247 evidence: AppLockEvidence::default(),
248 ..AppLockPackage::default()
249 }
250 }
251
252 #[test]
253 fn existing_store_path_passes_when_sha512_matches() {
254 let root = tempdir().expect("tempdir");
255 let instance_dir = root.path().join(".weft").join("weft-claw");
256 let store_path = root
257 .path()
258 .join(".weft")
259 .join("store")
260 .join("agent-runtime-0.1.0");
261 std::fs::create_dir_all(store_path.parent().expect("parent")).expect("store dir");
262 std::fs::write(&store_path, b"store-bytes").expect("store file");
263 let digest = format!("{:x}", Sha512::digest(b"store-bytes"));
264
265 let results = verify_local_store_packages(
266 Some(&instance_dir),
267 &[store_package(
268 "agent-runtime",
269 &format!("store:sha512:{digest}"),
270 ".weft/store/agent-runtime-0.1.0",
271 &digest,
272 )],
273 AppProfile::Safe,
274 );
275
276 assert_eq!(results.len(), 1);
277 assert!(results[0].passed);
278 assert!(results[0].message.contains("matches expected store digest"));
279 }
280
281 #[test]
282 fn missing_store_path_fails_for_safe_and_trusted_profiles() {
283 let root = tempdir().expect("tempdir");
284 let instance_dir = root.path().join(".weft").join("weft-claw");
285 let package = store_package(
286 "agent-runtime",
287 "store:sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
288 ".weft/store/missing-agent-runtime-0.1.0",
289 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
290 );
291
292 for profile in [AppProfile::Safe, AppProfile::Trusted] {
293 let results = verify_local_store_packages(
294 Some(&instance_dir),
295 std::slice::from_ref(&package),
296 profile,
297 );
298 assert!(!results[0].passed);
299 assert!(results[0].message.contains("path is missing"));
300 }
301 }
302
303 #[test]
304 fn developer_missing_store_path_warns_without_failing() {
305 let root = tempdir().expect("tempdir");
306 let instance_dir = root.path().join(".weft").join("weft-claw");
307 let results = verify_local_store_packages(
308 Some(&instance_dir),
309 &[store_package(
310 "agent-runtime",
311 "store:sha512:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
312 ".weft/store/missing-agent-runtime-0.1.0",
313 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
314 )],
315 AppProfile::Developer,
316 );
317
318 assert!(results[0].passed);
319 assert!(results[0].message.contains("Warning:"));
320 assert!(results[0].message.contains("missing"));
321 }
322
323 #[test]
324 fn absent_store_metadata_stays_backward_compatible_warning() {
325 let results = verify_local_store_packages(
326 None,
327 &[store_package("agent-runtime", "", "", "")],
328 AppProfile::Trusted,
329 );
330
331 assert!(results[0].passed);
332 assert!(results[0].message.contains("Warning:"));
333 assert!(results[0].message.contains("backward compatibility"));
334 }
335}