Skip to main content

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}