1use std::path::{Path, PathBuf};
13use std::time::{Duration, Instant};
14
15use bynk_project::lock::FIRST_PARTY_REGISTRY;
16use serde_json::{Value, json};
17
18use super::Client;
19use crate::fetch::{FetchError, fail};
20
21pub(crate) const ACCOUNT_REFUSED: &str = "bynk.registry.account_refused";
22
23const SLOW_DOWN: u64 = 5;
25
26const MIN_INTERVAL: u64 = 1;
29
30#[derive(Debug, Clone)]
34pub struct Target {
35 pub project: PathBuf,
36 pub registry: Option<String>,
37}
38
39pub(crate) trait Api {
41 fn call(
43 &mut self,
44 method: &str,
45 path: &str,
46 body: Option<&Value>,
47 ) -> Result<(u16, Vec<u8>), FetchError>;
48}
49
50struct Live {
53 client: Client,
54 registry: String,
55 bearer: Option<String>,
56}
57
58impl Api for Live {
59 fn call(
60 &mut self,
61 method: &str,
62 path: &str,
63 body: Option<&Value>,
64 ) -> Result<(u16, Vec<u8>), FetchError> {
65 self.client
66 .account_call(&self.registry, method, path, body, self.bearer.as_deref())
67 .map_err(|mut e| {
68 if e.code == super::UNAVAILABLE && self.registry == FIRST_PARTY_REGISTRY {
70 e.message
71 .push_str(" — or pass `--registry <name>` to use a configured registry");
72 }
73 e
74 })
75 }
76}
77
78fn open(target: &Target, org: Option<&str>) -> (Client, String) {
81 let root = bynk_project::lock::lock_root(&target.project);
82 let client = Client::new(super::commands::registries_of(&root), None);
83 let registry = match (&target.registry, org) {
84 (Some(r), _) => r.clone(),
85 (None, Some(o)) => client.route(o),
86 (None, None) => FIRST_PARTY_REGISTRY.to_string(),
87 };
88 (client, registry)
89}
90
91fn signed_in(target: &Target, org: &str) -> Result<Live, FetchError> {
93 let (client, registry) = open(target, Some(org));
94 let session = session(®istry).ok_or_else(|| {
95 fail(
96 ACCOUNT_REFUSED,
97 format!(
98 "you aren't signed in to registry `{registry}`: run `bynk login --registry {registry}`"
99 ),
100 )
101 })?;
102 if session.expires_at.is_some_and(|t| t <= now_millis()) {
103 return Err(fail(
104 ACCOUNT_REFUSED,
105 format!(
106 "your session on registry `{registry}` expired: run `bynk login --registry {registry}`"
107 ),
108 ));
109 }
110 Ok(Live {
111 client,
112 registry,
113 bearer: Some(session.token),
114 })
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
123pub(crate) struct DeviceStart {
124 pub poll: String,
125 pub user_code: String,
126 pub verification_uri: String,
127 pub interval: u64,
128 pub expires_in: u64,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct SignedIn {
134 pub token: String,
135 pub login: String,
136 pub expires_at: i64,
137}
138
139pub fn login(target: &Target) -> Result<(), FetchError> {
142 let (client, registry) = open(target, None);
143 let mut api = Live {
144 client,
145 registry: registry.clone(),
146 bearer: None,
147 };
148 let start = start(&mut api, ®istry)?;
149 eprintln!(
150 "To sign in to registry `{registry}`, open {} and enter the code {}.\nNever enter a code you didn't request. Waiting…",
151 clean(&start.verification_uri),
152 clean(&start.user_code)
153 );
154 let began = Instant::now();
155 let signed = poll(&mut api, ®istry, &start, &mut |d| {
156 std::thread::sleep(d);
157 began.elapsed()
158 })?;
159 let path = credentials_file()?;
160 let mut doc = read_doc(&path).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
161 set_session(&mut doc, ®istry, &signed);
162 write_doc(&path, &doc).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
163 eprintln!(
164 "Signed in to `{registry}` as {} (until {}). Saved to {}.",
165 signed.login,
166 date(signed.expires_at),
167 path.display()
168 );
169 if std::env::var_os(override_variable(®istry)).is_some() {
170 eprintln!(
171 "bynk: warning: `{}` is set, and is used instead of the saved session",
172 override_variable(®istry)
173 );
174 }
175 Ok(())
176}
177
178pub fn logout(target: &Target) -> Result<(), FetchError> {
181 let (client, registry) = open(target, None);
182 let Some(session) = session(®istry) else {
183 eprintln!("bynk: you aren't signed in to registry `{registry}`");
184 return Ok(());
185 };
186 let mut api = Live {
187 client,
188 registry: registry.clone(),
189 bearer: Some(session.token),
190 };
191 let (status, body) = api.call("DELETE", "session", None)?;
192 if !(200..300).contains(&status) && status != 401 {
194 return Err(refused(®istry, status, &body));
195 }
196 if let Some(path) = super::credentials_path() {
197 let mut doc = read_doc(&path).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
198 if remove_session(&mut doc, ®istry) {
199 write_doc(&path, &doc).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
200 }
201 }
202 eprintln!("bynk: signed out of `{registry}`");
203 if session.from_env {
204 eprintln!(
205 "bynk: note: `{}` still holds the token; unset it",
206 override_variable(®istry)
207 );
208 }
209 Ok(())
210}
211
212pub(crate) fn start(api: &mut dyn Api, registry: &str) -> Result<DeviceStart, FetchError> {
214 let (status, body) = api.call("POST", "login", None)?;
215 if status != 200 {
216 return Err(refused(registry, status, &body));
217 }
218 let v = parse(registry, &body)?;
219 let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
220 let num = |k: &str| v.get(k).and_then(Value::as_u64);
221 match (
222 text("poll"),
223 text("userCode"),
224 text("verificationUri"),
225 num("interval"),
226 num("expiresIn"),
227 ) {
228 (Some(poll), Some(user_code), Some(verification_uri), Some(interval), Some(expires_in)) => {
229 Ok(DeviceStart {
230 poll,
231 user_code,
232 verification_uri,
233 interval,
234 expires_in,
235 })
236 }
237 _ => Err(malformed(registry)),
238 }
239}
240
241pub(crate) fn poll(
245 api: &mut dyn Api,
246 registry: &str,
247 start: &DeviceStart,
248 wait: &mut dyn FnMut(Duration) -> Duration,
249) -> Result<SignedIn, FetchError> {
250 let mut interval = start.interval.max(MIN_INTERVAL);
251 let deadline = Duration::from_secs(start.expires_in);
252 let expired = || {
253 fail(
254 ACCOUNT_REFUSED,
255 "the code expired before it was approved: run `bynk login` again",
256 )
257 };
258 loop {
259 if wait(Duration::from_secs(interval)) >= deadline {
260 return Err(expired());
261 }
262 let (status, body) =
263 api.call("POST", "login/poll", Some(&json!({ "poll": start.poll })))?;
264 if status == 200 {
265 let v = parse(registry, &body)?;
266 return match (
267 v.get("token").and_then(Value::as_str),
268 v.get("login").and_then(Value::as_str),
269 v.get("expiresAt").and_then(Value::as_i64),
270 ) {
271 (Some(token), Some(login), Some(expires_at)) => Ok(SignedIn {
272 token: token.to_owned(),
273 login: login.to_owned(),
274 expires_at,
275 }),
276 _ => Err(malformed(registry)),
277 };
278 }
279 match error_code(&body).as_deref() {
282 Some("authorization_pending") => {}
283 Some("slow_down") => interval += SLOW_DOWN,
284 Some("expired_token") => return Err(expired()),
285 Some("access_denied") => {
286 return Err(fail(ACCOUNT_REFUSED, "the sign-in was declined at GitHub"));
287 }
288 _ => return Err(refused(registry, status, &body)),
289 }
290 }
291}
292
293pub fn claim(
300 target: &Target,
301 org: &str,
302 block: bool,
303 owner: Option<&str>,
304) -> Result<(), FetchError> {
305 let mut api = signed_in(target, org)?;
306 let mut body = json!({ "block": block });
307 if let Some(o) = owner {
308 body["owner"] = json!(o);
309 }
310 let (status, answer) = api.call("POST", &orgs(&[org])?, Some(&body))?;
311 let registry = &api.registry;
312 match status {
313 201 => eprintln!(
314 "bynk: claimed `{org}` on `{registry}`{}",
315 if block { " (blocked)" } else { "" }
316 ),
317 200 => eprintln!("bynk: `{org}` on `{registry}` is already yours"),
318 _ => return Err(refused(registry, status, &answer)),
319 }
320 Ok(())
321}
322
323pub fn show(target: &Target, org: &str, json: bool) -> Result<(), FetchError> {
326 let mut api = signed_in(target, org)?;
327 let view = view(&mut api, org)?;
328 if json {
329 println!(
330 "{}",
331 serde_json::to_string_pretty(&view).expect("a JSON value")
332 );
333 } else {
334 print!("{}", render(&api.registry, &view));
335 }
336 Ok(())
337}
338
339pub fn set_blocked(target: &Target, org: &str, blocked: bool) -> Result<(), FetchError> {
341 let mut api = signed_in(target, org)?;
342 acknowledged(
343 &mut api,
344 "PUT",
345 &orgs(&[org, "blocked"])?,
346 Some(&json!({ "blocked": blocked })),
347 )?;
348 eprintln!(
349 "bynk: {} `{org}` on `{}`",
350 if blocked { "blocked" } else { "unblocked" },
351 api.registry
352 );
353 Ok(())
354}
355
356pub fn member_add(target: &Target, org: &str, login: &str) -> Result<(), FetchError> {
358 let mut api = signed_in(target, org)?;
359 acknowledged(&mut api, "PUT", &orgs(&[org, "members", login])?, None)?;
360 eprintln!("bynk: added {login} to `{org}` on `{}`", api.registry);
361 Ok(())
362}
363
364pub fn member_remove(target: &Target, org: &str, who: &str) -> Result<(), FetchError> {
366 let mut api = signed_in(target, org)?;
367 let id = member_id(&mut api, org, who)?;
368 acknowledged(&mut api, "DELETE", &orgs(&[org, "members", &id])?, None)?;
369 eprintln!("bynk: removed {who} from `{org}` on `{}`", api.registry);
370 Ok(())
371}
372
373#[derive(Debug, Clone)]
375pub struct Mint {
376 pub can: Vec<String>,
377 pub days: u32,
378 pub note: String,
379 pub save: bool,
382 pub force: bool,
384}
385
386pub fn token_mint(target: &Target, org: &str, mint: &Mint) -> Result<(), FetchError> {
389 let mut api = signed_in(target, org)?;
390 let registry = api.registry.clone();
391 let saving = if mint.save {
393 let path = credentials_file()?;
394 let doc = read_doc(&path).map_err(|e| fail(ACCOUNT_REFUSED, e))?;
395 check_publish_slot(&doc, ®istry, org, None, mint.force)
396 .map_err(|e| fail(ACCOUNT_REFUSED, e))?;
397 Some((path, doc))
398 } else {
399 None
400 };
401 let body = json!({ "can": mint.can, "days": mint.days, "note": mint.note });
402 let (status, answer) = api.call("POST", &orgs(&[org, "tokens"])?, Some(&body))?;
403 if status != 201 && status != 200 {
404 return Err(refused(®istry, status, &answer));
405 }
406 let v = parse(®istry, &answer)?;
407 let token = v
408 .get("token")
409 .and_then(Value::as_str)
410 .ok_or_else(|| malformed(®istry))?;
411 let info = v.get("info").cloned().unwrap_or(Value::Null);
412 let id = info.get("id").and_then(Value::as_str).unwrap_or("?");
413 let can = info
414 .get("can")
415 .and_then(Value::as_array)
416 .map(|c| {
417 c.iter()
418 .filter_map(Value::as_str)
419 .collect::<Vec<_>>()
420 .join(", ")
421 })
422 .unwrap_or_default();
423 let until = info
424 .get("expiresAt")
425 .and_then(Value::as_i64)
426 .map(date)
427 .unwrap_or_default();
428 eprintln!(
429 "bynk: minted token {} for `{org}` on `{registry}` ({can}; until {until})",
430 short_id(id)
431 );
432 match saving {
433 Some((path, mut doc)) => {
434 check_publish_slot(&doc, ®istry, org, Some(token), mint.force)
435 .map_err(|e| fail(ACCOUNT_REFUSED, e))?;
436 let saved = set_publish_token(&mut doc, ®istry, org, token)
437 .and_then(|()| write_doc(&path, &doc));
438 if let Err(e) = saved {
439 eprintln!(
441 "bynk: couldn't save it, so it is shown once, below; keep it somewhere safe, or revoke it with `bynk org token revoke {org} {}`",
442 short_id(id)
443 );
444 println!("{token}");
445 return Err(fail(ACCOUNT_REFUSED, e));
446 }
447 eprintln!(
448 "bynk: saved as `[registries.{registry}.organisations.{org}] token` in {}",
449 path.display()
450 );
451 if registry_wide_token(&doc, ®istry) {
452 eprintln!(
453 "bynk: `{org}` now uses its own token; `[registries.{registry}] token` still serves other organisations"
454 );
455 }
456 let var = bynk_project::manifest::registry_token_var(®istry);
457 if std::env::var_os(&var).is_some() {
458 eprintln!(
459 "bynk: warning: `{var}` is set, and `bynk publish` uses it instead of any saved token"
460 );
461 }
462 }
463 None => {
464 eprintln!("bynk: it is shown once, below; keep it somewhere safe");
465 println!("{token}");
466 }
467 }
468 Ok(())
469}
470
471pub fn token_revoke(target: &Target, org: &str, id: &str) -> Result<(), FetchError> {
474 let mut api = signed_in(target, org)?;
475 let id = token_id(&mut api, org, id)?;
476 acknowledged(&mut api, "DELETE", &orgs(&[org, "tokens", &id])?, None)?;
477 eprintln!(
478 "bynk: revoked token {} for `{org}` on `{}`",
479 short_id(&id),
480 api.registry
481 );
482 Ok(())
483}
484
485fn view(api: &mut Live, org: &str) -> Result<Value, FetchError> {
487 let (status, body) = api.call("GET", &orgs(&[org])?, None)?;
488 if status != 200 {
489 return Err(refused(&api.registry, status, &body));
490 }
491 parse(&api.registry, &body)
492}
493
494fn acknowledged(
496 api: &mut Live,
497 method: &str,
498 path: &str,
499 body: Option<&Value>,
500) -> Result<(), FetchError> {
501 let (status, answer) = api.call(method, path, body)?;
502 if (200..300).contains(&status) {
503 Ok(())
504 } else {
505 Err(refused(&api.registry, status, &answer))
506 }
507}
508
509fn member_id(api: &mut Live, org: &str, who: &str) -> Result<String, FetchError> {
513 if !who.is_empty() && who.bytes().all(|b| b.is_ascii_digit()) {
514 return Ok(who.to_owned());
515 }
516 let v = view(api, org)?;
517 find_member(&v, who)
518 .ok_or_else(|| fail(ACCOUNT_REFUSED, format!("`{org}` has no member `{who}`")))
519}
520
521pub(crate) fn find_member(view: &Value, login: &str) -> Option<String> {
524 view.get("members")?
525 .as_array()?
526 .iter()
527 .find(|m| {
528 m.get("login")
529 .and_then(Value::as_str)
530 .is_some_and(|l| l.eq_ignore_ascii_case(login))
531 })
532 .and_then(|m| m.get("id")?.as_i64())
533 .map(|id| id.to_string())
534}
535
536fn token_id(api: &mut Live, org: &str, prefix: &str) -> Result<String, FetchError> {
538 let v = view(api, org)?;
539 find_token(&v, prefix).map_err(|why| fail(ACCOUNT_REFUSED, format!("`{org}` {why}")))
540}
541
542pub(crate) fn find_token(view: &Value, prefix: &str) -> Result<String, String> {
544 let ids: Vec<&str> = view
545 .get("tokens")
546 .and_then(Value::as_array)
547 .map(|ts| {
548 ts.iter()
549 .filter_map(|t| t.get("id")?.as_str())
550 .filter(|id| !prefix.is_empty() && id.starts_with(prefix))
551 .collect()
552 })
553 .unwrap_or_default();
554 match ids.as_slice() {
555 [one] => Ok((*one).to_owned()),
556 [] => Err(format!("has no token `{prefix}` (see `bynk org show`)")),
557 _ => Err(format!(
558 "has {} tokens starting `{prefix}`: give more of the id",
559 ids.len()
560 )),
561 }
562}
563
564fn orgs(segments: &[&str]) -> Result<String, FetchError> {
567 let mut path = String::from("orgs");
568 for s in segments {
569 let plain = !s.is_empty()
570 && s.bytes()
571 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
572 if !plain {
573 return Err(fail(
574 ACCOUNT_REFUSED,
575 format!("`{s}` isn't a name the account API takes"),
576 ));
577 }
578 path.push('/');
579 path.push_str(s);
580 }
581 Ok(path)
582}
583
584pub(crate) fn render(registry: &str, view: &Value) -> String {
586 let user = |u: &Value| {
587 format!(
588 "{} (#{})",
589 u.get("login").and_then(Value::as_str).unwrap_or("?"),
590 u.get("id").and_then(Value::as_i64).unwrap_or_default()
591 )
592 };
593 let members: Vec<&Value> = view
594 .get("members")
595 .and_then(Value::as_array)
596 .map(|m| m.iter().collect())
597 .unwrap_or_default();
598 let login_of = |id: i64| {
599 members
600 .iter()
601 .copied()
602 .chain(view.get("owner"))
603 .find(|m| m.get("id").and_then(Value::as_i64) == Some(id))
604 .and_then(|m| m.get("login")?.as_str())
605 .map_or_else(
606 || {
607 if id == 0 {
608 "an operator".to_owned()
609 } else {
610 format!("#{id}")
611 }
612 },
613 str::to_owned,
614 )
615 };
616 let name = view.get("name").and_then(Value::as_str).unwrap_or("?");
617 let blocked = view.get("blocked").and_then(Value::as_bool) == Some(true);
618 let mut out = format!("{name} on `{registry}`\n");
619 out.push_str(&format!(
620 " owner: {}\n",
621 view.get("owner").map(user).unwrap_or_default()
622 ));
623 out.push_str(&format!(
624 " members: {}\n",
625 members
626 .iter()
627 .map(|m| user(m))
628 .collect::<Vec<_>>()
629 .join(", ")
630 ));
631 out.push_str(&format!(
632 " blocked: {}\n",
633 if blocked { "yes" } else { "no" }
634 ));
635 let tokens: Vec<&Value> = view
636 .get("tokens")
637 .and_then(Value::as_array)
638 .map(|t| t.iter().collect())
639 .unwrap_or_default();
640 if tokens.is_empty() {
641 out.push_str(" tokens: none\n");
642 } else {
643 out.push_str(" tokens:\n");
644 for t in tokens {
645 let id = t.get("id").and_then(Value::as_str).unwrap_or("?");
646 let can = t
647 .get("can")
648 .and_then(Value::as_array)
649 .map(|c| {
650 c.iter()
651 .filter_map(Value::as_str)
652 .collect::<Vec<_>>()
653 .join(", ")
654 })
655 .unwrap_or_default();
656 let until = t
657 .get("expiresAt")
658 .and_then(Value::as_i64)
659 .map(date)
660 .unwrap_or_default();
661 let by = login_of(t.get("mintedBy").and_then(Value::as_i64).unwrap_or(-1));
662 let note = t.get("note").and_then(Value::as_str).unwrap_or_default();
663 out.push_str(&format!(
664 " {} {can} until {until} minted by {by}",
665 short_id(id)
666 ));
667 if !note.is_empty() {
668 out.push_str(&format!(" {note:?}"));
669 }
670 out.push('\n');
671 }
672 }
673 out
674}
675
676fn short_id(id: &str) -> &str {
678 id.get(..12).unwrap_or(id)
679}
680
681fn clean(text: &str) -> String {
688 text.chars().filter(|c| !c.is_control()).collect()
689}
690
691fn error_code(body: &[u8]) -> Option<String> {
693 serde_json::from_slice::<Value>(body)
694 .ok()?
695 .get("error")?
696 .as_str()
697 .map(str::to_owned)
698}
699
700fn parse(registry: &str, body: &[u8]) -> Result<Value, FetchError> {
701 serde_json::from_slice(body).map_err(|_| malformed(registry))
702}
703
704fn malformed(registry: &str) -> FetchError {
705 fail(
706 ACCOUNT_REFUSED,
707 format!("registry `{registry}` answered the account API with something unexpected"),
708 )
709}
710
711pub(crate) fn refused(registry: &str, status: u16, body: &[u8]) -> FetchError {
713 let v = serde_json::from_slice::<Value>(body).ok();
714 let field = |k: &str| v.as_ref()?.get(k)?.as_str().map(str::to_owned);
715 let mut message = match (field("error"), field("message")) {
716 (Some(code), Some(why)) => {
717 format!(
718 "registry `{registry}` refused it (HTTP {status}, `{}`): {}",
719 clean(&code),
720 clean(&why)
721 )
722 }
723 _ if status == 404 || status == 405 => format!(
725 "registry `{registry}` has no account API (HTTP {status}) — only the first-party registry service has accounts"
726 ),
727 _ => format!("registry `{registry}` refused it (HTTP {status})"),
728 };
729 if status == 401 {
730 message.push_str(&format!(
731 " — sign in again with `bynk login --registry {registry}`"
732 ));
733 }
734 fail(ACCOUNT_REFUSED, message)
735}
736
737pub fn override_variable(name: &str) -> String {
744 format!(
745 "BYNK_ACCOUNT_{}_TOKEN",
746 name.to_ascii_uppercase().replace('-', "_")
747 )
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
752pub(crate) struct Session {
753 pub token: String,
754 pub expires_at: Option<i64>,
755 pub from_env: bool,
757}
758
759fn session(registry: &str) -> Option<Session> {
762 if let Some(t) = std::env::var(override_variable(registry))
763 .ok()
764 .filter(|t| !t.trim().is_empty())
765 {
766 return Some(Session {
767 token: t,
768 expires_at: None,
769 from_env: true,
770 });
771 }
772 let doc = read_doc(&super::credentials_path()?).ok()?;
773 saved_session(&doc, registry)
774}
775
776pub(crate) fn saved_session(doc: &toml_edit::DocumentMut, registry: &str) -> Option<Session> {
778 let entry = doc.get("accounts")?.get(registry)?;
779 Some(Session {
780 token: entry.get("token")?.as_str()?.to_owned(),
781 expires_at: entry.get("expires-at").and_then(|v| v.as_integer()),
782 from_env: false,
783 })
784}
785
786fn credentials_file() -> Result<PathBuf, FetchError> {
787 super::credentials_path().ok_or_else(|| {
788 fail(
789 ACCOUNT_REFUSED,
790 "there is no home directory to keep the credentials file in (set `XDG_CONFIG_HOME`)",
791 )
792 })
793}
794
795pub(crate) fn read_doc(path: &Path) -> Result<toml_edit::DocumentMut, String> {
797 match std::fs::read_to_string(path) {
798 Ok(text) => text
799 .parse()
800 .map_err(|e| format!("`{}` isn't valid TOML: {e}", path.display())),
801 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(toml_edit::DocumentMut::new()),
802 Err(e) => Err(format!("could not read `{}`: {e}", path.display())),
803 }
804}
805
806pub(crate) fn write_doc(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
810 let dir = path
811 .parent()
812 .ok_or("the credentials file has no directory")?;
813 std::fs::create_dir_all(dir)
814 .map_err(|e| format!("could not create `{}`: {e}", dir.display()))?;
815 let tmp = dir.join(format!(".credentials.toml.{}", std::process::id()));
816 let mut options = std::fs::OpenOptions::new();
817 let _ = std::fs::remove_file(&tmp);
820 options.write(true).create_new(true);
821 #[cfg(unix)]
822 {
823 use std::os::unix::fs::OpenOptionsExt;
824 options.mode(0o600);
825 }
826 let written = (|| {
827 use std::io::Write;
828 let mut f = options.open(&tmp)?;
829 f.write_all(doc.to_string().as_bytes())?;
830 f.sync_all()?;
831 std::fs::rename(&tmp, path)
832 })();
833 written.map_err(|e| {
834 let _ = std::fs::remove_file(&tmp);
835 format!("could not write `{}`: {e}", path.display())
836 })
837}
838
839fn entry<'d>(
841 doc: &'d mut toml_edit::DocumentMut,
842 table: &str,
843 name: &str,
844) -> &'d mut toml_edit::Table {
845 let outer = doc
846 .entry(table)
847 .or_insert_with(|| {
848 let mut t = toml_edit::Table::new();
849 t.set_implicit(true);
850 toml_edit::Item::Table(t)
851 })
852 .as_table_mut()
853 .expect("a table");
854 outer
855 .entry(name)
856 .or_insert_with(toml_edit::table)
857 .as_table_mut()
858 .expect("a table")
859}
860
861pub(crate) fn set_session(doc: &mut toml_edit::DocumentMut, registry: &str, signed: &SignedIn) {
863 let t = entry(doc, "accounts", registry);
864 t["token"] = toml_edit::value(signed.token.as_str());
865 t["login"] = toml_edit::value(signed.login.as_str());
866 t["expires-at"] = toml_edit::value(signed.expires_at);
867}
868
869pub(crate) fn remove_session(doc: &mut toml_edit::DocumentMut, registry: &str) -> bool {
871 let Some(accounts) = doc.get_mut("accounts").and_then(|a| a.as_table_mut()) else {
872 return false;
873 };
874 let removed = accounts.remove(registry).is_some();
875 if accounts.is_empty() {
876 doc.remove("accounts");
877 }
878 removed
879}
880
881pub(crate) fn check_publish_slot(
886 doc: &toml_edit::DocumentMut,
887 registry: &str,
888 org: &str,
889 token: Option<&str>,
890 force: bool,
891) -> Result<(), String> {
892 let held = doc
893 .get("registries")
894 .and_then(|r| r.get(registry))
895 .and_then(|r| r.get("organisations"))
896 .and_then(|o| o.get(org))
897 .and_then(|o| o.get("token"))
898 .and_then(|t| t.as_str());
899 match held {
900 Some(h) if !force && token != Some(h) => Err(format!(
901 "`[registries.{registry}.organisations.{org}] token` holds a token already: `--force` replaces it"
902 )),
903 _ => set_publish_token(&mut doc.clone(), registry, org, "-"),
905 }
906}
907
908pub(crate) fn set_publish_token(
912 doc: &mut toml_edit::DocumentMut,
913 registry: &str,
914 org: &str,
915 token: &str,
916) -> Result<(), String> {
917 let path = ["registries", registry, "organisations", org];
918 let mut t: &mut dyn toml_edit::TableLike = doc.as_table_mut();
919 for (i, key) in path.into_iter().enumerate() {
920 let new = !t.contains_key(key);
921 let item = t.entry(key).or_insert(toml_edit::table());
922 if new
923 && i + 1 < path.len()
924 && let Some(table) = item.as_table_mut()
925 {
926 table.set_implicit(true);
927 }
928 t = item.as_table_like_mut().ok_or_else(|| {
929 format!(
930 "`{}` in the credentials file isn't a table",
931 path[..=i].join(".")
932 )
933 })?;
934 }
935 t.insert("token", toml_edit::value(token));
936 Ok(())
937}
938
939fn registry_wide_token(doc: &toml_edit::DocumentMut, registry: &str) -> bool {
941 doc.get("registries")
942 .and_then(|r| r.get(registry))
943 .and_then(|r| r.get("token"))
944 .is_some()
945}
946
947fn now_millis() -> i64 {
948 std::time::SystemTime::now()
949 .duration_since(std::time::UNIX_EPOCH)
950 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
951}
952
953fn date(millis: i64) -> String {
955 time::OffsetDateTime::from_unix_timestamp(millis.div_euclid(1000))
956 .map_or_else(|_| millis.to_string(), |t| t.date().to_string())
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::collections::VecDeque;
963
964 struct Stub {
966 answers: VecDeque<(u16, Value)>,
967 asked: Vec<(String, String, Option<Value>)>,
968 }
969
970 impl Stub {
971 fn new(answers: &[(u16, Value)]) -> Stub {
972 Stub {
973 answers: answers.iter().cloned().collect(),
974 asked: Vec::new(),
975 }
976 }
977 }
978
979 impl Api for Stub {
980 fn call(
981 &mut self,
982 method: &str,
983 path: &str,
984 body: Option<&Value>,
985 ) -> Result<(u16, Vec<u8>), FetchError> {
986 self.asked
987 .push((method.to_owned(), path.to_owned(), body.cloned()));
988 let (status, v) = self.answers.pop_front().expect("an answer left");
989 Ok((status, v.to_string().into_bytes()))
990 }
991 }
992
993 fn problem(code: &str) -> Value {
994 json!({ "error": code, "message": "…" })
995 }
996
997 fn device(interval: u64, expires_in: u64) -> DeviceStart {
998 DeviceStart {
999 poll: "p1".into(),
1000 user_code: "WDJB-MJHT".into(),
1001 verification_uri: "https://github.com/login/device".into(),
1002 interval,
1003 expires_in,
1004 }
1005 }
1006
1007 fn run(stub: &mut Stub, start: &DeviceStart) -> (Result<SignedIn, FetchError>, Vec<u64>) {
1009 let mut waits = Vec::new();
1010 let mut elapsed = Duration::ZERO;
1011 let got = poll(stub, "bynk", start, &mut |d| {
1012 waits.push(d.as_secs());
1013 elapsed += d;
1014 elapsed
1015 });
1016 (got, waits)
1017 }
1018
1019 #[test]
1020 fn the_start_is_read_from_its_json() {
1021 let mut stub = Stub::new(&[(
1022 200,
1023 json!({ "poll": "p1", "userCode": "WDJB-MJHT", "verificationUri": "https://github.com/login/device", "interval": 5, "expiresIn": 900 }),
1024 )]);
1025 assert_eq!(start(&mut stub, "bynk").unwrap(), device(5, 900));
1026 assert_eq!(stub.asked[0].0, "POST");
1027 assert_eq!(stub.asked[0].1, "login");
1028 }
1029
1030 #[test]
1031 fn polling_waits_the_interval_and_slows_down_by_five_seconds() {
1032 let mut stub = Stub::new(&[
1033 (400, problem("authorization_pending")),
1034 (429, problem("slow_down")),
1035 (400, problem("authorization_pending")),
1036 (
1037 200,
1038 json!({ "token": "bynka_x", "login": "ann", "expiresAt": 1_795_392_000_000_i64 }),
1039 ),
1040 ]);
1041 let (got, waits) = run(&mut stub, &device(2, 900));
1042 assert_eq!(
1043 got.unwrap(),
1044 SignedIn {
1045 token: "bynka_x".into(),
1046 login: "ann".into(),
1047 expires_at: 1_795_392_000_000,
1048 }
1049 );
1050 assert_eq!(waits, [2, 2, 7, 7]);
1051 assert!(stub.asked.iter().all(|(m, p, b)| m == "POST"
1052 && p == "login/poll"
1053 && b == &Some(json!({ "poll": "p1" }))));
1054 }
1055
1056 #[test]
1057 fn polling_stops_on_expiry_denial_and_the_local_deadline() {
1058 let (got, _) = run(
1059 &mut Stub::new(&[(410, problem("expired_token"))]),
1060 &device(1, 900),
1061 );
1062 assert!(got.unwrap_err().message.contains("expired"));
1063 let (got, _) = run(
1064 &mut Stub::new(&[(403, problem("access_denied"))]),
1065 &device(1, 900),
1066 );
1067 assert!(got.unwrap_err().message.contains("declined"));
1068 let pending = vec![(400, problem("authorization_pending")); 3];
1071 let mut stub = Stub::new(&pending);
1072 let (got, waits) = run(&mut stub, &device(5, 12));
1073 assert!(got.unwrap_err().message.contains("expired"));
1074 assert_eq!(waits, [5, 5, 5]);
1075 assert_eq!(stub.asked.len(), 2);
1076 }
1077
1078 #[test]
1079 fn a_zero_interval_still_waits() {
1080 let mut stub = Stub::new(&[
1081 (400, problem("authorization_pending")),
1082 (
1083 200,
1084 json!({ "token": "bynka_x", "login": "ann", "expiresAt": 0 }),
1085 ),
1086 ]);
1087 let (got, waits) = run(&mut stub, &device(0, 900));
1088 assert!(got.is_ok());
1089 assert_eq!(waits, [1, 1]);
1090 }
1091
1092 #[test]
1093 fn server_text_is_printed_without_control_characters() {
1094 let body = json!({ "error": "x\u{1b}[2K", "message": "ok\rerror[bynk.fake]: no" });
1095 let e = refused("bynk", 403, body.to_string().as_bytes());
1096 assert!(!e.message.chars().any(char::is_control), "{:?}", e.message);
1097 }
1098
1099 #[test]
1100 fn polling_branches_on_the_error_code_not_the_status() {
1101 let (got, _) = run(
1103 &mut Stub::new(&[(400, problem("bad_request"))]),
1104 &device(0, 900),
1105 );
1106 let e = got.unwrap_err();
1107 assert_eq!(e.code, ACCOUNT_REFUSED);
1108 assert!(e.message.contains("`bad_request`"), "{}", e.message);
1109 }
1110
1111 #[test]
1112 fn a_401_says_to_sign_in_again() {
1113 let e = refused("bynk", 401, problem("unauthorised").to_string().as_bytes());
1114 assert!(
1115 e.message.contains("bynk login --registry bynk"),
1116 "{}",
1117 e.message
1118 );
1119 let e = refused("acme", 404, b"not json");
1120 assert!(e.message.contains("no account API"), "{}", e.message);
1121 let e = refused("acme", 405, b"");
1122 assert!(
1123 e.message.contains("no account API (HTTP 405)"),
1124 "{}",
1125 e.message
1126 );
1127 }
1128
1129 #[test]
1130 fn sessions_are_kept_apart_from_publish_tokens() {
1131 let mut doc: toml_edit::DocumentMut =
1132 "# mine\n[registries.bynk]\ntoken = \"bynk_old\" # laptop\n"
1133 .parse()
1134 .unwrap();
1135 set_session(
1136 &mut doc,
1137 "bynk",
1138 &SignedIn {
1139 token: "bynka_new".into(),
1140 login: "ann".into(),
1141 expires_at: 7,
1142 },
1143 );
1144 let text = doc.to_string();
1145 assert!(
1146 text.starts_with("# mine\n[registries.bynk]\ntoken = \"bynk_old\" # laptop\n"),
1147 "{text}"
1148 );
1149 assert!(
1150 text.contains(
1151 "[accounts.bynk]\ntoken = \"bynka_new\"\nlogin = \"ann\"\nexpires-at = 7\n"
1152 ),
1153 "{text}"
1154 );
1155 assert_eq!(
1156 saved_session(&doc, "bynk"),
1157 Some(Session {
1158 token: "bynka_new".into(),
1159 expires_at: Some(7),
1160 from_env: false,
1161 })
1162 );
1163 assert!(remove_session(&mut doc, "bynk"));
1164 assert!(!remove_session(&mut doc, "bynk"));
1165 assert_eq!(
1166 doc.to_string(),
1167 "# mine\n[registries.bynk]\ntoken = \"bynk_old\" # laptop\n"
1168 );
1169 }
1170
1171 #[test]
1172 fn saving_a_publish_token_never_replaces_another_without_force() {
1173 let mut doc = toml_edit::DocumentMut::new();
1174 assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_ok());
1175 set_publish_token(&mut doc, "bynk", "acme", "bynk_a").unwrap();
1176 assert_eq!(
1177 doc.to_string(),
1178 "[registries.bynk.organisations.acme]\ntoken = \"bynk_a\"\n"
1179 );
1180 assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_err());
1181 assert!(check_publish_slot(&doc, "bynk", "acme", Some("bynk_b"), false).is_err());
1182 assert!(check_publish_slot(&doc, "bynk", "acme", Some("bynk_a"), false).is_ok());
1183 assert!(check_publish_slot(&doc, "bynk", "acme", Some("bynk_b"), true).is_ok());
1184 assert!(check_publish_slot(&doc, "bynk", "acme-labs", None, false).is_ok());
1186 assert!(check_publish_slot(&doc, "other", "acme", None, false).is_ok());
1187 }
1188
1189 #[test]
1190 fn an_organisation_s_slot_sits_beside_the_registry_wide_one() {
1191 let mut doc: toml_edit::DocumentMut =
1192 "# mine\n[registries.bynk]\ntoken = \"bynk_wide\" # laptop\n"
1193 .parse()
1194 .unwrap();
1195 assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_ok());
1197 assert!(registry_wide_token(&doc, "bynk"));
1198 set_publish_token(&mut doc, "bynk", "acme", "bynk_acme").unwrap();
1199 set_publish_token(&mut doc, "bynk", "acme-labs", "bynk_labs").unwrap();
1200 assert_eq!(
1201 doc.to_string(),
1202 "# mine\n[registries.bynk]\ntoken = \"bynk_wide\" # laptop\n\n\
1203 [registries.bynk.organisations.acme]\ntoken = \"bynk_acme\"\n\n\
1204 [registries.bynk.organisations.acme-labs]\ntoken = \"bynk_labs\"\n"
1205 );
1206 assert!(!registry_wide_token(&doc, "other"));
1207 }
1208
1209 #[test]
1210 fn an_inline_organisations_table_is_written_into_and_a_non_table_refused() {
1211 let mut doc: toml_edit::DocumentMut =
1212 "[registries.bynk]\norganisations = { globex = { token = \"bynk_g\" } }\n"
1213 .parse()
1214 .unwrap();
1215 assert!(check_publish_slot(&doc, "bynk", "acme", None, false).is_ok());
1216 set_publish_token(&mut doc, "bynk", "acme", "bynk_a").unwrap();
1217 let text = doc.to_string();
1218 let parsed: toml::Table = text.parse().unwrap();
1219 let orgs = &parsed["registries"]["bynk"]["organisations"];
1220 assert_eq!(orgs["globex"]["token"].as_str(), Some("bynk_g"), "{text}");
1221 assert_eq!(orgs["acme"]["token"].as_str(), Some("bynk_a"), "{text}");
1222 let doc: toml_edit::DocumentMut = "[registries.bynk]\norganisations = \"x\"\n"
1224 .parse()
1225 .unwrap();
1226 let err = check_publish_slot(&doc, "bynk", "acme", None, false).unwrap_err();
1227 assert_eq!(
1228 err,
1229 "`registries.bynk.organisations` in the credentials file isn't a table"
1230 );
1231 }
1232
1233 #[test]
1234 fn an_organisation_named_like_its_registry_writes_no_empty_header() {
1235 let mut doc = toml_edit::DocumentMut::new();
1236 set_publish_token(&mut doc, "bynk", "bynk", "bynk_a").unwrap();
1237 assert_eq!(
1238 doc.to_string(),
1239 "[registries.bynk.organisations.bynk]\ntoken = \"bynk_a\"\n"
1240 );
1241 }
1242
1243 #[cfg(unix)]
1244 #[test]
1245 fn the_credentials_file_is_written_private() {
1246 use std::os::unix::fs::PermissionsExt;
1247 let dir = std::env::temp_dir().join(format!("bynk-account-{}", std::process::id()));
1248 let path = dir.join("bynk").join("credentials.toml");
1249 let mut doc = read_doc(&path).unwrap();
1250 set_publish_token(&mut doc, "bynk", "acme", "bynk_a").unwrap();
1251 write_doc(&path, &doc).unwrap();
1252 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1253 assert_eq!(mode & 0o777, 0o600);
1254 assert_eq!(read_doc(&path).unwrap().to_string(), doc.to_string());
1255 std::fs::remove_dir_all(&dir).unwrap();
1256 }
1257
1258 #[test]
1259 fn members_and_tokens_are_found_by_login_and_prefix() {
1260 let view = json!({
1261 "name": "acme",
1262 "owner": { "id": 1, "login": "ann" },
1263 "members": [{ "id": 1, "login": "ann" }, { "id": 2, "login": "Bob" }],
1264 "blocked": false,
1265 "tokens": [
1266 { "id": "7f3a00", "can": ["publish"], "expiresAt": 0, "note": "", "mintedBy": 2 },
1267 { "id": "7f3b00", "can": ["publish", "yank"], "expiresAt": 0, "note": "ci", "mintedBy": 0 },
1268 ],
1269 });
1270 assert_eq!(find_member(&view, "bob").as_deref(), Some("2"));
1271 assert_eq!(find_member(&view, "cy"), None);
1272 assert_eq!(find_token(&view, "7f3a").unwrap(), "7f3a00");
1273 assert!(find_token(&view, "7f3").unwrap_err().contains("2 tokens"));
1274 assert!(find_token(&view, "00").is_err());
1275 assert!(find_token(&view, "").is_err());
1276 assert_eq!(
1277 render("bynk", &view),
1278 "acme on `bynk`\n owner: ann (#1)\n members: ann (#1), Bob (#2)\n blocked: no\n tokens:\n 7f3a00 publish until 1970-01-01 minted by Bob\n 7f3b00 publish, yank until 1970-01-01 minted by an operator \"ci\"\n"
1279 );
1280 }
1281
1282 #[test]
1283 fn a_path_segment_must_be_a_plain_name() {
1284 assert_eq!(
1285 orgs(&["acme", "members", "bob"]).unwrap(),
1286 "orgs/acme/members/bob"
1287 );
1288 assert!(orgs(&["..", "x"]).is_err());
1289 assert!(orgs(&["acme/x"]).is_err());
1290 assert!(orgs(&[""]).is_err());
1291 }
1292
1293 #[test]
1294 fn the_override_variable_cannot_collide_with_a_publish_variable() {
1295 assert_eq!(
1296 override_variable("acme-internal"),
1297 "BYNK_ACCOUNT_ACME_INTERNAL_TOKEN"
1298 );
1299 assert_ne!(
1300 override_variable("foo"),
1301 bynk_project::manifest::registry_token_var("foo-account")
1302 );
1303 assert_eq!(date(1_795_392_000_000), "2026-11-23");
1304 }
1305}