1use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use super::FoundationError;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Requirement {
20 WorkspaceRead,
22 WorkspacePatch,
24 ShellExecute,
26 BrowserNavigate,
28 BrainQuery,
30 ScheduleManage,
32}
33
34impl Requirement {
35 pub fn parse(s: &str) -> Option<Self> {
37 match s {
38 "workspace.read" => Some(Self::WorkspaceRead),
39 "workspace.patch" => Some(Self::WorkspacePatch),
40 "shell.execute" => Some(Self::ShellExecute),
41 "browser.navigate" => Some(Self::BrowserNavigate),
42 "brain.query" => Some(Self::BrainQuery),
43 "schedule.manage" => Some(Self::ScheduleManage),
44 _ => None,
45 }
46 }
47
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 Self::WorkspaceRead => "workspace.read",
51 Self::WorkspacePatch => "workspace.patch",
52 Self::ShellExecute => "shell.execute",
53 Self::BrowserNavigate => "browser.navigate",
54 Self::BrainQuery => "brain.query",
55 Self::ScheduleManage => "schedule.manage",
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Trust {
65 Verified,
66 Unverified,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct LockedPackage {
72 pub name: String,
73 pub version: String,
74 pub digest: String,
76 pub source: String,
77 pub trust: Trust,
78 pub targets: Vec<String>,
80 #[serde(default)]
82 pub requirements: Vec<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PackagesFile {
88 pub schema_version: u32,
89 #[serde(default)]
90 pub packages: Vec<LockedPackage>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CapabilityDecision {
96 Allow,
98 Deny(&'static str),
100 Unsupported(&'static str),
104}
105
106pub fn read(lock: &Path, packages_root: &Path) -> Result<PackagesFile, FoundationError> {
109 let raw = std::fs::read_to_string(lock)?;
110 let parsed: PackagesFile = serde_json::from_str(&raw)?;
111 if parsed.schema_version != 1 {
112 return Err(FoundationError::UnsupportedSchema(parsed.schema_version));
113 }
114 for p in &parsed.packages {
115 if !p.targets.iter().any(|t| t == "oxicode") {
116 return Err(FoundationError::TargetMismatch {
117 package: p.name.clone(),
118 targets: p.targets.clone(),
119 });
120 }
121 if !matches!(p.trust, Trust::Verified) {
122 return Err(FoundationError::Parse(format!(
123 "package {} trust is not `verified`",
124 p.name
125 )));
126 }
127 for r in &p.requirements {
128 if Requirement::parse(r).is_none() {
129 return Err(FoundationError::UnsupportedRequirement(r.clone()));
130 }
131 }
132 let hex = p
136 .digest
137 .strip_prefix("sha256-")
138 .ok_or_else(|| FoundationError::Parse(format!("bad digest format: {}", p.digest)))?;
139 let content_dir = packages_root.join(hex);
140 if !content_dir.is_dir() {
141 return Err(FoundationError::DigestMismatch {
142 package: p.name.clone(),
143 expected: p.digest.clone(),
144 actual: "missing".to_string(),
145 });
146 }
147 let actual = compute_dir_digest(&content_dir).unwrap_or_else(|| "missing".to_string());
148 if !actual.eq_ignore_ascii_case(&p.digest) {
149 return Err(FoundationError::DigestMismatch {
150 package: p.name.clone(),
151 expected: p.digest.clone(),
152 actual,
153 });
154 }
155 }
156 Ok(parsed)
157}
158
159fn compute_dir_digest(dir: &Path) -> Option<String> {
162 use sha2::{Digest, Sha256};
163 let mut paths = Vec::new();
164 collect_paths(dir, &mut paths);
165 paths.sort();
166 let mut hasher = Sha256::new();
167 for p in paths {
168 if let Ok(content) = std::fs::read(p) {
169 hasher.update(&content);
170 }
171 }
172 let result = hasher.finalize();
173 Some(format!("sha256-{result:x}"))
174}
175
176fn collect_paths(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
177 let Ok(entries) = std::fs::read_dir(dir) else {
178 return;
179 };
180 for entry in entries.flatten() {
181 let path = entry.path();
182 if path.is_dir() {
183 collect_paths(&path, out);
184 } else if let Some(name) = path.file_name() {
185 if name == "MANIFEST" || name == "manifest.json" {
188 continue;
189 }
190 out.push(path);
191 }
192 }
193}
194
195pub fn map_requirement(req: Requirement) -> CapabilityDecision {
199 match req {
200 Requirement::WorkspaceRead => CapabilityDecision::Allow,
201 Requirement::WorkspacePatch => CapabilityDecision::Allow,
202 Requirement::ShellExecute => CapabilityDecision::Allow,
203 Requirement::BrowserNavigate => CapabilityDecision::Allow,
204 Requirement::BrainQuery => CapabilityDecision::Allow,
205 Requirement::ScheduleManage => CapabilityDecision::Allow,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn pkg(name: &str, digest: &str, requirements: &[&str]) -> LockedPackage {
214 LockedPackage {
215 name: name.to_string(),
216 version: "1.0.0".to_string(),
217 digest: digest.to_string(),
218 source: "foundation".to_string(),
219 trust: Trust::Verified,
220 targets: vec!["oxicode".to_string()],
221 requirements: requirements.iter().map(|s| s.to_string()).collect(),
222 }
223 }
224
225 #[test]
226 fn parse_requirement_roundtrip() {
227 for r in [
228 Requirement::WorkspaceRead,
229 Requirement::WorkspacePatch,
230 Requirement::ShellExecute,
231 Requirement::BrowserNavigate,
232 Requirement::BrainQuery,
233 Requirement::ScheduleManage,
234 ] {
235 assert_eq!(Requirement::parse(r.as_str()), Some(r));
236 }
237 }
238
239 #[test]
240 fn parse_requirement_unknown() {
241 assert_eq!(Requirement::parse(""), None);
242 assert_eq!(Requirement::parse("fs.read"), None);
243 }
244
245 #[test]
246 fn rejects_missing_target() {
247 let tmp = tempfile::tempdir().unwrap();
248 let lock = tmp.path().join("packages.lock");
249 let content_dir = tmp.path().join("packages");
250 std::fs::create_dir(&content_dir).unwrap();
251 let p = LockedPackage {
252 name: "x".to_string(),
253 version: "1.0.0".to_string(),
254 digest: "sha256-deadbeef".to_string(),
255 source: "foundation".to_string(),
256 trust: Trust::Verified,
257 targets: vec!["oxibrain".to_string()],
258 requirements: vec![],
259 };
260 let file = PackagesFile {
261 schema_version: 1,
262 packages: vec![p],
263 };
264 std::fs::write(&lock, serde_json::to_string(&file).unwrap()).unwrap();
265 let err = read(&lock, &content_dir).unwrap_err();
266 assert!(matches!(err, FoundationError::TargetMismatch { .. }));
267 }
268
269 #[test]
270 fn rejects_unknown_requirement() {
271 let tmp = tempfile::tempdir().unwrap();
272 let lock = tmp.path().join("packages.lock");
273 let content_dir = tmp.path().join("packages");
274 std::fs::create_dir(&content_dir).unwrap();
275 let mut p = pkg("x", "sha256-deadbeef", &["workspace.invalid"]);
276 p.targets = vec!["oxicode".to_string()];
277 let file = PackagesFile {
278 schema_version: 1,
279 packages: vec![p],
280 };
281 std::fs::write(&lock, serde_json::to_string(&file).unwrap()).unwrap();
282 let err = read(&lock, &content_dir).unwrap_err();
283 assert!(matches!(err, FoundationError::UnsupportedRequirement(_)));
284 }
285
286 #[test]
287 fn rejects_digest_mismatch() {
288 let tmp = tempfile::tempdir().unwrap();
289 let lock = tmp.path().join("packages.lock");
290 let content_dir = tmp.path().join("packages");
291 let hex = "f000000000000000000000000000000000000000000000000000000000000000";
294 let pkg_dir = content_dir.join(hex);
295 std::fs::create_dir_all(&pkg_dir).unwrap();
296 std::fs::write(pkg_dir.join("file.txt"), "hello").unwrap();
297 let p = LockedPackage {
298 name: "x".to_string(),
299 version: "1.0.0".to_string(),
300 digest: format!("sha256-{hex}"),
301 source: "foundation".to_string(),
302 trust: Trust::Verified,
303 targets: vec!["oxicode".to_string()],
304 requirements: vec![],
305 };
306 let file = PackagesFile {
307 schema_version: 1,
308 packages: vec![p],
309 };
310 std::fs::write(&lock, serde_json::to_string(&file).unwrap()).unwrap();
311 let err = read(&lock, &content_dir).unwrap_err();
312 assert!(matches!(err, FoundationError::DigestMismatch { .. }));
313 }
314
315 #[test]
316 fn accepts_valid_package() {
317 let tmp = tempfile::tempdir().unwrap();
318 let lock = tmp.path().join("packages.lock");
319 let content_dir = tmp.path().join("packages");
320 std::fs::create_dir(&content_dir).unwrap();
321 let content = b"hello".to_vec();
323 use sha2::{Digest, Sha256};
324 let actual = format!("sha256-{:x}", Sha256::digest(&content));
325 let hex = actual.strip_prefix("sha256-").unwrap().to_string();
326 let pkg_dir = content_dir.join(hex);
327 std::fs::create_dir_all(&pkg_dir).unwrap();
328 std::fs::write(pkg_dir.join("file.txt"), &content).unwrap();
329 let p = LockedPackage {
330 name: "x".to_string(),
331 version: "1.0.0".to_string(),
332 digest: actual,
333 source: "foundation".to_string(),
334 trust: Trust::Verified,
335 targets: vec!["oxicode".to_string()],
336 requirements: vec!["workspace.read".to_string(), "brain.query".to_string()],
337 };
338 let file = PackagesFile {
339 schema_version: 1,
340 packages: vec![p],
341 };
342 std::fs::write(&lock, serde_json::to_string(&file).unwrap()).unwrap();
343 let parsed = read(&lock, &content_dir).unwrap();
344 assert_eq!(parsed.packages.len(), 1);
345 assert_eq!(parsed.packages[0].name, "x");
346 }
347
348 #[test]
349 fn map_requirement_allows_known() {
350 for r in [
351 Requirement::WorkspaceRead,
352 Requirement::WorkspacePatch,
353 Requirement::ShellExecute,
354 Requirement::BrowserNavigate,
355 Requirement::BrainQuery,
356 Requirement::ScheduleManage,
357 ] {
358 assert_eq!(map_requirement(r), CapabilityDecision::Allow);
359 }
360 }
361}