Skip to main content

lean_ctx/core/addons/
publish.rs

1//! Build the distribution view of an addon (GH #724/#726, Phase 2): a
2//! signed `kind=addon` `.ctxpkg` whose content embeds the authoring
3//! `lean-ctx-addon.toml` verbatim.
4//!
5//! This is the write side of unified distribution. The authoring contract
6//! (`docs/contracts/addon-manifest-v1.md`) is untouched — authors keep one
7//! TOML; `lean-ctx addon publish` wraps it into the same package format,
8//! registry and trust chain every other pack uses. Local gates run **before
9//! any network I/O** and mirror the hosted registry's listing bar, so a
10//! publish that would be rejected server-side fails here first, with the
11//! same vocabulary (`AuditVerdict`).
12
13use 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/// Everything `addon publish` needs after the local build+gate stage: the
27/// signed bundle bytes plus the facts the CLI discloses. Producing the plan
28/// performs **no network I/O** — `--check` stops here.
29#[derive(Debug)]
30pub struct AddonPackPlan {
31    /// Registry namespace (from `--namespace`), without the `@`.
32    pub namespace: String,
33    /// Addon slug — `addon.name` from the authoring manifest.
34    pub slug: String,
35    /// Version being published (`addon.version`).
36    pub version: String,
37    /// The signed `.ctxpkg` document (pretty JSON, ready for upload).
38    pub bundle_json: String,
39    /// The local audit that gated this build.
40    pub audit: AuditReport,
41    /// Target triples with prebuilt binaries (`[artifacts]`, GH #725).
42    pub artifact_platforms: Vec<String>,
43    /// True when the pack embeds an `[install]` bootstrap fallback.
44    pub has_bootstrap: bool,
45}
46
47/// Namespace rule shared with ctxpkg.com account names: lowercase slug,
48/// digits and single dashes, 2–39 chars (the GitHub-username envelope).
49pub 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
65/// Build, gate and sign the `kind=addon` pack from an authoring manifest.
66///
67/// Gate order (all local, deterministic):
68/// 1. TOML parses + `AddonManifest::validate` (schema bar)
69/// 2. runnable `[mcp]` endpoint (`is_installable`)
70/// 3. `audit::audit` verdict must not be `Fail` (the hosted listing bar —
71///    `Review` publishes with a disclosed warning, malware/wiring blocks)
72/// 4. signing key present (created on first use, same as `pack export --sign`)
73pub 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    // Integrity exactly like the context builder: compact content JSON is
118    // the hashed byte stream, the package hash chains name+version onto it.
119    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    // Typed bundle (not `json!`): serde keeps struct field order, so the
171    // content text in the document stays byte-identical to the bytes hashed
172    // into `integrity.content_hash` above.
173    #[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    // Self-check: the exact bytes we would upload must verify cleanly —
185    // catches any writer/reader drift at build time, not at install time.
186    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        // The bundle round-trips through the standalone verifier…
271        let report = verify::verify_package_text(&plan.bundle_json);
272        assert!(report.valid(), "errors: {:?}", report.errors);
273        // …and through the publish preflight (signed + scoped).
274        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}