Skip to main content

yapi_core/chest/
lock.rs

1//! `chest.lock`: what a manifest's entries resolved to, pinned so that
2//! another checkout installs the same bytes.
3
4use serde::{Deserialize, Serialize};
5
6use super::manifest::{Entry, Manifest, Section, valid_name};
7use super::{ChestError, invalid};
8use crate::packages::npm::{self, Pinned};
9
10/// The comment that opens every lock chest writes.
11pub const HEADER: &str = "# Generated by chest. Do not edit.\n";
12
13/// A `chest.lock`. `[patch]` entries never appear in it.
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct Lock {
17    /// The format version, 1.
18    pub version: u32,
19    /// The `[[skill]]` entries.
20    #[serde(default, rename = "skill", skip_serializing_if = "Vec::is_empty")]
21    pub skills: Vec<Locked>,
22    /// The `[[agent]]` entries.
23    #[serde(default, rename = "agent", skip_serializing_if = "Vec::is_empty")]
24    pub agents: Vec<Locked>,
25    /// The `[[extension]]` entries.
26    #[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/// What one manifest entry resolved to.
42#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Locked {
45    /// The entry's name.
46    pub name: String,
47    /// The git repository, as the manifest gives it.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub git: Option<String>,
50    /// The git revision the manifest asks for.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub rev: Option<String>,
53    /// The commit the revision resolved to.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub commit: Option<String>,
56    /// The npm package.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub npm: Option<String>,
59    /// The npm version installed.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub version: Option<String>,
62    /// The subpath in the git or npm package, or the source of a path entry.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub path: Option<String>,
65    /// The tarball of an npm skill or agent.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub tarball: Option<String>,
68    /// The integrity of that tarball.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub integrity: Option<String>,
71    /// The [`tree_hash`](super::hash::tree_hash) of the installed entry.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub hash: Option<String>,
74    /// Without a `path`, each item the package provides, with its hash.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub items: Vec<Item>,
77    /// An npm extension's full installed tree.
78    #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
79    pub packages: Vec<Pinned>,
80}
81
82/// One item of an entry that installs everything its package provides.
83#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct Item {
86    /// The item's name.
87    pub name: String,
88    /// Its [`tree_hash`](super::hash::tree_hash).
89    pub hash: String,
90}
91
92impl Lock {
93    /// The entries of `section`. `[patch]` entries are never locked.
94    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    /// The lock in `text`. Fails on unknown keys, on versions other than 1,
104    /// and on entry and item names that break the manifest's name rule or
105    /// package paths that replay would refuse, so that no name or path from
106    /// the lock leaves the folder it is joined to.
107    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                // Exact versions only: replay builds tarball URLs from them.
131                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    /// The lock as chest writes it, the same for the same contents: the
155    /// header, then entries sorted by name, their items by name and their
156    /// packages by path. A path entry keeps only its name and path, since
157    /// path entries are never hashed.
158    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    /// The entries of `manifest` that this lock does not record as the
191    /// manifest asks, and the locked entries the manifest no longer has, as
192    /// `section.name`. Empty when the lock is up to date. `[patch]` entries
193    /// are never compared.
194    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
215/// Whether `locked` still records what manifest `entry` asks for: the same
216/// source kind, location and subpath, the same git `rev`, and an npm
217/// version within the entry's range. A range that is a dist-tag, or none,
218/// keeps any locked version. Capabilities, events and filters never make
219/// an entry stale.
220pub 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        // A hash on a path entry is dropped.
282        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        // Order in never changes what is written, and the text reads back.
347        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        // No name or package path from a lock leaves the folder it joins.
364        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            // A merge conflict's marker reads on one line, with where it is.
392            (
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        // Grants and filters stay out of the lock, so changing them is fresh.
453        let regranted = Entry {
454            capabilities: Some(vec!["fs".into()]),
455            skills: Some(vec!["!x".into()]),
456            ..entry.clone()
457        };
458        assert!(fresh(&regranted, &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        // Missing and extra names drift too.
510        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}