Skip to main content

gen_types/
lockfile.rs

1//! Typed [`Lockfile`] — resolved, content-addressed dependency tree.
2//!
3//! Every package manager ships a lockfile (`Cargo.lock`,
4//! `package-lock.json`, `Gemfile.lock`, `poetry.lock`, `go.sum`,
5//! `mix.lock`, …). Each pins exact resolved versions + integrity
6//! hashes so subsequent installs are deterministic.
7//!
8//! Adapters parse their native lockfile into this canonical shape;
9//! the resolver in `gen-engine` validates the manifest's typed
10//! constraints against the lockfile's resolved versions.
11
12use crate::{PackageId, PackageSource};
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17/// BLAKE3-32 content hash. Used for lockfile integrity + cache keys.
18#[derive(Clone, Copy, PartialEq, Eq, Hash)]
19pub struct ContentHash(pub [u8; 32]);
20
21impl ContentHash {
22    /// Hash arbitrary bytes.
23    #[must_use]
24    pub fn of(bytes: &[u8]) -> Self {
25        Self(*blake3::hash(bytes).as_bytes())
26    }
27
28    /// Genesis sentinel (all-zero).
29    #[must_use]
30    pub const fn genesis() -> Self {
31        Self([0u8; 32])
32    }
33
34    /// 64-char lowercase hex.
35    #[must_use]
36    pub fn hex(&self) -> String {
37        let mut s = String::with_capacity(64);
38        for byte in &self.0 {
39            s.push_str(&format!("{byte:02x}"));
40        }
41        s
42    }
43
44    /// Parse a 64-char hex string. Returns `None` for any other length
45    /// or non-hex content.
46    #[must_use]
47    pub fn from_hex(s: &str) -> Option<Self> {
48        if s.len() != 64 {
49            return None;
50        }
51        let mut out = [0u8; 32];
52        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
53            let hex_str = std::str::from_utf8(chunk).ok()?;
54            out[i] = u8::from_str_radix(hex_str, 16).ok()?;
55        }
56        Some(Self(out))
57    }
58
59    /// Parse a hex string of any length: 64 chars → BLAKE3-32, otherwise
60    /// re-hash the raw string. Useful for ingesting heterogeneous
61    /// integrity hashes (sha256:hex / sha512:hex / etc.) into the
62    /// typed [`ContentHash`] cache-key space without losing identity.
63    #[must_use]
64    pub fn from_hex_padded(s: &str) -> Option<Self> {
65        if s.len() == 64 {
66            Self::from_hex(s)
67        } else {
68            Some(Self::of(s.as_bytes()))
69        }
70    }
71}
72
73impl fmt::Debug for ContentHash {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        write!(f, "ContentHash({}…)", &self.hex()[..16])
76    }
77}
78
79impl fmt::Display for ContentHash {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(&self.hex())
82    }
83}
84
85impl Serialize for ContentHash {
86    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
87        ser.serialize_str(&self.hex())
88    }
89}
90
91impl<'de> Deserialize<'de> for ContentHash {
92    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
93        let s = String::deserialize(de)?;
94        if s.len() != 64 {
95            return Err(serde::de::Error::custom(format!(
96                "ContentHash expected 64 hex chars, got {}",
97                s.len()
98            )));
99        }
100        let mut out = [0u8; 32];
101        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
102            let hex_str = std::str::from_utf8(chunk).map_err(serde::de::Error::custom)?;
103            out[i] = u8::from_str_radix(hex_str, 16).map_err(serde::de::Error::custom)?;
104        }
105        Ok(Self(out))
106    }
107}
108
109/// One resolved package in the lockfile. Pins exact source +
110/// integrity.
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ResolvedPackage {
113    pub id: PackageId,
114    pub source: PackageSource,
115    /// `sha256:…` / `sha512:…` / etc. from the registry.
116    pub integrity: Option<String>,
117    /// Resolved dependencies as edges to other `PackageId`s in the
118    /// same lockfile.
119    #[serde(default)]
120    pub resolved_dependencies: Vec<PackageId>,
121    /// `[package] links = "<symbol>"` from the package's `Cargo.toml`.
122    /// Cargo sets `CARGO_MANIFEST_LINKS` from this value when invoking
123    /// the build script. Build scripts for `ring`, the `*-sys` family
124    /// (openssl-sys, bzip2-sys, libsqlite3-sys, …), and other native-
125    /// shim crates ASSERT on this env var matching the declared
126    /// `links`. Without this field threaded through the renderer the
127    /// emitted Nix derivation builds the build-script binary with an
128    /// empty `CARGO_MANIFEST_LINKS`, the assertion fires, and the
129    /// crate fails to build. Populated by the consumer (gen-cli)
130    /// from `Cargo.build-spec.json` at render time. Default-None so
131    /// existing lockfiles stay deserialisable.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub links: Option<String>,
134}
135
136/// Typed lockfile — content-addressed, deterministic.
137#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
138pub struct Lockfile {
139    pub resolved: IndexMap<String, ResolvedPackage>,
140    /// BLAKE3 of the canonical serialised lockfile. Cache layer keys
141    /// on this for "did the workspace change?" gates.
142    pub content_addressed_hash: ContentHash,
143}
144
145impl Lockfile {
146    /// Empty lockfile (fresh project, never resolved). The
147    /// content-addressed hash is the all-zero genesis.
148    #[must_use]
149    pub fn empty() -> Self {
150        Self {
151            resolved: IndexMap::new(),
152            content_addressed_hash: ContentHash::genesis(),
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::{Registry, Version};
161
162    #[test]
163    fn content_hash_round_trips_through_serde() {
164        let h = ContentHash::of(b"some bytes");
165        let j = serde_json::to_string(&h).unwrap();
166        let parsed: ContentHash = serde_json::from_str(&j).unwrap();
167        assert_eq!(h, parsed);
168    }
169
170    #[test]
171    fn content_hash_genesis_is_all_zero() {
172        assert_eq!(ContentHash::genesis().0, [0u8; 32]);
173    }
174
175    #[test]
176    fn content_hash_hex_is_64_chars() {
177        let h = ContentHash::of(b"x");
178        assert_eq!(h.hex().len(), 64);
179    }
180
181    #[test]
182    fn lockfile_round_trips_through_serde() {
183        let mut resolved = IndexMap::new();
184        resolved.insert(
185            "serde".to_string(),
186            ResolvedPackage {
187                id: PackageId {
188                    name: "serde".into(),
189                    version: Version::new(1, 0, 228),
190                    registry: Registry::CratesIo,
191                },
192                source: PackageSource::Registry {
193                    registry: Registry::CratesIo,
194                    registry_name: "serde".into(),
195                    integrity_hash: Some("sha256:abc".into()),
196                },
197                integrity: Some("sha256:abc".into()),
198                resolved_dependencies: vec![],
199                links: None,
200            },
201        );
202        let l = Lockfile {
203            resolved,
204            content_addressed_hash: ContentHash::of(b"snapshot"),
205        };
206        let j = serde_json::to_string(&l).unwrap();
207        let parsed: Lockfile = serde_json::from_str(&j).unwrap();
208        assert_eq!(l, parsed);
209    }
210}