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: addon.dependencies.clone(),
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 _iso = crate::core::data_dir::isolated_data_dir();
264 let dir = tempfile::tempdir().expect("tempdir");
265 let path = write_manifest(&dir, GOOD_TOML);
266
267 let plan = build_addon_pack(&path, "das-tholo").expect("plan");
268 assert_eq!(plan.slug, "lean-md");
269 assert_eq!(plan.version, "1.2.0");
270 assert_eq!(plan.artifact_platforms, vec!["aarch64-apple-darwin"]);
271 assert!(!plan.has_bootstrap);
272 assert_eq!(plan.audit.verdict, AuditVerdict::Pass);
273
274 let report = verify::verify_package_text(&plan.bundle_json);
276 assert!(report.valid(), "errors: {:?}", report.errors);
277 let (ns, name, version) =
279 crate::core::context_package::remote::preflight_bundle(plan.bundle_json.as_bytes())
280 .expect("preflight");
281 assert_eq!((ns.as_str(), name.as_str()), ("das-tholo", "lean-md"));
282 assert_eq!(version, "1.2.0");
283 }
284
285 #[test]
286 fn embedded_toml_is_verbatim() {
287 let _iso = crate::core::data_dir::isolated_data_dir();
290 let dir = tempfile::tempdir().expect("tempdir");
291 let path = write_manifest(&dir, GOOD_TOML);
292
293 let plan = build_addon_pack(&path, "das-tholo").expect("plan");
294 let doc: serde_json::Value = serde_json::from_str(&plan.bundle_json).expect("json");
295 assert_eq!(
296 doc["content"]["addon"]["manifest_toml"].as_str(),
297 Some(GOOD_TOML)
298 );
299 assert_eq!(doc["manifest"]["kind"].as_str(), Some("addon"));
300 }
301
302 #[test]
303 fn refuses_shell_exec_wiring() {
304 let dir = tempfile::tempdir().expect("tempdir");
305 let bad = GOOD_TOML
306 .replace("command = \"lean-md\"", "command = \"bash\"")
307 .replace("args = [\"serve\"]", "args = [\"-c\", \"echo hi\"]");
308 let path = write_manifest(&dir, &bad);
309
310 let err = build_addon_pack(&path, "acme").expect_err("must fail");
311 assert!(err.contains("FAIL"), "got: {err}");
312 }
313
314 #[test]
315 fn refuses_missing_description() {
316 let dir = tempfile::tempdir().expect("tempdir");
317 let bad = GOOD_TOML.replace(
318 "description = \"Markdown skills runtime for lean agents\"",
319 "description = \"\"",
320 );
321 let path = write_manifest(&dir, &bad);
322
323 let err = build_addon_pack(&path, "acme").expect_err("must fail");
324 assert!(err.contains("description"), "got: {err}");
325 }
326
327 #[test]
328 fn build_addon_pack_forwards_declared_dependencies() {
329 let _iso = crate::core::data_dir::isolated_data_dir();
332 let dir = std::env::temp_dir().join(format!("lc-addon-deps-{}", std::process::id()));
333 std::fs::create_dir_all(&dir).expect("tmp dir");
334 let path = dir.join("lean-ctx-addon.toml");
335 std::fs::write(
336 &path,
337 r#"
338[addon]
339name = "demo"
340version = "0.2.0"
341description = "a demo addon"
342
343[mcp]
344command = "demo-bin"
345
346[[dependencies]]
347name = "@dasTholo/lean-md-skills"
348version_req = "^0.2"
349"#,
350 )
351 .expect("write manifest");
352
353 let plan = build_addon_pack(&path, "dastholo").expect("builds");
354 std::fs::remove_dir_all(&dir).ok();
355
356 let bundle: serde_json::Value =
357 serde_json::from_str(&plan.bundle_json).expect("bundle json");
358 let deps = &bundle["manifest"]["dependencies"];
359 assert_eq!(deps[0]["name"], "@dasTholo/lean-md-skills");
360 assert_eq!(deps[0]["version_req"], "^0.2");
361 }
362}