choir_cli/init.rs
1//! `choir init` — from nothing to a node you can push to.
2//!
3//! The four things a new instance needs are a repository root, a
4//! credential, an actor key the node will trust, and a `.choir/config`
5//! so the other commands stop asking which node you mean. Each was
6//! already possible by hand — `openssl rand`, `choir key`, a `printf`
7//! into a file at the right mode — and every one of them is a step
8//! where a first-time reader gets a permission bit wrong and finds out
9//! much later.
10//!
11//! # What it will not do
12//!
13//! It refuses when anything it would write already exists, and names
14//! what. Overwriting an auth file locks you out of your own node with
15//! no undo: the token in it is the only copy, the node was started
16//! reading it, and nothing anywhere else can reissue it. `--force` is
17//! the deliberate reset, and says what it destroyed.
18//!
19//! # Examples
20//!
21//! ```
22//! use choir_cli::init::Plan;
23//!
24//! // The default plan is loopback, because a node that binds anything
25//! // else refuses to start without TLS (invariant 9) — and a first run
26//! // that fails on a certificate teaches nothing about choir.
27//! let plan = Plan::new(std::path::Path::new("/tmp/choir-example"), 8417);
28//! assert!(plan.node_url().starts_with("http://127.0.0.1:"));
29//! ```
30
31use std::path::{Path, PathBuf};
32
33/// Where everything `choir init` writes is going.
34///
35/// Built before anything is created so the refusal can name every
36/// conflict at once. A tool that fails on the first collision, is fixed,
37/// and then fails on the second is a tool that gets run four times.
38pub struct Plan {
39 /// `~/.choir`, or the directory given.
40 pub state: PathBuf,
41 /// Where bare repositories live; the node's positional root.
42 pub repos: PathBuf,
43 /// `user:token`, mode 0600.
44 pub auth: PathBuf,
45 /// This actor's 32 secret bytes, mode 0600.
46 pub key: PathBuf,
47 /// The public keys the node will accept ops from.
48 pub trusted: PathBuf,
49 /// `.choir/config` in the working directory, not in `state`: it is
50 /// found the way git finds a repository, by walking up from wherever
51 /// you are, so a checkout can name a different node than the one
52 /// your home directory happens to point at.
53 pub config: PathBuf,
54 /// The port the node will bind.
55 pub port: u16,
56}
57
58impl Plan {
59 /// The default layout under `state`.
60 #[must_use]
61 pub fn new(state: &Path, port: u16) -> Plan {
62 Plan {
63 state: state.to_path_buf(),
64 repos: state.join("repos"),
65 auth: state.join("auth"),
66 key: state.join("agent.key"),
67 trusted: state.join("keys"),
68 config: PathBuf::from(".choir/config"),
69 port,
70 }
71 }
72
73 /// The URL the other commands will use.
74 ///
75 /// Loopback, and not configurable here on purpose: [`Node`] refuses
76 /// a non-loopback bind without TLS, which is the privacy rule
77 /// expressed as code rather than a doc note. Offering `--bind` on
78 /// the command that runs *first* would put a certificate between a
79 /// reader and their first push.
80 ///
81 /// [`Node`]: choir_node::Node
82 #[must_use]
83 pub fn node_url(&self) -> String {
84 format!("http://127.0.0.1:{}", self.port)
85 }
86
87 /// Every file this plan would write, in the order it writes them.
88 #[must_use]
89 pub fn files(&self) -> Vec<&Path> {
90 vec![
91 self.auth.as_path(),
92 self.key.as_path(),
93 self.trusted.as_path(),
94 self.config.as_path(),
95 ]
96 }
97
98 /// Which of them are already there.
99 #[must_use]
100 pub fn conflicts(&self) -> Vec<&Path> {
101 self.files().into_iter().filter(|p| p.exists()).collect()
102 }
103}
104
105/// Mints a bearer token.
106///
107/// `openssl rand`, not a crate: this workspace adds dependencies
108/// reluctantly, already requires `openssl` for every other secret it
109/// handles, and a randomness crate pulled in for one call site is a
110/// supply-chain edge bought for nothing. A failure here is fatal rather
111/// than fallen back on — there is no second source of randomness worth
112/// having, and a predictable token is worse than no node.
113///
114/// # Errors
115///
116/// Returns a description when `openssl` is missing or fails.
117pub fn mint_token() -> Result<String, String> {
118 let out = std::process::Command::new("openssl")
119 .args(["rand", "-hex", "32"])
120 .output()
121 .map_err(|_| {
122 "could not run `openssl`, which mints the token: install it and run this again"
123 .to_string()
124 })?;
125 if !out.status.success() {
126 return Err("`openssl rand` failed; the token would not be random".to_string());
127 }
128 let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
129 // 32 bytes as hex is 64 characters. Anything shorter means openssl
130 // answered something this did not expect, and a short token must
131 // never be written as though it were a full one.
132 if token.len() != 64 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
133 return Err("`openssl rand` returned something that is not 32 hex bytes".to_string());
134 }
135 Ok(token)
136}
137
138/// What `choir init` did, so the caller can print it and the tests can
139/// assert on it.
140pub struct Created {
141 /// The credential line's user half.
142 pub user: String,
143 /// Files written, in order.
144 pub wrote: Vec<PathBuf>,
145 /// Files replaced because `--force` was given.
146 pub replaced: Vec<PathBuf>,
147}
148
149/// Runs the plan.
150///
151/// Order matters: the directories first, then the secrets, then the
152/// config last. `.choir/config` is what makes every other command point
153/// here, so writing it before the credential exists would leave a
154/// working directory that confidently names a node nobody can reach.
155///
156/// # Errors
157///
158/// Refuses when anything the plan would write already exists and
159/// `force` is false, naming all of them. Otherwise fails on the first
160/// filesystem or `openssl` error.
161pub fn run(plan: &Plan, force: bool) -> Result<Created, String> {
162 let existing: Vec<PathBuf> = plan
163 .conflicts()
164 .into_iter()
165 .map(Path::to_path_buf)
166 .collect();
167 if !existing.is_empty() && !force {
168 let names: Vec<String> = existing.iter().map(|p| p.display().to_string()).collect();
169 return Err(format!(
170 "these already exist:\n {}\n\nNothing was changed. Overwriting the auth file \
171 locks you out of the node it belongs to — the token in it is the only copy, and \
172 nothing can reissue it. Use --force to replace them deliberately.",
173 names.join("\n ")
174 ));
175 }
176
177 for dir in [&plan.state, &plan.repos] {
178 std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
179 }
180 if let Some(parent) = plan.config.parent() {
181 std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
182 }
183
184 let user = "choir".to_string();
185 let token = mint_token()?;
186 // Private from creation, never write-then-chmod: the window between
187 // the two is a window where the token is world-readable.
188 choir_fs::write_atomic_private(&plan.auth, format!("{user}:{token}\n"))
189 .map_err(|e| format!("write {}: {e}", plan.auth.display()))?;
190
191 let key = choir_identity::ActorKey::generate();
192 choir_fs::write_atomic_private(&plan.key, key.secret_bytes())
193 .map_err(|e| format!("write {}: {e}", plan.key.display()))?;
194
195 // The public half, in the form the node's --keys-file reads. Not
196 // private: it is a public key, and a 0600 file the node runs as a
197 // different user cannot read is a node that trusts nobody.
198 let hex: String = key
199 .public_key_bytes()
200 .iter()
201 .map(|b| format!("{b:02x}"))
202 .collect();
203 choir_fs::write_atomic(&plan.trusted, format!("op {hex}\n"))
204 .map_err(|e| format!("write {}: {e}", plan.trusted.display()))?;
205
206 choir_fs::write_atomic(
207 &plan.config,
208 format!(
209 "# Which node the `choir` commands talk to when they are not\n\
210 # given one. Found by walking up from the working directory,\n\
211 # the way git finds a repository.\n\
212 node = {}\n",
213 plan.node_url()
214 ),
215 )
216 .map_err(|e| format!("write {}: {e}", plan.config.display()))?;
217
218 Ok(Created {
219 user,
220 wrote: plan.files().into_iter().map(Path::to_path_buf).collect(),
221 replaced: existing,
222 })
223}