oxicode/storage/packages/
lockfile.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct LockEntry {
21 pub source: String,
23 pub name: String,
25 pub version: String,
27 pub integrity: Option<String>,
29 pub scope: SourceScope,
31 pub source_type: String,
33 #[serde(default)]
35 pub dependencies: BTreeMap<String, String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub foundation: Option<FoundationPackageProvenance>,
42}
43
44impl LockEntry {
45 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 pub fn with_foundation(mut self, foundation: FoundationPackageProvenance) -> Self {
71 self.foundation = Some(foundation);
72 self
73 }
74}
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct FoundationPackageProvenance {
78 pub digest: String,
80 pub trust: String,
82 #[serde(default)]
84 pub targets: Vec<String>,
85 #[serde(default)]
87 pub requirements: Vec<String>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Lockfile {
93 pub version: u32,
95 pub packages: BTreeMap<String, LockEntry>,
97}
98
99impl Lockfile {
100 pub fn new() -> Self {
102 Self {
103 version: 1,
104 packages: BTreeMap::new(),
105 }
106 }
107
108 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 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 pub fn insert(&mut self, entry: LockEntry) {
130 self.packages.insert(entry.name.clone(), entry);
131 }
132
133 pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
135 self.packages.remove(name)
136 }
137
138 pub fn contains(&self, name: &str) -> bool {
140 self.packages.contains_key(name)
141 }
142
143 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct ResourceCounts {
158 pub extensions: usize,
160 pub skills: usize,
162 pub prompts: usize,
164 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
192pub(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
208pub(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
241pub(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}