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            cfg.add_dep("anthropic/core".parse().unwrap());
290        } else {
291            cfg.version = None;
292        }
293        write_workspace_config(&root, &cfg).unwrap();
294        root
295    }
296
297    /// Write a minimal valid spec entity directly to disk.
298    /// `assemble_archive` walks the directory itself so the entity
299    /// just needs to exist on disk in canonical markdown form.
300    fn write_spec(root: &Path, slug: &str, title: &str) {
301        std::fs::write(
302            root.join(format!("{slug}.md")),
303            format!("---\ntype: spec\n---\n# {title}\n"),
304        )
305        .unwrap();
306    }
307
308    #[test]
309    fn assemble_archive_round_trips_through_validator() {
310        let tmp = TempDir::new().unwrap();
311        let root = write_workspace(&tmp, "demo", true);
312
313        // Two entities so the archive has a non-empty markdown set.
314        write_spec(&root, "first", "First");
315        write_spec(&root, "second", "Second");
316
317        let bytes = assemble_archive(&root).expect("archive must build");
318        assert!(!bytes.is_empty());
319
320        // Round-trip through the same validator the registry uses.
321        let limits = ValidatorLimits::default();
322        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
323
324        // Config: present and projects to the archive shape (no `deps`).
325        let cfg_text = String::from_utf8_lossy(&entries.config_bytes);
326        assert!(cfg_text.contains("\"name\": \"demo\""));
327        assert!(cfg_text.contains("\"version\": \"0.1.0\""));
328        assert!(!cfg_text.contains("\"deps\""), "deps must drop on publish");
329
330        // Schema: at least the manifest is present.
331        let schema_paths: Vec<_> = entries
332            .schema_files
333            .iter()
334            .map(|s| s.archive_path.as_str())
335            .collect();
336        assert!(schema_paths.contains(&".memstead/schema/schema.yaml"));
337
338        // Entities: both markdown files made it in.
339        let md_paths: Vec<_> = entries
340            .markdown_files
341            .iter()
342            .map(|m| m.path.as_str())
343            .collect();
344        assert!(md_paths.contains(&"first.md"));
345        assert!(md_paths.contains(&"second.md"));
346    }
347
348    /// The engine-agnostic assembler embeds the mem's anchors sidecar —
349    /// closing the gap where a bare `memstead publish` of a folder mem
350    /// silently shipped without the anchors its engine-exported sibling
351    /// carries. A malformed sidecar refuses (never a silent drop); an
352    /// anchorless (empty-entities) sidecar file embeds no member; and
353    /// [`redact_archive_anchors`] over the assembled bytes blanks the
354    /// references while the package keeps validating.
355    #[test]
356    fn assemble_archive_embeds_and_redacts_anchors() {
357        let tmp = TempDir::new().unwrap();
358        let root = write_workspace(&tmp, "demo", true);
359        write_spec(&root, "first", "First");
360        std::fs::write(
361            root.join(".memstead").join("anchors.json"),
362            br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
363        )
364        .unwrap();
365
366        let bytes = assemble_archive(&root).expect("archive must build");
367        let limits = ValidatorLimits::default();
368        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
369        let sidecar_bytes = entries.anchors_bytes.expect("anchors member embedded");
370        assert!(String::from_utf8_lossy(&sidecar_bytes).contains("src/private.rs"));
371
372        // Redaction over the assembled bytes: sentinel in, identity out,
373        // and the package still validates.
374        let redacted = redact_archive_anchors(&bytes).unwrap();
375        let entries = extract_entries(&redacted, &limits).expect("redacted archive validates");
376        let sidecar =
377            crate::anchor::AnchorSidecar::from_bytes(&entries.anchors_bytes.unwrap()).unwrap();
378        assert_eq!(
379            sidecar.get("demo--first")[0].artifact,
380            crate::anchor::REDACTED_ARTIFACT_SENTINEL
381        );
382        assert!(!String::from_utf8_lossy(&redacted).contains("src/private.rs"));
383
384        // An empty-entities sidecar embeds no member.
385        std::fs::write(
386            root.join(".memstead").join("anchors.json"),
387            br#"{"version":1,"entities":{}}"#,
388        )
389        .unwrap();
390        let bytes = assemble_archive(&root).unwrap();
391        assert!(
392            extract_entries(&bytes, &limits)
393                .unwrap()
394                .anchors_bytes
395                .is_none(),
396            "no anchors ⇒ no member"
397        );
398
399        // A malformed sidecar refuses the assembly.
400        std::fs::write(root.join(".memstead").join("anchors.json"), b"{ nope").unwrap();
401        assert!(matches!(
402            assemble_archive(&root),
403            Err(AssembleError::Anchors(_))
404        ));
405    }
406
407    #[test]
408    fn assemble_archive_resolves_installed_workspace_schema() {
409        // Regression: bare `memstead publish` / `memstead export --format
410        // mem` on a folder workspace pinned to an INSTALLED custom schema
411        // used to fail with "schema <ref> not found — candidate paths
412        // tried: []" because the resolver ran built-in-only. The archive
413        // assembler must consult `.memstead/schemas/<name>@<version>/` —
414        // the `memstead schema install` destination.
415        let tmp = TempDir::new().unwrap();
416        let root = tmp.path().join("demo");
417        std::fs::create_dir_all(&root).unwrap();
418        let mut cfg = WorkspaceConfig::new("demo", versioned("cookbook", "0.1.0"));
419        cfg.description = Some("custom-schema mem".into());
420        write_workspace_config(&root, &cfg).unwrap();
421
422        // Install-shaped package dir, as `memstead schema install` writes it.
423        let schema_dir = root
424            .join(".memstead")
425            .join("schemas")
426            .join("cookbook@0.1.0");
427        std::fs::create_dir_all(schema_dir.join("types")).unwrap();
428        std::fs::write(
429            schema_dir.join("schema.yaml"),
430            "name: cookbook\nversion: 0.1.0\ndescription: installed-cookbook-manifest\ntypes:\n  - note\n",
431        )
432        .unwrap();
433        std::fs::write(
434            schema_dir.join("types").join("note.yaml"),
435            "name: note\ndescription: test\n",
436        )
437        .unwrap();
438
439        write_spec(&root, "only", "Only");
440
441        let bytes = assemble_archive(&root).expect("installed schema must resolve");
442        let limits = ValidatorLimits::default();
443        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
444
445        // The embedded schema is the *installed* package, not a builtin.
446        let manifest = entries
447            .schema_files
448            .iter()
449            .find(|s| s.archive_path == ".memstead/schema/schema.yaml")
450            .expect("manifest must embed");
451        assert!(
452            manifest.content.contains("installed-cookbook-manifest"),
453            "embedded manifest must come from .memstead/schemas/cookbook@0.1.0"
454        );
455        assert!(
456            entries
457                .schema_files
458                .iter()
459                .any(|s| s.archive_path == ".memstead/schema/types/note.yaml"),
460            "installed type definitions must embed too"
461        );
462    }
463
464    #[test]
465    fn assemble_archive_rejects_workspace_without_version() {
466        let tmp = TempDir::new().unwrap();
467        // Skip `version` on the workspace config — `to_published`
468        // surfaces `MissingVersion`.
469        let root = write_workspace(&tmp, "demo", false);
470
471        let err = assemble_archive(&root).expect_err("missing version must fail");
472        assert!(matches!(
473            err,
474            AssembleError::Config(PublishConversionError::MissingVersion)
475        ));
476    }
477
478    #[test]
479    fn assemble_archive_excludes_engine_internal_dirs() {
480        // The walker already skips `.git/` and `.memstead/`; this is the
481        // contract test that the publish path inherits that behaviour. A
482        // stray markdown file inside the meta dir must NOT land in the
483        // archive's markdown set.
484        let tmp = TempDir::new().unwrap();
485        let root = write_workspace(&tmp, "demo", true);
486        std::fs::write(
487            root.join(".memstead").join("rogue.md"),
488            "---\ntype: spec\n---\n# Rogue\n\n## Identity\n\nNo.\n",
489        )
490        .unwrap();
491
492        write_spec(&root, "visible", "Visible");
493
494        let bytes = assemble_archive(&root).unwrap();
495        let limits = ValidatorLimits::default();
496        let entries = extract_entries(&bytes, &limits).unwrap();
497        let md_paths: Vec<_> = entries
498            .markdown_files
499            .iter()
500            .map(|m| m.path.as_str())
501            .collect();
502        assert!(md_paths.contains(&"visible.md"));
503        assert!(!md_paths.iter().any(|p| p.contains("rogue")));
504    }
505
506    #[test]
507    fn assemble_archive_is_deterministic_across_calls() {
508        let tmp = TempDir::new().unwrap();
509        let root = write_workspace(&tmp, "demo", true);
510        for (slug, title) in [("a", "A"), ("b", "B"), ("c", "C")] {
511            write_spec(&root, slug, title);
512        }
513
514        let bytes1 = assemble_archive(&root).unwrap();
515        let bytes2 = assemble_archive(&root).unwrap();
516        assert_eq!(
517            bytes1, bytes2,
518            "two assemble calls on the same workspace must yield byte-identical archives"
519        );
520    }
521}