Skip to main content

bynk/registry/
mod.rs

1//! Registries (packaging slice 4a, #2274; track `design/tracks/packaging.md`
2//! §6.1–§6.2): the client `bynk` resolves `{ package, version }` dependencies
3//! with, and the reference tooling for a static registry.
4//!
5//! The `Client` opens the registry an organisation routes to — through a
6//! mirror, when one serves it — decides which key it trusts (a key pinned in
7//! `[registries]`, else the one `bynk.lock` recorded, else trust on first
8//! use), follows the key-rotation chain from it, and from then on admits only
9//! index lines signed by a key on that chain and bound to the registry's `id`
10//! and the package's name. Archives are unpacked into staging and admitted to
11//! the store only when their tree checksum is the one the signed line names.
12
13pub 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
39/// A registry, opened and trusted.
40struct Opened {
41    /// The index this client reads from (a mirror's, when one serves it).
42    index: String,
43    /// #2330 (C): the mirror the index is read through, when one serves it —
44    /// its unsigned fields are the mirror's word, not the origin's.
45    mirror: Option<String>,
46    /// The archive base.
47    dl: String,
48    /// #2287: the publish API's base, when the registry has one.
49    api: Option<String>,
50    /// The origin registry's `id`, which every line must bind.
51    id: String,
52    /// The keys a line may be signed by: the trusted key, every key the
53    /// rotation chain reaches from it, and every valid, unrevoked delegate.
54    keys: Vec<String>,
55    /// #2316: delegates a valid revocation names — a line signed by one is
56    /// refused, and the message says why.
57    revoked: Vec<String>,
58    transport: Transport,
59    /// R3.2 (#2383, E): where the token the transport carries came from.
60    token_source: Option<TokenSource>,
61    /// Index files already read, by package.
62    lines: BTreeMap<String, Vec<IndexLine>>,
63}
64
65/// The registries one resolution uses.
66pub(crate) struct Client {
67    /// The root's (or workspace's) `[registries]`.
68    configs: Vec<RegistryConfig>,
69    /// The trust records the existing lock holds.
70    locked: Vec<LockedRegistry>,
71    opened: BTreeMap<String, Opened>,
72    /// The trust records this resolution used, for the lock.
73    pub trust: BTreeMap<String, LockedRegistry>,
74    /// One-line notices (trust on first use, a rotated key, credentials).
75    pub notices: Vec<String>,
76    /// R3.2 (#2383, B): the organisation a `bynk publish` or `bynk yank`
77    /// acts for, whose own token slot is preferred.
78    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    /// R3.2 (#2383, B): act for `org` — every registry this client opens
94    /// carries `org`'s own publish token, when one is saved.
95    pub(crate) fn acting_for(mut self, org: &str) -> Client {
96        self.acting_for = Some(org.to_string());
97        self
98    }
99
100    /// R3.2 (#2383, E): where `registry`'s token came from, once opened.
101    pub(crate) fn token_source(&self, registry: &str) -> Option<&TokenSource> {
102        self.opened.get(registry)?.token_source.as_ref()
103    }
104
105    /// The registry `org` routes to: the one whose `organisations` lists it,
106    /// else the first-party registry.
107    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    /// Open `name`, deciding its trusted key.
115    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        // The first-party registry isn't live until its key ships: nothing —
120        // not even a mirror of it — may be trusted for it on first use.
121        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        // Where to read it from: a mirror of it, else itself.
128        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        // A token the user supplied goes with every request to the registry's
161        // origin — a private registry protects its `config.json` too.
162        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        // The origin's id: the lock's record, else (first use) what it says.
175        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        // The lock records the trust *anchor* — the key first trusted (or
227        // pinned) — and every key the chain reaches from it is accepted. A
228        // planned rotation therefore changes nothing in the lock, and lines
229        // signed before it still verify.
230        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        // A registry's `config.json` can't send the client somewhere its
240        // `[registries]` entry didn't: a local registry keeps local archives,
241        // and a remote one remote archives over a scheme at least as strong.
242        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        // #2287: the publish API obeys the same scheme rule as `dl`.
258        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        // #2316 (B): the chain keys reached from the anchor may sign lines,
279        // and so may each delegate a chain key delegated and none revoked.
280        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    /// `package`'s verified index lines on `registry`.
303    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    /// #2330 (C): refuse a write to `registry` when a mirror serves it. The
362    /// mirror's `config.json` names the `api`, so the upload (and the mirror's
363    /// token) would go where the mirror says — and the read-back would come
364    /// through the mirror too. Decided from `[registries]` alone, before
365    /// anything is fetched.
366    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    /// #2287: a write to `registry`'s publish API at `path` (relative to its
384    /// `api`): `PUT` with `body`, or `POST`. A registry with no `api` takes no
385    /// publishes over HTTP, and one read through a mirror takes none at all.
386    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    /// R3.1 (#2378, decision B): a request to `registry`'s account API at
407    /// `path`, which is the `account` sibling of its verified `api`, carrying
408    /// `bearer`. A registry read through a mirror is refused: the mirror's
409    /// `config.json` names the `api`, and an account token goes only to the
410    /// origin.
411    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    /// Forget `package`'s cached index lines on `registry`, so the next read
441    /// is fresh.
442    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    /// #2330 (B, C): the failure for a locked version the registry withdrew,
449    /// naming the reason (sanitised), the way off it, and — when the line was
450    /// read through a mirror — that the origin wasn't consulted.
451    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    /// Fetch `line`'s archive into the store unless its tree is there,
472    /// admitting it only if its tree checksum is the signed one.
473    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
534/// #2330 (C): a `withdrawn` reason as the client prints it. It is untrusted
535/// text from the index, so control characters are removed (it can't move the
536/// cursor, clear the line or forge another diagnostic), it is cut to
537/// [`protocol::WITHDRAWN_MAX`] characters, and it is quoted.
538pub(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
547/// R3.1 (#2378): the account API's base, the `account` sibling of `api` —
548/// or, for an `api` at the origin's root, `account` under it (resolving
549/// against the root would climb out of the origin, to a host named
550/// `account`).
551fn 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
563/// A URL's scheme, lower-cased.
564fn scheme(url: &str) -> Option<String> {
565    url.split_once("://").map(|(s, _)| s.to_ascii_lowercase())
566}
567
568/// The first-party registry's pinned key, once live.
569fn 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/// R3.2 (#2383, E): where a publish token was found. It names the place,
577/// never the value.
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub(crate) enum TokenSource {
580    /// `BYNK_REGISTRY_<NAME>_TOKEN`.
581    Var(String),
582    /// `[registries.<name>.organisations.<org>] token`.
583    Organisation { registry: String, org: String },
584    /// `[registries.<name>] token`.
585    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
600/// The token for registry `name`, and where it came from:
601/// `BYNK_REGISTRY_<NAME>_TOKEN`, else — R3.2 (#2383, C) — acting for `org`,
602/// `[registries.<name>.organisations.<org>] token`, else
603/// `[registries.<name>] token` in the user's credentials file.
604fn 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 _ = &notices;
629    let doc: toml::Table = text.parse().ok()?;
630    saved_token(&doc, name, org)
631}
632
633/// The saved publish token for `name` in a credentials file: acting for
634/// `org`, its own slot first, then the registry-wide one.
635fn 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
657/// `$XDG_CONFIG_HOME/bynk/credentials.toml`, else
658/// `~/.config/bynk/credentials.toml`.
659pub(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        // Another organisation, and resolution (no organisation), fall back.
698        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        // An organisation's slot never serves a client not acting for it.
707        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        // Control characters can't move the cursor or forge a diagnostic.
744        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}