memstead-cli 0.2.0

Command-line interface for Memstead — query and mutate typed entity graphs from the shell. Default build produces the full `memstead` binary (multi-mem, git-backed); `--no-default-features` builds the lean folder-only surface.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! HTTP client for the Memstead registry (memstead.io).
//!
//! Thin wrapper around `reqwest::blocking` that knows the two routes
//! the CLI consumes (`POST /api/publish`, `GET /api/mem/...`) plus
//! the typed error envelope (`ApiError`) the registry emits.

use std::io::Read;
use std::path::Path;
use std::time::Duration;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// Default registry when no `--registry` / `MEMSTEAD_REGISTRY` is set.
///
/// `memstead.io` is the canonical registry. The legacy domain is retired —
/// it no longer serves the public registry and is not a fallback.
pub const DEFAULT_REGISTRY: &str = "https://memstead.io";

/// The decoded wire-level error shape the registry returns on any
/// non-2xx. `variant` is present only for `validation_failed`
/// responses; other error kinds leave it `None`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApiErrorBody {
    pub error: String,
    #[serde(default)]
    pub variant: Option<String>,
    #[serde(default)]
    pub detail: Option<String>,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub retry_after_seconds: Option<i64>,
}

/// Outcome of a successful `/api/publish` POST.
#[derive(Debug, Clone, Deserialize)]
pub struct PublishResponse {
    #[allow(dead_code)]
    pub ok: bool,
    pub scope: String,
    pub name: String,
    pub version: String,
    /// The version that is `current` for the handle after this publish
    /// (highest published, semver). Differs from `version` when an older
    /// version was published while a higher one exists. Absent on older
    /// servers that predate the field.
    #[serde(default)]
    pub current: Option<String>,
    /// Path-only — typically `/v/<scope>/<name>`. Caller composes the
    /// full URL against the registry base.
    pub url: String,
}

/// Resolve the registry base URL in priority order: CLI flag →
/// `MEMSTEAD_REGISTRY` env → `DEFAULT_REGISTRY`. Trailing slashes are
/// stripped so callers can unconditionally append route segments.
pub fn registry_base(explicit: Option<&str>) -> String {
    let raw = explicit
        .map(str::to_string)
        .or_else(|| std::env::var("MEMSTEAD_REGISTRY").ok())
        .unwrap_or_else(|| DEFAULT_REGISTRY.to_string());
    raw.trim_end_matches('/').to_string()
}

/// Extract the hostname for credentials keying. Falls back to the
/// full URL on parse failure so nothing silently collides.
pub fn registry_host(base: &str) -> String {
    base.split_once("://")
        .map_or(base, |(_, rest)| rest)
        .split('/')
        .next()
        .unwrap_or(base)
        .to_ascii_lowercase()
}

/// Shared HTTP client. 30 s timeout is comfortable for the 2 MB cap
/// times a slow upstream — GitHub API is also cheap to tolerate.
pub fn build_http() -> Result<reqwest::blocking::Client> {
    reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(30))
        .user_agent(concat!("memstead/", env!("CARGO_PKG_VERSION")))
        .build()
        .context("building HTTP client")
}

/// The publisher-terms version the CLI accepts on publish. Running
/// `memstead publish` is a deliberate act, so the CLI accepts the current
/// terms on the publisher's behalf by sending this version. Must track the
/// registry's `CURRENT_TERMS_VERSION`; a mismatch surfaces as a
/// `terms_not_accepted` refusal naming the version, telling the user to update.
pub const ACCEPTED_TERMS_VERSION: &str = "1.0";

/// A per-publish domain-authority signature, presented in request headers for a
/// `<domain>:<handle>` publish. The CLI builds this from the domain's stored key
/// (see `crate::auth::domain_key`); the registry verifies it against the hosted
/// proof manifest.
#[derive(Debug, Clone)]
pub struct DomainSignature {
    /// `ed25519:<base64>` public key the signature was made with.
    pub key: String,
    /// `ed25519:<base64>` signature over the canonical publish payload.
    pub signature: String,
    /// Publish timestamp, unix seconds.
    pub timestamp: i64,
}

