Skip to main content

weft_core/app/
store.rs

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}