lean_ctx/core/context_package/
remote.rs1use sha2::{Digest, Sha256};
9
10use super::manifest::PackageManifest;
11
12pub const DEFAULT_REGISTRY: &str = "https://ctxpkg.com/api";
14
15pub 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
26pub 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#[derive(Debug, PartialEq, Eq)]
38pub struct RemoteRef {
39 pub namespace: String,
40 pub name: String,
41 pub version: Option<String>,
42}
43
44pub 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#[derive(Debug)]
64pub struct VersionInfo {
65 pub version: String,
66 pub artifact_sha256: String,
67 pub yanked: bool,
68}
69
70pub 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
101pub 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
119pub 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#[derive(Debug)]
142pub struct PublishReceipt {
143 pub published: String,
144 pub artifact_sha256: String,
145}
146
147pub 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 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
203pub 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
234fn 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
244fn 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 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}