Skip to main content

lean_ctx/core/context_package/
remote.rs

1//! Client for the hosted ctxpkg registry (GL #406) — publish, resolve, fetch.
2//!
3//! Trust model: the registry is the authenticity gate, this client is the
4//! integrity gate. Every download is verified locally — artifact SHA-256
5//! against the package index AND the embedded ed25519 manifest signature —
6//! so a compromised registry cannot hand us altered content undetected.
7
8use sha2::{Digest, Sha256};
9
10use super::manifest::PackageManifest;
11
12/// Default public registry, served via ctxpkg.com (nginx → control plane).
13pub const DEFAULT_REGISTRY: &str = "https://ctxpkg.com/api";
14
15/// Resolve the registry base URL: explicit flag > `CTXPKG_REGISTRY` env >
16/// the public default. Trailing slashes are trimmed for clean joins.
17pub fn registry_base(flag: Option<&str>) -> String {
18    flag.map(str::to_string)
19        .or_else(|| std::env::var("CTXPKG_REGISTRY").ok())
20        .filter(|s| !s.trim().is_empty())
21        .unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
22        .trim_end_matches('/')
23        .to_string()
24}
25
26/// Resolve the registry token: explicit flag > `CTXPKG_TOKEN` env. Used for
27/// publish (`ctxp_…`) and for installing private packages (`ctxp_…` or the
28/// read-only `ctxr_…`, GL #524).
29pub fn publish_token(flag: Option<&str>) -> Option<String> {
30    flag.map(str::to_string)
31        .or_else(|| std::env::var("CTXPKG_TOKEN").ok())
32        .filter(|s| !s.trim().is_empty())
33}
34
35/// A remote package reference: `@ns/name` or `ns/name`, optional `@version`
36/// pin after the name (`acme/auth-context@1.2.0`).
37#[derive(Debug, PartialEq, Eq)]
38pub struct RemoteRef {
39    pub namespace: String,
40    pub name: String,
41    pub version: Option<String>,
42}
43
44/// Parse a remote reference. Returns `None` for plain local names (no `/`).
45pub fn parse_remote_ref(input: &str) -> Option<RemoteRef> {
46    let trimmed = input.strip_prefix('@').unwrap_or(input);
47    let (ns, rest) = trimmed.split_once('/')?;
48    let (name, version) = match rest.split_once('@') {
49        Some((n, v)) => (n, Some(v.to_string())),
50        None => (rest, None),
51    };
52    if ns.is_empty() || name.is_empty() {
53        return None;
54    }
55    Some(RemoteRef {
56        namespace: ns.to_string(),
57        name: name.to_string(),
58        version,
59    })
60}
61
62/// One version entry from the package index.
63#[derive(Debug)]
64pub struct VersionInfo {
65    pub version: String,
66    pub artifact_sha256: String,
67    pub yanked: bool,
68}
69
70/// `GET {base}/v1/packages/{ns}/{name}/index.json` → all versions.
71/// `token` unlocks private packages; public ones need none.
72pub fn fetch_versions(
73    base: &str,
74    ns: &str,
75    name: &str,
76    token: Option<&str>,
77) -> Result<Vec<VersionInfo>, String> {
78    let url = format!("{base}/v1/packages/{ns}/{name}/index.json");
79    let body = http_get(&url, token)?;
80    let doc: serde_json::Value =
81        serde_json::from_str(&body).map_err(|e| format!("registry returned non-JSON: {e}"))?;
82    let versions = doc
83        .get("versions")
84        .and_then(|v| v.as_array())
85        .ok_or("registry index has no versions array")?;
86    Ok(versions
87        .iter()
88        .filter_map(|v| {
89            Some(VersionInfo {
90                version: v.get("version")?.as_str()?.to_string(),
91                artifact_sha256: v.get("artifact_sha256")?.as_str()?.to_string(),
92                yanked: v
93                    .get("yanked")
94                    .and_then(serde_json::Value::as_bool)
95                    .unwrap_or(false),
96            })
97        })
98        .collect())
99}
100
101/// Pick the version to install: an explicit pin (yanked allowed, warned by
102/// the caller) or the newest non-yanked version.
103pub fn select_version<'a>(
104    versions: &'a [VersionInfo],
105    pin: Option<&str>,
106) -> Result<&'a VersionInfo, String> {
107    match pin {
108        Some(want) => versions
109            .iter()
110            .find(|v| v.version == want)
111            .ok_or(format!("version {want} not found in the registry")),
112        None => versions
113            .iter()
114            .find(|v| !v.yanked)
115            .ok_or("no installable (non-yanked) version found".to_string()),
116    }
117}
118
119/// Download an artifact and verify its SHA-256 against the index entry.
120pub fn download_verified(
121    base: &str,
122    ns: &str,
123    name: &str,
124    info: &VersionInfo,
125    token: Option<&str>,
126) -> Result<Vec<u8>, String> {
127    let url = format!("{base}/v1/packages/{ns}/{name}/{}/download", info.version);
128    let bytes = http_get_bytes(&url, token)?;
129    let actual = sha256_hex(&bytes);
130    if actual != info.artifact_sha256 {
131        return Err(format!(
132            "artifact checksum mismatch — registry index says {}, downloaded bytes hash to {actual}; \
133             refusing to install",
134            info.artifact_sha256
135        ));
136    }
137    Ok(bytes)
138}
139
140/// Publish receipt as returned by the registry.
141#[derive(Debug)]
142pub struct PublishReceipt {
143    pub published: String,
144    pub artifact_sha256: String,
145}
146
147/// `PUT {base}/v1/packages/{ns}/{name}/{version}` with the artifact bytes.
148pub fn publish(
149    base: &str,
150    token: &str,
151    ns: &str,
152    name: &str,
153    version: &str,
154    bytes: &[u8],
155) -> Result<PublishReceipt, String> {
156    let url = format!("{base}/v1/packages/{ns}/{name}/{version}");
157    let agent: ureq::Agent = ureq::config::Config::builder()
158        .tls_config(crate::core::http_client::platform_tls_config())
159        .http_status_as_error(false)
160        .timeout_resolve(Some(std::time::Duration::from_secs(5)))
161        .timeout_connect(Some(std::time::Duration::from_secs(10)))
162        .timeout_recv_response(Some(std::time::Duration::from_mins(1)))
163        .build()
164        .into();
165    let resp = agent
166        .put(&url)
167        .header("Authorization", &format!("Bearer {token}"))
168        .header("Content-Type", "application/octet-stream")
169        .send(bytes)
170        .map_err(|e| format!("registry unreachable: {e}"))?;
171    let status = resp.status().as_u16();
172    let body = resp
173        .into_body()
174        .read_to_string()
175        .map_err(|e| format!("read registry response: {e}"))?;
176
177    if status == 201 {
178        let doc: serde_json::Value =
179            serde_json::from_str(&body).map_err(|e| format!("registry returned non-JSON: {e}"))?;
180        return Ok(PublishReceipt {
181            published: doc
182                .get("published")
183                .and_then(|v| v.as_str())
184                .unwrap_or("(unknown)")
185                .to_string(),
186            artifact_sha256: doc
187                .get("artifact_sha256")
188                .and_then(|v| v.as_str())
189                .unwrap_or("")
190                .to_string(),
191        });
192    }
193    // Error bodies are JSON {"error": …} or plain text — surface either.
194    let detail = serde_json::from_str::<serde_json::Value>(&body)
195        .ok()
196        .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
197        .unwrap_or(body);
198    Err(format!(
199        "registry rejected the publish (HTTP {status}): {detail}"
200    ))
201}
202
203/// Parse + verify a local bundle before any network call: must be a valid
204/// manifest with a verifying ed25519 signature, and the scoped name must
205/// match the publish target. Returns `(namespace, name, version)`.
206pub fn preflight_bundle(bytes: &[u8]) -> Result<(String, String, String), String> {
207    #[derive(serde::Deserialize)]
208    struct BundleProbe {
209        manifest: PackageManifest,
210    }
211    let probe: BundleProbe =
212        serde_json::from_slice(bytes).map_err(|e| format!("not a ctxpkg bundle: {e}"))?;
213    let manifest = probe.manifest;
214
215    let signed = super::signing::verify_signature(&manifest)?;
216    if !signed {
217        return Err(
218            "package is unsigned — the hosted registry requires ed25519 signatures \
219             (re-export with `lean-ctx pack export <name> --sign`)"
220                .to_string(),
221        );
222    }
223
224    let scoped = manifest.name.clone();
225    let stripped = scoped.strip_prefix('@').ok_or(format!(
226        "manifest.name '{scoped}' is not scoped — hosted packages need '@namespace/name'"
227    ))?;
228    let (ns, name) = stripped
229        .split_once('/')
230        .ok_or(format!("manifest.name '{scoped}' is not '@namespace/name'"))?;
231    Ok((ns.to_string(), name.to_string(), manifest.version))
232}
233
234/// Private packages return 404 for outsiders — hint at the token when none
235/// was sent, so `install` failures stay actionable.
236fn not_found_hint(token: Option<&str>) -> &'static str {
237    if token.is_some() {
238        "package not found in the registry (or your token's namespace does not own it)"
239    } else {
240        "package not found in the registry — private packages need CTXPKG_TOKEN"
241    }
242}
243
244/// Paid packs answer 402 with an actionable message in `{"error": …}`
245/// (where to buy, how to install) — surface it verbatim (GL #529).
246fn payment_hint(body: &str) -> String {
247    serde_json::from_str::<serde_json::Value>(body)
248        .ok()
249        .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
250        .unwrap_or_else(|| "this is a paid package — purchase required".to_string())
251}
252
253fn http_get(url: &str, token: Option<&str>) -> Result<String, String> {
254    let agent: ureq::Agent = ureq::config::Config::builder()
255        .tls_config(crate::core::http_client::platform_tls_config())
256        .http_status_as_error(false)
257        .timeout_resolve(Some(std::time::Duration::from_secs(5)))
258        .timeout_connect(Some(std::time::Duration::from_secs(10)))
259        .timeout_recv_response(Some(std::time::Duration::from_secs(30)))
260        .build()
261        .into();
262    let mut req = agent.get(url);
263    if let Some(t) = token {
264        req = req.header("Authorization", &format!("Bearer {t}"));
265    }
266    let resp = req
267        .call()
268        .map_err(|e| format!("registry unreachable: {e}"))?;
269    let status = resp.status().as_u16();
270    let body = resp
271        .into_body()
272        .read_to_string()
273        .map_err(|e| format!("read registry response: {e}"))?;
274    if status == 404 {
275        return Err(not_found_hint(token).to_string());
276    }
277    if status == 402 {
278        return Err(payment_hint(&body));
279    }
280    if status >= 400 {
281        return Err(format!("registry error (HTTP {status})"));
282    }
283    Ok(body)
284}
285
286fn http_get_bytes(url: &str, token: Option<&str>) -> Result<Vec<u8>, String> {
287    let agent: ureq::Agent = ureq::config::Config::builder()
288        .tls_config(crate::core::http_client::platform_tls_config())
289        .http_status_as_error(false)
290        .timeout_resolve(Some(std::time::Duration::from_secs(5)))
291        .timeout_connect(Some(std::time::Duration::from_secs(10)))
292        .timeout_recv_response(Some(std::time::Duration::from_mins(1)))
293        .build()
294        .into();
295    let mut req = agent.get(url);
296    if let Some(t) = token {
297        req = req.header("Authorization", &format!("Bearer {t}"));
298    }
299    let resp = req
300        .call()
301        .map_err(|e| format!("registry unreachable: {e}"))?;
302    let status = resp.status().as_u16();
303    if status == 404 {
304        return Err(not_found_hint(token).to_string());
305    }
306    let mut reader = resp.into_body().into_reader();
307    let mut buf = Vec::new();
308    std::io::Read::read_to_end(&mut reader, &mut buf).map_err(|e| format!("read artifact: {e}"))?;
309    if status == 402 {
310        return Err(payment_hint(&String::from_utf8_lossy(&buf)));
311    }
312    if status >= 400 {
313        return Err(format!("registry error (HTTP {status})"));
314    }
315    Ok(buf)
316}
317
318fn sha256_hex(bytes: &[u8]) -> String {
319    let mut h = Sha256::new();
320    h.update(bytes);
321    crate::core::agent_identity::hex_encode(&h.finalize())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn remote_ref_parsing() {
330        assert_eq!(
331            parse_remote_ref("acme/auth-context"),
332            Some(RemoteRef {
333                namespace: "acme".into(),
334                name: "auth-context".into(),
335                version: None
336            })
337        );
338        assert_eq!(
339            parse_remote_ref("@acme/auth-context@1.2.0"),
340            Some(RemoteRef {
341                namespace: "acme".into(),
342                name: "auth-context".into(),
343                version: Some("1.2.0".into())
344            })
345        );
346        assert_eq!(parse_remote_ref("local-package"), None);
347        assert_eq!(parse_remote_ref("/x"), None);
348        assert_eq!(parse_remote_ref("ns/"), None);
349    }
350
351    #[test]
352    fn version_selection_skips_yanked_unless_pinned() {
353        let versions = vec![
354            VersionInfo {
355                version: "2.0.0".into(),
356                artifact_sha256: "b".into(),
357                yanked: true,
358            },
359            VersionInfo {
360                version: "1.0.0".into(),
361                artifact_sha256: "a".into(),
362                yanked: false,
363            },
364        ];
365        assert_eq!(
366            select_version(&versions, None).expect("latest").version,
367            "1.0.0"
368        );
369        assert_eq!(
370            select_version(&versions, Some("2.0.0"))
371                .expect("pinned")
372                .version,
373            "2.0.0"
374        );
375        assert!(select_version(&versions, Some("3.0.0")).is_err());
376    }
377
378    #[test]
379    fn registry_base_resolution_order() {
380        assert_eq!(
381            registry_base(Some("https://r.example/api/")),
382            "https://r.example/api"
383        );
384        // No flag, no env (tests run without CTXPKG_REGISTRY) → default.
385        if std::env::var("CTXPKG_REGISTRY").is_err() {
386            assert_eq!(registry_base(None), DEFAULT_REGISTRY);
387        }
388    }
389
390    #[test]
391    fn preflight_rejects_garbage_and_unscoped() {
392        assert!(preflight_bundle(b"not json").is_err());
393    }
394}