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/// Fails the build when the embedded terms carry a carriage return. The
29/// on-chain hash commits to the LF bytes of `TERMS.md`; a checkout that converts
30/// line endings to CRLF would otherwise build a binary whose terms hash never
31/// matches the network. `.gitattributes` keeps the file byte-exact.
32const _: () = assert!(
33 !contains_cr(TERMS_TEXT.as_bytes()),
34 "TERMS.md has CRLF line endings; check it out byte-exact (see .gitattributes)"
35);
36
37const fn contains_cr(bytes: &[u8]) -> bool {
38 let mut rest = bytes;
39 while let [first, tail @ ..] = rest {
40 if *first == b'\r' {
41 return true;
42 }
43 rest = tail;
44 }
45 false
46}
47
48/// `keccak256` of the embedded terms — the value the operator's signature
49/// commits to and which must equal the network's on-chain `currentTermsHash`.
50#[must_use]
51pub fn terms_hash() -> B256 {
52 keccak256(TERMS_TEXT.as_bytes())
53}
54
55/// Human-readable version pulled from the `**Version:**` line of `TERMS.md`
56/// (display only — the hash is the authoritative identifier).
57#[must_use]
58pub fn terms_version() -> &'static str {
59 TERMS_TEXT
60 .lines()
61 .find_map(|line| line.strip_prefix("**Version:**"))
62 .map_or("unknown", str::trim)
63}
64
65/// Outcome of the acceptance policy given the invocation context.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Decision {
68 /// Terms accepted (explicit `--accept-terms`); proceed without a prompt.
69 Proceed,
70 /// Interactive terminal, no flag — show the terms and prompt `[y/N]`.
71 Prompt,
72 /// No terminal and no `--accept-terms` — cannot record consent; abort.
73 NeedFlag,
74 /// The embedded terms do not match the network's current terms; the client
75 /// is stale and must be updated before it can register
76 /// (ADR 019 § Governance-canonical terms version).
77 StaleClient,
78}
79
80/// Pure acceptance policy. `local` is [`terms_hash`]; `chain` is the network's
81/// `currentTermsHash`. The staleness check takes precedence over everything —
82/// we never sign a hash the operator was not shown, even with `--accept-terms`.
83#[must_use]
84pub fn decide(accept_flag: bool, is_tty: bool, local: B256, chain: B256) -> Decision {
85 if local != chain {
86 Decision::StaleClient
87 } else if accept_flag {
88 Decision::Proceed
89 } else if is_tty {
90 Decision::Prompt
91 } else {
92 Decision::NeedFlag
93 }
94}
95
96/// Enforce operator-terms acceptance before registration signs `registerNode`.
97///
98/// `chain_terms_hash` is the network's on-chain `currentTermsHash`;
99/// `accept_flag` is `--accept-terms`. Returns `Ok(())` only when the operator
100/// has accepted the current terms; otherwise returns a descriptive error and
101/// registration must not proceed.
102///
103/// # Errors
104///
105/// - Client stale (embedded terms ≠ on-chain terms).
106/// - No terminal and `--accept-terms` not passed.
107/// - The operator answered no at the interactive prompt.
108pub fn ensure_accepted(chain_terms_hash: B256, accept_flag: bool) -> anyhow::Result<()> {
109 let local = terms_hash();
110 // Interactive only when BOTH streams are terminals: the terms + prompt go to
111 // stderr and the answer is read from stdin, so if either is redirected the
112 // operator can't see the prompt — fall back to requiring `--accept-terms`
113 // rather than blocking on invisible input.
114 let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
115 match decide(accept_flag, interactive, local, chain_terms_hash) {
116 Decision::StaleClient => anyhow::bail!(
117 "operator terms mismatch: this build ships terms {} ({:#x}) but the network's \
118 current terms are {:#x}. Update `decdn` to a build whose terms match the network \
119 (ADR 019 § Terms Acceptance), then retry.",
120 terms_version(),
121 local,
122 chain_terms_hash,
123 ),
124 Decision::Proceed => {
125 // Flag path: the operator asserts prior review; record a one-line
126 // confirmation rather than re-dumping the full text.
127 let mut err = io::stderr().lock();
128 writeln!(
129 err,
130 "Accepting deCDN operator terms {} ({:#x}) per --accept-terms.",
131 terms_version(),
132 local,
133 )?;
134 Ok(())
135 }
136 Decision::NeedFlag => {
137 print_terms()?;
138 anyhow::bail!(
139 "operator terms not accepted: no interactive terminal detected — re-run with \
140 `--accept-terms` after reviewing the terms above."
141 )
142 }
143 Decision::Prompt => {
144 print_terms()?;
145 if prompt_yes_no()? {
146 Ok(())
147 } else {
148 anyhow::bail!("registration cancelled: operator terms not accepted.")
149 }
150 }
151 }
152}
153
154/// Async wrapper over [`ensure_accepted`]. The interactive branch does blocking
155/// `stdin`/`stderr` IO, so run the whole gate on a blocking thread rather than
156/// parking a tokio runtime worker while it waits for the operator.
157///
158/// # Errors
159///
160/// Propagates [`ensure_accepted`]'s errors; also errors if the blocking task
161/// panics.
162pub async fn ensure_accepted_async(
163 chain_terms_hash: B256,
164 accept_flag: bool,
165) -> anyhow::Result<()> {
166 tokio::task::spawn_blocking(move || ensure_accepted(chain_terms_hash, accept_flag))
167 .await
168 .context("terms acceptance task panicked")?
169}
170
171/// Render the full terms + version + hash to stderr (stdout stays reserved for
172/// the machine-readable registration outcome / `--json`).
173fn print_terms() -> io::Result<()> {
174 let mut err = io::stderr().lock();
175 let rule = "─".repeat(64);
176 writeln!(
177 err,
178 "\n{rule}\ndeCDN Operator Terms {} ({:#x})\n{rule}",
179 terms_version(),
180 terms_hash(),
181 )?;
182 writeln!(err, "{}", TERMS_TEXT.trim_end())?;
183 writeln!(err, "{rule}")?;
184 Ok(())
185}
186
187/// Prompt for acceptance on the controlling terminal. Defaults to no: only an
188/// explicit `y` / `yes` accepts.
189fn prompt_yes_no() -> anyhow::Result<bool> {
190 let mut err = io::stderr().lock();
191 write!(err, "Accept these operator terms? [y/N] ")?;
192 err.flush()?;
193 let mut line = String::new();
194 io::stdin().read_line(&mut line)?;
195 let answer = line.trim().to_ascii_lowercase();
196 Ok(answer == "y" || answer == "yes")
197}
198
199#[cfg(test)]
200mod tests;