Skip to main content

mkit_cli/commands/
visibility.rs

1//! `mkit visibility set` (WP-2.14, R-156): switch a repository between public
2//! and private (SPEC-WRITE-GRANTS ยง9.1).
3//!
4//! Two modes, chosen by `--statement`:
5//!
6//! * **Envelope** (default): a signed auth v2 write of `SetRepoVisibility`
7//!   with the repository signing key, so it needs `transport_auth = envelope`
8//!   and a trusted remote, like `mkit push`. A grant never authorizes it.
9//! * **Statement** (`--statement`): an owner-signed `mkit-repo-visibility:v1`
10//!   statement, sent with no envelope and with `X-Repository`. It signs with
11//!   any owner scheme, including a wallet or passkey by import.
12//!
13//! Either way the server may answer `unavailable` + `Retry-After` while a
14//! change to private takes effect everywhere; the command waits.
15
16use std::io::Write as _;
17
18use clap::{Args, Parser, Subcommand, ValueEnum};
19use mkit_attest::grant::{Visibility, VisibilityStatement};
20use mkit_transport_connect::{VisibilityChoice, VisibilityRequest};
21
22use crate::clap_shim;
23use crate::commands::{error, usage_error};
24use crate::exit;
25use crate::grants::cli::{Ctx, finish_wait, parse_timeout, print_statement};
26use crate::grants::now_ms;
27use crate::grants::owner::{Kind, OwnerArgs, Produced, produce};
28use crate::grants::remote::{Driven, check_audiences, drive, interruptible_sleep, resolve_target};
29use crate::grants::spec::{build_visibility, canonical_audiences, statement_lifetime_ms};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
32enum VisibilityArg {
33    Public,
34    Private,
35}
36
37#[derive(Debug, Parser)]
38#[command(
39    name = "mkit visibility",
40    about = "Switch a repository between public and private."
41)]
42struct VisibilityOpts {
43    #[command(subcommand)]
44    command: VisibilityCommand,
45}
46
47#[derive(Debug, Subcommand)]
48enum VisibilityCommand {
49    /// Set a repository's visibility on a remote.
50    Set(SetOpts),
51}
52
53#[derive(Debug, Args)]
54struct SetOpts {
55    /// Remote name, or an `mkit+https://` URL naming `<namespace>/<name>`.
56    remote: String,
57    /// The visibility to set.
58    #[arg(value_enum)]
59    visibility: VisibilityArg,
60    /// Send an owner-signed statement instead of a signed request. Lets a
61    /// wallet or passkey owner change visibility (see --print-statement).
62    #[arg(long)]
63    statement: bool,
64    /// Audience of the statement (repeatable; default: the remote's origin).
65    /// Only with --statement.
66    #[arg(long, value_name = "ORIGIN", requires = "statement")]
67    audience: Vec<String>,
68    /// Longest to wait for the change to complete (for example 5m).
69    #[arg(long, value_name = "DURATION", default_value = "5m")]
70    timeout: String,
71    #[command(flatten)]
72    owner: OwnerArgs,
73}
74
75#[must_use]
76pub fn run(args: &[String]) -> u8 {
77    let opts = match clap_shim::parse::<VisibilityOpts>("mkit visibility", args) {
78        Ok(opts) => opts,
79        Err(code) => return code,
80    };
81    match opts.command {
82        VisibilityCommand::Set(opts) => set(&opts),
83    }
84}
85
86fn owner_flags_used(owner: &OwnerArgs) -> bool {
87    owner.scheme.is_some()
88        || owner.print_statement
89        || owner.statement_file.is_some()
90        || owner.signature.is_some()
91        || owner.webauthn_assertion.is_some()
92}
93
94#[allow(clippy::too_many_lines)] // linear flow: resolve, sign once, send until done, report
95fn set(opts: &SetOpts) -> u8 {
96    let ctx = match Ctx::load() {
97        Ok(ctx) => ctx,
98        Err(code) => return code,
99    };
100    let timeout = match parse_timeout(&opts.timeout) {
101        Ok(t) => t,
102        Err(e) => return usage_error(&format!("--timeout: {e}")),
103    };
104    if !opts.statement && owner_flags_used(&opts.owner) {
105        return usage_error(
106            "--scheme, --print-statement, --statement-file, --signature and --webauthn-assertion sign a statement: add --statement",
107        );
108    }
109    let target = match resolve_target(&ctx.layered, Some(&opts.remote)) {
110        Ok(target) => target,
111        Err(e) => return usage_error(&e),
112    };
113    let choice = match opts.visibility {
114        VisibilityArg::Public => VisibilityChoice::Public,
115        VisibilityArg::Private => VisibilityChoice::Private,
116    };
117
118    let outcome = if opts.statement {
119        let tx = match ctx.open_unsigned(&target) {
120            Ok(tx) => tx,
121            Err(e) => return error(&e, exit::UNAVAILABLE),
122        };
123        let repository = tx.repository().clone();
124        let Some(namespace) = repository.namespace().copied() else {
125            return usage_error(
126                "a visibility statement names a full <namespace>/<name> repository: use a remote URL that includes it",
127            );
128        };
129        let audiences = if opts.audience.is_empty() {
130            vec![tx.origin().to_owned()]
131        } else {
132            match canonical_audiences(&opts.audience) {
133                Ok(a) => a,
134                Err(e) => return usage_error(&e),
135            }
136        };
137        if let Err(e) = check_audiences(&audiences, Some(&target)) {
138            return error(&e, exit::USAGE);
139        }
140        let plan = match ctx.plan(&opts.owner, Some(namespace)) {
141            Ok(plan) => plan,
142            Err(e) => return error(&e, exit::USAGE),
143        };
144        let wanted = match opts.visibility {
145            VisibilityArg::Public => Visibility::Public,
146            VisibilityArg::Private => Visibility::Private,
147        };
148        let now = now_ms();
149        let lifetime_ms = statement_lifetime_ms(timeout);
150        let signed = match produce(
151            plan,
152            |_| {
153                build_visibility(&repository, wanted, &audiences, now, lifetime_ms)?
154                    .encode()
155                    .map_err(crate::grants::spec::statement_error)
156            },
157            Kind::Visibility(&repository),
158            &ctx.relying_parties,
159            now,
160        ) {
161            Ok(Produced::Signed(signed)) => signed,
162            Ok(Produced::Print {
163                statement,
164                namespace,
165            }) => return print_statement(&statement, &namespace),
166            Err(e) => return error(&e, exit::DATAERR),
167        };
168        match VisibilityStatement::parse(&signed.statement) {
169            Ok(s) if s.repository == repository && s.visibility == wanted => {
170                if let Err(e) = check_audiences(&s.audiences, Some(&target)) {
171                    return error(&e, exit::USAGE);
172                }
173            }
174            Ok(_) => {
175                return error(
176                    "the imported statement is for a different repository or visibility than the command asks for",
177                    exit::DATAERR,
178                );
179            }
180            Err(e) => return error(&format!("invalid statement: {e}"), exit::DATAERR),
181        }
182        drive(
183            || tx.set_repo_visibility(VisibilityRequest::Statement(&signed.header)),
184            timeout,
185            interruptible_sleep,
186        )
187    } else {
188        let tx = match ctx.open_signed(&target) {
189            Ok(tx) => tx,
190            Err(e) => return error(&e, exit::NOPERM),
191        };
192        drive(
193            || tx.set_repo_visibility(VisibilityRequest::Envelope(choice)),
194            timeout,
195            interruptible_sleep,
196        )
197    };
198    let outcome = match outcome {
199        Ok(outcome) => outcome,
200        Err(e) => {
201            let hint = match e {
202                mkit_core::protocol::TransportError::AccessDenied => {
203                    " (only the repository owner can change visibility; a grant never can)"
204                }
205                _ => "",
206            };
207            return error(&format!("SetRepoVisibility: {e}{hint}"), exit::NOPERM);
208        }
209    };
210    if let Some(code) = finish_wait(&outcome, "the visibility change") {
211        return code;
212    }
213    if !matches!(outcome, Driven::Done(())) {
214        return exit::GENERAL_ERROR;
215    }
216    let mut stdout = std::io::stdout().lock();
217    let _ = writeln!(
218        stdout,
219        "{} is now {}",
220        target
221            .repository()
222            .map_or_else(|| target.endpoint.clone(), |r| r.to_string()),
223        match opts.visibility {
224            VisibilityArg::Public => "public",
225            VisibilityArg::Private => "private",
226        }
227    );
228    exit::OK
229}