choir_cli/join.rs
1//! The first five minutes: the invite link, and what this machine is.
2//!
3//! An invite is one link, because a link is what survives being pasted
4//! into a chat window. The node mints it in exactly one place and it
5//! looks like `https://node.example/join?i=<id>&k=<secret>`. Everything
6//! `choir join` needs is inside it — which node, and the credential that
7//! redeems one account there — so a person holding the link should never
8//! have to take it apart by hand into an API base, an invite file and a
9//! key path. [`Link::parse`] is that taking-apart, kept pure so the
10//! whole of it is checkable without a node.
11//!
12//! The second half is [`Role`]: the one-line answer to "what is this
13//! machine set up as", which `choir doctor` prints first and a bare
14//! `choir` prints instead of the index. It is computed from paths that
15//! are passed in rather than read here, so the caller's own resolution
16//! is what gets reported and the logic is testable against a temporary
17//! directory.
18//!
19//! # Examples
20//!
21//! ```
22//! let link = choir_cli::join::Link::parse("https://node.example/join?i=abc&k=s3cret")
23//! .expect("an invite link");
24//! assert_eq!(link.api, "https://node.example");
25//! assert_eq!(link.id, "abc");
26//! assert_eq!(link.secret, "s3cret");
27//! ```
28
29/// An invite link taken apart into the three things redeeming one needs.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Link {
32 /// The node's API base: scheme and authority, no path, no trailing
33 /// slash. The same string every other command takes as `<api>`.
34 pub api: String,
35 /// The invite's id, which is the user half of the credential that
36 /// redeems it.
37 pub id: String,
38 /// The invite's secret, which is the password half.
39 pub secret: String,
40}
41
42impl Link {
43 /// Whether a string is worth handing to [`Link::parse`].
44 ///
45 /// Used by the argument matcher to tell `choir join <link>` from
46 /// `choir join <api> <invite-file> <key-file>`, whose first argument
47 /// is also a URL. Only the path decides, so a mistyped link still
48 /// reaches `parse` and gets `parse`'s message rather than being
49 /// silently read as the three-argument form with two arguments
50 /// missing.
51 #[must_use]
52 pub fn looks_like(argument: &str) -> bool {
53 let Some((scheme, rest)) = argument.split_once("://") else {
54 return false;
55 };
56 (scheme == "http" || scheme == "https") && rest.contains("/join?")
57 }
58
59 /// Recovers the node and the invite from the link the operator sent.
60 ///
61 /// Hand-written rather than pulled from a URL crate: the grammar is
62 /// one path and two query parameters, both of which the node mints
63 /// itself, and this workspace adds dependencies reluctantly.
64 ///
65 /// # Errors
66 ///
67 /// Returns the sentence to print when the string is not an invite
68 /// link: no scheme, a scheme with no API base behind it, no `/join`
69 /// path, or either query parameter missing or empty. Every one of
70 /// them ends with the `next:` line a reader can act on.
71 pub fn parse(link: &str) -> Result<Link, String> {
72 let bad = |why: &str| {
73 format!(
74 "that does not look like an invite link ({why}).\n\
75 An invite is one link, like https://node.example/join?i=…&k=…\n\
76 next: paste the whole link, quoted: choir join '<link>'"
77 )
78 };
79 let (scheme, rest) = link.split_once("://").ok_or_else(|| bad("no scheme"))?;
80 if scheme != "http" && scheme != "https" {
81 return Err(bad("not an http(s) URL"));
82 }
83 let (authority, path) = rest.split_once('/').ok_or_else(|| bad("no /join path"))?;
84 // Everything before the last `@` is userinfo. A link should
85 // carry none, and one that does must not leave its tail in the
86 // host.
87 let host = authority
88 .rsplit_once('@')
89 .map_or(authority, |(_, host)| host);
90 if host.is_empty() {
91 return Err(bad("no host"));
92 }
93 let (route, query) = path.split_once('?').ok_or_else(|| bad("no invite in it"))?;
94 if route.trim_end_matches('/') != "join" {
95 return Err(bad("its path is not /join"));
96 }
97 // A shell that ate the `&` leaves `?i=<id>` alone and drops the
98 // rest, which is the single most likely way this arrives broken.
99 // Naming the quoting is more use than naming the missing field.
100 let mut id = None;
101 let mut secret = None;
102 for pair in query.split('&') {
103 match pair.split_once('=') {
104 Some(("i", value)) if !value.is_empty() => id = Some(value),
105 Some(("k", value)) if !value.is_empty() => secret = Some(value),
106 _ => {}
107 }
108 }
109 let (Some(id), Some(secret)) = (id, secret) else {
110 return Err(format!(
111 "that invite link is missing half of itself.\n\
112 A shell eats the `&` in an unquoted URL, which leaves exactly this.\n\
113 next: quote it: choir join '{link}&k=…'"
114 ));
115 };
116 Ok(Link {
117 api: format!("{scheme}://{host}"),
118 id: id.to_string(),
119 secret: secret.to_string(),
120 })
121 }
122}
123
124/// The repositories an invite's grants name, each with the folder
125/// `choir join` clones it into, in grant order.
126///
127/// A grant is the node's `<repo|*> <level> [until=…]` spelling. `*`
128/// names no repository, so it clones nothing; the same repository under
129/// both of the ACL's spellings (`owner/name` and `owner/name.git`) is one
130/// clone, not two into the same folder. The folder is the last path
131/// segment, which is where a plain `git clone` of the same URL would put
132/// it, and a segment that is not a folder name is skipped rather than
133/// handed to git as a path.
134#[must_use]
135pub fn repositories<'a>(grants: &[&'a str]) -> Vec<(&'a str, &'a str)> {
136 let mut out: Vec<(&str, &str)> = Vec::new();
137 for grant in grants {
138 let Some(target) = grant.split_whitespace().next() else {
139 continue;
140 };
141 let repo = target.strip_suffix(".git").unwrap_or(target);
142 let folder = repo.rsplit('/').next().unwrap_or(repo);
143 if repo == "*" || matches!(folder, "" | "." | "..") {
144 continue;
145 }
146 if !out.iter().any(|(seen, _)| *seen == repo) {
147 out.push((repo, folder));
148 }
149 }
150 out
151}
152
153/// What one machine is set up as, in the vocabulary the documentation
154/// uses.
155///
156/// Three personas, and a machine is at most one of them: a reviewer
157/// never opens a terminal, so no local state can say "reviewer" and the
158/// enum does not pretend otherwise.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum Role {
161 /// Nothing has been set up here yet.
162 Nothing,
163 /// Holds a credential for somebody else's node.
164 Contributor,
165 /// Runs a node: `choir init` has written a repository root here.
166 Operator,
167}
168
169impl Role {
170 /// Reads the role off the two paths that distinguish it.
171 ///
172 /// `state` is `~/.choir`. The operator test is the repository root
173 /// `choir init` creates, not the credential — an operator's node
174 /// issues itself one too, so the credential alone cannot tell the
175 /// two apart, and the root is the thing only an operator has.
176 #[must_use]
177 pub fn of(state: &std::path::Path, auth_file: Option<&str>) -> Role {
178 if state.join("repos").is_dir() {
179 return Role::Operator;
180 }
181 match auth_file {
182 Some(path) if std::path::Path::new(path).exists() => Role::Contributor,
183 _ => Role::Nothing,
184 }
185 }
186
187 /// The line `choir doctor` prints above its checks.
188 #[must_use]
189 pub fn line(self) -> &'static str {
190 match self {
191 Role::Nothing => "nothing configured yet, run `choir join <link>` or `choir init`",
192 Role::Contributor => "contributor: a credential for somebody else's node",
193 Role::Operator => "operator: this machine runs a node",
194 }
195 }
196}
197
198/// The whole of `choir` for a machine that has nothing set up.
199///
200/// Six lines, because the index is forty-eight commands long and a
201/// newcomer's question is not "what can this do". A reader who wants the
202/// index asks for it by name, which is what the last line says.
203///
204/// The install step a contributor also needs is deliberately not here.
205/// Anybody reading this already ran it — the binary printing the line is
206/// the evidence — and naming a release host this workspace does not yet
207/// have would be inventing one.
208#[must_use]
209pub fn orientation(style: crate::style::Style) -> String {
210 let row = |who: &str, command: &str, what: &str| {
211 format!(
212 " {} {} {}\n",
213 style.dim(&format!("{who:11}")),
214 style.cyan(&format!("{command:24}")),
215 style.dim(what)
216 )
217 };
218 let mut out = format!(
219 "{}\n\n",
220 style.bold("choir: one order over one repository, worked on by many agents at once.")
221 );
222 out.push_str(&row(
223 "contributor",
224 "choir join '<link>'",
225 "redeem the invite you were sent",
226 ));
227 out.push_str(&row("operator", "choir init", "run a node on this machine"));
228 out.push_str(&row(
229 "reviewer",
230 "(nothing to install)",
231 "open the link in a browser",
232 ));
233 out.push_str(&format!(
234 " {}\n",
235 style.dim("docs choir --help every command, and its arguments")
236 ));
237 out
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn a_link_with_a_port_keeps_it() {
246 let link = Link::parse("http://127.0.0.1:8417/join?i=a&k=b").expect("parses");
247 assert_eq!(link.api, "http://127.0.0.1:8417");
248 }
249
250 #[test]
251 fn the_three_argument_form_is_not_mistaken_for_a_link() {
252 assert!(!Link::looks_like("https://node.example"));
253 assert!(Link::looks_like("https://node.example/join?i=a&k=b"));
254 }
255
256 #[test]
257 fn a_link_the_shell_truncated_says_so() {
258 let error = Link::parse("https://node.example/join?i=a").expect_err("half a link");
259 assert!(error.contains("missing half of itself"), "{error}");
260 assert!(error.contains("next:"), "{error}");
261 }
262
263 #[test]
264 fn every_refusal_names_a_next_step() {
265 for bad in ["node.example/join?i=a&k=b", "ssh://x/join?i=a&k=b"] {
266 let error = Link::parse(bad).expect_err("refused");
267 assert!(error.contains("next:"), "{bad}: {error}");
268 }
269 }
270
271 #[test]
272 fn an_invite_clones_each_repository_it_names_once_and_never_the_wildcard() {
273 let grants = [
274 "agents/demo write",
275 // The same repository spelled the ACL's other way is not a
276 // second clone into the same folder.
277 "agents/demo.git read",
278 // `*` names no repository, so there is nothing to clone.
279 "* write",
280 "other/notes read until=1864000",
281 // A last segment that is not a folder name is skipped rather
282 // than handed to git as a path.
283 "other/.. read",
284 ];
285 assert_eq!(
286 repositories(&grants),
287 [("agents/demo", "demo"), ("other/notes", "notes")]
288 );
289 }
290
291 #[test]
292 fn the_orientation_is_at_most_six_lines() {
293 let text = orientation(crate::style::Style::plain());
294 assert!(
295 text.lines().count() <= 6,
296 "orientation is {} lines:\n{text}",
297 text.lines().count()
298 );
299 }
300}