Skip to main content

memstead_base/filesystem/
publish.rs

1//! Filesystem-mem → `.mem` archive assembler.
2//!
3//! Walks a workspace root on disk, reads the workspace config,
4//! projects it to the strict archive shape, embeds the resolved schema
5//! source under `.memstead/schema/`, and packs every entity `.md` file
6//! into a deterministic zip.
7//!
8//! Engine-agnostic: the caller passes a path. Used by both
9//! `memstead publish` and `memstead export --format mem`, neither of which
10//! needs a live engine for the archive build (the workspace config
11//! and the entity walker both read from disk directly).
12//!
13//! ## Archive layout (matches `memstead-base::validator::archive`)
14//!
15//! ```text
16//! .memstead/config.json               # archive shape (PublishedMemConfig)
17//! .memstead/schema/schema.yaml        # schema manifest
18//! .memstead/schema/types/<name>.yaml  # per-type definitions
19//! <mem-relative entity path>.md     # one per entity in the workspace
20//! ```
21//!
22//! ## Determinism
23//!
24//! - Entity `.md` files are emitted in mem-relative path order — the
25//!   same order [`crate::entity::source::EntitySource::Directory`]
26//!   yields them on read.
27//! - Schema files come from
28//!   [`memstead_schema::collect_schema_source`], which already sorts by
29//!   `archive_path`.
30//! - The `.memstead/config.json` is serialised pretty-printed for human
31//!   inspection but with deterministic key order via
32//!   [`memstead_schema::PublishedMemConfig`]'s `Serialize` impl.
33//! - Compression is fixed at `Stored` so repeated assembly of the same
34//!   workspace yields byte-identical archive bytes (modulo zip-level
35//!   timestamps, which the writer leaves at zero by default).
36//!
37//! ## What this does NOT do
38//!
39//! - HTTP. The CLI's `commands::publish` posts the bytes; this module
40//!   stops at "build the byte buffer". Same separation as the
41//!   mem-repo `export_mem` → `commands::publish` flow.
42//! - Validate. The bytes go through `validator::archive::extract_entries`
43//!   on the registry side; doing it here too would double the work for
44//!   the same answer. Tests in this module *do* re-validate so future
45//!   layout changes surface as test failures rather than registry
46//!   rejections.
47
48use std::io::{Cursor, Write as _};
49use std::path::Path;
50
51use memstead_schema::{
52    ARCHIVE_CONFIG_PATH, ARCHIVE_SCHEMA_PREFIX, PublishConversionError, SchemaRef,
53    SchemaSourceError, collect_schema_source,
54};
55use zip::CompressionMethod;
56use zip::result::ZipError;
57use zip::write::SimpleFileOptions;
58
59use super::config::{WorkspaceConfigError, read_workspace_config};
60use crate::entity::source::EntitySource;
61
62/// Errors surfaced by [`assemble_archive`].
63#[derive(Debug, thiserror::Error)]
64pub enum AssembleError {
65    /// The workspace config could not be read
66    /// or parsed (missing file, malformed JSON, format mismatch).
67    #[error("workspace config: {0}")]
68    WorkspaceConfig(#[from] WorkspaceConfigError),
69    /// The workspace config does not project cleanly to the archive
70    /// shape — typically because `version` is unset on the workspace
71    /// config.
72    #[error("config projection: {0}")]
73    Config(#[from] PublishConversionError),
74    /// Resolving the schema's source files failed — either the
75    /// `name@version` pin does not match any builtin (and there's no
76    /// workspace-local schema dir) or the on-disk schema directory is
77    /// malformed.
78    #[error("schema source: {0}")]
79    Schema(#[from] SchemaSourceError),
80    /// I/O while reading entity `.md` files from the workspace.
81    #[error("workspace io: {0}")]
82    Io(String),
83    /// Zip-level error while writing into the in-memory buffer.
84    /// Should not happen in practice — the buffer is unbounded — but
85    /// surfaces cleanly if a future zip version starts failing earlier.
86    #[error("zip writer: {0}")]
87    Zip(#[from] ZipError),
88    /// Serialising the archive's `.memstead/config.json`.
89    #[error("config serialisation: {0}")]
90    Serialise(#[from] serde_json::Error),
91    /// The workspace's anchors sidecar (or an archive's anchors member)
92    /// is unreadable or malformed. A refusal, never a silent drop — an
93    /// archive shipping without the anchors its workspace carries is
94    /// exactly the publish-strip failure the anchors contract closes.
95    #[error("anchors sidecar: {0}")]
96    Anchors(String),
97}
98
99/// Build the archive bytes for the workspace at `workspace_root`.
100///
101/// The caller writes the bytes to a tempfile and POSTs them to the
102/// registry — this function does not touch the network. It reads
103/// the workspace config and walks every `.md` file
104/// under `workspace_root`; both reads are direct (no engine
105/// involvement).
106pub fn assemble_archive(workspace_root: &Path) -> Result<Vec<u8>, AssembleError> {
107    // 1. Read the workspace config and project it to the strict
108    //    archive shape.
109    let config = read_workspace_config(workspace_root)?;
110    let published = config.to_published()?;
111    // The projection guarantees a versioned schema pin; reuse it for
112    // the schema-source resolver.
113    let schema_ref: SchemaRef = published.schema.clone();
114
115    // 2. Resolve the schema source files. Installed packages live under
116    //    `.memstead/schemas/` (the fixed `memstead schema install`
117    //    destination — same wiring as `Engine::export_mem_to_bytes`);
118    //    the workspace root also enables the `.memstead.cache/schemas/`
119    //    layer. Builtins remain the final fallback.
120    let schemas_dir = workspace_root.join(".memstead").join("schemas");
121    let schema_files =
122        collect_schema_source(Some(workspace_root), Some(&schemas_dir), &schema_ref)?;
123
124    // 3. Walk every entity `.md` under the workspace.
125    let source = EntitySource::Directory {
126        root: workspace_root.to_path_buf(),
127    };
128    let (source_entries, read_errors) = source
129        .read_all()
130        .map_err(|e| AssembleError::Io(e.to_string()))?;
131    if let Some(first) = read_errors.first() {
132        return Err(AssembleError::Io(format!(
133            "{}: {}",
134            first.source_path.display(),
135            first.error
136        )));
137    }
138
139    // 4. Pack into a zip. Sort entries by archive path for
140    //    determinism — the directory walker already sorts but
141    //    re-sorting here makes the contract explicit (a future change
142    //    in the walker won't break archive determinism).
143    let mut buf: Vec<u8> = Vec::new();
144    {
145        let cursor = Cursor::new(&mut buf);
146        let mut zip = zip::ZipWriter::new(cursor);
147        let opts = SimpleFileOptions::default()
148            .compression_method(CompressionMethod::Stored)
149            .last_modified_time(zip::DateTime::default());
150
151        // .memstead/config.json (archive shape — `deps` and other
152        // workspace-local fields are dropped by `to_published`).
153        let config_bytes = serde_json::to_vec_pretty(&published)?;
154        zip.start_file(ARCHIVE_CONFIG_PATH, opts)?;
155        zip.write_all(&config_bytes)
156            .map_err(|e| AssembleError::Io(format!("write config: {e}")))?;
157
158        // .memstead/anchors.json — the engine-owned anchors sidecar,
159        // when the mem carries one. The engine exporters have always
160        // threaded it (E3a: anchors travel in published archives, by
161        // contract); this walker previously did not, so a bare
162        // `memstead publish` of a folder mem silently shipped without
163        // its anchors — the publish-strip failure the contract exists
164        // to close. A present-but-malformed sidecar refuses rather than
165        // drops.
166        let anchors_path = workspace_root.join(crate::anchor::ANCHOR_SIDECAR_PATH);
167        if anchors_path.exists() {
168            let bytes = std::fs::read(&anchors_path)
169                .map_err(|e| AssembleError::Anchors(format!("read: {e}")))?;
170            let sidecar = crate::anchor::AnchorSidecar::from_bytes(&bytes)
171                .map_err(|e| AssembleError::Anchors(e.to_string()))?;
172            // An anchorless sidecar file embeds nothing — the archive
173            // contract is "no anchors ⇒ no member".
174            if !sidecar.entities.is_empty() {
175                zip.start_file(crate::anchor::ANCHOR_SIDECAR_PATH, opts)?;
176                zip.write_all(&bytes)
177                    .map_err(|e| AssembleError::Io(format!("write anchors: {e}")))?;
178            }
179        }
180
181        // .memstead/schema/* — `collect_schema_source` returns paths
182        // rooted at the schema dir (`schema.yaml`, `types/<name>.yaml`).
183        // Prepend the archive's `.memstead/schema/` root so the validator
184        // picks them up under the right path.
185        for sf in &schema_files {
186            let archive_path = format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path);
187            zip.start_file(&archive_path, opts)?;
188            zip.write_all(&sf.bytes)
189                .map_err(|e| AssembleError::Io(format!("write schema: {e}")))?;
190        }
191
192        // Entity .md files. Source-walked paths use the platform
193        // separator on Directory; normalise to forward-slash so the
194        // archive is portable across OSes.
195        let mut entries = source_entries;
196        entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
197        for entry in &entries {
198            let archive_path = entry.relative_path.replace('\\', "/");
199            zip.start_file(&archive_path, opts)?;
200            zip.write_all(entry.content.as_bytes())
201                .map_err(|e| AssembleError::Io(format!("write entity {archive_path}: {e}")))?;
202        }
203
204        zip.finish()?;
205    }
206    Ok(buf)
207}
208
209/// Redact the anchors sidecar inside assembled `.mem` archive bytes:
210/// every `artifact` and every `derived_from` entry becomes
211/// [`crate::anchor::REDACTED_ARTIFACT_SENTINEL`]; class, grain,
212/// `at_version`, hash, hash-stability, binding, source, and the
213/// per-entity anchor counts survive — redact, not strip, so the trust
214/// grade stays readable without the source's identity.
215///
216/// Operates on finished archive bytes so ANY packaging caller can apply
217/// it, whichever assembler produced them (the engine's
218/// `export_mem_to_bytes`, this module's [`assemble_archive`]). An archive
219/// with no anchors member returns byte-identical input. Every member is
220/// rewritten with the same deterministic options the assembler uses; the
221/// registry's canonical re-pack normalises the bytes again regardless.
222pub fn redact_archive_anchors(archive: &[u8]) -> Result<Vec<u8>, AssembleError> {
223    use crate::anchor::{ANCHOR_SIDECAR_PATH, AnchorSidecar};
224    use std::io::Read as _;
225
226    let mut zip = zip::ZipArchive::new(Cursor::new(archive))
227        .map_err(|e| AssembleError::Anchors(format!("read archive: {e}")))?;
228    let names: Vec<String> = zip.file_names().map(str::to_string).collect();
229    if !names.iter().any(|n| n == ANCHOR_SIDECAR_PATH) {
230        return Ok(archive.to_vec());
231    }
232
233    let mut buf: Vec<u8> = Vec::new();
234    {
235        let cursor = Cursor::new(&mut buf);
236        let mut out = zip::ZipWriter::new(cursor);
237        let opts = SimpleFileOptions::default()
238            .compression_method(CompressionMethod::Stored)
239            .last_modified_time(zip::DateTime::default());
240        for index in 0..zip.len() {
241            let mut member = zip
242                .by_index(index)
243                .map_err(|e| AssembleError::Anchors(format!("read member: {e}")))?;
244            let name = member.name().to_string();
245            let mut bytes = Vec::new();
246            member
247                .read_to_end(&mut bytes)
248                .map_err(|e| AssembleError::Io(format!("read member {name}: {e}")))?;
249            if name == ANCHOR_SIDECAR_PATH {
250                let mut sidecar = AnchorSidecar::from_bytes(&bytes)
251                    .map_err(|e| AssembleError::Anchors(e.to_string()))?;
252                sidecar.redact_artifact_references();
253                bytes = sidecar.to_bytes();
254            }
255            out.start_file(&name, opts)?;
256            out.write_all(&bytes)
257                .map_err(|e| AssembleError::Io(format!("write member {name}: {e}")))?;
258        }
259        out.finish()?;
260    }
261    Ok(buf)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::filesystem::config::{WorkspaceConfig, write_workspace_config};
268    use crate::validator::ValidatorLimits;
269    use crate::validator::archive::extract_entries;
270    use memstead_schema::SchemaRef;
271    use std::path::PathBuf;
272    use tempfile::TempDir;
273
274    fn versioned(name: &str, version: &str) -> SchemaRef {
275        SchemaRef::new(name, semver::Version::parse(version).unwrap())
276    }
277
278    /// Create the mem in a folder *named after it* (identity is path-derived
279    /// under the unified layout) and return the mem root.
280    fn write_workspace(tmp: &TempDir, name: &str, with_version: bool) -> PathBuf {
281        let root = tmp.path().join(name);
282        std::fs::create_dir_all(&root).unwrap();
283        // F1: `WorkspaceConfig::new` now seeds `version = Some(0.1.0)`
284        // by default. To simulate the pre-gate / externally-imported
285        // config in the no-version test path, clear it explicitly.
286        let mut cfg = WorkspaceConfig::new(name, versioned("default", "1.0.0"));
287        if with_version {
288            cfg.description = Some("test mem".into());
289        } else {
290            cfg.version = None;
291        }
292        write_workspace_config(&root, &cfg).unwrap();
293        root
294    }
295
296    /// Write a minimal valid spec entity directly to disk.
297    /// `assemble_archive` walks the directory itself so the entity
298    /// just needs to exist on disk in canonical markdown form.
299    fn write_spec(root: &Path, slug: &str, title: &str) {
300        std::fs::write(
301            root.join(format!("{slug}.md")),
302            format!("---\ntype: spec\n---\n# {title}\n"),
303        )
304        .unwrap();
305    }
306
307    #[test]
308    fn assemble_archive_round_trips_through_validator() {
309        let tmp = TempDir::new().unwrap();
310        let root = write_workspace(&tmp, "demo", true);
311
312        // Two entities so the archive has a non-empty markdown set.
313        write_spec(&root, "first", "First");
314        write_spec(&root, "second", "Second");
315
316        let bytes = assemble_archive(&root).expect("archive must build");
317        assert!(!bytes.is_empty());
318
319        // Round-trip through the same validator the registry uses.
320        let limits = ValidatorLimits::default();
321        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
322
323        // Config: present and projects to the archive shape (no `deps`).
324        let cfg_text = String::from_utf8_lossy(&entries.config_bytes);
325        assert!(cfg_text.contains("\"name\": \"demo\""));
326        assert!(cfg_text.contains("\"version\": \"0.1.0\""));
327        assert!(!cfg_text.contains("\"deps\""), "deps must drop on publish");
328
329        // Schema: at least the manifest is present.
330        let schema_paths: Vec<_> = entries
331            .schema_files
332            .iter()
333            .map(|s| s.archive_path.as_str())
334            .collect();
335        assert!(schema_paths.contains(&".memstead/schema/schema.yaml"));
336
337        // Entities: both markdown files made it in.
338        let md_paths: Vec<_> = entries
339            .markdown_files
340            .iter()
341            .map(|m| m.path.as_str())
342            .collect();
343        assert!(md_paths.contains(&"first.md"));
344        assert!(md_paths.contains(&"second.md"));
345    }
346
347    /// The engine-agnostic assembler embeds the mem's anchors sidecar —
348    /// closing the gap where a bare `memstead publish` of a folder mem
349    /// silently shipped without the anchors its engine-exported sibling
350    /// carries. A malformed sidecar refuses (never a silent drop); an
351    /// anchorless (empty-entities) sidecar file embeds no member; and
352    /// [`redact_archive_anchors`] over the assembled bytes blanks the
353    /// references while the package keeps validating.
354    #[test]
355    fn assemble_archive_embeds_and_redacts_anchors() {
356        let tmp = TempDir::new().unwrap();
357        let root = write_workspace(&tmp, "demo", true);
358        write_spec(&root, "first", "First");
359        std::fs::write(
360            root.join(".memstead").join("anchors.json"),
361            br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
362        )
363        .unwrap();
364
365        let bytes = assemble_archive(&root).expect("archive must build");
366        let limits = ValidatorLimits::default();
367        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
368        let sidecar_bytes = entries.anchors_bytes.expect("anchors member embedded");
369        assert!(String::from_utf8_lossy(&sidecar_bytes).contains("src/private.rs"));
370
371        // Redaction over the assembled bytes: sentinel in, identity out,
372        // and the package still validates.
373        let redacted = redact_archive_anchors(&bytes).unwrap();
374        let entries = extract_entries(&redacted, &limits).expect("redacted archive validates");
375        let sidecar =
376            crate::anchor::AnchorSidecar::from_bytes(&entries.anchors_bytes.unwrap()).unwrap();
377        assert_eq!(
378            sidecar.get("demo--first")[0].artifact,
379            crate::anchor::REDACTED_ARTIFACT_SENTINEL
380        );
381        assert!(!String::from_utf8_lossy(&redacted).contains("src/private.rs"));
382
383        // An empty-entities sidecar embeds no member.
384        std::fs::write(
385            root.join(".memstead").join("anchors.json"),
386            br#"{"version":1,"entities":{}}"#,
387        )
388        .unwrap();
389        let bytes = assemble_archive(&root).unwrap();
390        assert!(
391            extract_entries(&bytes, &limits)
392                .unwrap()
393                .anchors_bytes
394                .is_none(),
395            "no anchors ⇒ no member"
396        );
397
398        // A malformed sidecar refuses the assembly.
399        std::fs::write(root.join(".memstead").join("anchors.json"), b"{ nope").unwrap();
400        assert!(matches!(
401            assemble_archive(&root),
402            Err(AssembleError::Anchors(_))
403        ));
404    }
405
406    #[test]
407    fn assemble_archive_resolves_installed_workspace_schema() {
408        // Regression: bare `memstead publish` / `memstead export --format
409        // mem` on a folder workspace pinned to an INSTALLED custom schema
410        // used to fail with "schema <ref> not found — candidate paths
411        // tried: []" because the resolver ran built-in-only. The archive
412        // assembler must consult `.memstead/schemas/<name>@<version>/` —
413        // the `memstead schema install` destination.
414        let tmp = TempDir::new().unwrap();
415        let root = tmp.path().join("demo");
416        std::fs::create_dir_all(&root).unwrap();
417        let mut cfg = WorkspaceConfig::new("demo", versioned("cookbook", "0.1.0"));
418        cfg.description = Some("custom-schema mem".into());
419        write_workspace_config(&root, &cfg).unwrap();
420
421        // Install-shaped package dir, as `memstead schema install` writes it.
422        let schema_dir = root
423            .join(".memstead")
424            .join("schemas")
425            .join("cookbook@0.1.0");
426        std::fs::create_dir_all(schema_dir.join("types")).unwrap();
427        std::fs::write(
428            schema_dir.join("schema.yaml"),
429            "name: cookbook\nversion: 0.1.0\ndescription: installed-cookbook-manifest\ntypes:\n  - note\n",
430        )
431        .unwrap();
432        std::fs::write(
433            schema_dir.join("types").join("note.yaml"),
434            "name: note\ndescription: test\n",
435        )
436        .unwrap();
437
438        write_spec(&root, "only", "Only");
439
440        let bytes = assemble_archive(&root).expect("installed schema must resolve");
441        let limits = ValidatorLimits::default();
442        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
443
444        // The embedded schema is the *installed* package, not a builtin.
445        let manifest = entries
446            .schema_files
447            .iter()
448            .find(|s| s.archive_path == ".memstead/schema/schema.yaml")
449            .expect("manifest must embed");
450        assert!(
451            manifest.content.contains("installed-cookbook-manifest"),
452            "embedded manifest must come from .memstead/schemas/cookbook@0.1.0"
453        );
454        assert!(
455            entries
456                .schema_files
457                .iter()
458                .any(|s| s.archive_path == ".memstead/schema/types/note.yaml"),
459            "installed type definitions must embed too"
460        );
461    }
462
463    #[test]
464    fn assemble_archive_rejects_workspace_without_version() {
465        let tmp = TempDir::new().unwrap();
466        // Skip `version` on the workspace config — `to_published`
467        // surfaces `MissingVersion`.
468        let root = write_workspace(&tmp, "demo", false);
469
470        let err = assemble_archive(&root).expect_err("missing version must fail");
471        assert!(matches!(
472            err,
473            AssembleError::Config(PublishConversionError::MissingVersion)
474        ));
475    }
476
477    #[test]
478    fn assemble_archive_excludes_engine_internal_dirs() {
479        // The walker already skips `.git/` and `.memstead/`; this is the
480        // contract test that the publish path inherits that behaviour. A
481        // stray markdown file inside the meta dir must NOT land in the
482        // archive's markdown set.
483        let tmp = TempDir::new().unwrap();
484        let root = write_workspace(&tmp, "demo", true);
485        std::fs::write(
486            root.join(".memstead").join("rogue.md"),
487            "---\ntype: spec\n---\n# Rogue\n\n## Identity\n\nNo.\n",
488        )
489        .unwrap();
490
491        write_spec(&root, "visible", "Visible");
492
493        let bytes = assemble_archive(&root).unwrap();
494        let limits = ValidatorLimits::default();
495        let entries = extract_entries(&bytes, &limits).unwrap();
496        let md_paths: Vec<_> = entries
497            .markdown_files
498            .iter()
499            .map(|m| m.path.as_str())
500            .collect();
501        assert!(md_paths.contains(&"visible.md"));
502        assert!(!md_paths.iter().any(|p| p.contains("rogue")));
503    }
504
505    #[test]
506    fn assemble_archive_is_deterministic_across_calls() {
507        let tmp = TempDir::new().unwrap();
508        let root = write_workspace(&tmp, "demo", true);
509        for (slug, title) in [("a", "A"), ("b", "B"), ("c", "C")] {
510            write_spec(&root, slug, title);
511        }
512
513        let bytes1 = assemble_archive(&root).unwrap();
514        let bytes2 = assemble_archive(&root).unwrap();
515        assert_eq!(
516            bytes1, bytes2,
517            "two assemble calls on the same workspace must yield byte-identical archives"
518        );
519    }
520}