Skip to main content

memstead_cli/registry/
mod.rs

1//! HTTP client for the Memstead registry (memstead.io).
2//!
3//! Thin wrapper around `reqwest::blocking` that knows the two routes
4//! the CLI consumes (`POST /api/publish`, `GET /api/mem/...`) plus
5//! the typed error envelope (`ApiError`) the registry emits.
6
7use std::io::Read;
8use std::path::Path;
9use std::time::Duration;
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14/// Default registry when no `--registry` / `MEMSTEAD_REGISTRY` is set.
15///
16/// `memstead.io` is the canonical registry. The legacy domain is retired —
17/// it no longer serves the public registry and is not a fallback.
18pub const DEFAULT_REGISTRY: &str = "https://memstead.io";
19
20/// The decoded wire-level error shape the registry returns on any
21/// non-2xx. `variant` is present only for `validation_failed`
22/// responses; other error kinds leave it `None`.
23#[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/// Outcome of a successful `/api/publish` POST.
37#[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    /// The version that is `current` for the handle after this publish
45    /// (highest published, semver). Differs from `version` when an older
46    /// version was published while a higher one exists. Absent on older
47    /// servers that predate the field.
48    #[serde(default)]
49    pub current: Option<String>,
50    /// Path-only — typically `/v/<scope>/<name>`. Caller composes the
51    /// full URL against the registry base.
52    pub url: String,
53}
54
55/// Resolve the registry base URL in priority order: CLI flag →
56/// `MEMSTEAD_REGISTRY` env → `DEFAULT_REGISTRY`. Trailing slashes are
57/// stripped so callers can unconditionally append route segments.
58pub 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
66/// Extract the hostname for credentials keying. Falls back to the
67/// full URL on parse failure so nothing silently collides.
68pub 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
77/// Shared HTTP client. 30 s timeout is comfortable for the 2 MB cap
78/// times a slow upstream — GitHub API is also cheap to tolerate.
79pub 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
87/// The publisher-terms version the CLI accepts on publish. Running
88/// `memstead publish` is a deliberate act, so the CLI accepts the current
89/// terms on the publisher's behalf by sending this version. Must track the
90/// registry's `CURRENT_TERMS_VERSION`; a mismatch surfaces as a
91/// `terms_not_accepted` refusal naming the version, telling the user to update.
92pub const ACCEPTED_TERMS_VERSION: &str = "1.0";
93
94/// A per-publish domain-authority signature, presented in request headers for a
95/// `<domain>:<handle>` publish. The CLI builds this from the domain's stored key
96/// (see `crate::auth::domain_key`); the registry verifies it against the hosted
97/// proof manifest.
98#[derive(Debug, Clone)]
99pub struct DomainSignature {
100    /// `ed25519:<base64>` public key the signature was made with.
101    pub key: String,
102    /// `ed25519:<base64>` signature over the canonical publish payload.
103    pub signature: String,
104    /// Publish timestamp, unix seconds.
105    pub timestamp: i64,
106}
107
108/// POST a sealed `.mem` archive to `/api/publish`. Returns the parsed
109/// success body or a typed `ApiErrorBody` on any non-2xx.
110///
111/// Authorisation is one of two channels: a GitHub `token` (the default path),
112/// or a `domain_sig` for a `<domain>:<handle>` publish, which needs no GitHub
113/// account. Exactly one should be supplied for a given publish.
114pub 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    // Non-2xx: try the typed envelope, fall back to raw text.
157    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/// Outcome of a successful `DELETE /api/mem/<scope>/<name>`.
167#[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
175/// DELETE a mem from the registry. Same auth + error envelope as
176/// publish, so reuse `PublishError` for the failure shape.
177pub 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
211/// Admin-only takedown of a published mem: same `DELETE` route as
212/// `unpublish`, but with the `x-memstead-takedown` header carrying the
213/// statement-of-reasons notice reference. The server selects the
214/// takedown path (deny-list the bytes, tombstone, burn the name)
215/// instead of an ordinary hard-delete, and refuses non-admins with 403.
216pub 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/// Outcome of a successful `POST /api/admin/denylist`.
252#[derive(Debug, Clone, Deserialize)]
253pub struct DenylistResponse {
254    #[allow(dead_code)]
255    pub ok: bool,
256    pub content_sha256: String,
257}
258
259/// Admin-only: add a canonical-bytes SHA-256 to the content deny-list so
260/// those exact bytes can never be published. Refuses non-admins with 403.
261pub 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
291/// GET a sealed `.mem` archive from the registry, streaming into
292/// `dest_path`. Returns the number of bytes written.
293pub 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
325/// Minimal percent-encoding for a single path segment. Our scope +
326/// name are slug-safe by server validation (`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`),
327/// so a character set check is sufficient — the server would 400 any
328/// non-slug anyway. Kept as a fn for explicitness.
329fn url_segment(raw: &str) -> String {
330    // Preserve the scope-form characters `:` (scheme/domain separator) and
331    // `.` (domain labels) — both valid in a URL path segment — alongside the
332    // slug characters; drop anything else as a path-shape defence.
333    raw.chars()
334        .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_' | ':' | '.'))
335        .collect()
336}
337
338/// Parse a registry ref `<scope>/<name>` in one of the three scope forms
339/// (`github:<handle>/<name>`, `<domain>:<handle>/<name>`, or a bare
340/// `<handle>/<name>`). The legacy `@scope/name` syntax is rejected by the
341/// caller before this is reached. Returns `None` for anything that is not a
342/// valid registry ref (e.g. a local file path), so `install` can fall back to
343/// a local install.
344pub fn parse_ref(raw: &str) -> Option<(String, String)> {
345    let (scope, name) = raw.split_once('/')?;
346    // The name is a bare slug — no extension, no further path segments.
347    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
364/// `github:<handle>`, `<domain>:<handle>` (domain has a `.`), or bare `<handle>`.
365fn 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}