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::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.
79///
80/// Typed, not `INTERNAL`: the only realistic failure is a TLS backend
81/// the host cannot initialise, which is the same "the registry is
82/// unreachable from here" class every other transport failure on this
83/// path reports.
84pub 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
99/// The publisher-terms version the CLI accepts on publish. Running
100/// `memstead publish` is a deliberate act, so the CLI accepts the current
101/// terms on the publisher's behalf by sending this version. Must track the
102/// registry's `CURRENT_TERMS_VERSION`; a mismatch surfaces as a
103/// `terms_not_accepted` refusal naming the version, telling the user to update.
104pub const ACCEPTED_TERMS_VERSION: &str = "1.0";
105
106/// A per-publish domain-authority signature, presented in request headers for a
107/// `<domain>:<handle>` publish. The CLI builds this from the domain's stored key
108/// (see `crate::auth::domain_key`); the registry verifies it against the hosted
109/// proof manifest.
110#[derive(Debug, Clone)]
111pub struct DomainSignature {
112    /// `ed25519:<base64>` public key the signature was made with.
113    pub key: String,
114    /// `ed25519:<base64>` signature over the canonical publish payload.
115    pub signature: String,
116    /// Publish timestamp, unix seconds.
117    pub timestamp: i64,
118}
119
120/// POST a sealed `.mem` archive to `/api/publish`. Returns the parsed
121/// success body or a typed `ApiErrorBody` on any non-2xx.
122///
123/// Authorisation is one of two channels: a GitHub `token` (the default path),
124/// or a `domain_sig` for a `<domain>:<handle>` publish, which needs no GitHub
125/// account. Exactly one should be supplied for a given publish.
126pub 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    // Non-2xx: try the typed envelope, fall back to raw text.
169    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/// Outcome of a successful `DELETE /api/mem/<scope>/<name>`.
179#[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
187/// DELETE a mem from the registry. Same auth + error envelope as
188/// publish, so reuse `PublishError` for the failure shape.
189pub 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
223/// Admin-only takedown of a published mem: same `DELETE` route as
224/// `unpublish`, but with the `x-memstead-takedown` header carrying the
225/// statement-of-reasons notice reference. The server selects the
226/// takedown path (deny-list the bytes, tombstone, burn the name)
227/// instead of an ordinary hard-delete, and refuses non-admins with 403.
228pub 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/// Outcome of a successful `POST /api/admin/denylist`.
264#[derive(Debug, Clone, Deserialize)]
265pub struct DenylistResponse {
266    #[allow(dead_code)]
267    pub ok: bool,
268    pub content_sha256: String,
269}
270
271/// Admin-only: add a canonical-bytes SHA-256 to the content deny-list so
272/// those exact bytes can never be published. Refuses non-admins with 403.
273pub 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
303/// GET a sealed `.mem` archive from the registry, streaming into
304/// `dest_path`. Returns the number of bytes written.
305pub 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
337/// Minimal percent-encoding for a single path segment. Our scope +
338/// name are slug-safe by server validation (`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`),
339/// so a character set check is sufficient — the server would 400 any
340/// non-slug anyway. Kept as a fn for explicitness.
341fn url_segment(raw: &str) -> String {
342    // Preserve the scope-form characters `:` (scheme/domain separator) and
343    // `.` (domain labels) — both valid in a URL path segment — alongside the
344    // slug characters; drop anything else as a path-shape defence.
345    raw.chars()
346        .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_' | ':' | '.'))
347        .collect()
348}
349
350/// Parse a registry ref `<scope>/<name>` in one of the three scope forms
351/// (`github:<handle>/<name>`, `<domain>:<handle>/<name>`, or a bare
352/// `<handle>/<name>`). The legacy `@scope/name` syntax is rejected by the
353/// caller before this is reached. Returns `None` for anything that is not a
354/// valid registry ref (e.g. a local file path), so `install` can fall back to
355/// a local install.
356pub fn parse_ref(raw: &str) -> Option<(String, String)> {
357    let (scope, name) = raw.split_once('/')?;
358    // The name is a bare slug — no extension, no further path segments.
359    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
376/// `github:<handle>`, `<domain>:<handle>` (domain has a `.`), or bare `<handle>`.
377fn 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}