use serde::{Deserialize, Serialize};
use super::manifest::{Entry, Manifest, Section, valid_name};
use super::{ChestError, invalid};
use crate::packages::npm::{self, Pinned};
pub const HEADER: &str = "# Generated by chest. Do not edit.\n";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lock {
pub version: u32,
#[serde(default, rename = "skill", skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<Locked>,
#[serde(default, rename = "agent", skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<Locked>,
#[serde(default, rename = "extension", skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<Locked>,
}
impl Default for Lock {
fn default() -> Lock {
Lock {
version: 1,
skills: Vec::new(),
agents: Vec::new(),
extensions: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Locked {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub npm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tarball: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub integrity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<Item>,
#[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<Pinned>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Item {
pub name: String,
pub hash: String,
}
impl Lock {
pub fn section(&self, section: Section) -> &[Locked] {
match section {
Section::Skills => &self.skills,
Section::Agents => &self.agents,
Section::Extensions => &self.extensions,
Section::Patch => &[],
}
}
pub fn parse(text: &str) -> Result<Lock, ChestError> {
let lock: Lock = toml_edit::de::from_str(text)
.map_err(|err| super::manifest::toml_error(text, err.message(), err.span()))?;
if lock.version != 1 {
return Err(invalid(format!("unsupported version {}", lock.version)));
}
let sections = [
("skill", &lock.skills),
("agent", &lock.agents),
("extension", &lock.extensions),
];
for (key, entries) in sections {
for entry in entries {
let name = &entry.name;
if !valid_name(name) {
return Err(invalid(format!("{key} `{name}`: invalid name")));
}
if let Some(item) = entry.items.iter().find(|item| !valid_name(&item.name)) {
return Err(invalid(format!(
"{key} `{name}`: invalid item name `{}`",
item.name
)));
}
let exact = |version: &str| semver::Version::parse(version).is_ok();
if let Some(version) = entry.version.as_deref().filter(|_| entry.npm.is_some())
&& !exact(version)
{
return Err(invalid(format!(
"{key} `{name}`: invalid version `{version}`"
)));
}
for pin in &entry.packages {
npm::pin_names(&pin.path)
.map_err(|err| invalid(format!("{key} `{name}`: {err}")))?;
if !exact(&pin.version) {
return Err(invalid(format!(
"{key} `{name}`: invalid version `{}` for {}",
pin.version, pin.path
)));
}
}
}
}
Ok(lock)
}
pub fn to_toml(&self) -> Result<String, ChestError> {
let sorted = |entries: &[Locked]| -> Vec<Locked> {
let mut entries: Vec<Locked> = entries
.iter()
.map(|entry| {
if entry.git.is_none() && entry.npm.is_none() {
return Locked {
name: entry.name.clone(),
path: entry.path.clone(),
..Locked::default()
};
}
let mut entry = entry.clone();
entry.items.sort_by(|a, b| a.name.cmp(&b.name));
entry.packages.sort_by(|a, b| a.path.cmp(&b.path));
entry
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
};
let lock = Lock {
version: self.version,
skills: sorted(&self.skills),
agents: sorted(&self.agents),
extensions: sorted(&self.extensions),
};
let body =
toml_edit::ser::to_string_pretty(&lock).map_err(|err| invalid(format!("{err}")))?;
Ok(format!("{HEADER}{body}"))
}
pub fn drift(&self, manifest: &Manifest) -> Vec<String> {
let mut drift = Vec::new();
for section in [Section::Skills, Section::Agents, Section::Extensions] {
let locked = self.section(section);
let entries = manifest.section(section);
for (name, entry) in entries {
let found = locked.iter().find(|locked| &locked.name == name);
if !found.is_some_and(|locked| fresh(entry, locked)) {
drift.push(format!("{}.{name}", section.key()));
}
}
for locked in locked {
if !entries.contains_key(&locked.name) {
drift.push(format!("{}.{}", section.key(), locked.name));
}
}
}
drift
}
}
pub fn fresh(entry: &Entry, locked: &Locked) -> bool {
let same_source = entry.git == locked.git
&& entry.npm == locked.npm
&& entry.path == locked.path
&& entry.rev == locked.rev;
let in_range = match (&entry.npm, entry.version.as_deref()) {
(Some(_), Some(range)) if npm::is_range(range) => locked
.version
.as_deref()
.is_some_and(|version| npm::satisfies(version, range)),
_ => true,
};
same_source && in_range
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Scope;
fn pin(path: &str) -> Pinned {
Pinned {
path: path.into(),
name: path.rsplit('/').next().unwrap_or_default().into(),
version: "1.0.0".into(),
tarball: format!("https://registry.npmjs.org/{path}.tgz"),
integrity: "sha512-AAAA".into(),
}
}
#[test]
fn lock_sorted_deterministic() {
let subagent = Locked {
name: "subagent".into(),
npm: Some("@acme/pi-subagent".into()),
version: Some("1.4.2".into()),
hash: Some("sha256-1".into()),
packages: vec![
pin("node_modules/b"),
pin("node_modules/@acme/pi-subagent"),
pin("node_modules/a"),
],
..Locked::default()
};
let anthropic = Locked {
name: "anthropic".into(),
git: Some("https://github.com/anthropics/skills".into()),
rev: Some("v1.2.0".into()),
commit: Some("0123456789abcdef0123456789abcdef01234567".into()),
items: vec![
Item {
name: "xlsx".into(),
hash: "sha256-x".into(),
},
Item {
name: "pdf".into(),
hash: "sha256-p".into(),
},
],
..Locked::default()
};
let notes = Locked {
name: "notes".into(),
path: Some("tools/skills/notes".into()),
hash: Some("sha256-n".into()),
..Locked::default()
};
let lock = Lock {
version: 1,
skills: vec![notes.clone(), anthropic.clone()],
agents: Vec::new(),
extensions: vec![subagent.clone()],
};
let text = lock.to_toml().unwrap();
assert_eq!(
text,
r#"# Generated by chest. Do not edit.
version = 1
[[skill]]
name = "anthropic"
git = "https://github.com/anthropics/skills"
rev = "v1.2.0"
commit = "0123456789abcdef0123456789abcdef01234567"
[[skill.items]]
name = "pdf"
hash = "sha256-p"
[[skill.items]]
name = "xlsx"
hash = "sha256-x"
[[skill]]
name = "notes"
path = "tools/skills/notes"
[[extension]]
name = "subagent"
npm = "@acme/pi-subagent"
version = "1.4.2"
hash = "sha256-1"
[[extension.package]]
path = "node_modules/@acme/pi-subagent"
name = "pi-subagent"
version = "1.0.0"
tarball = "https://registry.npmjs.org/node_modules/@acme/pi-subagent.tgz"
integrity = "sha512-AAAA"
[[extension.package]]
path = "node_modules/a"
name = "a"
version = "1.0.0"
tarball = "https://registry.npmjs.org/node_modules/a.tgz"
integrity = "sha512-AAAA"
[[extension.package]]
path = "node_modules/b"
name = "b"
version = "1.0.0"
tarball = "https://registry.npmjs.org/node_modules/b.tgz"
integrity = "sha512-AAAA"
"#
);
let shuffled = Lock {
skills: vec![anthropic, notes],
..lock.clone()
};
assert_eq!(shuffled.to_toml().unwrap(), text);
let read = Lock::parse(&text).unwrap();
assert_eq!(read.to_toml().unwrap(), text);
assert_eq!(
read.extensions[0].packages[0].path,
"node_modules/@acme/pi-subagent"
);
assert_eq!(
Lock::default().to_toml().unwrap(),
format!("{HEADER}version = 1\n")
);
assert!(Lock::parse("version = 2\n").is_err());
let pin = "path = \"node_modules/../x\"\nname = \"x\"\nversion = \"1.0.0\"\ntarball = \"t\"\nintegrity = \"i\"\n";
let climbing = "path = \"node_modules/x\"\nname = \"x\"\nversion = \"1.0.0/../../../evil/-/evil-1.0.0\"\ntarball = \"t\"\nintegrity = \"i\"\n";
for (text, message) in [
(
"[[skill]]\nname = \"../x\"\n",
"skill `../x`: invalid name".to_owned(),
),
(
"[[skill]]\nname = \"ok\"\nitems = [{ name = \"../x\", hash = \"h\" }]\n",
"skill `ok`: invalid item name `../x`".into(),
),
(
"[[agent]]\nname = \"ok\"\nitems = [{ name = \"..\", hash = \"h\" }]\n",
"agent `ok`: invalid item name `..`".into(),
),
(
&format!("[[extension]]\nname = \"ok\"\n[[extension.package]]\n{pin}"),
"extension `ok`: Invalid package path: node_modules/../x".into(),
),
(
&format!("[[extension]]\nname = \"ok\"\n[[extension.package]]\n{climbing}"),
"extension `ok`: invalid version `1.0.0/../../../evil/-/evil-1.0.0` for node_modules/x".into(),
),
(
"[[skill]]\nname = \"ok\"\nnpm = \"x\"\nversion = \"^1\"\n",
"skill `ok`: invalid version `^1`".into(),
),
(
"<<<<<<< ours\n",
"TOML parse error at line 2, column 9: key with no value, expected `=`".into(),
),
] {
let error = Lock::parse(&format!("version = 1\n{text}")).unwrap_err();
assert_eq!(error.to_string(), message);
}
assert!(Lock::parse("version = 1\n[[skill]]\nname = \"x\"\nsecret = 1\n").is_err());
}
#[test]
fn lock_drift_detected() {
let manifest = Manifest::parse(
r#"
[skills]
pdf = { git = "https://github.com/anthropics/skills", rev = "v1.2.0", path = "skills/pdf" }
notes = { path = "tools/skills/notes" }
[extensions]
subagent = { npm = "@acme/pi-subagent", version = "^1.4", capabilities = ["all"] }
latest = { npm = "@acme/latest" }
tagged = { npm = "@acme/tagged", version = "next" }
[patch]
subagent = { path = "../pi-subagent" }
"#,
Scope::Project,
)
.unwrap();
let locked = |name: &str, entry: &Entry, version: Option<&str>| Locked {
name: name.into(),
git: entry.git.clone(),
rev: entry.rev.clone(),
npm: entry.npm.clone(),
path: entry.path.clone(),
version: version.map(str::to_owned),
..Locked::default()
};
let lock = Lock {
version: 1,
skills: vec![
locked("pdf", &manifest.skills["pdf"], None),
locked("notes", &manifest.skills["notes"], None),
],
agents: Vec::new(),
extensions: vec![
locked("subagent", &manifest.extensions["subagent"], Some("1.4.2")),
locked("latest", &manifest.extensions["latest"], Some("3.0.0")),
locked("tagged", &manifest.extensions["tagged"], Some("2.0.0-rc.1")),
],
};
assert!(
lock.drift(&manifest).is_empty(),
"{:?}",
lock.drift(&manifest)
);
let entry = &manifest.extensions["subagent"];
let base = locked("subagent", entry, Some("1.4.2"));
let regranted = Entry {
capabilities: Some(vec!["fs".into()]),
skills: Some(vec!["!x".into()]),
..entry.clone()
};
assert!(fresh(®ranted, &base));
let pdf = &manifest.skills["pdf"];
let stale = [
(
entry,
Locked {
version: Some("2.0.0".into()),
..base.clone()
},
),
(
entry,
Locked {
version: None,
..base.clone()
},
),
(
entry,
Locked {
npm: Some("@acme/other".into()),
..base.clone()
},
),
(
entry,
Locked {
path: Some("sub".into()),
..base.clone()
},
),
(
pdf,
Locked {
rev: Some("v1.3.0".into()),
..locked("pdf", pdf, None)
},
),
(
pdf,
Locked {
git: Some("https://github.com/fork/skills".into()),
..locked("pdf", pdf, None)
},
),
(pdf, locked("pdf", &manifest.skills["notes"], None)),
];
for (entry, locked) in stale {
assert!(!fresh(entry, &locked), "{locked:?}");
}
let mut changed = lock.clone();
changed.skills.retain(|locked| locked.name != "notes");
changed.agents.push(Locked {
name: "gone".into(),
..Locked::default()
});
changed.extensions[0].version = Some("1.3.0".into());
assert_eq!(
changed.drift(&manifest),
["skills.notes", "agents.gone", "extensions.subagent"]
);
}
}