1use std::io::Read;
8use std::path::Path;
9use std::time::Duration;
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13
14pub const DEFAULT_REGISTRY: &str = "https://memstead.io";
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
24pub struct ApiErrorBody {
25 pub error: String,
26 #[serde(default)]
27 pub variant: Option<String>,
28 #[serde(default)]
29 pub detail: Option<String>,
30 #[serde(default)]
31 pub path: Option<String>,
32 #[serde(default)]
33 pub retry_after_seconds: Option<i64>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
38pub struct PublishResponse {
39 #[allow(dead_code)]
40 pub ok: bool,
41 pub scope: String,
42 pub name: String,
43 pub version: String,
44 #[serde(default)]
49 pub current: Option<String>,
50 pub url: String,
53}
54
55pub fn registry_base(explicit: Option<&str>) -> String {
59 let raw = explicit
60 .map(str::to_string)
61 .or_else(|| std::env::var("MEMSTEAD_REGISTRY").ok())
62 .unwrap_or_else(|| DEFAULT_REGISTRY.to_string());
63 raw.trim_end_matches('/').to_string()
64}
65
66pub fn registry_host(base: &str) -> String {
69 base.split_once("://")
70 .map_or(base, |(_, rest)| rest)
71 .split('/')
72 .next()
73 .unwrap_or(base)
74 .to_ascii_lowercase()
75}
76
77pub fn build_http() -> Result<reqwest::blocking::Client> {
85 reqwest::blocking::Client::builder()
86 .timeout(Duration::from_secs(30))
87 .user_agent(concat!("memstead/", env!("CARGO_PKG_VERSION")))
88 .build()
89 .map_err(|e| {
90 crate::CliError::new(
91 crate::output::ExitKind::Generic,
92 "REGISTRY_ERROR",
93 format!("could not build the HTTP client used to reach the registry: {e}"),
94 )
95 .into()
96 })
97}
98
99pub const ACCEPTED_TERMS_VERSION: &str = "1.0";
105
106#[derive(Debug, Clone)]
111pub struct DomainSignature {
112 pub key: String,
114 pub signature: String,
116 pub timestamp: i64,
118}
119
120pub fn publish(
127 client: &reqwest::blocking::Client,
128 base: &str,
129 archive: &Path,
130 token: Option<&str>,
131 scope_override: Option<&str>,
132 domain_sig: Option<&DomainSignature>,
133) -> Result<PublishResponse, PublishError> {
134 use memstead_base::domain_authority_wire::{HEADER_KEY, HEADER_SIGNATURE, HEADER_TIMESTAMP};
135
136 let url = format!("{base}/api/publish");
137 let mut file = std::fs::File::open(archive).map_err(PublishError::Io)?;
138 let mut bytes = Vec::new();
139 file.read_to_end(&mut bytes).map_err(PublishError::Io)?;
140
141 let mut req = client
142 .post(&url)
143 .header("content-type", "application/octet-stream")
144 .header("x-memstead-accept-terms", ACCEPTED_TERMS_VERSION)
145 .body(bytes);
146 if let Some(t) = token {
147 req = req.bearer_auth(t);
148 }
149 if let Some(s) = scope_override {
150 req = req.header("x-memstead-scope", s);
151 }
152 if let Some(ds) = domain_sig {
153 req = req
154 .header(HEADER_KEY, &ds.key)
155 .header(HEADER_SIGNATURE, &ds.signature)
156 .header(HEADER_TIMESTAMP, ds.timestamp.to_string());
157 }
158
159 let resp = req.send().map_err(PublishError::Network)?;
160 let status = resp.status();
161 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
162
163 if status.is_success() {
164 return serde_json::from_slice::<PublishResponse>(&body_bytes)
165 .map_err(|e| PublishError::Malformed(e.to_string()));
166 }
167
168 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
170 Ok(envelope) => Err(PublishError::Api { status, envelope }),
171 Err(_) => {
172 let text = String::from_utf8_lossy(&body_bytes).into_owned();
173 Err(PublishError::Raw { status, text })
174 }
175 }
176}
177
178#[derive(Debug, Clone, Deserialize)]
180pub struct UnpublishResponse {
181 #[allow(dead_code)]
182 pub ok: bool,
183 pub scope: String,
184 pub name: String,
185}
186
187pub fn unpublish(
190 client: &reqwest::blocking::Client,
191 base: &str,
192 scope: &str,
193 name: &str,
194 token: &str,
195) -> Result<UnpublishResponse, PublishError> {
196 let url = format!(
197 "{base}/api/mem/{scope}/{name}",
198 scope = url_segment(scope),
199 name = url_segment(name),
200 );
201 let resp = client
202 .delete(&url)
203 .bearer_auth(token)
204 .send()
205 .map_err(PublishError::Network)?;
206 let status = resp.status();
207 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
208
209 if status.is_success() {
210 return serde_json::from_slice::<UnpublishResponse>(&body_bytes)
211 .map_err(|e| PublishError::Malformed(e.to_string()));
212 }
213
214 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
215 Ok(envelope) => Err(PublishError::Api { status, envelope }),
216 Err(_) => {
217 let text = String::from_utf8_lossy(&body_bytes).into_owned();
218 Err(PublishError::Raw { status, text })
219 }
220 }
221}
222
223pub fn admin_takedown(
229 client: &reqwest::blocking::Client,
230 base: &str,
231 scope: &str,
232 name: &str,
233 notice: &str,
234 token: &str,
235) -> Result<UnpublishResponse, PublishError> {
236 let url = format!(
237 "{base}/api/mem/{scope}/{name}",
238 scope = url_segment(scope),
239 name = url_segment(name),
240 );
241 let resp = client
242 .delete(&url)
243 .bearer_auth(token)
244 .header("x-memstead-takedown", notice)
245 .send()
246 .map_err(PublishError::Network)?;
247 let status = resp.status();
248 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
249
250 if status.is_success() {
251 return serde_json::from_slice::<UnpublishResponse>(&body_bytes)
252 .map_err(|e| PublishError::Malformed(e.to_string()));
253 }
254 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
255 Ok(envelope) => Err(PublishError::Api { status, envelope }),
256 Err(_) => {
257 let text = String::from_utf8_lossy(&body_bytes).into_owned();
258 Err(PublishError::Raw { status, text })
259 }
260 }
261}
262
263#[derive(Debug, Clone, Deserialize)]
265pub struct DenylistResponse {
266 #[allow(dead_code)]
267 pub ok: bool,
268 pub content_sha256: String,
269}
270
271pub fn admin_denylist(
274 client: &reqwest::blocking::Client,
275 base: &str,
276 content_sha256: &str,
277 reason: Option<&str>,
278 token: &str,
279) -> Result<DenylistResponse, PublishError> {
280 let url = format!("{base}/api/admin/denylist");
281 let resp = client
282 .post(&url)
283 .bearer_auth(token)
284 .json(&serde_json::json!({ "content_sha256": content_sha256, "reason": reason }))
285 .send()
286 .map_err(PublishError::Network)?;
287 let status = resp.status();
288 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
289
290 if status.is_success() {
291 return serde_json::from_slice::<DenylistResponse>(&body_bytes)
292 .map_err(|e| PublishError::Malformed(e.to_string()));
293 }
294 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
295 Ok(envelope) => Err(PublishError::Api { status, envelope }),
296 Err(_) => {
297 let text = String::from_utf8_lossy(&body_bytes).into_owned();
298 Err(PublishError::Raw { status, text })
299 }
300 }
301}
302
303pub fn download_mem(
306 client: &reqwest::blocking::Client,
307 base: &str,
308 scope: &str,
309 name: &str,
310 dest_path: &Path,
311) -> Result<u64, DownloadError> {
312 let url = format!(
313 "{base}/api/mem/{scope}/{name}.mem",
314 scope = url_segment(scope),
315 name = url_segment(name),
316 );
317 let resp = client.get(&url).send().map_err(DownloadError::Network)?;
318 let status = resp.status();
319 if !status.is_success() {
320 return match status.as_u16() {
321 404 => Err(DownloadError::NotFound),
322 410 => Err(DownloadError::Gone),
323 _ => {
324 let text = resp.text().unwrap_or_default();
325 Err(DownloadError::Http {
326 status,
327 text: text.chars().take(500).collect(),
328 })
329 }
330 };
331 }
332 let bytes = resp.bytes().map_err(DownloadError::Network)?;
333 std::fs::write(dest_path, &bytes).map_err(DownloadError::Io)?;
334 Ok(bytes.len() as u64)
335}
336
337fn url_segment(raw: &str) -> String {
342 raw.chars()
346 .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_' | ':' | '.'))
347 .collect()
348}
349
350pub fn parse_ref(raw: &str) -> Option<(String, String)> {
357 let (scope, name) = raw.split_once('/')?;
358 if name.is_empty() || name.contains('.') || name.contains('/') || name.contains('\\') {
360 return None;
361 }
362 if !is_valid_scope_form(scope) {
363 return None;
364 }
365 Some((scope.to_string(), name.to_string()))
366}
367
368fn is_valid_handle(h: &str) -> bool {
369 !h.is_empty()
370 && h.len() <= 39
371 && !h.starts_with('-')
372 && !h.ends_with('-')
373 && h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
374}
375
376fn is_valid_scope_form(scope: &str) -> bool {
378 match scope.split_once(':') {
379 Some((prefix, handle)) => {
380 is_valid_handle(handle)
381 && (prefix == "github"
382 || (prefix.contains('.')
383 && prefix.split('.').all(|label| {
384 !label.is_empty()
385 && label
386 .bytes()
387 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
388 })))
389 }
390 None => is_valid_handle(scope),
391 }
392}
393
394#[derive(Debug, thiserror::Error)]
395pub enum PublishError {
396 #[error("io: {0}")]
397 Io(#[from] std::io::Error),
398 #[error("network: {0}")]
399 Network(reqwest::Error),
400 #[error("registry returned {status}: {envelope:?}")]
401 Api {
402 status: reqwest::StatusCode,
403 envelope: ApiErrorBody,
404 },
405 #[error("registry returned {status}: {text}")]
406 Raw {
407 status: reqwest::StatusCode,
408 text: String,
409 },
410 #[error("malformed success response: {0}")]
411 Malformed(String),
412}
413
414#[derive(Debug, thiserror::Error)]
415pub enum DownloadError {
416 #[error("io: {0}")]
417 Io(#[from] std::io::Error),
418 #[error("network: {0}")]
419 Network(reqwest::Error),
420 #[error("not found")]
421 NotFound,
422 #[error("content taken down")]
423 Gone,
424 #[error("registry returned {status}: {text}")]
425 Http {
426 status: reqwest::StatusCode,
427 text: String,
428 },
429}