Skip to main content

oxi/storage/packages/
lockfile.rs

1//! Lockfile types and integrity helpers.
2//!
3//! `Lockfile` is the on-disk record (`oxi-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}
37
38/// The lockfile structure
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Lockfile {
41    /// Lockfile version
42    pub version: u32,
43    /// Locked packages
44    pub packages: BTreeMap<String, LockEntry>,
45}
46
47impl Lockfile {
48    /// Create a new empty lockfile
49    pub fn new() -> Self {
50        Self {
51            version: 1,
52            packages: BTreeMap::new(),
53        }
54    }
55
56    /// Read lockfile from disk
57    pub fn read(path: &Path) -> Result<Option<Self>> {
58        if !path.exists() {
59            return Ok(None);
60        }
61        let content = fs::read_to_string(path)
62            .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
63        let lock: Lockfile = serde_json::from_str(&content)
64            .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
65        Ok(Some(lock))
66    }
67
68    /// Write lockfile to disk
69    pub fn write(&self, path: &Path) -> Result<()> {
70        let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
71        fs::write(path, content)
72            .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
73        Ok(())
74    }
75
76    /// Add or update an entry
77    pub fn insert(&mut self, entry: LockEntry) {
78        self.packages.insert(entry.name.clone(), entry);
79    }
80
81    /// Remove an entry
82    pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
83        self.packages.remove(name)
84    }
85
86    /// Check if a package is locked
87    pub fn contains(&self, name: &str) -> bool {
88        self.packages.contains_key(name)
89    }
90
91    /// Get an entry
92    pub fn get(&self, name: &str) -> Option<&LockEntry> {
93        self.packages.get(name)
94    }
95}
96
97impl Default for Lockfile {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// Counts of each resource type in a package
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct ResourceCounts {
106    /// pub.
107    pub extensions: usize,
108    /// pub.
109    pub skills: usize,
110    /// pub.
111    pub prompts: usize,
112    /// pub.
113    pub themes: usize,
114}
115
116impl std::fmt::Display for ResourceCounts {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        let mut parts = Vec::new();
119        if self.extensions > 0 {
120            parts.push(format!("{} ext", self.extensions));
121        }
122        if self.skills > 0 {
123            parts.push(format!("{} skill", self.skills));
124        }
125        if self.prompts > 0 {
126            parts.push(format!("{} prompt", self.prompts));
127        }
128        if self.themes > 0 {
129            parts.push(format!("{} theme", self.themes));
130        }
131        if parts.is_empty() {
132            write!(f, "-")?;
133        } else {
134            write!(f, "{}", parts.join(", "))?;
135        }
136        Ok(())
137    }
138}
139
140/// Compute a SHA-256 hash of a directory's contents for integrity checking
141pub(crate) fn compute_dir_hash(dir: &Path) -> Option<String> {
142    let mut hasher = Sha256::new();
143    let mut files = collect_file_paths(dir);
144    files.sort();
145
146    for file_path in &files {
147        if let Ok(content) = fs::read(file_path) {
148            hasher.update(&content);
149        }
150    }
151
152    let result = hasher.finalize();
153    Some(format!("sha256-{:x}", result))
154}
155
156/// Verify that an installed package directory matches its lockfile integrity hash.
157///
158/// `expected` must be in the `"sha256-<hex>"` format produced by
159/// [`compute_dir_hash`]. Returns `Ok(())` on match, `Err(reason)` on
160/// mismatch, missing directory, or hash format error. Missing files are
161/// skipped silently (same as `compute_dir_hash`) so a partially-installed
162/// package still hashes consistently with its lockfile.
163///
164/// This is the *consumer-side* companion to `compute_dir_hash`: the writer
165/// (install) computes and stores; the reader (load) recomputes and compares.
166/// Before this function existed (audit finding F-1), `integrity` was a
167/// write-only field and a local attacker could swap files under
168/// `~/.oxi/packages/<name>/` without detection.
169pub(crate) fn verify_lockfile_integrity(install_dir: &Path, expected: &str) -> Result<(), String> {
170    let expected_hex = expected.strip_prefix("sha256-").ok_or_else(|| {
171        format!("lockfile integrity value not in `sha256-<hex>` form: {expected}")
172    })?;
173
174    let actual = compute_dir_hash(install_dir)
175        .ok_or_else(|| format!("could not hash install dir {}", install_dir.display()))?;
176    let actual_hex = actual
177        .strip_prefix("sha256-")
178        .ok_or_else(|| format!("recomputed hash not in expected form: {actual}"))?;
179
180    if actual_hex.eq_ignore_ascii_case(expected_hex) {
181        Ok(())
182    } else {
183        Err(format!(
184            "sha256 mismatch: expected sha256-{expected_hex}, got {actual_hex}"
185        ))
186    }
187}
188
189/// Collect all file paths in a directory recursively
190pub(crate) fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
191    let mut paths = Vec::new();
192    if !dir.exists() {
193        return paths;
194    }
195
196    let entries = match fs::read_dir(dir) {
197        Ok(e) => e,
198        Err(_) => return paths,
199    };
200
201    for entry in entries.flatten() {
202        let path = entry.path();
203        if path.is_dir() {
204            paths.extend(collect_file_paths(&path));
205        } else {
206            paths.push(path);
207        }
208    }
209
210    paths
211}