1use std::path::Path;
14
15use chrono::Utc;
16
17use super::audit::{self, AuditReport, AuditVerdict};
18use super::manifest::AddonManifest;
19use crate::core::context_package::content::{AddonContent, PackageContent};
20use crate::core::context_package::manifest::{
21 CompatibilitySpec, PackageIntegrity, PackageKind, PackageManifest, PackageProvenance,
22 PackageStats,
23};
24use crate::core::context_package::{keys, signing, verify};
25
26#[derive(Debug)]
30pub struct AddonPackPlan {
31 pub namespace: String,
33 pub slug: String,
35 pub version: String,
37 pub bundle_json: String,
39 pub audit: AuditReport,
41 pub artifact_platforms: Vec<String>,
43 pub has_bootstrap: bool,
45}
46
47pub fn validate_namespace(ns: &str) -> Result<(), String> {
50 let ok_len = (2..=39).contains(&ns.len());
51 let ok_chars = ns
52 .chars()
53 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
54 let ok_edges = !ns.starts_with('-') && !ns.ends_with('-') && !ns.contains("--");
55 if ok_len && ok_chars && ok_edges {
56 Ok(())
57 } else {
58 Err(format!(
59 "invalid namespace `{ns}` — lowercase letters, digits and single dashes, \
60 2–39 characters (e.g. `acme` or `das-tholo`)"
61 ))
62 }
63}
64
65pub fn build_addon_pack(manifest_path: &Path, namespace: &str) -> Result<AddonPackPlan, String> {
74 validate_namespace(namespace)?;
75
76 let toml_text = std::fs::read_to_string(manifest_path)
77 .map_err(|e| format!("read {}: {e}", manifest_path.display()))?;
78 let addon = AddonManifest::from_toml(&toml_text)?;
79 addon.validate()?;
80
81 if !addon.is_installable() {
82 return Err(
83 "the addon has no runnable [mcp] endpoint — nothing to publish (fill in \
84 `[mcp] command` or a remote `url`)"
85 .into(),
86 );
87 }
88 if addon.addon.description.trim().is_empty() {
89 return Err("addon.description is required for a published listing".into());
90 }
91
92 let report = audit::audit(&addon);
93 if report.verdict == AuditVerdict::Fail {
94 let blocking: Vec<String> = report
95 .findings
96 .iter()
97 .map(|f| format!("{} — {}", f.code, f.message))
98 .collect();
99 return Err(format!(
100 "audit verdict: FAIL — the hosted registry refuses this listing, so publish \
101 stops here:\n {}",
102 blocking.join("\n ")
103 ));
104 }
105
106 let slug = addon.addon.name.clone();
107 let version = addon.addon.version.clone();
108 let pack_name = format!("@{namespace}/{slug}");
109
110 let content = PackageContent {
111 addon: Some(AddonContent {
112 manifest_toml: toml_text,
113 }),
114 ..PackageContent::default()
115 };
116
117 let content_json = serde_json::to_string(&content).map_err(|e| e.to_string())?;
120 let content_hash = sha256_hex(content_json.as_bytes());
121 let sha256 = sha256_hex(format!("{pack_name}:{version}:{content_hash}").as_bytes());
122
123 let mut manifest = PackageManifest {
124 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
125 conformance_level: None,
126 kind: PackageKind::Addon,
127 name: pack_name,
128 version: version.clone(),
129 description: addon.addon.description.clone(),
130 author: (!addon.addon.author.trim().is_empty()).then(|| addon.addon.author.clone()),
131 scope: Some(format!("@{namespace}")),
132 created_at: Utc::now(),
133 updated_at: None,
134 layers: Vec::new(),
135 dependencies: Vec::new(),
136 tags: addon
137 .addon
138 .categories
139 .iter()
140 .chain(addon.addon.keywords.iter())
141 .cloned()
142 .collect(),
143 visibility: None,
144 integrity: PackageIntegrity {
145 sha256,
146 content_hash,
147 byte_size: content_json.len() as u64,
148 },
149 provenance: PackageProvenance {
150 tool: "lean-ctx".into(),
151 tool_version: env!("CARGO_PKG_VERSION").into(),
152 project_hash: None,
153 source_session_id: None,
154 },
155 compatibility: CompatibilitySpec::default(),
156 stats: PackageStats::default(),
157 signature: None,
158 graph_summary: None,
159 marketplace: None,
160 };
161 manifest.validate().map_err(|errs| errs.join("; "))?;
162 verify::validate_kind_coherence(&manifest, &content).map_err(|errs| errs.join("; "))?;
163
164 let (signing_key, created) = keys::load_or_create()?;
165 if created {
166 tracing::info!("ctxpkg: created a new ed25519 signing key for this machine");
167 }
168 signing::sign_package(&mut manifest, &content, &signing_key);
169
170 #[derive(serde::Serialize)]
174 struct Bundle<'a> {
175 manifest: &'a PackageManifest,
176 content: &'a PackageContent,
177 }
178 let bundle_json = serde_json::to_string_pretty(&Bundle {
179 manifest: &manifest,
180 content: &content,
181 })
182 .map_err(|e| e.to_string())?;
183
184 let self_check = verify::verify_package_text(&bundle_json);
187 if !self_check.valid() {
188 return Err(format!(
189 "internal error — the built pack fails verification: {}",
190 self_check.errors.join("; ")
191 ));
192 }
193
194 Ok(AddonPackPlan {
195 namespace: namespace.to_string(),
196 slug,
197 version,
198 bundle_json,
199 audit: report,
200 artifact_platforms: addon.artifacts.keys().cloned().collect(),
201 has_bootstrap: !addon.install.is_absent(),
202 })
203}
204
205fn sha256_hex(data: &[u8]) -> String {
206 use sha2::{Digest, Sha256};
207 let mut h = Sha256::new();
208 h.update(data);
209 crate::core::agent_identity::hex_encode(&h.finalize())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 const GOOD_TOML: &str = r#"
217[addon]
218name = "lean-md"
219version = "1.2.0"
220description = "Markdown skills runtime for lean agents"
221author = "dasTholo"
222categories = ["skills"]
223keywords = ["markdown"]
224
225[mcp]
226transport = "stdio"
227command = "lean-md"
228args = ["serve"]
229sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
230
231[capabilities]
232network = "none"
233filesystem = "read_only"
234exec = "none"
235
236[artifacts.aarch64-apple-darwin]
237filename = "lean-md-aarch64-apple-darwin"
238url = "https://github.com/dastholo/lean-md/releases/download/v1.2.0/lean-md-aarch64-apple-darwin"
239sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
240"#;
241
242 fn write_manifest(dir: &tempfile::TempDir, text: &str) -> std::path::PathBuf {
243 let p = dir.path().join("lean-ctx-addon.toml");
244 std::fs::write(&p, text).expect("write manifest");
245 p
246 }
247
248 #[test]
249 fn namespace_rules() {
250 assert!(validate_namespace("acme").is_ok());
251 assert!(validate_namespace("das-tholo").is_ok());
252 assert!(validate_namespace("a").is_err());
253 assert!(validate_namespace("Bad").is_err());
254 assert!(validate_namespace("-x-").is_err());
255 assert!(validate_namespace("a--b").is_err());
256 }
257
258 #[test]
259 fn builds_a_signed_verifying_addon_pack() {
260 let dir = tempfile::tempdir().expect("tempdir");
261 let path = write_manifest(&dir, GOOD_TOML);
262
263 let plan = build_addon_pack(&path, "das-tholo").expect("plan");
264 assert_eq!(plan.slug, "lean-md");
265 assert_eq!(plan.version, "1.2.0");
266 assert_eq!(plan.artifact_platforms, vec!["aarch64-apple-darwin"]);
267 assert!(!plan.has_bootstrap);
268 assert_eq!(plan.audit.verdict, AuditVerdict::Pass);
269
270 let report = verify::verify_package_text(&plan.bundle_json);
272 assert!(report.valid(), "errors: {:?}", report.errors);
273 let (ns, name, version) =
275 crate::core::context_package::remote::preflight_bundle(plan.bundle_json.as_bytes())
276 .expect("preflight");
277 assert_eq!((ns.as_str(), name.as_str()), ("das-tholo", "lean-md"));
278 assert_eq!(version, "1.2.0");
279 }
280
281 #[test]
282 fn embedded_toml_is_verbatim() {
283 let dir = tempfile::tempdir().expect("tempdir");
284 let path = write_manifest(&dir, GOOD_TOML);
285
286 let plan = build_addon_pack(&path, "das-tholo").expect("plan");
287 let doc: serde_json::Value = serde_json::from_str(&plan.bundle_json).expect("json");
288 assert_eq!(
289 doc["content"]["addon"]["manifest_toml"].as_str(),
290 Some(GOOD_TOML)
291 );
292 assert_eq!(doc["manifest"]["kind"].as_str(), Some("addon"));
293 }
294
295 #[test]
296 fn refuses_shell_exec_wiring() {
297 let dir = tempfile::tempdir().expect("tempdir");
298 let bad = GOOD_TOML
299 .replace("command = \"lean-md\"", "command = \"bash\"")
300 .replace("args = [\"serve\"]", "args = [\"-c\", \"echo hi\"]");
301 let path = write_manifest(&dir, &bad);
302
303 let err = build_addon_pack(&path, "acme").expect_err("must fail");
304 assert!(err.contains("FAIL"), "got: {err}");
305 }
306
307 #[test]
308 fn refuses_missing_description() {
309 let dir = tempfile::tempdir().expect("tempdir");
310 let bad = GOOD_TOML.replace(
311 "description = \"Markdown skills runtime for lean agents\"",
312 "description = \"\"",
313 );
314 let path = write_manifest(&dir, &bad);
315
316 let err = build_addon_pack(&path, "acme").expect_err("must fail");
317 assert!(err.contains("description"), "got: {err}");
318 }
319}