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