1#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LockedPackage {
27 pub name: String,
28 pub version: String,
29 pub checksum: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Disagreement {
37 Version {
39 name: String,
40 pinned: String,
41 locked: String,
42 },
43 Checksum {
46 name: String,
47 version: String,
48 pinned: String,
49 locked: String,
50 },
51}
52
53impl std::fmt::Display for Disagreement {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::Version {
57 name,
58 pinned,
59 locked,
60 } => write!(
61 f,
62 "{name}: the layer pins {pinned}, the lockfile resolves {locked}"
63 ),
64 Self::Checksum {
65 name,
66 version,
67 pinned,
68 locked,
69 } => write!(
70 f,
71 "{name} {version}: same version, DIFFERENT bytes — layer {pinned}, lockfile {locked}"
72 ),
73 }
74 }
75}
76
77#[derive(Debug, thiserror::Error)]
78pub enum LockError {
79 #[error("cannot parse {path} as a Cargo lockfile: {reason}")]
80 Parse { path: String, reason: String },
81}
82
83pub fn parse_lockfile(text: &str, path: &str) -> Result<Vec<LockedPackage>, LockError> {
86 let doc: toml::Value = toml::from_str(text).map_err(|e| LockError::Parse {
87 path: path.to_string(),
88 reason: e.to_string(),
89 })?;
90 let Some(packages) = doc.get("package").and_then(|p| p.as_array()) else {
91 return Ok(Vec::new());
93 };
94 let mut out = Vec::new();
95 for p in packages {
96 let (Some(name), Some(version)) = (
97 p.get("name").and_then(|v| v.as_str()),
98 p.get("version").and_then(|v| v.as_str()),
99 ) else {
100 return Err(LockError::Parse {
101 path: path.to_string(),
102 reason: "a [[package]] entry lacks name or version".into(),
103 });
104 };
105 out.push(LockedPackage {
106 name: name.to_string(),
107 version: version.to_string(),
108 checksum: p
109 .get("checksum")
110 .and_then(|v| v.as_str())
111 .map(|s| s.to_string()),
112 });
113 }
114 Ok(out)
115}
116
117pub fn disagreements(
121 pinned: &[crate::crateexport::CrateEntry],
122 locked: &[LockedPackage],
123) -> Vec<Disagreement> {
124 let mut out = Vec::new();
125 for p in pinned {
126 for l in locked.iter().filter(|l| l.name == p.name) {
127 if l.version != p.version {
128 if pinned
134 .iter()
135 .any(|q| q.name == l.name && q.version == l.version)
136 {
137 continue;
138 }
139 out.push(Disagreement::Version {
140 name: p.name.clone(),
141 pinned: p.version.clone(),
142 locked: l.version.clone(),
143 });
144 } else if let Some(lc) = &l.checksum
145 && !lc.is_empty()
146 && lc != &p.cksum
147 {
148 out.push(Disagreement::Checksum {
149 name: p.name.clone(),
150 version: p.version.clone(),
151 pinned: p.cksum.clone(),
152 locked: lc.clone(),
153 });
154 }
155 }
156 }
157 out.sort_by_key(|d| match d {
158 Disagreement::Version { name, .. } | Disagreement::Checksum { name, .. } => name.clone(),
159 });
160 out
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::crateexport::CrateEntry;
167
168 fn pinned(name: &str, version: &str, cksum: &str) -> CrateEntry {
169 CrateEntry {
170 name: name.into(),
171 version: version.into(),
172 cksum: cksum.into(),
173 bytes: vec![],
174 }
175 }
176
177 const LOCK: &str = r#"
178version = 4
179
180[[package]]
181name = "wit-bindgen-rt"
182version = "0.41.0"
183checksum = "aaaa"
184
185[[package]]
186name = "serde"
187version = "1.0.229"
188checksum = "bbbb"
189
190[[package]]
191name = "my-local-crate"
192version = "0.1.0"
193"#;
194
195 #[test]
197 fn a_lockfile_parses_to_name_version_and_checksum() {
198 let pkgs = parse_lockfile(LOCK, "Cargo.lock").unwrap();
199 assert_eq!(pkgs.len(), 3);
200 let wb = pkgs.iter().find(|p| p.name == "wit-bindgen-rt").unwrap();
201 assert_eq!(wb.version, "0.41.0");
202 assert_eq!(wb.checksum.as_deref(), Some("aaaa"));
203 let local = pkgs.iter().find(|p| p.name == "my-local-crate").unwrap();
205 assert_eq!(local.checksum, None);
206 }
207
208 #[test]
210 fn the_reported_drift_is_caught() {
211 let d = disagreements(
214 &[pinned("wit-bindgen-rt", "0.58.0", "zzzz")],
215 &parse_lockfile(LOCK, "l").unwrap(),
216 );
217 assert_eq!(d.len(), 1);
218 match &d[0] {
219 Disagreement::Version {
220 name,
221 pinned,
222 locked,
223 } => {
224 assert_eq!(name, "wit-bindgen-rt");
225 assert_eq!(pinned, "0.58.0");
226 assert_eq!(locked, "0.41.0");
227 }
228 other => panic!("expected a version disagreement, got {other:?}"),
229 }
230 }
231
232 #[test]
234 fn same_version_different_bytes_is_the_more_serious_case() {
235 let d = disagreements(
236 &[pinned("serde", "1.0.229", "DIFFERENT")],
237 &parse_lockfile(LOCK, "l").unwrap(),
238 );
239 assert_eq!(d.len(), 1);
240 assert!(matches!(d[0], Disagreement::Checksum { .. }), "{:?}", d[0]);
241 }
242
243 #[test]
245 fn agreement_is_silent_and_unpinned_packages_are_not_our_business() {
246 let locked = parse_lockfile(LOCK, "l").unwrap();
247 assert!(disagreements(&[pinned("serde", "1.0.229", "bbbb")], &locked).is_empty());
249 assert!(disagreements(&[pinned("not-used-here", "9.9.9", "cccc")], &locked).is_empty());
251 assert!(disagreements(&[], &locked).is_empty());
254 }
255
256 #[test]
258 fn a_layer_pinning_two_versions_of_one_crate_agrees_with_a_lockfile_holding_both() {
259 let lock = "version = 4\n\n\
266 [[package]]\nname = \"serde\"\nversion = \"1.0.200\"\nchecksum = \"aa\"\n\n\
267 [[package]]\nname = \"serde\"\nversion = \"1.0.210\"\nchecksum = \"bb\"\n";
268 let locked = parse_lockfile(lock, "Cargo.lock").unwrap();
269 let both = [
270 pinned("serde", "1.0.200", "aa"),
271 pinned("serde", "1.0.210", "bb"),
272 ];
273 assert!(
274 disagreements(&both, &locked).is_empty(),
275 "both versions are pinned AND locked — that is agreement: {:?}",
276 disagreements(&both, &locked)
277 );
278
279 let other = parse_lockfile(
282 "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.5\"\n",
283 "Cargo.lock",
284 )
285 .unwrap();
286 assert!(!disagreements(&both, &other).is_empty());
287 let wrong_bytes = [
288 pinned("serde", "1.0.200", "aa"),
289 pinned("serde", "1.0.210", "NOPE"),
290 ];
291 assert!(matches!(
292 disagreements(&wrong_bytes, &locked).as_slice(),
293 [Disagreement::Checksum { version, .. }] if version == "1.0.210"
294 ));
295 }
296
297 #[test]
299 fn a_malformed_lockfile_is_an_error_not_a_silent_pass() {
300 assert!(parse_lockfile("this is not toml {{{", "Cargo.lock").is_err());
303 assert!(parse_lockfile("[[package]]\nversion = \"1.0\"\n", "Cargo.lock").is_err());
304 }
305}