Skip to main content

hypha/spore/
release.rs

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