Skip to main content

varve_core/
lockpin.rs

1//! Lockfile agreement (REQ-LOCKPIN-001).
2//!
3//! varve pins what it DISPATCHES. But a code-generating dependency reaches the
4//! artifact as a Cargo dependency compiled INTO it — `wit-bindgen-rt` is baked
5//! into every component one consumer publishes, and supplies `cabi_realloc`,
6//! the symbol whose absence shipped a raw core module downstream (varve#52).
7//! There is no binary to shim, so `varve which` has nothing to point at, and
8//! that consumer reported running three versions of one generator at once with
9//! nothing recording which produced a given artifact.
10//!
11//! varve already carries the `crate` payload kind, so it can DISTRIBUTE such a
12//! dependency. What was missing is AGREEMENT: this module compares a project's
13//! resolved lockfile against the layer's `crate` entries and reports every
14//! disagreement.
15//!
16//! THE BOUNDARY, chosen rather than overlooked: varve cannot intercept a Cargo
17//! build and cannot guarantee the compiler used the bytes it pins. Provenance
18//! for a compiled-in dependency is by ASSERTED AGREEMENT — the lockfile and the
19//! layer must say the same thing, mechanically, or CI goes red — not by
20//! dispatch. Packages the layer does not pin are ignored: the layer does not
21//! claim to cover every dependency, and pretending otherwise would make this
22//! check noise instead of signal.
23
24/// One package as the lockfile resolved it.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LockedPackage {
27    pub name: String,
28    pub version: String,
29    /// Registry checksum, when the lockfile records one (path and git
30    /// dependencies have none).
31    pub checksum: Option<String>,
32}
33
34/// How a pinned crate and the lockfile disagree.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Disagreement {
37    /// Same name, different version — the drift that goes unnoticed.
38    Version {
39        name: String,
40        pinned: String,
41        locked: String,
42    },
43    /// Same name and version, different bytes. Rarer and more serious: two
44    /// different artifacts are claiming one identity.
45    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
83/// Parse the `[[package]]` entries of a Cargo lockfile. Deliberately minimal:
84/// only name, version and checksum are read, because only those are compared.
85pub 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        // A lockfile with no packages is unusual but not malformed.
92        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
117/// Compare the layer's pinned crates against the lockfile. Returns every
118/// disagreement, in a stable order. A pinned crate absent from the lockfile is
119/// NOT a disagreement — the project simply does not depend on it.
120pub 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                // A layer may pin SEVERAL versions of one crate (REQ-STORE-002):
129                // varve's own lockfile has 14 such names. The locked version
130                // agreeing with a DIFFERENT pinned entry of the same name is
131                // agreement, not drift — reporting it would make this gate go
132                // red on every real dependency graph.
133                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    // rivet: verifies REQ-LOCKPIN-001
196    #[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        // A path dependency has no checksum, and that is not an error.
204        let local = pkgs.iter().find(|p| p.name == "my-local-crate").unwrap();
205        assert_eq!(local.checksum, None);
206    }
207
208    // rivet: verifies REQ-LOCKPIN-001
209    #[test]
210    fn the_reported_drift_is_caught() {
211        // The consumer's actual case: the layer pins 0.58.0, the project's
212        // components resolve 0.41.0, and nothing said so.
213        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    // rivet: verifies REQ-LOCKPIN-001
233    #[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    // rivet: verifies REQ-LOCKPIN-001
244    #[test]
245    fn agreement_is_silent_and_unpinned_packages_are_not_our_business() {
246        let locked = parse_lockfile(LOCK, "l").unwrap();
247        // Exact agreement: nothing to report.
248        assert!(disagreements(&[pinned("serde", "1.0.229", "bbbb")], &locked).is_empty());
249        // The layer pins something the project does not use: not a finding.
250        assert!(disagreements(&[pinned("not-used-here", "9.9.9", "cccc")], &locked).is_empty());
251        // The project uses something the layer does not pin: also not a
252        // finding — the layer never claimed to cover every dependency.
253        assert!(disagreements(&[], &locked).is_empty());
254    }
255
256    // rivet: verifies REQ-STORE-002, REQ-LOCKPIN-001
257    #[test]
258    fn a_layer_pinning_two_versions_of_one_crate_agrees_with_a_lockfile_holding_both() {
259        // REQ-STORE-002 lets a layer hold several versions of one name — the
260        // ordinary shape of a dependency graph, 14 such names in varve's own
261        // lockfile. This gate compared every pinned entry against every locked
262        // package OF THE SAME NAME, so the moment such a layer existed it
263        // reported 1.0.200-vs-1.0.210 as drift and went red on a lockfile it
264        // agrees with completely.
265        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        // The gate still bites: a version the layer does not pin at all is
280        // still drift, and same-version-different-bytes is still caught.
281        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    // rivet: verifies REQ-LOCKPIN-001
298    #[test]
299    fn a_malformed_lockfile_is_an_error_not_a_silent_pass() {
300        // Failing open here would report "agreement" for a file we could not
301        // read — the worst possible answer for a gate.
302        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}