1use clap::{Parser, Subcommand};
10use serde_json::json;
11
12use crate::CliError;
13use crate::auth::resolve_token;
14use crate::output::{ExitKind, print_json, print_markdown};
15use crate::registry::{self, ApiErrorBody, PublishError};
16use crate::setup::CliContext;
17
18#[derive(Subcommand, Debug)]
19pub enum AdminAction {
20 Takedown(TakedownArgs),
25
26 Denylist(DenylistArgs),
30}
31
32#[derive(Parser, Debug)]
33pub struct TakedownArgs {
34 #[arg(value_name = "SCOPE/NAME")]
36 pub target: String,
37
38 #[arg(long, value_name = "REF")]
42 pub notice: String,
43
44 #[arg(long, value_name = "TOKEN")]
47 pub token: Option<String>,
48
49 #[arg(long, value_name = "URL")]
51 pub registry: Option<String>,
52}
53
54#[derive(Parser, Debug)]
55pub struct DenylistArgs {
56 #[arg(value_name = "SHA256")]
58 pub sha256: String,
59
60 #[arg(long, value_name = "TEXT")]
62 pub reason: Option<String>,
63
64 #[arg(long, value_name = "TOKEN")]
67 pub token: Option<String>,
68
69 #[arg(long, value_name = "URL")]
71 pub registry: Option<String>,
72}
73
74pub fn run_takedown(ctx: &CliContext, args: TakedownArgs) -> anyhow::Result<()> {
75 let (scope, name) = registry::parse_ref(&args.target).ok_or_else(|| {
76 CliError::new(
77 ExitKind::Generic,
78 "INVALID_INPUT",
79 format!("expected `<scope>/<name>`, got `{}`", args.target),
80 )
81 })?;
82 if args.notice.trim().is_empty() {
83 return Err(CliError::new(
84 ExitKind::Validation,
85 "INVALID_INPUT",
86 "--notice must be a non-empty statement-of-reasons reference",
87 )
88 .into());
89 }
90
91 let base = registry::registry_base(args.registry.as_deref());
92 let host = registry::registry_host(&base);
93 let client = registry::build_http()?;
94 let token = resolve_admin_token(&host, args.token.as_deref())?;
95
96 match registry::admin_takedown(&client, &base, &scope, &name, &args.notice, &token) {
97 Ok(resp) => {
98 if ctx.json {
99 print_json(&json!({
100 "ok": true,
101 "action": "takedown",
102 "scope": resp.scope,
103 "name": resp.name,
104 "notice": args.notice,
105 }))?;
106 } else {
107 print_markdown(&format!(
108 "# Took down {}/{}\n\n- Bytes deny-listed, every version tombstoned, name burned.\n- The `{}/{}` name can no longer be re-published.\n- Notice: {}",
109 resp.scope, resp.name, resp.scope, resp.name, args.notice,
110 ));
111 }
112 Ok(())
113 }
114 Err(e) => Err(map_admin_error(e).into()),
115 }
116}
117
118pub fn run_denylist(ctx: &CliContext, args: DenylistArgs) -> anyhow::Result<()> {
119 let sha = args.sha256.trim().to_ascii_lowercase();
120 if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
121 return Err(CliError::new(
122 ExitKind::Validation,
123 "INVALID_INPUT",
124 format!("expected a 64-hex-char SHA-256, got `{}`", args.sha256),
125 )
126 .into());
127 }
128
129 let base = registry::registry_base(args.registry.as_deref());
130 let host = registry::registry_host(&base);
131 let client = registry::build_http()?;
132 let token = resolve_admin_token(&host, args.token.as_deref())?;
133
134 match registry::admin_denylist(&client, &base, &sha, args.reason.as_deref(), &token) {
135 Ok(resp) => {
136 if ctx.json {
137 print_json(&json!({
138 "ok": true,
139 "action": "denylist",
140 "content_sha256": resp.content_sha256,
141 }))?;
142 } else {
143 print_markdown(&format!(
144 "# Deny-listed {}\n\n- A publish of these exact bytes is now refused.",
145 resp.content_sha256,
146 ));
147 }
148 Ok(())
149 }
150 Err(e) => Err(map_admin_error(e).into()),
151 }
152}
153
154fn resolve_admin_token(host: &str, flag: Option<&str>) -> anyhow::Result<String> {
157 match resolve_token(host, flag)? {
158 Some(r) => Ok(r.token),
159 None => Err(CliError::new(
160 ExitKind::Generic,
161 "NOT_AUTHENTICATED",
162 "not logged in — run `memstead login` or set MEMSTEAD_TOKEN \
163 (admin actions do not auto-trigger Device Flow)",
164 )
165 .into()),
166 }
167}
168
169fn map_admin_error(err: PublishError) -> CliError {
170 match err {
171 PublishError::Io(e) => {
172 CliError::new(ExitKind::Generic, crate::INTERNAL_CODE, format!("io: {e}"))
173 }
174 PublishError::Network(e) => CliError::new(
175 ExitKind::Generic,
176 "NETWORK_ERROR",
177 format!("network error: {e}"),
178 ),
179 PublishError::Malformed(e) => CliError::new(
180 ExitKind::Generic,
181 "REGISTRY_MALFORMED_RESPONSE",
182 format!("registry sent an unparseable success response: {e}"),
183 ),
184 PublishError::Raw { status, text } => CliError::new(
185 ExitKind::Generic,
186 "REGISTRY_ERROR",
187 format!("registry returned {status}: {text}"),
188 ),
189 PublishError::Api { status, envelope } => map_admin_api_error(status, envelope),
190 }
191}
192
193fn map_admin_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
194 let (kind, code): (ExitKind, &'static str) = match status.as_u16() {
195 401 => (ExitKind::Generic, "NOT_AUTHENTICATED"),
196 403 => (ExitKind::Generic, "FORBIDDEN"),
197 404 => (ExitKind::NotFound, "REGISTRY_NOT_FOUND"),
198 _ => (ExitKind::Generic, "REGISTRY_ERROR"),
199 };
200 let mut msg = match status.as_u16() {
201 401 => {
202 "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
203 }
204 403 => {
205 "forbidden — this is an admin-only action; your GitHub account id is not in MEMSTEAD_ADMINS"
206 .to_string()
207 }
208 404 => "no such mem on the registry".to_string(),
209 _ => envelope
210 .detail
211 .clone()
212 .unwrap_or_else(|| format!("registry returned {status}")),
213 };
214 if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
215 msg = format!("{msg} [{}]", envelope.error);
216 }
217 CliError::new(kind, code, msg)
218}