decdn_cli/commands/terms.rs
1//! Operator-terms acceptance at registration (ADR 019 § Terms Acceptance).
2//!
3//! The canonical operator terms ship embedded in the binary ([`TERMS_TEXT`]);
4//! their `keccak256` is the exact preimage the on-chain `currentTermsHash`
5//! commits to. Before signing `registerNode`, the CLI shows the operator the
6//! terms and records an explicit affirmative acknowledgement — interactively on
7//! a terminal, or via `--accept-terms` for headless/automated provisioning.
8//!
9//! Enforcement is deliberately split: [`decide`] is the pure policy (unit
10//! tested), and [`ensure_accepted`] is the thin IO wrapper that displays the
11//! terms and prompts.
12
13use std::io::{self, IsTerminal, Write};
14
15use alloy::primitives::{B256, keccak256};
16use anyhow::Context;
17
18/// The canonical operator terms, embedded from this crate's `TERMS.md` so the
19/// displayed text is the exact preimage of the recorded hash — there is no
20/// external document to fetch or substitute (ADR 019 § Terms Acceptance).
21///
22/// The file lives in the crate rather than at the repo root because `include_str!`
23/// may not escape the package: a path outside it is unreachable from the
24/// published `.crate`, so `cargo install decdn-cli` would fail to build. See
25/// `TERMS_README.md` alongside it for the editing rules.
26pub const TERMS_TEXT: &str = include_str!("../../TERMS.md");
27
28/// `keccak256` of the embedded terms — the value the operator's signature
29/// commits to and which must equal the network's on-chain `currentTermsHash`.
30#[must_use]
31pub fn terms_hash() -> B256 {
32 keccak256(TERMS_TEXT.as_bytes())
33}
34
35/// Human-readable version pulled from the `**Version:**` line of `TERMS.md`
36/// (display only — the hash is the authoritative identifier).
37#[must_use]
38pub fn terms_version() -> &'static str {
39 TERMS_TEXT
40 .lines()
41 .find_map(|line| line.strip_prefix("**Version:**"))
42 .map_or("unknown", str::trim)
43}
44
45/// Outcome of the acceptance policy given the invocation context.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Decision {
48 /// Terms accepted (explicit `--accept-terms`); proceed without a prompt.
49 Proceed,
50 /// Interactive terminal, no flag — show the terms and prompt `[y/N]`.
51 Prompt,
52 /// No terminal and no `--accept-terms` — cannot record consent; abort.
53 NeedFlag,
54 /// The embedded terms do not match the network's current terms; the client
55 /// is stale and must be updated before it can register
56 /// (ADR 019 § Governance-canonical terms version).
57 StaleClient,
58}
59
60/// Pure acceptance policy. `local` is [`terms_hash`]; `chain` is the network's
61/// `currentTermsHash`. The staleness check takes precedence over everything —
62/// we never sign a hash the operator was not shown, even with `--accept-terms`.
63#[must_use]
64pub fn decide(accept_flag: bool, is_tty: bool, local: B256, chain: B256) -> Decision {
65 if local != chain {
66 Decision::StaleClient
67 } else if accept_flag {
68 Decision::Proceed
69 } else if is_tty {
70 Decision::Prompt
71 } else {
72 Decision::NeedFlag
73 }
74}
75
76/// Enforce operator-terms acceptance before registration signs `registerNode`.
77///
78/// `chain_terms_hash` is the network's on-chain `currentTermsHash`;
79/// `accept_flag` is `--accept-terms`. Returns `Ok(())` only when the operator
80/// has accepted the current terms; otherwise returns a descriptive error and
81/// registration must not proceed.
82///
83/// # Errors
84///
85/// - Client stale (embedded terms ≠ on-chain terms).
86/// - No terminal and `--accept-terms` not passed.
87/// - The operator answered no at the interactive prompt.
88pub fn ensure_accepted(chain_terms_hash: B256, accept_flag: bool) -> anyhow::Result<()> {
89 let local = terms_hash();
90 // Interactive only when BOTH streams are terminals: the terms + prompt go to
91 // stderr and the answer is read from stdin, so if either is redirected the
92 // operator can't see the prompt — fall back to requiring `--accept-terms`
93 // rather than blocking on invisible input.
94 let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
95 match decide(accept_flag, interactive, local, chain_terms_hash) {
96 Decision::StaleClient => anyhow::bail!(
97 "operator terms mismatch: this build ships terms {} ({:#x}) but the network's \
98 current terms are {:#x}. Update `decdn` to a build whose terms match the network \
99 (ADR 019 § Terms Acceptance), then retry.",
100 terms_version(),
101 local,
102 chain_terms_hash,
103 ),
104 Decision::Proceed => {
105 // Flag path: the operator asserts prior review; record a one-line
106 // confirmation rather than re-dumping the full text.
107 let mut err = io::stderr().lock();
108 writeln!(
109 err,
110 "Accepting deCDN operator terms {} ({:#x}) per --accept-terms.",
111 terms_version(),
112 local,
113 )?;
114 Ok(())
115 }
116 Decision::NeedFlag => {
117 print_terms()?;
118 anyhow::bail!(
119 "operator terms not accepted: no interactive terminal detected — re-run with \
120 `--accept-terms` after reviewing the terms above."
121 )
122 }
123 Decision::Prompt => {
124 print_terms()?;
125 if prompt_yes_no()? {
126 Ok(())
127 } else {
128 anyhow::bail!("registration cancelled: operator terms not accepted.")
129 }
130 }
131 }
132}
133
134/// Async wrapper over [`ensure_accepted`]. The interactive branch does blocking
135/// `stdin`/`stderr` IO, so run the whole gate on a blocking thread rather than
136/// parking a tokio runtime worker while it waits for the operator.
137///
138/// # Errors
139///
140/// Propagates [`ensure_accepted`]'s errors; also errors if the blocking task
141/// panics.
142pub async fn ensure_accepted_async(
143 chain_terms_hash: B256,
144 accept_flag: bool,
145) -> anyhow::Result<()> {
146 tokio::task::spawn_blocking(move || ensure_accepted(chain_terms_hash, accept_flag))
147 .await
148 .context("terms acceptance task panicked")?
149}
150
151/// Render the full terms + version + hash to stderr (stdout stays reserved for
152/// the machine-readable registration outcome / `--json`).
153fn print_terms() -> io::Result<()> {
154 let mut err = io::stderr().lock();
155 let rule = "─".repeat(64);
156 writeln!(
157 err,
158 "\n{rule}\ndeCDN Operator Terms {} ({:#x})\n{rule}",
159 terms_version(),
160 terms_hash(),
161 )?;
162 writeln!(err, "{}", TERMS_TEXT.trim_end())?;
163 writeln!(err, "{rule}")?;
164 Ok(())
165}
166
167/// Prompt for acceptance on the controlling terminal. Defaults to no: only an
168/// explicit `y` / `yes` accepts.
169fn prompt_yes_no() -> anyhow::Result<bool> {
170 let mut err = io::stderr().lock();
171 write!(err, "Accept these operator terms? [y/N] ")?;
172 err.flush()?;
173 let mut line = String::new();
174 io::stdin().read_line(&mut line)?;
175 let answer = line.trim().to_ascii_lowercase();
176 Ok(answer == "y" || answer == "yes")
177}
178
179#[cfg(test)]
180mod tests;