Skip to main content

oxicode/foundation/
packages.rs

1//! `packages.lock` parsing, digest verification, and capability mapping.
2//!
3//! A foundation package is a verified, immutable record. oxicode
4//! reads the lockfile, verifies each package's on-disk content
5//! against the recorded digest, and decides (a) whether the package
6//! is eligible to load and (b) which of oxicode's existing policy
7//! gates must approve the declared requirements.
8
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use super::FoundationError;
14
15/// Abstract requirement declared by a foundation package. oxicode
16/// maps these to its existing policy in [`map_requirement`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Requirement {
20    /// Read-only filesystem access within the workspace.
21    WorkspaceRead,
22    /// Patching files inside the workspace.
23    WorkspacePatch,
24    /// Executing shell commands.
25    ShellExecute,
26    /// Browser navigation (native-browser feature).
27    BrowserNavigate,
28    /// Brain-backed retrieval (oxibrain).
29    BrainQuery,
30    /// Schedule management.
31    ScheduleManage,
32}
33
34impl Requirement {
35    /// Parse a dotted string.
36    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/// Trust decision recorded in the lockfile. Anything other than
61/// `verified` is rejected at load time.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Trust {
65    Verified,
66    Unverified,
67}
68
69/// A single resolved package record.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct LockedPackage {
72    pub name: String,
73    pub version: String,
74    /// `sha256-<hex>`.
75    pub digest: String,
76    pub source: String,
77    pub trust: Trust,
78    /// Host ids the package claims to work with. MUST include `oxicode`.
79    pub targets: Vec<String>,
80    /// Abstract requirements; empty if the package has none.
81    #[serde(default)]
82    pub requirements: Vec<String>,
83}
84
85/// Typed `packages.lock`.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct PackagesFile {
88    pub schema_version: u32,
89    #[serde(default)]
90    pub packages: Vec<LockedPackage>,
91}
92
93/// Result of mapping a package requirement to oxicode's policy.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CapabilityDecision {
96    /// Allow the requirement.
97    Allow,
98    /// Deny the requirement. The package must be rejected.
99    Deny(&'static str),
100    /// The requirement is host-supplied (e.g. `brain.query`) and the
101    /// host feature isn't enabled. Either reject or surface a
102    /// user-visible hint.
103    Unsupported(&'static str),
104}
105
106/// Read and verify `packages.lock`. The on-disk content under
107/// `packages_root` is verified against the declared digests.
108pub 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        // Verify the package content on disk. Path is
133        // `packages_root/<sha256>/`, where `<sha256>` is the hex
134        // part of the digest.
135        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
159/// Compute a `sha256-<hex>` digest over the package content. The
160/// order is stable: filenames are sorted lexicographically.
161fn 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            // Skip the manifest filename to keep the digest stable
186            // across reinstalls that share the same content.
187            if name == "MANIFEST" || name == "manifest.json" {
188                continue;
189            }
190            out.push(path);
191        }
192    }
193}
194
195/// Map a parsed requirement to oxicode's policy. The decision is
196/// read-only — it does not mutate state. The caller is responsible
197/// for translating `Allow` into the right port wiring.
198pub 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        // The on-disk hash will be computed; we just need a directory
292        // whose name matches the digest so the check actually runs.
293        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        // Compute the real digest of the content we wrote.
322        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}