Skip to main content

choir_cli/
acl.rs

1//! Regenerating the readable half of an ACL file (D46).
2//!
3//! Grants are written against handles, because a channel is what
4//! `signing_hash` covers and therefore what the log keeps forever. That
5//! leaves the operator auditing lines like `7f3ac2ab19cd choir/choir.git
6//! own`, which is unreadable, and unreadable authorization is
7//! unaudited authorization.
8//!
9//! The settled answer is a generated trailing comment naming the person,
10//! and the reason it is safe is one asymmetry: **the node's ACL parser
11//! ignores everything after `#`.** A comment that has gone stale can
12//! mislead a reader; it can never change a grant. The two rejected
13//! options both failed on that axis — resolving names at load time turns
14//! a deleted account into a config error someone has to notice, and a
15//! separate roster file means auditing one grant needs two files open,
16//! which in practice means the audit stops happening.
17//!
18//! This module is the pure half: text and a roster in, text out, no
19//! network and no filesystem. The command that fetches the roster and
20//! writes the file lives in `main.rs`, so everything with a decision in
21//! it can be tested without either.
22//!
23//! # Examples
24//!
25//! ```
26//! let mut roster = std::collections::BTreeMap::new();
27//! roster.insert("7f3ac2ab19cd".to_string(), "Ada Lovelace".to_string());
28//!
29//! let rendered = choir_cli::acl::render("7f3ac2ab19cd choir/choir.git own\n", &roster);
30//! assert!(rendered.contains("# Ada Lovelace"));
31//! assert!(rendered.contains("7f3ac2ab19cd choir/choir.git own"));
32//! ```
33
34use std::collections::BTreeMap;
35
36/// Handle to readable name, as `GET /api/accounts` reports it.
37pub type Roster = BTreeMap<String, String>;
38
39/// The line written above a rendered file, telling the next reader that
40/// the comments are output rather than input.
41pub const HEADER: &str = "# regenerated by `choir acl render` -- do not hand-edit comments";
42
43/// Rewrites every grant line's trailing comment from `roster`.
44///
45/// The grant half of each line — everything before the first `#` — is
46/// copied through byte for byte. That is the property this command
47/// stands on: it edits an authorization file, and the one thing it must
48/// never do is change what the file authorizes.
49///
50/// A subject the roster cannot name loses its comment rather than
51/// keeping the one it had. That is not tidiness: the roster forgets a
52/// name when the account is revoked, so a comment left behind would be
53/// the deleted name surviving in a file, which is the whole thing the
54/// deletion was for.
55///
56/// Standalone comment lines and blank lines are passed through
57/// untouched. They are the operator's own writing, and a command that
58/// regenerates comments is not a command that owns every comment.
59#[must_use]
60pub fn render(acl_text: &str, roster: &Roster) -> String {
61    let mut out = String::with_capacity(acl_text.len() + 64);
62    let ends_with_newline = acl_text.is_empty() || acl_text.ends_with('\n');
63    let mut wrote_header = false;
64
65    for line in acl_text.lines() {
66        // The header is rewritten rather than duplicated: running the
67        // command twice must produce the same file as running it once.
68        if line.trim() == HEADER {
69            continue;
70        }
71        if !wrote_header {
72            out.push_str(HEADER);
73            out.push('\n');
74            wrote_header = true;
75        }
76
77        let grant = line.split('#').next().unwrap_or(line);
78        // A line with no grant text is a comment or blank: the
79        // operator's, not ours.
80        if grant.trim().is_empty() {
81            out.push_str(line);
82            out.push('\n');
83            continue;
84        }
85
86        let grant = grant.trim_end();
87        out.push_str(grant);
88        if let Some(name) = grant.split_whitespace().next().and_then(|s| roster.get(s)) {
89            out.push_str("  # ");
90            // A name carrying a newline would forge a grant line out of
91            // a comment. The node refuses one at issue, so this is the
92            // second of two defences rather than the only one.
93            out.push_str(&name.replace(['\n', '\r'], " "));
94        }
95        out.push('\n');
96    }
97
98    if !wrote_header {
99        out.push_str(HEADER);
100        out.push('\n');
101    }
102    if !ends_with_newline {
103        out.pop();
104    }
105    out
106}
107
108/// How many grant lines were rendered, and how many of them a name could
109/// be found for.
110///
111/// Returned for the command's summary line, which is the only signal an
112/// operator gets that a handle they expected to see named is not: a file
113/// that renders no comments at all looks exactly like a file that has
114/// no accounts.
115#[must_use]
116pub fn counts(acl_text: &str, roster: &Roster) -> (usize, usize) {
117    let mut grants = 0;
118    let mut named = 0;
119    for line in acl_text.lines() {
120        let grant = line.split('#').next().unwrap_or(line);
121        if grant.trim().is_empty() {
122            continue;
123        }
124        grants += 1;
125        if grant
126            .split_whitespace()
127            .next()
128            .is_some_and(|subject| roster.contains_key(subject))
129        {
130            named += 1;
131        }
132    }
133    (grants, named)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{counts, render, Roster, HEADER};
139
140    fn roster() -> Roster {
141        let mut roster = Roster::new();
142        roster.insert("7f3ac2ab19cd".to_string(), "Ada Lovelace".to_string());
143        roster.insert("91bd04ff2a17".to_string(), "Grace Hopper".to_string());
144        roster
145    }
146
147    /// The property the whole command rests on, asserted on the text
148    /// rather than inferred from the code: what a line authorizes is
149    /// byte-identical before and after.
150    #[test]
151    fn the_grant_half_of_every_line_survives_unchanged() {
152        let before = "\
1537f3ac2ab19cd choir/choir.git own
15491bd04ff2a17 choir/choir.git write
1550000deadbeef @node auditor
156";
157        let after = render(before, &roster());
158        let grants: Vec<&str> = after
159            .lines()
160            .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
161            .map(|l| l.split('#').next().unwrap_or(l).trim_end())
162            .collect();
163        assert_eq!(
164            grants,
165            vec![
166                "7f3ac2ab19cd choir/choir.git own",
167                "91bd04ff2a17 choir/choir.git write",
168                "0000deadbeef @node auditor",
169            ],
170            "rendering comments changed what the file authorizes: {after}"
171        );
172    }
173
174    /// A handle the roster cannot name loses its comment, because the
175    /// roster forgets a name exactly when the account is revoked. A
176    /// comment left behind is the deleted name surviving in a file.
177    #[test]
178    fn a_forgotten_name_is_removed_rather_than_kept() {
179        let stale = "7f3ac2ab19cd choir/choir.git own  # Ada Lovelace\n";
180        let after = render(stale, &Roster::new());
181        assert!(
182            !after.contains("Ada Lovelace"),
183            "a deleted name survived in the ACL file: {after}"
184        );
185        assert!(
186            after.contains("7f3ac2ab19cd choir/choir.git own"),
187            "the grant went with the name: {after}"
188        );
189    }
190
191    /// Running it twice must produce what running it once produced, or
192    /// the repair is not one an operator can run without thinking.
193    #[test]
194    fn rendering_is_idempotent() {
195        let before = "7f3ac2ab19cd choir/choir.git own\n\n# my own note\n";
196        let once = render(before, &roster());
197        let twice = render(&once, &roster());
198        assert_eq!(once, twice, "a second run changed the file");
199        assert_eq!(once.matches(HEADER).count(), 1, "the header was duplicated");
200    }
201
202    /// The operator's own comment lines are theirs. A command that
203    /// regenerates comments does not own every comment in the file.
204    #[test]
205    fn standalone_comments_and_blank_lines_are_left_alone() {
206        let before = "# who can push to the demo repo\n\n7f3ac2ab19cd agents/demo write\n";
207        let after = render(before, &roster());
208        assert!(after.contains("# who can push to the demo repo"), "{after}");
209        assert!(
210            after.contains("\n\n"),
211            "the blank line was swallowed: {after}"
212        );
213    }
214
215    /// A stale comment can mislead a reader; it must never be able to
216    /// become a grant. A name carrying a newline would do exactly that,
217    /// so it is flattened here as well as refused at issue.
218    #[test]
219    fn a_name_cannot_smuggle_a_second_line_into_the_file() {
220        let mut roster = Roster::new();
221        roster.insert(
222            "7f3ac2ab19cd".to_string(),
223            "Ada\n0000deadbeef @node auditor".to_string(),
224        );
225        let after = render("7f3ac2ab19cd agents/demo read\n", &roster);
226        assert!(
227            !after.contains("\n0000deadbeef"),
228            "a display name forged a grant line: {after}"
229        );
230    }
231
232    /// The summary is what tells an operator that a handle they expected
233    /// to see named is not: an unnamed file and an empty file look the
234    /// same otherwise.
235    #[test]
236    fn the_summary_counts_grants_and_the_names_found_for_them() {
237        let text = "\
238# a note
2397f3ac2ab19cd choir/choir.git own
2400000deadbeef @node auditor
241
24291bd04ff2a17 agents/demo write
243";
244        assert_eq!(counts(text, &roster()), (3, 2));
245    }
246}