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}