1pub mod account;
14pub(crate) mod archive;
15pub mod commands;
16pub mod conformance;
17pub mod protocol;
18pub mod publish;
19pub(crate) mod select;
20pub mod serve;
21pub(crate) mod transport;
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use bynk_project::RegistryConfig;
27use bynk_project::lock::{FIRST_PARTY, FIRST_PARTY_REGISTRY, Lock, LockedRegistry, store_dir};
28
29use crate::fetch::{FetchError, fail};
30use protocol::{Config, ConfigTrust, IndexLine, fingerprint, verify};
31use transport::{Transport, join};
32
33pub(crate) const BAD_SIGNATURE: &str = "bynk.registry.bad_signature";
34pub(crate) const KEY_MISMATCH: &str = "bynk.registry.key_mismatch";
35pub(crate) const UNAVAILABLE: &str = "bynk.registry.unavailable";
36pub(crate) const SOURCE_MISMATCH: &str = "bynk.registry.source_mismatch";
37pub(crate) const WITHDRAWN: &str = "bynk.registry.withdrawn";
38
39struct Opened {
41 index: String,
43 mirror: Option<String>,
46 dl: String,
48 api: Option<String>,
50 id: String,
52 keys: Vec<String>,
55 revoked: Vec<String>,
58 transport: Transport,
59 token_source: Option<TokenSource>,
61 lines: BTreeMap<String, Vec<IndexLine>>,
63}
64
65pub(crate) struct Client {
67 configs: Vec<RegistryConfig>,
69 locked: Vec<LockedRegistry>,
71 opened: BTreeMap<String, Opened>,
72 pub trust: BTreeMap<String, LockedRegistry>,
74 pub notices: Vec<String>,
76 acting_for: Option<String>,
79}
80
81impl Client {
82 pub(crate) fn new(configs: Vec<RegistryConfig>, existing: Option<&Lock>) -> Client {
83 Client {
84 configs,
85 locked: existing.map(|l| l.registries.clone()).unwrap_or_default(),
86 opened: BTreeMap::new(),
87 trust: BTreeMap::new(),
88 notices: Vec::new(),
89 acting_for: None,
90 }
91 }
92
93 pub(crate) fn acting_for(mut self, org: &str) -> Client {
96 self.acting_for = Some(org.to_string());
97 self
98 }
99
100 pub(crate) fn token_source(&self, registry: &str) -> Option<&TokenSource> {
102 self.opened.get(registry)?.token_source.as_ref()
103 }
104
105 pub(crate) fn route(&self, org: &str) -> String {
108 self.configs
109 .iter()
110 .find(|c| c.organisations.iter().any(|o| o == org))
111 .map_or_else(|| FIRST_PARTY_REGISTRY.to_string(), |c| c.name.clone())
112 }
113
114 fn open(&mut self, name: &str) -> Result<&mut Opened, FetchError> {
116 if self.opened.contains_key(name) {
117 return Ok(self.opened.get_mut(name).expect("checked"));
118 }
119 if name == FIRST_PARTY_REGISTRY && FIRST_PARTY.is_none() {
122 return Err(fail(
123 UNAVAILABLE,
124 "the first-party registry isn't live yet: add a `[registries.<name>]` whose `organisations` lists the organisation",
125 ));
126 }
127 let mirror = self
129 .configs
130 .iter()
131 .find(|c| c.mirror_of.as_deref() == Some(name))
132 .cloned();
133 let own = self.configs.iter().find(|c| c.name == name).cloned();
134 let (index, pinned, reader) = match (&mirror, &own) {
135 (Some(m), _) => (
136 m.index.clone(),
137 own.as_ref()
138 .and_then(|o| o.key.clone())
139 .or_else(|| first_party_key(name)),
140 m.name.clone(),
141 ),
142 (None, Some(o)) => (o.index.clone(), o.key.clone(), o.name.clone()),
143 (None, None) if name == FIRST_PARTY_REGISTRY => match FIRST_PARTY {
144 Some((index, key)) => (index.to_string(), Some(key.to_string()), name.to_string()),
145 None => {
146 return Err(fail(
147 UNAVAILABLE,
148 "the first-party registry isn't live yet: add a `[registries.<name>]` whose `organisations` lists the organisation",
149 ));
150 }
151 },
152 (None, None) => {
153 return Err(fail(
154 UNAVAILABLE,
155 format!("no `[registries.{name}]` is configured"),
156 ));
157 }
158 };
159 let config_url = join(&index, "config.json");
160 let (token, token_source) =
163 token_for(&reader, self.acting_for.as_deref(), &mut self.notices).unzip();
164 let mut transport = Transport::new(&reader, &[&index]).with_token(token);
165 let bytes = transport.get(&config_url)?.ok_or_else(|| {
166 fail(
167 UNAVAILABLE,
168 format!("registry `{reader}` has no `config.json` at `{config_url}`"),
169 )
170 })?;
171 let config = Config::parse(&bytes)
172 .map_err(|e| fail(UNAVAILABLE, format!("registry `{reader}`: {e}")))?;
173 let locked = self.locked.iter().find(|r| r.name == name).cloned();
174 let id = match &locked {
176 Some(r) => {
177 if mirror.is_none() && r.id != config.id {
178 return Err(fail(
179 KEY_MISMATCH,
180 format!(
181 "registry `{name}` now calls itself `{}`, but `bynk.lock` trusted it as `{}`",
182 config.id, r.id
183 ),
184 ));
185 }
186 r.id.clone()
187 }
188 None => config.id.clone(),
189 };
190 let trusted = match (&pinned, &locked) {
191 (Some(key), _) => fingerprint(key).ok_or_else(|| {
192 fail(
193 KEY_MISMATCH,
194 format!("`[registries.{name}] key` isn't an `ed25519:` key"),
195 )
196 })?,
197 (None, Some(r)) => r.key.clone(),
198 (None, None) => {
199 let newest = &config.keys.last().expect("parse checks").key;
200 let fp = fingerprint(newest).ok_or_else(|| {
201 fail(
202 KEY_MISMATCH,
203 format!("registry `{name}`'s key isn't an `ed25519:` key"),
204 )
205 })?;
206 self.notices.push(format!(
207 "trusting registry `{name}` (id `{}`) key {fp} on first use — pin it with `key = \"{newest}\"` in `[registries.{name}]`",
208 config.id
209 ));
210 fp
211 }
212 };
213 let start = config
214 .keys
215 .iter()
216 .position(|k| fingerprint(&k.key).as_deref() == Some(trusted.as_str()));
217 let chained = config.follow_chain(&trusted).is_some();
218 let (Some(start), true) = (start, chained) else {
219 return Err(fail(
220 KEY_MISMATCH,
221 format!(
222 "registry `{name}`'s keys don't lead from the trusted key {trusted} — if its key was replaced deliberately, pin the new one in `[registries.{name}]`"
223 ),
224 ));
225 };
226 self.trust.insert(
231 name.to_string(),
232 LockedRegistry {
233 name: name.to_string(),
234 id: id.clone(),
235 key: trusted.clone(),
236 },
237 );
238 let dl = transport::resolve(&config_url, &config.dl);
239 let allowed = matches!(
243 (scheme(&index).as_deref(), scheme(&dl).as_deref()),
244 (Some("file"), Some("file"))
245 | (Some("https"), Some("https"))
246 | (Some("http"), Some("http" | "https"))
247 );
248 if !allowed {
249 return Err(fail(
250 UNAVAILABLE,
251 format!(
252 "registry `{reader}`'s `config.json` puts its archives at `{dl}`, which its index `{index}` doesn't allow"
253 ),
254 ));
255 }
256 transport.allow_origin(&dl);
257 let api = match &config.api {
259 None => None,
260 Some(a) => {
261 let api = transport::resolve(&config_url, a);
262 let allowed = matches!(
263 (scheme(&index).as_deref(), scheme(&api).as_deref()),
264 (Some("https"), Some("https")) | (Some("http"), Some("http" | "https"))
265 );
266 if !allowed {
267 return Err(fail(
268 UNAVAILABLE,
269 format!(
270 "registry `{reader}`'s `config.json` puts its publish API at `{api}`, which its index `{index}` doesn't allow"
271 ),
272 ));
273 }
274 transport.allow_origin(&api);
275 Some(api)
276 }
277 };
278 let chain: Vec<String> = config.keys[start..].iter().map(|k| k.key.clone()).collect();
281 let signers = config.signers(&chain);
282 for why in &signers.ignored {
283 self.notices.push(format!(
284 "registry `{name}`'s `config.json` has {why} — ignoring it"
285 ));
286 }
287 let opened = Opened {
288 index,
289 mirror: mirror.map(|m| m.name),
290 dl,
291 api,
292 id,
293 keys: signers.keys,
294 revoked: signers.revoked,
295 transport,
296 token_source,
297 lines: BTreeMap::new(),
298 };
299 Ok(self.opened.entry(name.to_string()).or_insert(opened))
300 }
301
302 pub(crate) fn lines(
304 &mut self,
305 registry: &str,
306 package: &str,
307 ) -> Result<Vec<IndexLine>, FetchError> {
308 let opened = self.open(registry)?;
309 if let Some(lines) = opened.lines.get(package) {
310 return Ok(lines.clone());
311 }
312 let url = join(&opened.index, package);
313 let Some(bytes) = opened.transport.get(&url)? else {
314 return Err(fail(
315 UNAVAILABLE,
316 format!("registry `{registry}` has no package `{package}`"),
317 ));
318 };
319 let text = String::from_utf8(bytes).map_err(|_| {
320 fail(
321 BAD_SIGNATURE,
322 format!("`{package}`'s index on `{registry}` isn't UTF-8"),
323 )
324 })?;
325 let mut lines = Vec::new();
326 for raw in text.lines().filter(|l| !l.trim().is_empty()) {
327 let line = IndexLine::parse(raw)
328 .map_err(|e| fail(BAD_SIGNATURE, format!("`{package}` on `{registry}`: {e}")))?;
329 let payload = line.payload(&opened.id);
330 let signed = opened.keys.iter().any(|k| verify(k, &payload, &line.sig));
331 if !signed
332 && line.name == package
333 && let Some(k) = opened
334 .revoked
335 .iter()
336 .find(|k| verify(k, &payload, &line.sig))
337 {
338 return Err(fail(
339 BAD_SIGNATURE,
340 format!(
341 "`{package}` {} on registry `{registry}` is signed by {k}, a key the registry revoked — the registry must re-sign it (`bynk registry resign`)",
342 line.version
343 ),
344 ));
345 }
346 if !signed || line.name != package {
347 return Err(fail(
348 BAD_SIGNATURE,
349 format!(
350 "`{package}` {} on registry `{registry}` isn't signed by its trusted key for `{package}` on `{}` — refusing it",
351 line.version, opened.id
352 ),
353 ));
354 }
355 lines.push(line);
356 }
357 opened.lines.insert(package.to_string(), lines.clone());
358 Ok(lines)
359 }
360
361 pub(crate) fn writable(&self, registry: &str) -> Result<(), FetchError> {
367 match self
368 .configs
369 .iter()
370 .find(|c| c.mirror_of.as_deref() == Some(registry))
371 {
372 Some(mirror) => Err(fail(
373 publish::PUBLISH_REFUSED,
374 format!(
375 "registry `{registry}` is read through the mirror `{}`, and a publish or yank goes only to the registry itself — run this from a project without the mirror",
376 mirror.name
377 ),
378 )),
379 None => Ok(()),
380 }
381 }
382
383 pub(crate) fn api_write(
387 &mut self,
388 registry: &str,
389 method: &str,
390 path: &str,
391 body: Option<&[u8]>,
392 ) -> Result<(u16, Vec<u8>), FetchError> {
393 self.writable(registry)?;
394 let opened = self.open(registry)?;
395 let Some(api) = opened.api.clone() else {
396 return Err(fail(
397 publish::PUBLISH_REFUSED,
398 format!(
399 "registry `{registry}` doesn't take publishes over HTTP (its `config.json` has no `api`) — publish with `bynk registry add`"
400 ),
401 ));
402 };
403 opened.transport.send(method, &join(&api, path), body)
404 }
405
406 pub(crate) fn account_call(
412 &mut self,
413 registry: &str,
414 method: &str,
415 path: &str,
416 body: Option<&serde_json::Value>,
417 bearer: Option<&str>,
418 ) -> Result<(u16, Vec<u8>), FetchError> {
419 let opened = self.open(registry)?;
420 if let Some(mirror) = &opened.mirror {
421 return Err(fail(
422 account::ACCOUNT_REFUSED,
423 format!(
424 "registry `{registry}` is read through the mirror `{mirror}`, and an account token goes only to the registry itself — run this from a project without the mirror"
425 ),
426 ));
427 }
428 let Some(api) = opened.api.clone() else {
429 return Err(fail(
430 account::ACCOUNT_REFUSED,
431 format!(
432 "registry `{registry}` has no account API (its `config.json` has no `api`)"
433 ),
434 ));
435 };
436 let url = join(&account_base(&api), path);
437 opened.transport.json(method, &url, body, bearer)
438 }
439
440 pub(crate) fn forget(&mut self, registry: &str, package: &str) {
443 if let Some(o) = self.opened.get_mut(registry) {
444 o.lines.remove(package);
445 }
446 }
447
448 pub(crate) fn withdrawn(
452 &self,
453 registry: &str,
454 package: &str,
455 version: &str,
456 reason: &str,
457 alias: &str,
458 ) -> FetchError {
459 let mut message = format!(
460 "`{package}` {version} was withdrawn by registry `{registry}`: {}\n = note: `bynk.lock` locks this version; choose another with `bynk update {alias}`",
461 sanitise_reason(reason)
462 );
463 if let Some(mirror) = self.opened.get(registry).and_then(|o| o.mirror.as_deref()) {
464 message.push_str(&format!(
465 "\n = note: read through the mirror `{mirror}` — the origin wasn't consulted"
466 ));
467 }
468 fail(WITHDRAWN, message)
469 }
470
471 pub(crate) fn fetch(
474 &mut self,
475 registry: &str,
476 line: &IndexLine,
477 home: &Path,
478 ) -> Result<PathBuf, FetchError> {
479 let entry = store_dir(home, &line.checksum).ok_or_else(|| {
480 fail(
481 BAD_SIGNATURE,
482 format!("`{}`'s checksum is malformed", line.name),
483 )
484 })?;
485 if entry.is_dir() {
486 return Ok(entry);
487 }
488 let opened = self.open(registry)?;
489 let hex = entry
490 .file_name()
491 .expect("a store path")
492 .to_string_lossy()
493 .into_owned();
494 let url = join(&opened.dl, &format!("sha256/{hex}"));
495 let bytes = opened.transport.get(&url)?.ok_or_else(|| {
496 fail(
497 UNAVAILABLE,
498 format!(
499 "registry `{registry}` has no archive for `{}` {}",
500 line.name, line.version
501 ),
502 )
503 })?;
504 let staging = crate::fetch::staging_dir(home, &hex);
505 std::fs::create_dir_all(&staging).map_err(|e| {
506 fail(
507 archive::BAD_ARCHIVE,
508 format!("could not stage `{}`: {e}", line.name),
509 )
510 })?;
511 archive::unpack(&bytes, &staging)?;
512 let got = crate::fetch::place_in_store(
513 home,
514 &staging,
515 &format!("`{}` {}", line.name, line.version),
516 archive::BAD_ARCHIVE,
517 )?;
518 if got != line.checksum {
519 if let Some(wrong) = store_dir(home, &got) {
520 let _ = crate::fetch::remove_entry(&wrong);
521 }
522 return Err(fail(
523 crate::fetch::CHECKSUM_MISMATCH,
524 format!(
525 "`{}` {}'s archive from `{registry}` unpacks to {got}, but its signed line says {}",
526 line.name, line.version, line.checksum
527 ),
528 ));
529 }
530 Ok(entry)
531 }
532}
533
534pub(crate) fn sanitise_reason(reason: &str) -> String {
539 let clean: String = reason.chars().filter(|c| !c.is_control()).collect();
540 let mut cut: String = clean.chars().take(protocol::WITHDRAWN_MAX).collect();
541 if clean.chars().count() > protocol::WITHDRAWN_MAX {
542 cut.push('…');
543 }
544 format!("{cut:?}")
545}
546
547fn account_base(api: &str) -> String {
552 let api = api.trim_end_matches('/');
553 let at_root = api
554 .split_once("://")
555 .is_some_and(|(_, rest)| !rest.contains('/'));
556 if at_root {
557 join(api, "account")
558 } else {
559 transport::resolve(api, "account")
560 }
561}
562
563fn scheme(url: &str) -> Option<String> {
565 url.split_once("://").map(|(s, _)| s.to_ascii_lowercase())
566}
567
568fn first_party_key(name: &str) -> Option<String> {
570 (name == FIRST_PARTY_REGISTRY)
571 .then_some(FIRST_PARTY)
572 .flatten()
573 .map(|(_, key)| key.to_string())
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
579pub(crate) enum TokenSource {
580 Var(String),
582 Organisation { registry: String, org: String },
584 Registry(String),
586}
587
588impl std::fmt::Display for TokenSource {
589 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
590 match self {
591 TokenSource::Var(var) => write!(f, "`{var}`"),
592 TokenSource::Organisation { registry, org } => {
593 write!(f, "`[registries.{registry}.organisations.{org}] token`")
594 }
595 TokenSource::Registry(registry) => write!(f, "`[registries.{registry}] token`"),
596 }
597 }
598}
599
600fn token_for(
605 name: &str,
606 org: Option<&str>,
607 notices: &mut Vec<String>,
608) -> Option<(String, TokenSource)> {
609 let var = bynk_project::manifest::registry_token_var(name);
610 if let Some(t) = std::env::var(&var).ok().filter(|t| !t.trim().is_empty()) {
611 return Some((t, TokenSource::Var(var)));
612 }
613 let path = credentials_path()?;
614 let text = std::fs::read_to_string(&path).ok()?;
615 #[cfg(unix)]
616 {
617 use std::os::unix::fs::PermissionsExt;
618 if let Ok(meta) = std::fs::metadata(&path)
619 && meta.permissions().mode() & 0o077 != 0
620 {
621 notices.push(format!(
622 "`{}` is readable by others — `chmod 600` it",
623 path.display()
624 ));
625 }
626 }
627 #[cfg(not(unix))]
628 let _ = ¬ices;
629 let doc: toml::Table = text.parse().ok()?;
630 saved_token(&doc, name, org)
631}
632
633fn saved_token(doc: &toml::Table, name: &str, org: Option<&str>) -> Option<(String, TokenSource)> {
636 let entry = doc.get("registries")?.get(name)?;
637 let own = org.and_then(|org| {
638 let t = entry
639 .get("organisations")?
640 .get(org)?
641 .get("token")?
642 .as_str()?;
643 Some((
644 t.to_owned(),
645 TokenSource::Organisation {
646 registry: name.to_string(),
647 org: org.to_string(),
648 },
649 ))
650 });
651 own.or_else(|| {
652 let t = entry.get("token")?.as_str()?;
653 Some((t.to_owned(), TokenSource::Registry(name.to_string())))
654 })
655}
656
657pub(crate) fn credentials_path() -> Option<PathBuf> {
660 if let Some(c) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
661 return Some(PathBuf::from(c).join("bynk").join("credentials.toml"));
662 }
663 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
664 Some(
665 PathBuf::from(home)
666 .join(".config")
667 .join("bynk")
668 .join("credentials.toml"),
669 )
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn an_organisation_s_own_slot_is_preferred_and_only_when_acting_for_it() {
678 let doc: toml::Table = r#"
679 [registries.bynk]
680 token = "bynk_wide"
681 [registries.bynk.organisations.acme]
682 token = "bynk_acme"
683 [registries.solo.organisations.acme]
684 token = "bynk_solo_acme"
685 "#
686 .parse()
687 .unwrap();
688 let own = TokenSource::Organisation {
689 registry: "bynk".into(),
690 org: "acme".into(),
691 };
692 let wide = TokenSource::Registry("bynk".into());
693 assert_eq!(
694 saved_token(&doc, "bynk", Some("acme")),
695 Some(("bynk_acme".into(), own.clone()))
696 );
697 assert_eq!(
699 saved_token(&doc, "bynk", Some("acme-labs")),
700 Some(("bynk_wide".into(), wide.clone()))
701 );
702 assert_eq!(
703 saved_token(&doc, "bynk", None),
704 Some(("bynk_wide".into(), wide))
705 );
706 assert_eq!(saved_token(&doc, "solo", None), None);
708 assert_eq!(saved_token(&doc, "solo", Some("other")), None);
709 assert_eq!(saved_token(&doc, "other", Some("acme")), None);
710 assert_eq!(
711 own.to_string(),
712 "`[registries.bynk.organisations.acme] token`"
713 );
714 }
715
716 #[test]
717 fn the_account_base_stays_on_the_api_origin() {
718 assert_eq!(
719 account_base("https://r.example/api"),
720 "https://r.example/account"
721 );
722 assert_eq!(
723 account_base("https://r.example/api/"),
724 "https://r.example/account"
725 );
726 assert_eq!(
727 account_base("https://r.example/reg/api"),
728 "https://r.example/reg/account"
729 );
730 assert_eq!(
731 account_base("https://r.example/"),
732 "https://r.example/account"
733 );
734 assert_eq!(
735 account_base("http://localhost:8730"),
736 "http://localhost:8730/account"
737 );
738 }
739
740 #[test]
741 fn a_withdrawn_reason_is_printed_sanitised() {
742 assert_eq!(sanitise_reason("a takedown"), "\"a takedown\"");
743 assert_eq!(
745 sanitise_reason("ok\u{1b}[2K\rerror[bynk.fake]: \"x\"\n"),
746 "\"ok[2Kerror[bynk.fake]: \\\"x\\\"\""
747 );
748 let long = "x".repeat(protocol::WITHDRAWN_MAX + 10);
749 let shown = sanitise_reason(&long);
750 assert_eq!(
751 shown.chars().count(),
752 protocol::WITHDRAWN_MAX + 3,
753 "{shown}"
754 );
755 assert!(shown.ends_with("…\""));
756 }
757}