use std::io::Read;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const DEFAULT_REGISTRY: &str = "https://memstead.io";
#[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>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PublishResponse {
#[allow(dead_code)]
pub ok: bool,
pub scope: String,
pub name: String,
pub version: String,
#[serde(default)]
pub current: Option<String>,
pub url: String,
}
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()
}
pub fn registry_host(base: &str) -> String {
base.split_once("://")
.map_or(base, |(_, rest)| rest)
.split('/')
.next()
.unwrap_or(base)
.to_ascii_lowercase()
}
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")
}
pub const ACCEPTED_TERMS_VERSION: &str = "1.0";
#[derive(Debug, Clone)]
pub struct DomainSignature {
pub key: String,
pub signature: String,
pub timestamp: i64,
}
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()));
}
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 })
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UnpublishResponse {
#[allow(dead_code)]
pub ok: bool,
pub scope: String,
pub name: String,
}
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 })
}
}
}
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 })
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DenylistResponse {
#[allow(dead_code)]
pub ok: bool,
pub content_sha256: String,
}
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 })
}
}
}
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)
}
fn url_segment(raw: &str) -> String {
raw.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_' | ':' | '.'))
.collect()
}
pub fn parse_ref(raw: &str) -> Option<(String, String)> {
let (scope, name) = raw.split_once('/')?;
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'-')
}
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,
},
}