1use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19use crate::discovery::read_source;
20
21pub const FIRST_PARTY_REGISTRY: &str = "bynk";
23
24pub const FIRST_PARTY: Option<(&str, &str)> = None;
29
30pub const LOCK_FILE: &str = "bynk.lock";
32
33pub const LOCK_VERSION: i64 = 1;
35
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
38pub struct LockedPackage {
39 pub name: String,
41 pub version: Option<String>,
43 pub source: String,
45 pub checksum: Option<String>,
47 pub dependencies: Vec<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
56pub struct LockedNpm {
57 pub package: String,
58 pub range: String,
59 pub required_by: Vec<String>,
61 pub approved: bool,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
70pub struct LockedRegistry {
71 pub name: String,
74 pub id: String,
76 pub key: String,
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct Lock {
83 pub packages: Vec<LockedPackage>,
84 pub registries: Vec<LockedRegistry>,
86 pub npm: Vec<LockedNpm>,
90}
91
92impl Lock {
93 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 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 ®istries {
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#[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
156pub 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
300pub fn registry_source(name: &str) -> String {
302 format!("registry+{name}")
303}
304
305pub fn parse_registry_source(source: &str) -> Option<&str> {
307 source.strip_prefix("registry+")
308}
309
310impl Lock {
311 pub fn package(&self, name: &str) -> Option<&LockedPackage> {
313 self.packages.iter().find(|p| p.name == name)
314 }
315
316 pub fn registry(&self, name: &str) -> Option<&LockedRegistry> {
318 self.registries.iter().find(|r| r.name == name)
319 }
320}
321
322pub 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
343pub fn lock_root(project_root: &Path) -> PathBuf {
347 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
355pub 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
372pub fn git_source(url: &str, rev: &str, commit: &str) -> String {
374 format!("git+{url}?rev={rev}#{commit}")
375}
376
377pub 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
385pub 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
394pub 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
407pub 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
414pub fn checksum_of(digest: &[u8; 32]) -> String {
416 format!("sha256-{}", base64_encode(digest))
417}
418
419pub 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
467fn 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}