Skip to main content

oxicode/storage/packages/
lockfile.rs

1//! Lockfile types and integrity helpers.
2//!
3//! `Lockfile` is the on-disk record (`oxicode-lock.json`) that pins every
4//! installed package to its exact source, version, scope, and a SHA-256
5//! integrity hash. `ResourceCounts` is a small summary struct exposed
6//! for the CLI's list output. The SHA-256 helpers (`compute_dir_hash`,
7//! `verify_lockfile_integrity`, `collect_file_paths`) live here too —
8//! they're lockfile-integrity machinery, not generic FS utilities.
9
10use super::types::SourceScope;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::BTreeMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18/// Lockfile entry for an installed package
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct LockEntry {
21    /// Source specifier
22    pub source: String,
23    /// Package name
24    pub name: String,
25    /// Resolved version or ref
26    pub version: String,
27    /// Integrity hash (sha256)
28    pub integrity: Option<String>,
29    /// Scope
30    pub scope: SourceScope,
31    /// Type of source
32    pub source_type: String,
33    /// Dependencies
34    #[serde(default)]
35    pub dependencies: BTreeMap<String, String>,
36    /// Foundation provenance. `None` for non-Foundation packages.
37    /// When `Some`, the package originated from `~/.oxi/foundation/v1/packages.lock`
38    /// and the listed requirements have been validated against the
39    /// Foundation contract.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub foundation: Option<FoundationPackageProvenance>,
42}
43
44impl LockEntry {
45    /// Build a new install entry. The `foundation` field is `None`
46    /// by default; use [`with_foundation`](Self::with_foundation)
47    /// to attach provenance.
48    pub fn new(
49        source: impl Into<String>,
50        name: impl Into<String>,
51        version: impl Into<String>,
52        integrity: Option<String>,
53        scope: SourceScope,
54        source_type: impl Into<String>,
55        dependencies: BTreeMap<String, String>,
56    ) -> Self {
57        Self {
58            source: source.into(),
59            name: name.into(),
60            version: version.into(),
61            integrity,
62            scope,
63            source_type: source_type.into(),
64            dependencies,
65            foundation: None,
66        }
67    }
68
69    /// Attach Foundation provenance.
70    pub fn with_foundation(mut self, foundation: FoundationPackageProvenance) -> Self {
71        self.foundation = Some(foundation);
72        self
73    }
74}
75/// Provenance record attached to a Foundation-managed package entry.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct FoundationPackageProvenance {
78    /// `sha256-<hex>` digest of the verified content root.
79    pub digest: String,
80    /// Trust decision recorded in the lockfile.
81    pub trust: String,
82    /// Hosts the package is allowed to load on.
83    #[serde(default)]
84    pub targets: Vec<String>,
85    /// Abstract requirements declared by the package.
86    #[serde(default)]
87    pub requirements: Vec<String>,
88}
89
90/// The lockfile structure
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Lockfile {
93    /// Lockfile version
94    pub version: u32,
95    /// Locked packages
96    pub packages: BTreeMap<String, LockEntry>,
97}
98
99impl Lockfile {
100    /// Create a new empty lockfile
101    pub fn new() -> Self {
102        Self {
103            version: 1,
104            packages: BTreeMap::new(),
105        }
106    }
107
108    /// Read lockfile from disk
109    pub fn read(path: &Path) -> Result<Option<Self>> {
110        if !path.exists() {
111            return Ok(None);
112        }
113        let content = fs::read_to_string(path)
114            .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
115        let lock: Lockfile = serde_json::from_str(&content)
116            .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
117        Ok(Some(lock))
118    }
119
120    /// Write lockfile to disk
121    pub fn write(&self, path: &Path) -> Result<()> {
122        let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
123        fs::write(path, content)
124            .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
125        Ok(())
126    }
127
128    /// Add or update an entry
129    pub fn insert(&mut self, entry: LockEntry) {
130        self.packages.insert(entry.name.clone(), entry);
131    }
132
133    /// Remove an entry
134    pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
135        self.packages.remove(name)
136    }
137
138    /// Check if a package is locked
139    pub fn contains(&self, name: &str) -> bool {
140        self.packages.contains_key(name)
141    }
142
143    /// Get an entry
144    pub fn get(&self, name: &str) -> Option<&LockEntry> {
145        self.packages.get(name)
146    }
147}
148
149impl Default for Lockfile {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155/// Counts of each resource type in a package
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct ResourceCounts {
158    /// pub.
159    pub extensions: usize,
160    /// pub.
161    pub skills: usize,
162    /// pub.
163    pub prompts: usize,
164    /// pub.
165    pub themes: usize,
166}
167
168impl std::fmt::Display for ResourceCounts {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        let mut parts = Vec::new();
171        if self.extensions > 0 {
172            parts.push(format!("{} ext", self.extensions));
173        }
174        if self.skills > 0 {
175            parts.push(format!("{} skill", self.skills));
176        }
177        if self.prompts > 0 {
178            parts.push(format!("{} prompt", self.prompts));
179        }
180        if self.themes > 0 {
181            parts.push(format!("{} theme", self.themes));
182        }
183        if parts.is_empty() {
184            write!(f, "-")?;
185        } else {
186            write!(f, "{}", parts.join(", "))?;
187        }
188        Ok(())
189    }
190}
191
192/// Compute a SHA-256 hash of a directory's contents for integrity checking
193pub(crate) fn compute_dir_hash(dir: &Path) -> Option<String> {
194    let mut hasher = Sha256::new();
195    let mut files = collect_file_paths(dir);
196    files.sort();
197
198    for file_path in &files {
199        if let Ok(content) = fs::read(file_path) {
200            hasher.update(&content);
201        }
202    }
203
204    let result = hasher.finalize();
205    Some(format!("sha256-{:x}", result))
206}
207
208/// Verify that an installed package directory matches its lockfile integrity hash.
209///
210/// `expected` must be in the `"sha256-<hex>"` format produced by
211/// [`compute_dir_hash`]. Returns `Ok(())` on match, `Err(reason)` on
212/// mismatch, missing directory, or hash format error. Missing files are
213/// skipped silently (same as `compute_dir_hash`) so a partially-installed
214/// package still hashes consistently with its lockfile.
215///
216/// This is the *consumer-side* companion to `compute_dir_hash`: the writer
217/// (install) computes and stores; the reader (load) recomputes and compares.
218/// Before this function existed (audit finding F-1), `integrity` was a
219/// write-only field and a local attacker could swap files under
220/// `~/.oxicode/packages/<name>/` without detection.
221pub(crate) fn verify_lockfile_integrity(install_dir: &Path, expected: &str) -> Result<(), String> {
222    let expected_hex = expected.strip_prefix("sha256-").ok_or_else(|| {
223        format!("lockfile integrity value not in `sha256-<hex>` form: {expected}")
224    })?;
225
226    let actual = compute_dir_hash(install_dir)
227        .ok_or_else(|| format!("could not hash install dir {}", install_dir.display()))?;
228    let actual_hex = actual
229        .strip_prefix("sha256-")
230        .ok_or_else(|| format!("recomputed hash not in expected form: {actual}"))?;
231
232    if actual_hex.eq_ignore_ascii_case(expected_hex) {
233        Ok(())
234    } else {
235        Err(format!(
236            "sha256 mismatch: expected sha256-{expected_hex}, got {actual_hex}"
237        ))
238    }
239}
240
241/// Collect all file paths in a directory recursively
242pub(crate) fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
243    let mut paths = Vec::new();
244    if !dir.exists() {
245        return paths;
246    }
247
248    let entries = match fs::read_dir(dir) {
249        Ok(e) => e,
250        Err(_) => return paths,
251    };
252
253    for entry in entries.flatten() {
254        let path = entry.path();
255        if path.is_dir() {
256            paths.extend(collect_file_paths(&path));
257        } else {
258            paths.push(path);
259        }
260    }
261
262    paths
263}