/// POST a sealed `.mem` archive to `/api/publish`. Returns the parsed
/// success body or a typed `ApiErrorBody` on any non-2xx.
///
/// Authorisation is one of two channels: a GitHub `token` (the default path),
/// or a `domain_sig` for a `<domain>:<handle>` publish, which needs no GitHub
/// account. Exactly one should be supplied for a given publish.
pub fn publish(
    client: &reqwest::blocking::Client,
    base: &str,
    archive: &Path,
    token: Option<&str>,
    scope_override: Option<&str>,
    domain_sig: Option<&DomainSignature>,
) -> Result<PublishResponse, PublishError> {
    use memstead_base::domain_authority_wire::{HEADER_KEY, HEADER_SIGNATURE, HEADER_TIMESTAMP};

    let url = format!("{base}/api/publish");
    let mut file = std::fs::File::open(archive).map_err(PublishError::Io)?;
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes).map_err(PublishError::Io)?;

    let mut req = client
        .post(&url)
        .header("content-type", "application/octet-stream")
        .header("x-memstead-accept-terms", ACCEPTED_TERMS_VERSION)
        .body(bytes);
    if let Some(t) = token {
        req = req.bearer_auth(t);
    }
    if let Some(s) = scope_override {
        req = req.header("x-memstead-scope", s);
    }
    if let Some(ds) = domain_sig {
        req = req
            .header(HEADER_KEY, &ds.key)
            .header(HEADER_SIGNATURE, &ds.signature)
            .header(HEADER_TIMESTAMP, ds.timestamp.to_string());
    }

    let resp = req.send().map_err(PublishError::Network)?;
    let status = resp.status();
    let body_bytes = resp.bytes().map_err(PublishError::Network)?;

    if status.is_success() {
        return serde_json::from_slice::<PublishResponse>(&body_bytes)
            .map_err(|e| PublishError::Malformed(e.to_string()));
    }

    // Non-2xx: try the typed envelope, fall back to raw text.
    match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
        Ok(envelope) => Err(PublishError::Api { status, envelope }),
        Err(_) => {
            let text = String::from_utf8_lossy(&body_bytes).into_owned();
            Err(PublishError::Raw { status, text })
        }
    }
}

/// Outcome of a successful `DELETE /api/mem/<scope>/<name>`.
#[derive(Debug, Clone, Deserialize)]
pub struct UnpublishResponse {
    #[allow(dead_code)]
    pub ok: bool,
    pub scope: String,
    pub name: String,
}

/// DELETE a mem from the registry. Same auth + error envelope as
/// publish, so reuse `PublishError` for the failure shape.
pub fn unpublish(
    client: &reqwest::blocking::Client,
    base: &str,
    scope: &str,
    name: &str,
    token: &str,
) -> Result<UnpublishResponse, PublishError> {
    let url = format!(
        "{base}/api/mem/{scope}/{name}",
        scope = url_segment(scope),
        name = url_segment(name),
    );
    let resp = client
        .delete(&url)
        .bearer_auth(token)
        .send()
        .map_err(PublishError::Network)?;
    let status = resp.status();
    let body_bytes = resp.bytes().map_err(PublishError::Network)?;

    if status.is_success() {
        return serde_json::from_slice::<UnpublishResponse>(&body_bytes)
            .map_err(|e| PublishError::Malformed(e.to_string()));
    }

    match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
        Ok(envelope) => Err(PublishError::Api { status, envelope }),
        Err(_) => {
            let text = String::from_utf8_lossy(&body_bytes).into_owned();
            Err(PublishError::Raw { status, text })
        }
    }
}

/// Admin-only takedown of a published mem: same `DELETE` route as
/// `unpublish`, but with the `x-memstead-takedown` header carrying the
/// statement-of-reasons notice reference. The server selects the
/// takedown path (deny-list the bytes, tombstone, burn the name)
/// instead of an ordinary hard-delete, and refuses non-admins with 403.
pub fn admin_takedown(
    client: &reqwest::blocking::Client,
    base: &str,
    scope: &str,
    name: &str,
    notice: &str,
    token: &str,
) -> Result<UnpublishResponse, PublishError> {
    let url = format!(
        "{base}/api/mem/{scope}/{name}",
        scope = url_segment(scope),
        name = url_segment(name),
    );
    let resp = client
        .delete(&url)
        .bearer_auth(token)
        .header("x-memstead-takedown", notice)
        .send()
        .map_err(PublishError::Network)?;
    let status = resp.status();
    let body_bytes = resp.bytes().map_err(PublishError::Network)?;

    if status.is_success() {
        return serde_json::from_slice::<UnpublishResponse>(&body_bytes)
            .map_err(|e| PublishError::Malformed(e.to_string()));
    }
    match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
        Ok(envelope) => Err(PublishError::Api { status, envelope }),
        Err(_) => {
            let text = String::from_utf8_lossy(&body_bytes).into_owned();
            Err(PublishError::Raw { status, text })
        }
    }
}

/// Outcome of a successful `POST /api/admin/denylist`.
#[derive(Debug, Clone, Deserialize)]
pub struct DenylistResponse {
    #[allow(dead_code)]
    pub ok: bool,
    pub content_sha256: String,
}

