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).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                // Exact versions only: replay builds tarball URLs from them.
130                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    /// The lock as chest writes it, the same for the same contents: the
154    /// header, then entries sorted by name, their items by name and their
155    /// packages by path. A path entry keeps only its name and path, since
156    /// path entries are never hashed.
157    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    /// The entries of `manifest` that this lock does not record as the
190    /// manifest asks, and the locked entries the manifest no longer has, as
191    /// `section.name`. Empty when the lock is up to date. `[patch]` entries
192    /// are never compared.
193    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
214/// Whether `locked` still records what manifest `entry` asks for: the same
215/// source kind, location and subpath, the same git `rev`, and an npm
216/// version within the entry's range. A range that is a dist-tag, or none,
217/// keeps any locked version. Capabilities, events and filters never make
218/// an entry stale.
219pub 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        // A hash on a path entry is dropped.
281        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        // Order in never changes what is written, and the text reads back.
346        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        // No name or package path from a lock leaves the folder it joins.
363        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        // Grants and filters stay out of the lock, so changing them is fresh.
447        let regranted = Entry {
448            capabilities: Some(vec!["fs".into()]),
449            skills: Some(vec!["!x".into()]),
450            ..entry.clone()
451        };
452        assert!(fresh(&regranted, &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        // Missing and extra names drift too.
504        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}