Skip to main content

bynk_project/
lock.rs

1//! Packaging slice 2b (#2240, track `design/tracks/packaging.md` §5.2, §6.4):
2//! `bynk.lock` and the store.
3//!
4//! The lock pins a project's (or a workspace's) resolved package graph: each
5//! git dependency's requested revision, the commit it resolved to and the
6//! sha256 of its canonical tree, and each path dependency's directory (never
7//! hashed — it is the author's working copy). Only the `bynk` CLI writes it
8//! and fills the store; the compile, `bynkc` and the editor read both and
9//! never fetch ([`crate::resolve_graph`]).
10//!
11//! The format is versioned and written in one deterministic order, and a file
12//! with no `version` is refused rather than read as empty, as
13//! `bynk.deploy.lock` refuses one: a truncated lock must not silently
14//! re-resolve every dependency.
15
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use crate::discovery::read_source;
20
21/// #2274: the first-party registry's name in routing and in a lock source.
22pub const FIRST_PARTY_REGISTRY: &str = "bynk";
23
24/// #2274: the first-party registry's index URL and pinned key
25/// (`ed25519:<base64>`), once the hosted service (packaging slice 4b) is live.
26/// Until then, an organisation no `[registries]` entry lists resolves nowhere
27/// (`bynk.registry.unavailable`).
28pub const FIRST_PARTY: Option<(&str, &str)> = None;
29
30/// The lock's file name, beside `bynk.toml` (or the workspace root's).
31pub const LOCK_FILE: &str = "bynk.lock";
32
33/// The lock format this toolchain reads and writes.
34pub const LOCK_VERSION: i64 = 1;
35
36/// One locked package.
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
38pub struct LockedPackage {
39    /// `org/package`.
40    pub name: String,
41    /// Its `[package] version`, when it declares one.
42    pub version: Option<String>,
43    /// Where it came from: [`git_source`] or `path+<dir>`.
44    pub source: String,
45    /// `sha256-<base64>` of its canonical tree; `None` for a path package.
46    pub checksum: Option<String>,
47    /// The packages (`org/package`) it depends on, sorted.
48    pub dependencies: Vec<String>,
49}
50
51/// #2255: one npm package a dependency's adapters may bring into the build,
52/// at one range. Two dependencies requiring one package at two ranges are two
53/// rows; the compile refuses the conflict (`bynk.requires.conflicting_range`)
54/// if both are reached.
55#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
56pub struct LockedNpm {
57    pub package: String,
58    pub range: String,
59    /// The adapters requiring it, as `org/package:adapter`, sorted.
60    pub required_by: Vec<String>,
61    /// #2296: whether the project approved this `(package, range)` entering
62    /// its build. Required for a fetched (git or registry) package's adapter
63    /// to reach `out/package.json`; absent reads as `false`.
64    pub approved: bool,
65}
66
67/// #2274: a registry a build used, and the key first trusted for it — what
68/// makes trust on first use stick across fresh clones (track §6.2).
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
70pub struct LockedRegistry {
71    /// The `[registries.<name>]` it was routed through (`bynk` for the
72    /// first-party registry).
73    pub name: String,
74    /// The `id` its `config.json` declares, which its signed lines bind.
75    pub id: String,
76    /// `ed25519-fp:<base64>` — the trusted key's fingerprint.
77    pub key: String,
78}
79
80/// A parsed `bynk.lock`.
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct Lock {
83    pub packages: Vec<LockedPackage>,
84    /// #2274: the registries resolution used, with their trusted keys.
85    pub registries: Vec<LockedRegistry>,
86    /// #2255: every npm package a dependency's adapters may require — what
87    /// *could* enter a build, recorded at resolve time; `out/package.json`
88    /// holds what a build's reached adapters actually require.
89    pub npm: Vec<LockedNpm>,
90}
91
92impl Lock {
93    /// The locked git package for `name` requested as `url` at `rev`.
94    pub fn git(&self, name: &str, url: &str, rev: &str) -> Option<(&LockedPackage, String)> {
95        self.packages.iter().find_map(|p| {
96            let (u, r, commit) = parse_git_source(&p.source)?;
97            (p.name == name && u == url && r == rev).then_some((p, commit))
98        })
99    }
100
101    /// The lock as `bynk.lock` text: packages sorted by name then source,
102    /// each key in a fixed order.
103    pub fn render(&self) -> String {
104        let mut packages = self.packages.clone();
105        packages.sort();
106        let mut out =
107            String::from("# bynk.lock — written by `bynk`; commit it. Do not edit by hand.\n");
108        out.push_str(&format!("version = {LOCK_VERSION}\n"));
109        for p in &packages {
110            out.push_str("\n[[package]]\n");
111            out.push_str(&format!("name = {}\n", toml_string(&p.name)));
112            if let Some(v) = &p.version {
113                out.push_str(&format!("version = {}\n", toml_string(v)));
114            }
115            out.push_str(&format!("source = {}\n", toml_string(&p.source)));
116            if let Some(c) = &p.checksum {
117                out.push_str(&format!("checksum = {}\n", toml_string(c)));
118            }
119            let deps: Vec<String> = p.dependencies.iter().map(|d| toml_string(d)).collect();
120            out.push_str(&format!("dependencies = [{}]\n", deps.join(", ")));
121        }
122        let mut registries = self.registries.clone();
123        registries.sort();
124        for r in &registries {
125            out.push_str("\n[[registry]]\n");
126            out.push_str(&format!("name = {}\n", toml_string(&r.name)));
127            out.push_str(&format!("id = {}\n", toml_string(&r.id)));
128            out.push_str(&format!("key = {}\n", toml_string(&r.key)));
129        }
130        let mut npm = self.npm.clone();
131        npm.sort();
132        for n in &npm {
133            out.push_str("\n[[npm]]\n");
134            out.push_str(&format!("package = {}\n", toml_string(&n.package)));
135            out.push_str(&format!("range = {}\n", toml_string(&n.range)));
136            let by: Vec<String> = n.required_by.iter().map(|d| toml_string(d)).collect();
137            out.push_str(&format!("required-by = [{}]\n", by.join(", ")));
138            if n.approved {
139                out.push_str("approved = true\n");
140            }
141        }
142        out
143    }
144}
145
146/// Why a `bynk.lock` couldn't be read.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct LockError(pub String);
149
150impl std::fmt::Display for LockError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        f.write_str(&self.0)
153    }
154}
155
156/// Parse `bynk.lock`'s text.
157pub fn parse_lock(text: &str) -> Result<Lock, LockError> {
158    let doc: toml::Table = text
159        .parse()
160        .map_err(|_| LockError("`bynk.lock` is not valid TOML".to_string()))?;
161    match doc.get("version").and_then(|v| v.as_integer()) {
162        Some(LOCK_VERSION) => {}
163        Some(v) => {
164            return Err(LockError(format!(
165                "`bynk.lock` is version {v}, which this toolchain doesn't read (it reads version {LOCK_VERSION})"
166            )));
167        }
168        None => {
169            return Err(LockError(
170                "`bynk.lock` has no `version` — it may be truncated; delete it and run `bynk fetch`"
171                    .to_string(),
172            ));
173        }
174    }
175    let mut packages = Vec::new();
176    let entries = match doc.get("package") {
177        None => Vec::new(),
178        Some(toml::Value::Array(items)) => items.clone(),
179        Some(_) => {
180            return Err(LockError(
181                "`bynk.lock`'s `package` must be an array of tables".into(),
182            ));
183        }
184    };
185    for entry in entries {
186        let Some(t) = entry.as_table() else {
187            return Err(LockError(
188                "a `[[package]]` in `bynk.lock` isn't a table".into(),
189            ));
190        };
191        let text = |k: &str| t.get(k).and_then(|v| v.as_str()).map(str::to_owned);
192        let (Some(name), Some(source)) = (text("name"), text("source")) else {
193            return Err(LockError(
194                "a `[[package]]` in `bynk.lock` lacks `name` or `source`".into(),
195            ));
196        };
197        let dependencies = t
198            .get("dependencies")
199            .and_then(|v| v.as_array())
200            .map(|a| {
201                a.iter()
202                    .filter_map(|d| d.as_str().map(str::to_owned))
203                    .collect()
204            })
205            .unwrap_or_default();
206        packages.push(LockedPackage {
207            name,
208            version: text("version"),
209            source,
210            checksum: text("checksum"),
211            dependencies,
212        });
213    }
214    let mut npm = Vec::new();
215    let rows = match doc.get("npm") {
216        None => Vec::new(),
217        Some(toml::Value::Array(items)) => items.clone(),
218        Some(_) => {
219            return Err(LockError(
220                "`bynk.lock`'s `npm` must be an array of tables".into(),
221            ));
222        }
223    };
224    for row in rows {
225        let Some(t) = row.as_table() else {
226            return Err(LockError(
227                "an `[[npm]]` in `bynk.lock` isn't a table".into(),
228            ));
229        };
230        let text = |k: &str| t.get(k).and_then(|v| v.as_str()).map(str::to_owned);
231        let (Some(package), Some(range)) = (text("package"), text("range")) else {
232            return Err(LockError(
233                "an `[[npm]]` in `bynk.lock` lacks `package` or `range`".into(),
234            ));
235        };
236        let required_by = match t.get("required-by") {
237            None => Vec::new(),
238            Some(toml::Value::Array(items)) => items
239                .iter()
240                .map(|d| d.as_str().map(str::to_owned))
241                .collect::<Option<Vec<_>>>()
242                .ok_or_else(|| {
243                    LockError(
244                        "an `[[npm]]`'s `required-by` in `bynk.lock` must list strings".into(),
245                    )
246                })?,
247            Some(_) => {
248                return Err(LockError(
249                    "an `[[npm]]`'s `required-by` in `bynk.lock` must be a list".into(),
250                ));
251            }
252        };
253        let approved = match t.get("approved") {
254            None => false,
255            Some(toml::Value::Boolean(b)) => *b,
256            Some(_) => {
257                return Err(LockError(
258                    "an `[[npm]]`'s `approved` in `bynk.lock` must be `true` or `false`".into(),
259                ));
260            }
261        };
262        npm.push(LockedNpm {
263            package,
264            range,
265            required_by,
266            approved,
267        });
268    }
269    let mut registries = Vec::new();
270    let rows = match doc.get("registry") {
271        None => Vec::new(),
272        Some(toml::Value::Array(items)) => items.clone(),
273        Some(_) => {
274            return Err(LockError(
275                "`bynk.lock`'s `registry` must be an array of tables".into(),
276            ));
277        }
278    };
279    for row in rows {
280        let t = row.as_table();
281        let text = |k: &str| {
282            t.and_then(|t| t.get(k))
283                .and_then(|v| v.as_str())
284                .map(str::to_owned)
285        };
286        let (Some(name), Some(id), Some(key)) = (text("name"), text("id"), text("key")) else {
287            return Err(LockError(
288                "a `[[registry]]` in `bynk.lock` needs `name`, `id` and `key`".into(),
289            ));
290        };
291        registries.push(LockedRegistry { name, id, key });
292    }
293    Ok(Lock {
294        packages,
295        registries,
296        npm,
297    })
298}
299
300/// #2274: a registry package's `source`: `registry+<name>`.
301pub fn registry_source(name: &str) -> String {
302    format!("registry+{name}")
303}
304
305/// The registry name of a `registry+<name>` source.
306pub fn parse_registry_source(source: &str) -> Option<&str> {
307    source.strip_prefix("registry+")
308}
309
310impl Lock {
311    /// The locked package called `name`, whatever its source.
312    pub fn package(&self, name: &str) -> Option<&LockedPackage> {
313        self.packages.iter().find(|p| p.name == name)
314    }
315
316    /// The locked trust record for registry `name`.
317    pub fn registry(&self, name: &str) -> Option<&LockedRegistry> {
318        self.registries.iter().find(|r| r.name == name)
319    }
320}
321
322/// #2255: the npm packages an adapter source file's `binding … requires { … }`
323/// names, with the adapter's declared name: `None` when the file isn't an
324/// adapter with a binding. Recovery-parsed, so a file with an error elsewhere
325/// still yields its clause; the compile reports the error.
326pub fn adapter_requires(source: &str) -> Option<(String, Vec<(String, String)>)> {
327    let tokens = bynk_syntax::lexer::tokenize(source).ok()?;
328    let (unit, _errors) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, source);
329    let bynk_syntax::ast::SourceUnit::Adapter(a) = unit? else {
330        return None;
331    };
332    let binding = a.binding.as_ref()?;
333    Some((
334        a.name.joined(),
335        binding
336            .requires
337            .iter()
338            .map(|d| (d.package.clone(), d.range.clone()))
339            .collect(),
340    ))
341}
342
343/// Where the lock for the project at `project_root` lives: its workspace's
344/// root when it is a member (one lock per workspace, track §4.2), else its own
345/// root.
346pub fn lock_root(project_root: &Path) -> PathBuf {
347    // A relative root (`.`) has no ancestors to search, so the workspace is
348    // looked for from its real path.
349    let real = project_root
350        .canonicalize()
351        .unwrap_or_else(|_| project_root.to_path_buf());
352    crate::workspace::find_parent_workspace(&real).unwrap_or_else(|| project_root.to_path_buf())
353}
354
355/// Read the lock at `lock_root`: the overlay first, then the disk. `Ok(None)`
356/// when there is none.
357pub fn read_lock(
358    lock_root: &Path,
359    overlay: &HashMap<PathBuf, String>,
360) -> Result<Option<Lock>, LockError> {
361    let path = lock_root.join(LOCK_FILE);
362    let text = match read_source(&path, overlay) {
363        Ok(text) => text,
364        Err(_) => match std::fs::read_to_string(&path) {
365            Ok(text) => text,
366            Err(_) => return Ok(None),
367        },
368    };
369    parse_lock(&text).map(Some)
370}
371
372/// A git package's `source`: `git+<url>?rev=<rev>#<commit>`.
373pub fn git_source(url: &str, rev: &str, commit: &str) -> String {
374    format!("git+{url}?rev={rev}#{commit}")
375}
376
377/// `(url, rev, commit)` of a git `source`, or `None` for any other source.
378pub fn parse_git_source(source: &str) -> Option<(String, String, String)> {
379    let rest = source.strip_prefix("git+")?;
380    let (rest, commit) = rest.rsplit_once('#')?;
381    let (url, rev) = rest.rsplit_once("?rev=")?;
382    Some((url.to_owned(), rev.to_owned(), commit.to_owned()))
383}
384
385/// A path package's `source`: `path+<dir>`, `/`-separated.
386pub fn path_source(dir: &Path) -> String {
387    let parts: Vec<String> = dir
388        .components()
389        .map(|c| c.as_os_str().to_string_lossy().into_owned())
390        .collect();
391    format!("path+{}", parts.join("/"))
392}
393
394/// The store's root: `$BYNK_HOME`, else `$XDG_CACHE_HOME/bynk`, else
395/// `~/.cache/bynk`; `None` when none can be determined.
396pub fn bynk_home() -> Option<PathBuf> {
397    if let Some(home) = std::env::var_os("BYNK_HOME").filter(|v| !v.is_empty()) {
398        return Some(PathBuf::from(home));
399    }
400    if let Some(cache) = std::env::var_os("XDG_CACHE_HOME").filter(|v| !v.is_empty()) {
401        return Some(PathBuf::from(cache).join("bynk"));
402    }
403    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
404    Some(PathBuf::from(home).join(".cache").join("bynk"))
405}
406
407/// The store directory holding the tree whose checksum is `checksum`
408/// (`sha256-<base64>`): `<home>/store/sha256/<hex>`.
409pub fn store_dir(home: &Path, checksum: &str) -> Option<PathBuf> {
410    let hex = checksum_hex(checksum)?;
411    Some(home.join("store").join("sha256").join(hex))
412}
413
414/// `sha256-<base64>` for a 32-byte digest.
415pub fn checksum_of(digest: &[u8; 32]) -> String {
416    format!("sha256-{}", base64_encode(digest))
417}
418
419/// The lowercase hex of a `sha256-<base64>` checksum's digest.
420pub fn checksum_hex(checksum: &str) -> Option<String> {
421    let bytes = base64_decode(checksum.strip_prefix("sha256-")?)?;
422    if bytes.len() != 32 {
423        return None;
424    }
425    Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
426}
427
428const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
429
430pub fn base64_encode(bytes: &[u8]) -> String {
431    let mut out = String::new();
432    for chunk in bytes.chunks(3) {
433        let b = [
434            chunk[0],
435            *chunk.get(1).unwrap_or(&0),
436            *chunk.get(2).unwrap_or(&0),
437        ];
438        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
439        for i in 0..4 {
440            if i <= chunk.len() {
441                out.push(B64[((n >> (18 - 6 * i)) & 63) as usize] as char);
442            } else {
443                out.push('=');
444            }
445        }
446    }
447    out
448}
449
450pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
451    let mut out = Vec::new();
452    let mut buf = 0u32;
453    let mut bits = 0;
454    for c in text.bytes().filter(|&c| c != b'=') {
455        let v = B64.iter().position(|&b| b == c)? as u32;
456        buf = (buf << 6) | v;
457        bits += 6;
458        if bits >= 8 {
459            bits -= 8;
460            out.push((buf >> bits) as u8);
461            buf &= (1 << bits) - 1;
462        }
463    }
464    Some(out)
465}
466
467/// `s` as a TOML basic string.
468fn toml_string(s: &str) -> String {
469    let mut out = String::from("\"");
470    for c in s.chars() {
471        match c {
472            '"' => out.push_str("\\\""),
473            '\\' => out.push_str("\\\\"),
474            '\n' => out.push_str("\\n"),
475            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
476            c => out.push(c),
477        }
478    }
479    out.push('"');
480    out
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn checksums_round_trip_through_base64() {
489        let digest: [u8; 32] = std::array::from_fn(|i| (i * 7) as u8);
490        let c = checksum_of(&digest);
491        assert!(c.starts_with("sha256-") && c.ends_with('='));
492        let hex = checksum_hex(&c).unwrap();
493        assert_eq!(hex.len(), 64);
494        assert_eq!(&hex[..6], "00070e");
495        assert_eq!(checksum_hex("sha256-short"), None);
496    }
497
498    #[test]
499    fn the_lock_round_trips_in_a_fixed_order() {
500        let lock = Lock {
501            registries: vec![LockedRegistry {
502                name: "acme-internal".into(),
503                id: "acme".into(),
504                key: "ed25519-fp:abc".into(),
505            }],
506            npm: vec![
507                LockedNpm {
508                    package: "jose".into(),
509                    range: "^5.2.0".into(),
510                    required_by: vec!["acme/ledger:auth".into()],
511                    approved: true,
512                },
513                LockedNpm {
514                    package: "stripe".into(),
515                    range: "^14.2.0".into(),
516                    required_by: vec!["acme/ledger:pay".into()],
517                    approved: false,
518                },
519            ],
520            packages: vec![
521                LockedPackage {
522                    name: "acme/money".into(),
523                    version: None,
524                    source: "path+../packages/money".into(),
525                    checksum: None,
526                    dependencies: vec![],
527                },
528                LockedPackage {
529                    name: "acme/ledger".into(),
530                    version: Some("0.3.0".into()),
531                    source: git_source("https://x/ledger", "v0.3.0", "9f1c"),
532                    checksum: Some(checksum_of(&[1; 32])),
533                    dependencies: vec!["acme/money".into()],
534                },
535            ],
536        };
537        let text = lock.render();
538        assert!(text.find("acme/ledger").unwrap() < text.find("acme/money").unwrap());
539        let back = parse_lock(&text).unwrap();
540        assert_eq!(back.render(), text);
541        assert!(text.contains("[[npm]]\npackage = \"jose\"\nrange = \"^5.2.0\"\nrequired-by = [\"acme/ledger:auth\"]\napproved = true\n"));
542        assert!(
543            text.contains("required-by = [\"acme/ledger:pay\"]\n\n")
544                || text.ends_with("required-by = [\"acme/ledger:pay\"]\n")
545        );
546        assert_eq!(back.npm, lock.npm, "approval survives the round trip");
547        assert!(text.find("[[npm]]").unwrap() > text.find("[[package]]").unwrap());
548        assert!(text.contains(
549            "[[registry]]\nname = \"acme-internal\"\nid = \"acme\"\nkey = \"ed25519-fp:abc\"\n"
550        ));
551        assert_eq!(back.registry("acme-internal").unwrap().id, "acme");
552        let (p, commit) = back
553            .git("acme/ledger", "https://x/ledger", "v0.3.0")
554            .unwrap();
555        assert_eq!(commit, "9f1c");
556        assert_eq!(p.version.as_deref(), Some("0.3.0"));
557        assert!(
558            back.git("acme/ledger", "https://x/ledger", "main")
559                .is_none()
560        );
561    }
562
563    #[test]
564    fn a_malformed_required_by_is_refused() {
565        let row = |by: &str| {
566            format!(
567                "version = 1\n\n[[npm]]\npackage = \"jose\"\nrange = \"^5\"\nrequired-by = {by}\n"
568            )
569        };
570        assert!(
571            parse_lock(&row("\"a/b:c\""))
572                .unwrap_err()
573                .0
574                .contains("must be a list")
575        );
576        assert!(
577            parse_lock(&row("[1]"))
578                .unwrap_err()
579                .0
580                .contains("must list strings")
581        );
582        assert_eq!(
583            parse_lock(&row("[\"a/b:c\"]")).unwrap().npm[0].required_by,
584            ["a/b:c"]
585        );
586    }
587
588    #[test]
589    fn a_lock_without_a_version_is_refused() {
590        assert!(
591            parse_lock("[[package]]\nname = \"a/b\"\nsource = \"path+x\"\n")
592                .unwrap_err()
593                .0
594                .contains("no `version`")
595        );
596        assert!(
597            parse_lock("version = 2\n")
598                .unwrap_err()
599                .0
600                .contains("version 2")
601        );
602        assert_eq!(parse_lock("version = 1\n").unwrap(), Lock::default());
603    }
604}
605
606#[cfg(test)]
607mod requires_tests {
608    use super::adapter_requires;
609
610    #[test]
611    fn an_adapters_requires_are_read() {
612        let src = "adapter tokens {\n  binding \"./t.binding.ts\" requires { \"jose\": \"^5\", \"zod\": \"3.23.8\" }\n}\n";
613        assert_eq!(
614            adapter_requires(src),
615            Some((
616                "tokens".to_string(),
617                vec![
618                    ("jose".into(), "^5".into()),
619                    ("zod".into(), "3.23.8".into())
620                ]
621            ))
622        );
623        assert_eq!(adapter_requires("commons x\n"), None);
624    }
625}