/// Admin-only: add a canonical-bytes SHA-256 to the content deny-list so
/// those exact bytes can never be published. Refuses non-admins with 403.
pub fn admin_denylist(
    client: &reqwest::blocking::Client,
    base: &str,
    content_sha256: &str,
    reason: Option<&str>,
    token: &str,
) -> Result<DenylistResponse, PublishError> {
    let url = format!("{base}/api/admin/denylist");
    let resp = client
        .post(&url)
        .bearer_auth(token)
        .json(&serde_json::json!({ "content_sha256": content_sha256, "reason": reason }))
        .send()
        .map_err(PublishError::Network)?;
    let status = resp.status();
    let body_bytes = resp.bytes().map_err(PublishError::Network)?;

    if status.is_success() {
        return serde_json::from_slice::<DenylistResponse>(&body_bytes)
            .map_err(|e| PublishError::Malformed(e.to_string()));
    }
    match serde_json::from_slice::<ApiErrorBody>(&body_bytes) {
        Ok(envelope) => Err(PublishError::Api { status, envelope }),
        Err(_) => {
            let text = String::from_utf8_lossy(&body_bytes).into_owned();
            Err(PublishError::Raw { status, text })
        }
    }
}

/// GET a sealed `.mem` archive from the registry, streaming into
/// `dest_path`. Returns the number of bytes written.
pub fn download_mem(
    client: &reqwest::blocking::Client,
    base: &str,
    scope: &str,
    name: &str,
    dest_path: &Path,
) -> Result<u64, DownloadError> {
    let url = format!(
        "{base}/api/mem/{scope}/{name}.mem",
        scope = url_segment(scope),
        name = url_segment(name),
    );
    let resp = client.get(&url).send().map_err(DownloadError::Network)?;
    let status = resp.status();
    if !status.is_success() {
        return match status.as_u16() {
            404 => Err(DownloadError::NotFound),
            410 => Err(DownloadError::Gone),
            _ => {
                let text = resp.text().unwrap_or_default();
                Err(DownloadError::Http {
                    status,
                    text: text.chars().take(500).collect(),
                })
            }
        };
    }
    let bytes = resp.bytes().map_err(DownloadError::Network)?;
    std::fs::write(dest_path, &bytes).map_err(DownloadError::Io)?;
    Ok(bytes.len() as u64)
}

/// Minimal percent-encoding for a single path segment. Our scope +
/// name are slug-safe by server validation (`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`),
/// so a character set check is sufficient — the server would 400 any
/// non-slug anyway. Kept as a fn for explicitness.
fn url_segment(raw: &str) -> String {
    // Preserve the scope-form characters `:` (scheme/domain separator) and
    // `.` (domain labels) — both valid in a URL path segment — alongside the
    // slug characters; drop anything else as a path-shape defence.
    raw.chars()
        .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_' | ':' | '.'))
        .collect()
}

/// Parse a registry ref `<scope>/<name>` in one of the three scope forms
/// (`github:<handle>/<name>`, `<domain>:<handle>/<name>`, or a bare
/// `<handle>/<name>`). The legacy `@scope/name` syntax is rejected by the
/// caller before this is reached. Returns `None` for anything that is not a
/// valid registry ref (e.g. a local file path), so `install` can fall back to
/// a local install.
pub fn parse_ref(raw: &str) -> Option<(String, String)> {
    let (scope, name) = raw.split_once('/')?;
    // The name is a bare slug — no extension, no further path segments.
    if name.is_empty() || name.contains('.') || name.contains('/') || name.contains('\\') {
        return None;
    }
    if !is_valid_scope_form(scope) {
        return None;
    }
    Some((scope.to_string(), name.to_string()))
}

fn is_valid_handle(h: &str) -> bool {
    !h.is_empty()
        && h.len() <= 39
        && !h.starts_with('-')
        && !h.ends_with('-')
        && h.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}

/// `github:<handle>`, `<domain>:<handle>` (domain has a `.`), or bare `<handle>`.
fn is_valid_scope_form(scope: &str) -> bool {
    match scope.split_once(':') {
        Some((prefix, handle)) => {
            is_valid_handle(handle)
                && (prefix == "github"
                    || (prefix.contains('.')
                        && prefix.split('.').all(|label| {
                            !label.is_empty()
                                && label
                                    .bytes()
                                    .all(|b| b.is_ascii_alphanumeric() || b == b'-')
                        })))
        }
        None => is_valid_handle(scope),
    }
}

#[derive(Debug, thiserror::Error)]
pub enum PublishError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("network: {0}")]
    Network(reqwest::Error),
    #[error("registry returned {status}: {envelope:?}")]
    Api {
        status: reqwest::StatusCode,
        envelope: ApiErrorBody,
    },
    #[error("registry returned {status}: {text}")]
    Raw {
        status: reqwest::StatusCode,
        text: String,
    },
    #[error("malformed success response: {0}")]
    Malformed(String),
}

#[derive(Debug, thiserror::Error)]
pub enum DownloadError {
    #[error("io: {0}")]
    Io(#[from] std::io::Error),
    #[error("network: {0}")]
    Network(reqwest::Error),
    #[error("not found")]
    NotFound,
    #[error("content taken down")]
    Gone,
    #[error("registry returned {status}: {text}")]
    Http {
        status: reqwest::StatusCode,
        text: String,
    },
}