Skip to main content

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}