1use std::io::Read;
8use std::path::Path;
9use std::time::Duration;
10
11use anyhow::{Context, 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> {
80 reqwest::blocking::Client::builder()
81 .timeout(Duration::from_secs(30))
82 .user_agent(concat!("memstead/", env!("CARGO_PKG_VERSION")))
83 .build()
84 .context("building HTTP client")
85}
86
87pub const ACCEPTED_TERMS_VERSION: &str = "1.0";
93
94#[derive(Debug, Clone)]
99pub struct DomainSignature {
100 pub key: String,
102 pub signature: String,
104 pub timestamp: i64,
106}
107
108pub fn publish(
115 client: &reqwest::blocking::Client,
116 base: &str,
117 archive: &Path,
118 token: Option<&str>,
119 scope_override: Option<&str>,
120 domain_sig: Option<&DomainSignature>,
121) -> Result<PublishResponse, PublishError> {
122 use memstead_base::domain_authority_wire::{HEADER_KEY, HEADER_SIGNATURE, HEADER_TIMESTAMP};
123
124 let url = format!("{base}/api/publish");
125 let mut file = std::fs::File::open(archive).map_err(PublishError::Io)?;
126 let mut bytes = Vec::new();
127 file.read_to_end(&mut bytes).map_err(PublishError::Io)?;
128
129 let mut req = client
130 .post(&url)
131 .header("content-type", "application/octet-stream")
132 .header("x-memstead-accept-terms", ACCEPTED_TERMS_VERSION)
133 .body(bytes);
134 if let Some(t) = token {
135 req = req.bearer_auth(t);
136 }
137 if let Some(s) = scope_override {
138 req = req.header("x-memstead-scope", s);
139 }
140 if let Some(ds) = domain_sig {
141 req = req
142 .header(HEADER_KEY, &ds.key)
143 .header(HEADER_SIGNATURE, &ds.signature)
144 .header(HEADER_TIMESTAMP, ds.timestamp.to_string());
145 }
146
147 let resp = req.send().map_err(PublishError::Network)?;
148 let status = resp.status();
149 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
150
151 if status.is_success() {
152 return serde_json::from_slice::<PublishResponse>(&body_bytes)
153 .map_err(|e| PublishError::Malformed(e.to_string()));
154 }
155
156 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
158 Ok(envelope) => Err(PublishError::Api { status, envelope }),
159 Err(_) => {
160 let text = String::from_utf8_lossy(&body_bytes).into_owned();
161 Err(PublishError::Raw { status, text })
162 }
163 }
164}
165
166#[derive(Debug, Clone, Deserialize)]
168pub struct UnpublishResponse {
169 #[allow(dead_code)]
170 pub ok: bool,
171 pub scope: String,
172 pub name: String,
173}
174
175pub fn unpublish(
178 client: &reqwest::blocking::Client,
179 base: &str,
180 scope: &str,
181 name: &str,
182 token: &str,
183) -> Result<UnpublishResponse, PublishError> {
184 let url = format!(
185 "{base}/api/mem/{scope}/{name}",
186 scope = url_segment(scope),
187 name = url_segment(name),
188 );
189 let resp = client
190 .delete(&url)
191 .bearer_auth(token)
192 .send()
193 .map_err(PublishError::Network)?;
194 let status = resp.status();
195 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
196
197 if status.is_success() {
198 return serde_json::from_slice::<UnpublishResponse>(&body_bytes)
199 .map_err(|e| PublishError::Malformed(e.to_string()));
200 }
201
202 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
203 Ok(envelope) => Err(PublishError::Api { status, envelope }),
204 Err(_) => {
205 let text = String::from_utf8_lossy(&body_bytes).into_owned();
206 Err(PublishError::Raw { status, text })
207 }
208 }
209}
210
211pub fn admin_takedown(
217 client: &reqwest::blocking::Client,
218 base: &str,
219 scope: &str,
220 name: &str,
221 notice: &str,
222 token: &str,
223) -> Result<UnpublishResponse, PublishError> {
224 let url = format!(
225 "{base}/api/mem/{scope}/{name}",
226 scope = url_segment(scope),
227 name = url_segment(name),
228 );
229 let resp = client
230 .delete(&url)
231 .bearer_auth(token)
232 .header("x-memstead-takedown", notice)
233 .send()
234 .map_err(PublishError::Network)?;
235 let status = resp.status();
236 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
237
238 if status.is_success() {
239 return serde_json::from_slice::<UnpublishResponse>(&body_bytes)
240 .map_err(|e| PublishError::Malformed(e.to_string()));
241 }
242 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
243 Ok(envelope) => Err(PublishError::Api { status, envelope }),
244 Err(_) => {
245 let text = String::from_utf8_lossy(&body_bytes).into_owned();
246 Err(PublishError::Raw { status, text })
247 }
248 }
249}
250
251#[derive(Debug, Clone, Deserialize)]
253pub struct DenylistResponse {
254 #[allow(dead_code)]
255 pub ok: bool,
256 pub content_sha256: String,
257}
258
259pub fn admin_denylist(
262 client: &reqwest::blocking::Client,
263 base: &str,
264 content_sha256: &str,
265 reason: Option<&str>,
266 token: &str,
267) -> Result<DenylistResponse, PublishError> {
268 let url = format!("{base}/api/admin/denylist");
269 let resp = client
270 .post(&url)
271 .bearer_auth(token)
272 .json(&serde_json::json!({ "content_sha256": content_sha256, "reason": reason }))
273 .send()
274 .map_err(PublishError::Network)?;
275 let status = resp.status();
276 let body_bytes = resp.bytes().map_err(PublishError::Network)?;
277
278 if status.is_success() {
279 return serde_json::from_slice::<DenylistResponse>(&body_bytes)
280 .map_err(|e| PublishError::Malformed(e.to_string()));
281 }
282 match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
283 Ok(envelope) => Err(PublishError::Api { status, envelope }),
284 Err(_) => {
285 let text = String::from_utf8_lossy(&body_bytes).into_owned();
286 Err(PublishError::Raw { status, text })
287 }
288 }
289}
290
291pub fn download_mem(
294 client: &reqwest::blocking::Client,
295 base: &str,
296 scope: &str,
297 name: &str,
298 dest_path: &Path,
299) -> Result<u64, DownloadError> {
300 let url = format!(
301 "{base}/api/mem/{scope}/{name}.mem",
302 scope = url_segment(scope),
303 name = url_segment(name),
304 );
305 let resp = client.get(&url).send().map_err(DownloadError::Network)?;
306 let status = resp.status();
307 if !status.is_success() {
308 return match status.as_u16() {
309 404 => Err(DownloadError::NotFound),
310 410 => Err(DownloadError::Gone),
311 _ => {
312 let text = resp.text().unwrap_or_default();
313 Err(DownloadError::Http {
314 status,
315 text: text.chars().take(500).collect(),
316 })
317 }
318 };
319 }
320 let bytes = resp.bytes().map_err(DownloadError::Network)?;
321 std::fs::write(dest_path, &bytes).map_err(DownloadError::Io)?;
322 Ok(bytes.len() as u64)
323}
324
325fn url_segment(raw: &str) -> String {
330 raw.chars()
334 .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_' | ':' | '.'))
335 .collect()
336}
337
338pub fn parse_ref(raw: &str) -> Option<(String, String)> {
345 let (scope, name) = raw.split_once('/')?;
346 if name.is_empty() || name.contains('.') || name.contains('/') || name.contains('\\') {
348 return None;
349 }
350 if !is_valid_scope_form(scope) {
351 return None;
352 }
353 Some((scope.to_string(), name.to_string()))
354}
355
356fn is_valid_handle(h: &str) -> bool {
357 !h.is_empty()
358 && h.len() <= 39
359 && !h.starts_with('-')
360 && !h.ends_with('-')
361 && h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
362}
363
364fn is_valid_scope_form(scope: &str) -> bool {
366 match scope.split_once(':') {
367 Some((prefix, handle)) => {
368 is_valid_handle(handle)
369 && (prefix == "github"
370 || (prefix.contains('.')
371 && prefix.split('.').all(|label| {
372 !label.is_empty()
373 && label
374 .bytes()
375 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
376 })))
377 }
378 None => is_valid_handle(scope),
379 }
380}
381
382#[derive(Debug, thiserror::Error)]
383pub enum PublishError {
384 #[error("io: {0}")]
385 Io(#[from] std::io::Error),
386 #[error("network: {0}")]
387 Network(reqwest::Error),
388 #[error("registry returned {status}: {envelope:?}")]
389 Api {
390 status: reqwest::StatusCode,
391 envelope: ApiErrorBody,
392 },
393 #[error("registry returned {status}: {text}")]
394 Raw {
395 status: reqwest::StatusCode,
396 text: String,
397 },
398 #[error("malformed success response: {0}")]
399 Malformed(String),
400}
401
402#[derive(Debug, thiserror::Error)]
403pub enum DownloadError {
404 #[error("io: {0}")]
405 Io(#[from] std::io::Error),
406 #[error("network: {0}")]
407 Network(reqwest::Error),
408 #[error("not found")]
409 NotFound,
410 #[error("content taken down")]
411 Gone,
412 #[error("registry returned {status}: {text}")]
413 Http {
414 status: reqwest::StatusCode,
415 text: String,
416 },
417}