1use serde::{Deserialize, Serialize};
5
6use super::manifest::{Entry, Manifest, Section, valid_name};
7use super::{ChestError, invalid};
8use crate::packages::npm::{self, Pinned};
9
10pub const HEADER: &str = "# Generated by chest. Do not edit.\n";
12
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct Lock {
17 pub version: u32,
19 #[serde(default, rename = "skill", skip_serializing_if = "Vec::is_empty")]
21 pub skills: Vec<Locked>,
22 #[serde(default, rename = "agent", skip_serializing_if = "Vec::is_empty")]
24 pub agents: Vec<Locked>,
25 #[serde(default, rename = "extension", skip_serializing_if = "Vec::is_empty")]
27 pub extensions: Vec<Locked>,
28}
29
30impl Default for Lock {
31 fn default() -> Lock {
32 Lock {
33 version: 1,
34 skills: Vec::new(),
35 agents: Vec::new(),
36 extensions: Vec::new(),
37 }
38 }
39}
40
41#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Locked {
45 pub name: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub git: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub rev: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub commit: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub npm: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub version: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub path: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub tarball: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub integrity: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub hash: Option<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub items: Vec<Item>,
77 #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
79 pub packages: Vec<Pinned>,
80}
81
82#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct Item {
86 pub name: String,
88 pub hash: String,
90}
91
92impl Lock {
93 pub fn section(&self, section: Section) -> &[Locked] {
95 match section {
96 Section::Skills => &self.skills,
97 Section::Agents => &self.agents,
98 Section::Extensions => &self.extensions,
99 Section::Patch => &[],
100 }
101 }
102
103 pub fn parse(text: &str) -> Result<Lock, ChestError> {
108 let lock: Lock = toml_edit::de::from_str(text).map_err(|err| invalid(format!("{err}")))?;
109 if lock.version != 1 {
110 return Err(invalid(format!("unsupported version {}", lock.version)));
111 }
112 let sections = [
113 ("skill", &lock.skills),
114 ("agent", &lock.agents),
115 ("extension", &lock.extensions),
116 ];
117 for (key, entries) in sections {
118 for entry in entries {
119 let name = &entry.name;
120 if !valid_name(name) {
121 return Err(invalid(format!("{key} `{name}`: invalid name")));
122 }
123 if let Some(item) = entry.items.iter().find(|item| !valid_name(&item.name)) {
124 return Err(invalid(format!(
125 "{key} `{name}`: invalid item name `{}`",
126 item.name
127 )));
128 }
129 let exact = |version: &str| semver::Version::parse(version).is_ok();
131 if let Some(version) = entry.version.as_deref().filter(|_| entry.npm.is_some())
132 && !exact(version)
133 {
134 return Err(invalid(format!(
135 "{key} `{name}`: invalid version `{version}`"
136 )));
137 }
138 for pin in &entry.packages {
139 npm::pin_names(&pin.path)
140 .map_err(|err| invalid(format!("{key} `{name}`: {err}")))?;
141 if !exact(&pin.version) {
142 return Err(invalid(format!(
143 "{key} `{name}`: invalid version `{}` for {}",
144 pin.version, pin.path
145 )));
146 }
147 }
148 }
149 }
150 Ok(lock)
151 }
152
153 pub fn to_toml(&self) -> Result<String, ChestError> {
158 let sorted = |entries: &[Locked]| -> Vec<Locked> {
159 let mut entries: Vec<Locked> = entries
160 .iter()
161 .map(|entry| {
162 if entry.git.is_none() && entry.npm.is_none() {
163 return Locked {
164 name: entry.name.clone(),
165 path: entry.path.clone(),
166 ..Locked::default()
167 };
168 }
169 let mut entry = entry.clone();
170 entry.items.sort_by(|a, b| a.name.cmp(&b.name));
171 entry.packages.sort_by(|a, b| a.path.cmp(&b.path));
172 entry
173 })
174 .collect();
175 entries.sort_by(|a, b| a.name.cmp(&b.name));
176 entries
177 };
178 let lock = Lock {
179 version: self.version,
180 skills: sorted(&self.skills),
181 agents: sorted(&self.agents),
182 extensions: sorted(&self.extensions),
183 };
184 let body =
185 toml_edit::ser::to_string_pretty(&lock).map_err(|err| invalid(format!("{err}")))?;
186 Ok(format!("{HEADER}{body}"))
187 }
188
189 pub fn drift(&self, manifest: &Manifest) -> Vec<String> {
194 let mut drift = Vec::new();
195 for section in [Section::Skills, Section::Agents, Section::Extensions] {
196 let locked = self.section(section);
197 let entries = manifest.section(section);
198 for (name, entry) in entries {
199 let found = locked.iter().find(|locked| &locked.name == name);
200 if !found.is_some_and(|locked| fresh(entry, locked)) {
201 drift.push(format!("{}.{name}", section.key()));
202 }
203 }
204 for locked in locked {
205 if !entries.contains_key(&locked.name) {
206 drift.push(format!("{}.{}", section.key(), locked.name));
207 }
208 }
209 }
210 drift
211 }
212}
213
214pub fn fresh(entry: &Entry, locked: &Locked) -> bool {
220 let same_source = entry.git == locked.git
221 && entry.npm == locked.npm
222 && entry.path == locked.path
223 && entry.rev == locked.rev;
224 let in_range = match (&entry.npm, entry.version.as_deref()) {
225 (Some(_), Some(range)) if npm::is_range(range) => locked
226 .version
227 .as_deref()
228 .is_some_and(|version| npm::satisfies(version, range)),
229 _ => true,
230 };
231 same_source && in_range
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::settings::Scope;
238
239 fn pin(path: &str) -> Pinned {
240 Pinned {
241 path: path.into(),
242 name: path.rsplit('/').next().unwrap_or_default().into(),
243 version: "1.0.0".into(),
244 tarball: format!("https://registry.npmjs.org/{path}.tgz"),
245 integrity: "sha512-AAAA".into(),
246 }
247 }
248
249 #[test]
250 fn lock_sorted_deterministic() {
251 let subagent = Locked {
252 name: "subagent".into(),
253 npm: Some("@acme/pi-subagent".into()),
254 version: Some("1.4.2".into()),
255 hash: Some("sha256-1".into()),
256 packages: vec![
257 pin("node_modules/b"),
258 pin("node_modules/@acme/pi-subagent"),
259 pin("node_modules/a"),
260 ],
261 ..Locked::default()
262 };
263 let anthropic = Locked {
264 name: "anthropic".into(),
265 git: Some("https://github.com/anthropics/skills".into()),
266 rev: Some("v1.2.0".into()),
267 commit: Some("0123456789abcdef0123456789abcdef01234567".into()),
268 items: vec![
269 Item {
270 name: "xlsx".into(),
271 hash: "sha256-x".into(),
272 },
273 Item {
274 name: "pdf".into(),
275 hash: "sha256-p".into(),
276 },
277 ],
278 ..Locked::default()
279 };
280 let notes = Locked {
282 name: "notes".into(),
283 path: Some("tools/skills/notes".into()),
284 hash: Some("sha256-n".into()),
285 ..Locked::default()
286 };
287 let lock = Lock {
288 version: 1,
289 skills: vec![notes.clone(), anthropic.clone()],
290 agents: Vec::new(),
291 extensions: vec![subagent.clone()],
292 };
293 let text = lock.to_toml().unwrap();
294 assert_eq!(
295 text,
296 r#"# Generated by chest. Do not edit.
297version = 1
298
299[[skill]]
300name = "anthropic"
301git = "https://github.com/anthropics/skills"
302rev = "v1.2.0"
303commit = "0123456789abcdef0123456789abcdef01234567"
304
305[[skill.items]]
306name = "pdf"
307hash = "sha256-p"
308
309[[skill.items]]
310name = "xlsx"
311hash = "sha256-x"
312
313[[skill]]
314name = "notes"
315path = "tools/skills/notes"
316
317[[extension]]
318name = "subagent"
319npm = "@acme/pi-subagent"
320version = "1.4.2"
321hash = "sha256-1"
322
323[[extension.package]]
324path = "node_modules/@acme/pi-subagent"
325name = "pi-subagent"
326version = "1.0.0"
327tarball = "https://registry.npmjs.org/node_modules/@acme/pi-subagent.tgz"
328integrity = "sha512-AAAA"
329
330[[extension.package]]
331path = "node_modules/a"
332name = "a"
333version = "1.0.0"
334tarball = "https://registry.npmjs.org/node_modules/a.tgz"
335integrity = "sha512-AAAA"
336
337[[extension.package]]
338path = "node_modules/b"
339name = "b"
340version = "1.0.0"
341tarball = "https://registry.npmjs.org/node_modules/b.tgz"
342integrity = "sha512-AAAA"
343"#
344 );
345 let shuffled = Lock {
347 skills: vec![anthropic, notes],
348 ..lock.clone()
349 };
350 assert_eq!(shuffled.to_toml().unwrap(), text);
351 let read = Lock::parse(&text).unwrap();
352 assert_eq!(read.to_toml().unwrap(), text);
353 assert_eq!(
354 read.extensions[0].packages[0].path,
355 "node_modules/@acme/pi-subagent"
356 );
357 assert_eq!(
358 Lock::default().to_toml().unwrap(),
359 format!("{HEADER}version = 1\n")
360 );
361 assert!(Lock::parse("version = 2\n").is_err());
362 let pin = "path = \"node_modules/../x\"\nname = \"x\"\nversion = \"1.0.0\"\ntarball = \"t\"\nintegrity = \"i\"\n";
364 let climbing = "path = \"node_modules/x\"\nname = \"x\"\nversion = \"1.0.0/../../../evil/-/evil-1.0.0\"\ntarball = \"t\"\nintegrity = \"i\"\n";
365 for (text, message) in [
366 (
367 "[[skill]]\nname = \"../x\"\n",
368 "skill `../x`: invalid name".to_owned(),
369 ),
370 (
371 "[[skill]]\nname = \"ok\"\nitems = [{ name = \"../x\", hash = \"h\" }]\n",
372 "skill `ok`: invalid item name `../x`".into(),
373 ),
374 (
375 "[[agent]]\nname = \"ok\"\nitems = [{ name = \"..\", hash = \"h\" }]\n",
376 "agent `ok`: invalid item name `..`".into(),
377 ),
378 (
379 &format!("[[extension]]\nname = \"ok\"\n[[extension.package]]\n{pin}"),
380 "extension `ok`: Invalid package path: node_modules/../x".into(),
381 ),
382 (
383 &format!("[[extension]]\nname = \"ok\"\n[[extension.package]]\n{climbing}"),
384 "extension `ok`: invalid version `1.0.0/../../../evil/-/evil-1.0.0` for node_modules/x".into(),
385 ),
386 (
387 "[[skill]]\nname = \"ok\"\nnpm = \"x\"\nversion = \"^1\"\n",
388 "skill `ok`: invalid version `^1`".into(),
389 ),
390 ] {
391 let error = Lock::parse(&format!("version = 1\n{text}")).unwrap_err();
392 assert_eq!(error.to_string(), message);
393 }
394 assert!(Lock::parse("version = 1\n[[skill]]\nname = \"x\"\nsecret = 1\n").is_err());
395 }
396
397 #[test]
398 fn lock_drift_detected() {
399 let manifest = Manifest::parse(
400 r#"
401[skills]
402pdf = { git = "https://github.com/anthropics/skills", rev = "v1.2.0", path = "skills/pdf" }
403notes = { path = "tools/skills/notes" }
404
405[extensions]
406subagent = { npm = "@acme/pi-subagent", version = "^1.4", capabilities = ["all"] }
407latest = { npm = "@acme/latest" }
408tagged = { npm = "@acme/tagged", version = "next" }
409
410[patch]
411subagent = { path = "../pi-subagent" }
412"#,
413 Scope::Project,
414 )
415 .unwrap();
416 let locked = |name: &str, entry: &Entry, version: Option<&str>| Locked {
417 name: name.into(),
418 git: entry.git.clone(),
419 rev: entry.rev.clone(),
420 npm: entry.npm.clone(),
421 path: entry.path.clone(),
422 version: version.map(str::to_owned),
423 ..Locked::default()
424 };
425 let lock = Lock {
426 version: 1,
427 skills: vec![
428 locked("pdf", &manifest.skills["pdf"], None),
429 locked("notes", &manifest.skills["notes"], None),
430 ],
431 agents: Vec::new(),
432 extensions: vec![
433 locked("subagent", &manifest.extensions["subagent"], Some("1.4.2")),
434 locked("latest", &manifest.extensions["latest"], Some("3.0.0")),
435 locked("tagged", &manifest.extensions["tagged"], Some("2.0.0-rc.1")),
436 ],
437 };
438 assert!(
439 lock.drift(&manifest).is_empty(),
440 "{:?}",
441 lock.drift(&manifest)
442 );
443
444 let entry = &manifest.extensions["subagent"];
445 let base = locked("subagent", entry, Some("1.4.2"));
446 let regranted = Entry {
448 capabilities: Some(vec!["fs".into()]),
449 skills: Some(vec!["!x".into()]),
450 ..entry.clone()
451 };
452 assert!(fresh(®ranted, &base));
453 let pdf = &manifest.skills["pdf"];
454 let stale = [
455 (
456 entry,
457 Locked {
458 version: Some("2.0.0".into()),
459 ..base.clone()
460 },
461 ),
462 (
463 entry,
464 Locked {
465 version: None,
466 ..base.clone()
467 },
468 ),
469 (
470 entry,
471 Locked {
472 npm: Some("@acme/other".into()),
473 ..base.clone()
474 },
475 ),
476 (
477 entry,
478 Locked {
479 path: Some("sub".into()),
480 ..base.clone()
481 },
482 ),
483 (
484 pdf,
485 Locked {
486 rev: Some("v1.3.0".into()),
487 ..locked("pdf", pdf, None)
488 },
489 ),
490 (
491 pdf,
492 Locked {
493 git: Some("https://github.com/fork/skills".into()),
494 ..locked("pdf", pdf, None)
495 },
496 ),
497 (pdf, locked("pdf", &manifest.skills["notes"], None)),
498 ];
499 for (entry, locked) in stale {
500 assert!(!fresh(entry, &locked), "{locked:?}");
501 }
502
503 let mut changed = lock.clone();
505 changed.skills.retain(|locked| locked.name != "notes");
506 changed.agents.push(Locked {
507 name: "gone".into(),
508 ..Locked::default()
509 });
510 changed.extensions[0].version = Some("1.3.0".into());
511 assert_eq!(
512 changed.drift(&manifest),
513 ["skills.notes", "agents.gone", "extensions.subagent"]
514 );
515 }
516}