Skip to main content

mkit_cli/grants/
cli.rs

1//! What `mkit grant`, `mkit epoch` and `mkit visibility` share: loading the
2//! layered config and relying-party pins, choosing an owner-signing plan,
3//! opening the remote, and printing.
4
5use std::io::Write as _;
6use std::time::Duration;
7
8use mkit_attest::grant::{Namespace, RelyingParty};
9use mkit_core::layout::RepoLayout;
10use mkit_transport_connect::ConnectTransport;
11
12use super::owner::{self, NativeOwner, OwnerArgs, Plan, SignCtx};
13use super::remote::{Driven, Target, drive, interruptible_sleep};
14use super::spec::parse_ttl;
15use crate::commands::{error, usage_error};
16use crate::config::{self, LayeredConfig};
17use crate::exit;
18use crate::remote_dispatch;
19
20/// Longest a command waits for one plain read (`GetGrantEpoch`).
21const READ_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// Everything a command needs before it does anything.
24#[derive(Debug)]
25pub struct Ctx {
26    pub layout: RepoLayout,
27    pub layered: LayeredConfig,
28    pub relying_parties: Vec<RelyingParty>,
29}
30
31impl Ctx {
32    /// Load the config. Works outside a repository: the layout then names the
33    /// current directory and there is simply no repository config.
34    ///
35    /// # Errors
36    /// The exit code, after printing the reason.
37    pub fn load() -> Result<Self, u8> {
38        let cwd =
39            std::env::current_dir().map_err(|e| error(&format!("cwd: {e}"), exit::NOINPUT))?;
40        let layout = crate::commands::resolve_layout(&cwd)?;
41        let layered = config::read_layered(&layout)
42            .map_err(|e| error(&format!("config: {e}"), exit::CONFIG_ERROR))?;
43        let relying_parties = super::parse_relying_parties(&layered.merged.grant_webauthn_rp)
44            .map_err(|e| error(&format!("grant.webauthn_rp: {e}"), exit::CONFIG_ERROR))?;
45        Ok(Self {
46            layout,
47            layered,
48            relying_parties,
49        })
50    }
51
52    /// Choose how to sign; see [`owner::resolve`].
53    ///
54    /// # Errors
55    /// The message to print.
56    pub fn plan(&self, args: &OwnerArgs, hint: Option<Namespace>) -> Result<Plan, String> {
57        let cfg = &self.layered.merged;
58        let ed25519 =
59            || NativeOwner::ed25519(remote_dispatch::owner_ed25519_signer(cfg, &self.layout)?);
60        owner::resolve(
61            args,
62            hint,
63            &SignCtx {
64                ed25519: &ed25519,
65                cfg,
66                relying_parties: &self.relying_parties,
67            },
68        )
69    }
70
71    /// Open `target` with no ambient signing identity, for the unsigned
72    /// epoch RPCs. The credential-trust gate still applies.
73    ///
74    /// # Errors
75    /// The message to print.
76    pub fn open_unsigned(&self, target: &Target) -> Result<ConnectTransport, String> {
77        self.open(target, false)
78    }
79
80    /// Open `target` with the ambient signing identity (needs
81    /// `transport_auth = envelope` and a trusted remote, like `mkit push`).
82    ///
83    /// # Errors
84    /// The message to print.
85    pub fn open_signed(&self, target: &Target) -> Result<ConnectTransport, String> {
86        if !self.layered.merged.transport_auth_envelope() {
87            return Err(
88                "envelope-mode visibility signs with your mkit key: run `mkit config transport_auth envelope` and trust the remote with `mkit config trusted_remote_endpoint <url>`, or use --statement to sign with an owner key instead"
89                    .to_owned(),
90            );
91        }
92        self.open(target, true)
93    }
94
95    fn open(&self, target: &Target, sign: bool) -> Result<ConnectTransport, String> {
96        remote_dispatch::open_connect_trusted(
97            &target.endpoint,
98            &target.name,
99            target.repo_chosen,
100            &self.layered,
101            &self.layout,
102            sign,
103        )
104        .map_err(|e| e.to_string())
105    }
106
107    /// The stored epoch of `namespace` at `tx`'s deployment.
108    ///
109    /// # Errors
110    /// The message to print.
111    pub fn read_epoch(tx: &ConnectTransport, namespace: &Namespace) -> Result<u64, String> {
112        let name = namespace.to_string();
113        match drive(
114            || tx.get_grant_epoch(&name),
115            READ_TIMEOUT,
116            interruptible_sleep,
117        )
118        .map_err(|e| format!("GetGrantEpoch for {name}: {e}"))?
119        {
120            Driven::Done(epoch) => Ok(epoch),
121            Driven::TimedOut { .. } => Err(format!(
122                "GetGrantEpoch for {name}: the server kept answering `unavailable`"
123            )),
124            Driven::Cancelled => Err("interrupted".to_owned()),
125        }
126    }
127}
128
129impl Ctx {
130    /// One `GetGrantEpoch` attempt, with no retry ladder and no waiting out
131    /// `Retry-After`: for advisory checks.
132    ///
133    /// # Errors
134    /// The message to print.
135    pub fn read_epoch_once(tx: &ConnectTransport, namespace: &Namespace) -> Result<u64, String> {
136        let name = namespace.to_string();
137        match tx
138            .get_grant_epoch_once(&name)
139            .map_err(|e| format!("GetGrantEpoch for {name}: {e}"))?
140        {
141            mkit_transport_connect::Completion::Done(epoch) => Ok(epoch),
142            mkit_transport_connect::Completion::Pending { .. } => Err(format!(
143                "GetGrantEpoch for {name}: the server answered `unavailable`"
144            )),
145        }
146    }
147}
148
149/// `--timeout`, in the same spellings as `--ttl`.
150///
151/// # Errors
152/// An unparsable duration.
153pub fn parse_timeout(text: &str) -> Result<Duration, String> {
154    let ms = parse_ttl(text)?;
155    Ok(Duration::from_millis(u64::try_from(ms).unwrap_or(0)))
156}
157
158/// Write the statement bytes to stdout exactly (no added newline) and the
159/// signing instructions to stderr: the `--print-statement` output.
160#[must_use]
161pub fn print_statement(statement: &[u8], namespace: &Namespace) -> u8 {
162    let mut stdout = std::io::stdout().lock();
163    if stdout
164        .write_all(statement)
165        .and_then(|()| stdout.flush())
166        .is_err()
167    {
168        return error("write statement", exit::GENERAL_ERROR);
169    }
170    let mut stderr = std::io::stderr().lock();
171    let _ = write!(
172        stderr,
173        "\n{}",
174        owner::signing_instructions(statement, namespace)
175    );
176    exit::OK
177}
178
179/// Parse `--namespace`.
180///
181/// # Errors
182/// The exit code after printing the reason.
183pub fn parse_namespace(text: Option<&str>) -> Result<Option<Namespace>, u8> {
184    text.map(|t| {
185        Namespace::parse(t).map_err(|e| {
186            usage_error(&format!(
187                "--namespace `{t}`: {e} (expected ed25519-<64 hex> or 0x<40 hex>)"
188            ))
189        })
190    })
191    .transpose()
192}
193
194/// Turn a wait outcome into an exit code, printing the reason for anything but
195/// success.
196#[must_use]
197pub fn finish_wait<T>(outcome: &Driven<T>, what: &str) -> Option<u8> {
198    match outcome {
199        Driven::Done(_) => None,
200        Driven::TimedOut { waited } => Some(error(
201            &format!(
202                "{what} is still completing after {}s; it may yet finish on the server. Check before retrying (a new run signs a new statement)",
203                waited.as_secs()
204            ),
205            exit::TEMPFAIL,
206        )),
207        Driven::Cancelled => Some(error(
208            &format!("interrupted; {what} may still complete on the server. Check before retrying"),
209            exit::TEMPFAIL,
210        )),
211    }
212}