Skip to main content

hypha/spore/
release.rs

1use serde_json::json;
2use std::path::Path;
3use std::process::ExitCode;
4
5use crate::api::Output;
6use crate::auth;
7use crate::site::{self, SiteDir};
8use substrate::{
9    BondRelation, PrettyJson, Spore, SporeBond, SporeCapsule, SporeCore, SPORE_CORE_SCHEMA,
10    SPORE_SCHEMA,
11};
12
13use super::read_spawned_from_uri;
14use super::updated_at::compute_updated_at_ms;
15
16/// Archive compression format
17#[derive(Debug, Clone, Copy)]
18pub enum ArchiveFormat {
19    Zstd,
20}
21
22impl ArchiveFormat {
23    pub(crate) fn from_str(s: &str) -> Result<Self, crate::sink::HyphaError> {
24        match s.to_lowercase().as_str() {
25            "zstd" | "zst" => Ok(Self::Zstd),
26            _ => Err(crate::sink::HyphaError::new(
27                "invalid_args",
28                format!(
29                    "Unsupported archive format for release generation: {}. Use: zstd",
30                    s
31                ),
32            )),
33        }
34    }
35
36    pub(crate) fn extension(&self) -> &'static str {
37        match self {
38            Self::Zstd => "tar.zst",
39        }
40    }
41}
42
43pub struct ReleaseArgs<'a> {
44    pub domain: &'a str,
45    pub source: Option<String>,
46    pub site_path: Option<&'a str>,
47    pub dist_git: Option<String>,
48    pub dist_ref: Option<String>,
49    pub archive: &'a str,
50    pub dry_run: bool,
51}
52
53pub fn handle_release(out: &Output, args: ReleaseArgs<'_>) -> ExitCode {
54    let ReleaseArgs {
55        domain,
56        source,
57        site_path,
58        dist_git,
59        dist_ref,
60        archive,
61        dry_run,
62    } = args;
63    let now_epoch_ms = crate::time::now_epoch_ms();
64
65    if site_path.is_none() {
66        if let Err(e) = site::validate_site_domain_path(domain) {
67            return out.error_hypha(&e);
68        }
69    }
70
71    // Parse archive format
72    let archive_format = match ArchiveFormat::from_str(archive) {
73        Ok(f) => f,
74        Err(e) => return out.error_hypha(&e),
75    };
76
77    // Validate distribution options
78    if dist_git.is_some() && dist_ref.is_none() {
79        return out.error("invalid_args", "--dist-git requires --dist-ref");
80    }
81
82    if dist_git.is_none() && dist_ref.is_some() {
83        return out.error("invalid_args", "--dist-ref requires --dist-git");
84    }
85
86    let site = SiteDir::from_args(domain, site_path);
87    if !site.exists() {
88        return out.error_hint(
89            "NO_SITE",
90            &format!("Site not found at {}", site.root.display()),
91            Some(&format!("run: hypha mycelium root --domain {}", domain)),
92        );
93    }
94
95    let working_dir = match source
96        .map(std::path::PathBuf::from)
97        .map(Ok)
98        .unwrap_or_else(std::env::current_dir)
99    {
100        Ok(d) => d,
101        Err(e) => {
102            return out.error(
103                "dir_error",
104                &format!("Failed to get working directory: {}", e),
105            );
106        }
107    };
108
109    let spore_core_path = working_dir.join("spore.core.json");
110
111    if !spore_core_path.exists() {
112        return out.error_hint(
113            "NO_SPORE",
114            &format!("No spore.core.json found at {}", working_dir.display()),
115            Some("run: hypha hatch"),
116        );
117    }
118
119    let draft_content = match std::fs::read_to_string(&spore_core_path) {
120        Ok(c) => c,
121        Err(e) => {
122            return out.error(
123                "read_error",
124                &format!("Failed to read spore.core.json: {}", e),
125            );
126        }
127    };
128
129    let draft_value: serde_json::Value = match serde_json::from_str(&draft_content) {
130        Ok(v) => v,
131        Err(e) => return out.error("parse_error", &format!("Invalid spore.core.json: {}", e)),
132    };
133    let schema_type = match substrate::validate_schema(&draft_value) {
134        Ok(t) => t,
135        Err(e) => {
136            return out.error(
137                "schema_error",
138                &format!("spore.core.json schema validation failed: {}", e),
139            );
140        }
141    };
142    if schema_type != substrate::SchemaType::SporeCore {
143        return out.error(
144            "schema_error",
145            &format!("spore.core.json must use {}", SPORE_CORE_SCHEMA),
146        );
147    }
148    // Reject runtime-only fields that must not appear in spore.core.json
149    if draft_value.get("updated_at_epoch_ms").is_some() {
150        return out.error_hint(
151            "INVALID_FIELD",
152            "spore.core.json must not contain updated_at_epoch_ms (computed at release time)",
153            Some("run: hypha hatch   (hatch removes this field automatically)"),
154        );
155    }
156
157    let draft: SporeCore = match serde_json::from_value(draft_value) {
158        Ok(d) => d,
159        Err(e) => return out.error("parse_error", &format!("Invalid spore.core.json: {}", e)),
160    };
161
162    // Validate domain in spore.core.json matches --domain
163    if draft.domain.is_empty() {
164        return out.error_hint(
165            "DOMAIN_EMPTY",
166            "spore.core.json domain is empty",
167            Some(&format!("run: hypha hatch --domain {}", domain)),
168        );
169    }
170    if draft.domain != domain {
171        return out.error_hint(
172            "DOMAIN_MISMATCH",
173            &format!(
174                "spore.core.json domain '{}' does not match --domain '{}'",
175                draft.domain, domain
176            ),
177            Some(&format!("run: hypha hatch --domain {}", domain)),
178        );
179    }
180
181    // Get public key from site identity
182    let public_key = match auth::get_identity_with_site(domain, &site) {
183        Ok(info) => info.public_key,
184        Err(e) => return out.error_from("identity_error", &e),
185    };
186
187    // Validate key in spore.core.json matches domain identity
188    if draft.key.is_empty() {
189        return out.error_hint(
190            "KEY_EMPTY",
191            "spore.core.json key is empty",
192            Some(&format!("run: hypha hatch --domain {}", domain)),
193        );
194    }
195    if draft.key != public_key {
196        return out.error_hint(
197            "KEY_MISMATCH",
198            &format!(
199                "Key in spore.core.json does not match domain '{}' (key may have rotated)",
200                domain
201            ),
202            Some(&format!("run: hypha hatch --domain {}", domain)),
203        );
204    }
205
206    // Build bonds: start from spore.core.json (schema guarantees no spawned_from),
207    // then add spawned_from from .cmn/spawned-from/spore.json if present.
208    let mut release_bonds: Vec<SporeBond> = draft.bonds.clone();
209    let spawned_from_spore_path = working_dir
210        .join(".cmn")
211        .join("spawned-from")
212        .join("spore.json");
213    if let Some(parent_uri) = read_spawned_from_uri(&spawned_from_spore_path) {
214        release_bonds.push(SporeBond {
215            uri: parent_uri,
216            relation: BondRelation::SpawnedFrom,
217            id: None,
218            reason: None,
219            with: None,
220        });
221    }
222
223    // 1. Check for symlinks (not supported in spore content), then walk tree
224    if let Err(e) = crate::tree::check_no_symlinks(
225        &working_dir,
226        &draft.tree.exclude_names,
227        &draft.tree.follow_rules,
228    ) {
229        return out.error("SYMLINK_ERR", &format!("{}", e));
230    }
231    let entries = match crate::tree::walk_dir(
232        &working_dir,
233        &draft.tree.exclude_names,
234        &draft.tree.follow_rules,
235    ) {
236        Ok(e) => e,
237        Err(e) => return out.error("HASH_ERR", &format!("Failed to walk directory: {}", e)),
238    };
239    let (tree_hash, size_bytes) = match draft.tree.compute_hash_and_size(&entries) {
240        Ok(v) => v,
241        Err(e) => return out.error("HASH_ERR", &format!("Failed to compute tree hash: {}", e)),
242    };
243
244    let core = SporeCore {
245        id: draft.id.clone(),
246        version: draft.version.clone(),
247        name: draft.name.clone(),
248        domain: domain.to_string(),
249        key: public_key,
250        synopsis: draft.synopsis.clone(),
251        intent: draft.intent.clone(),
252        license: draft.license.clone(),
253        mutations: draft.mutations.clone(),
254        size_bytes,
255        bonds: release_bonds,
256        tree: draft.tree.clone(),
257        updated_at_epoch_ms: match compute_updated_at_ms(
258            &working_dir,
259            &draft.tree.exclude_names,
260            &draft.tree.follow_rules,
261        ) {
262            Ok(ms) if ms > 0 => ms,
263            _ => now_epoch_ms,
264        },
265    };
266
267    // 2. Sign core → core_signature
268    let core_signature = match auth::sign_json_with_site(&site, &core) {
269        Ok(sig) => sig,
270        Err(auth::JsonSignError::Jcs(message)) => return out.error("jcs_error", &message),
271        Err(auth::JsonSignError::Sign(err)) => return out.error_from("sign_error", &err),
272    };
273
274    // 3. Compute URI hash from tree_hash + core + core_signature
275    let uri_hash = match (substrate::Spore {
276        schema: substrate::SPORE_SCHEMA.to_string(),
277        capsule: substrate::SporeCapsule {
278            uri: String::new(),
279            core: core.clone(),
280            core_signature: core_signature.clone(),
281            dist: vec![],
282        },
283        capsule_signature: String::new(),
284    })
285    .computed_uri_hash_from_tree_hash(&tree_hash)
286    {
287        Ok(hash) => hash,
288        Err(e) => return out.error("jcs_error", &e.to_string()),
289    };
290    let filename = uri_hash.clone();
291
292    // 4. Build URI
293    let uri = format!("cmn://{}/{}", domain, uri_hash);
294
295    // Dry run: return URI without writing anything
296    if dry_run {
297        return out.ok_trace(
298            json!({
299                "uri": uri,
300                "hash": uri_hash,
301            }),
302            json!({
303                "status": "dry_run",
304                "site": site.public.display().to_string(),
305            }),
306        );
307    }
308
309    // 5. Build dist array based on options
310    let mut dist: Vec<substrate::SporeDist> = vec![];
311
312    // Scenario A: External git reference
313    if let (Some(git_url), Some(git_ref)) = (&dist_git, &dist_ref) {
314        dist.push(substrate::SporeDist {
315            kind: substrate::DistKind::Git,
316            filename: None,
317            url: Some(git_url.clone()),
318            git_ref: Some(git_ref.clone()),
319            cid: None,
320            extra: Default::default(),
321        });
322    }
323
324    // Scenario B: Archive (always generated) + optional delta
325    {
326        let mut files = substrate::flatten_entries(&entries);
327
328        let archive_filename = format!("{}.{}", filename, archive_format.extension());
329        let archive_dir = site.archive_dir();
330        if let Err(e) = std::fs::create_dir_all(&archive_dir) {
331            return out.error("dir_error", &format!("Failed to create archive dir: {}", e));
332        }
333        let archive_path = archive_dir.join(&archive_filename);
334
335        // Create archive directly from in-memory file list (optimized)
336        if let Err(e) = create_archive_from_files(&mut files, &archive_path, archive_format) {
337            return out.error("archive_error", &format!("Failed to create archive: {}", e));
338        }
339
340        // Generate delta archive if previous version exists.
341        if let Some(old_hash) = find_previous_hash(&site, domain, &draft.id) {
342            if old_hash != uri_hash {
343                let old_archive_path = archive_dir.join(format!("{}.tar.zst", old_hash));
344                if old_archive_path.exists() {
345                    match generate_delta_archive(
346                        &mut files,
347                        &old_archive_path,
348                        &archive_dir,
349                        &uri_hash,
350                        &old_hash,
351                    ) {
352                        Ok(_delta_filename) => {}
353                        Err(e) => {
354                            // Delta is optional — warn but continue
355                            out.warn(
356                                "DELTA_WARN",
357                                &format!("Failed to generate delta archive: {}", e),
358                            );
359                        }
360                    }
361                }
362            }
363        }
364
365        dist.push(substrate::SporeDist {
366            kind: substrate::DistKind::Archive,
367            filename: None,
368            url: None,
369            git_ref: None,
370            cid: None,
371            extra: Default::default(),
372        });
373    }
374
375    // 6. Build capsule with dist
376    let capsule = SporeCapsule {
377        uri: uri.clone(),
378        core,
379        core_signature,
380        dist,
381    };
382
383    // 7. Sign capsule → capsule_signature
384    let capsule_signature = match auth::sign_json_with_site(&site, &capsule) {
385        Ok(sig) => sig,
386        Err(auth::JsonSignError::Jcs(message)) => return out.error("jcs_error", &message),
387        Err(auth::JsonSignError::Sign(err)) => return out.error_from("sign_error", &err),
388    };
389
390    // 8. Check if spore already exists with same hash
391    let spore_manifest_path = site.spores_dir().join(format!("{}.json", filename));
392
393    if spore_manifest_path.exists() {
394        // Spore with same hash already exists - skip
395        let existing_json = match std::fs::read_to_string(&spore_manifest_path) {
396            Ok(j) => j,
397            Err(e) => {
398                return out.error(
399                    "read_error",
400                    &format!("Spore manifest exists but cannot be read: {}", e),
401                );
402            }
403        };
404        let existing: serde_json::Value = match serde_json::from_str(&existing_json) {
405            Ok(v) => v,
406            Err(e) => {
407                return out.error(
408                    "parse_error",
409                    &format!("Spore manifest exists but is invalid JSON: {}", e),
410                );
411            }
412        };
413
414        // Still save to .cmn/spawned-from/ so next release knows the parent
415        if let Some(parent) = spawned_from_spore_path.parent() {
416            let _ = std::fs::create_dir_all(parent);
417        }
418        let _ = std::fs::write(&spawned_from_spore_path, &existing_json);
419
420        let data = json!({
421            "uri": uri,
422            "hash": uri_hash,
423            "spore": existing,
424        });
425        let hypha = json!({
426            "status": "skipped",
427            "site": site.public.display().to_string(),
428        });
429
430        return out.ok_trace(&data, hypha);
431    }
432
433    // 9. Build complete spore manifest
434    let spore_manifest = Spore {
435        schema: SPORE_SCHEMA.to_string(),
436        capsule,
437        capsule_signature,
438    };
439
440    // Validate against schema before writing
441    let spore_value = match serde_json::to_value(&spore_manifest) {
442        Ok(v) => v,
443        Err(e) => return out.error("serialize_error", &e.to_string()),
444    };
445    if let Err(e) = substrate::validate_schema(&spore_value) {
446        return out.error(
447            "schema_error",
448            &format!("Spore schema validation failed: {}", e),
449        );
450    }
451
452    let spore_json = match spore_manifest.to_pretty_json() {
453        Ok(j) => j,
454        Err(e) => {
455            return out.error(
456                "serialize_error",
457                &format!("Failed to format spore manifest: {}", e),
458            );
459        }
460    };
461
462    if let Err(e) = std::fs::write(&spore_manifest_path, &spore_json) {
463        return out.error(
464            "write_error",
465            &format!("Failed to write spore manifest: {}", e),
466        );
467    }
468
469    // Update cmn.json inventory
470    if let Err(e) = crate::mycelium::update_inventory(
471        &site,
472        domain,
473        &draft.id,
474        &uri_hash,
475        &draft.name,
476        Some(&draft.synopsis),
477        now_epoch_ms,
478    ) {
479        return out.error(
480            "INVENTORY_ERR",
481            &format!("Failed to update cmn.json: {}", e),
482        );
483    }
484
485    // 10. Save released spore to .cmn/spawned-from/spore.json
486    //     Next release will read this to set spawned_from.
487    //     spore.core.json is NOT modified — no git diff noise.
488    {
489        if let Some(parent) = spawned_from_spore_path.parent() {
490            let _ = std::fs::create_dir_all(parent);
491        }
492        let _ = std::fs::write(&spawned_from_spore_path, &spore_json);
493    }
494
495    let data = json!({
496        "uri": uri,
497        "hash": uri_hash,
498        "spore": spore_manifest,
499    });
500    let hypha = json!({
501        "status": "released",
502        "site": site.public.display().to_string(),
503    });
504
505    out.ok_trace(&data, hypha)
506}
507
508/// Create archive directly from in-memory file list (optimized - no temp directory)
509fn create_archive_from_files(
510    files: &mut [(String, Vec<u8>, bool)],
511    output_path: &Path,
512    _format: ArchiveFormat,
513) -> anyhow::Result<()> {
514    create_tar_archive_from_files(files, output_path)
515}
516
517/// Build raw (uncompressed) tar bytes from in-memory file list (reproducible: deterministic headers)
518pub(crate) fn build_raw_tar_bytes(
519    files: &mut [(String, Vec<u8>, bool)],
520) -> anyhow::Result<Vec<u8>> {
521    // Sort by path for deterministic order
522    files.sort_by(|a, b| a.0.cmp(&b.0));
523
524    let mut buf = Vec::new();
525    {
526        let mut tar = tar::Builder::new(&mut buf);
527        for (rel_path, content, is_executable) in files.iter() {
528            let mut header = tar::Header::new_gnu();
529            header.set_size(content.len() as u64);
530            header.set_mode(if *is_executable { 0o755 } else { 0o644 });
531            header.set_mtime(0);
532            header.set_uid(0);
533            header.set_gid(0);
534            let _ = header.set_username("");
535            let _ = header.set_groupname("");
536            header.set_cksum();
537            tar.append_data(&mut header, rel_path.as_str(), content.as_slice())?;
538        }
539        tar.finish()?;
540    }
541    Ok(buf)
542}
543
544/// Create tar archive from in-memory file list (reproducible: deterministic headers)
545fn create_tar_archive_from_files(
546    files: &mut [(String, Vec<u8>, bool)],
547    output_path: &Path,
548) -> anyhow::Result<()> {
549    let raw_tar = build_raw_tar_bytes(files)?;
550    let compressed =
551        substrate::archive::encode_zstd(&raw_tar, 19).map_err(|e| anyhow::anyhow!("{}", e))?;
552    std::fs::write(output_path, &compressed)?;
553    Ok(())
554}
555
556/// Look up the previous hash for a spore id from the mycelium inventory.
557fn find_previous_hash(site: &SiteDir, domain: &str, spore_id: &str) -> Option<String> {
558    crate::mycelium::find_local_spore_hash(site, domain, spore_id)
559}
560
561/// Generate a delta archive using zstd dictionary compression.
562/// Returns the delta filename on success.
563fn generate_delta_archive(
564    files: &mut [(String, Vec<u8>, bool)],
565    old_archive_path: &Path,
566    archive_dir: &Path,
567    new_hash: &str,
568    old_hash: &str,
569) -> anyhow::Result<String> {
570    // Build new raw tar
571    let new_raw_tar = build_raw_tar_bytes(files)?;
572
573    // Decompress old archive to get raw tar (dictionary)
574    let old_compressed = std::fs::read(old_archive_path)?;
575    let old_raw_tar = substrate::archive::decode_zstd(&old_compressed, 512 * 1024 * 1024)
576        .map_err(|e| anyhow::anyhow!("{}", e))?;
577
578    // Create delta using dictionary compression
579    let delta_filename = format!("{}.from.{}.tar.zst", new_hash, old_hash);
580    let delta_path = archive_dir.join(&delta_filename);
581
582    let compressed = substrate::archive::encode_zstd_with_dict(&new_raw_tar, &old_raw_tar, 19)
583        .map_err(|e| anyhow::anyhow!("{}", e))?;
584    std::fs::write(&delta_path, &compressed)?;
585
586    Ok(delta_filename)
587}