Skip to main content

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;