memstead_base/ops/export.rs
1//! Mem-archive export.
2//!
3//! The folder-shaped export (the gix-free path) lives here so the
4//! unified `Engine::export_mem` can call it without crossing into
5//! `memstead-git-branch`. The git-branch-shaped variant
6//! (`export_mem_from_branch`) stays in `memstead-git-branch::ops::export`
7//! because it walks a gitdir.
8//!
9//! Output wire shape is deterministic and matches the git-branch
10//! variant byte-for-byte for equivalent input: `.memstead/config.json`
11//! carries the whitelist-projection of the author's `MemConfig`,
12//! `.memstead/schema/` embeds the pinned schema's source files, and the
13//! mem's `.md` blobs land at their mem-relative paths. Entries are
14//! sorted by path and zip-stamped with a fixed mtime so identical
15//! input produces byte-identical archives.
16
17use std::fs;
18use std::io::{Cursor, Write};
19use std::path::{Path, PathBuf};
20
21use memstead_schema::{
22 ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX,
23 ArchiveProvenance, EntityProvenance, MemConfig, PublishConversionError, SchemaSourceError,
24 SchemaSourceFile, collect_schema_source, published_config_from,
25};
26use zip::{CompressionMethod, DateTime, write::SimpleFileOptions};
27
28use crate::entity::EntityId;
29use crate::ops::MemExportResult;
30use crate::provenance::Provenance;
31use crate::validator::canonical::canonical_json;
32
33/// Build the per-entity authoring-provenance payload from a backend's
34/// mutation log (`read_provenance`). Keys by each entity's mem-relative
35/// path ([`EntityId::path`]) so the payload survives a remount under a
36/// different mem name. For each entity, keeps the most recent record
37/// that carries a non-empty note — the entity's *current* rationale.
38///
39/// No-fabrication: records with no entity (batch) or no note are skipped,
40/// so an entity authored without rationale is simply absent from the
41/// payload (the read path reports it absent). Returns `None` when no
42/// entity carried a note — the export then ships no provenance member,
43/// distinct from an empty payload.
44pub fn build_archive_provenance(records: &[Provenance]) -> Option<ArchiveProvenance> {
45 build_redacted_archive_provenance(records).0
46}
47
48/// The builder proper: every rationale passes through the private-pattern
49/// redaction (`ops::redaction`, the leak scan's classes) before it is
50/// summarised, so an archive never carries a span the public tree refuses;
51/// the per-class counts of the rationales that ship travel to the export
52/// result (a superseded note's redactions never reach the archive and are
53/// not counted).
54pub fn build_redacted_archive_provenance(
55 records: &[Provenance],
56) -> (
57 Option<ArchiveProvenance>,
58 Vec<crate::ops::redaction::RedactionCount>,
59) {
60 use std::collections::BTreeMap;
61 use std::time::SystemTime;
62
63 type Counted = (EntityProvenance, BTreeMap<&'static str, usize>);
64 let mut by_path: BTreeMap<String, (SystemTime, Counted)> = BTreeMap::new();
65 for r in records {
66 let Some(entity) = r.entity.as_deref() else {
67 continue;
68 };
69 let Some(note) = r.note.as_deref().map(str::trim).filter(|n| !n.is_empty()) else {
70 continue;
71 };
72 let path = EntityId(entity.to_string()).path().to_string();
73 if path.is_empty() {
74 continue;
75 }
76 let (note, counts) = crate::ops::redaction::redact(note);
77 let candidate = EntityProvenance {
78 rationale: Some(note),
79 kind: Some(r.kind.as_str().to_string()),
80 timestamp: Some(crate::filesystem::changelog::format_rfc3339_utc(
81 r.timestamp,
82 )),
83 actor: Some(r.actor.as_trailer().to_string()),
84 };
85 match by_path.get(&path) {
86 // Keep the existing entry when it is at least as recent.
87 Some((ts, _)) if *ts >= r.timestamp => {}
88 _ => {
89 by_path.insert(path, (r.timestamp, (candidate, counts)));
90 }
91 }
92 }
93 if by_path.is_empty() {
94 return (None, Vec::new());
95 }
96 let mut redacted_total: BTreeMap<&'static str, usize> = BTreeMap::new();
97 let mut entities = BTreeMap::new();
98 for (k, (_, (v, counts))) in by_path {
99 crate::ops::redaction::tally(&mut redacted_total, counts);
100 entities.insert(k, v);
101 }
102 (
103 Some(ArchiveProvenance::summarised(entities)),
104 crate::ops::redaction::counts_to_list(&redacted_total),
105 )
106}
107
108/// Byte-shaped output of [`export_mem_to_bytes`]. Bundles the
109/// produced archive bytes with the same metadata
110/// [`MemExportResult`] reports for path-based exports.
111#[derive(Debug, Clone)]
112pub struct MemExportBytes {
113 /// The `.mem` archive bytes — self-contained, ready to validate
114 /// via `extract_entries` and hydrate via `Engine::from_archive_bytes`.
115 pub bytes: Vec<u8>,
116 /// Mem name (mirrors `MemExportResult.name`).
117 pub name: String,
118 /// Mem version (mirrors `MemExportResult.version`).
119 pub version: String,
120 /// `.md` entity count in the produced archive.
121 pub entity_count: usize,
122 /// Per-class private-pattern redactions in the provenance member
123 /// (mirrors `MemExportResult.redactions`); empty when none.
124 pub redactions: Vec<crate::ops::redaction::RedactionCount>,
125 /// Cross-mem edges whose target won't travel inside this archive —
126 /// `install` will reject each. Mirrors
127 /// `MemExportResult.dangling_cross_mem_edges`; empty for a
128 /// self-contained export.
129 pub dangling_cross_mem_edges: Vec<crate::validator::DanglingCrossMemEdge>,
130}
131
132#[derive(Debug, thiserror::Error)]
133pub enum MemExportError {
134 #[error("mem directory not found: {0}")]
135 DirNotFound(String),
136 #[error(transparent)]
137 Convert(#[from] PublishConversionError),
138 #[error("io error: {0}")]
139 Io(#[from] std::io::Error),
140 #[error("zip error: {0}")]
141 Zip(#[from] zip::result::ZipError),
142 #[error("config serialization error: {0}")]
143 Canonical(String),
144 #[error(transparent)]
145 SchemaSource(#[from] SchemaSourceError),
146 #[error("branch read error: {0}")]
147 BranchRead(String),
148 /// The
149 /// produced archive failed strict validation. Reaches this state
150 /// when the mem carries on-disk drift from a pre-fix engine —
151 /// entities created when `MISSING_REQUIRED_SECTION` was a
152 /// warning, hand-edited markdown, or archive-imports from
153 /// non-canonical sources. Export and install share one strict
154 /// validator pass; the trust boundary now fires at export rather
155 /// than letting an invalid archive land on disk and refuse only
156 /// at the next install attempt.
157 #[error("export archive failed strict validation: {0}")]
158 ArchiveValidationFailed(String),
159}
160
161/// Export a mem directory as a portable `.mem` archive.
162///
163/// The archive contains `.memstead/config.json` (a **whitelist projection**
164/// of the author's `MemConfig` — author-only fields like
165/// `writeGuidance`, `mediums`, `projections`, `readMems` never enter
166/// the archive), every `.md` file under the mem root, and the pinned
167/// schema's source YAML under `schema/`.
168///
169/// Output is deterministic — entries are sorted by path and written
170/// with a fixed modification time — so identical input produces
171/// byte-identical archives, and archives produced here round-trip
172/// through `validate_and_normalize_archive` without rewriting.
173pub fn export_mem(
174 mem_dir: &Path,
175 config: &MemConfig,
176 output_path: &Path,
177 workspace_root: Option<&Path>,
178 workspace_schemas_dir: Option<&Path>,
179 ref_schema_source: Option<Vec<SchemaSourceFile>>,
180) -> Result<MemExportResult, MemExportError> {
181 let basename = mem_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
182 let explicit_name = config.name.as_deref().unwrap_or(basename);
183 let out = export_mem_to_bytes(
184 mem_dir,
185 config,
186 workspace_root,
187 workspace_schemas_dir,
188 explicit_name,
189 ref_schema_source,
190 )?;
191
192 if let Some(parent) = output_path.parent()
193 && !parent.as_os_str().is_empty()
194 {
195 fs::create_dir_all(parent)?;
196 }
197 fs::write(output_path, &out.bytes)?;
198
199 let size_bytes = fs::metadata(output_path)?.len();
200
201 Ok(MemExportResult {
202 unterminated_fence_entities: Vec::new(),
203 archive_path: output_path.display().to_string(),
204 name: out.name,
205 version: out.version,
206 entity_count: out.entity_count,
207 size_bytes,
208 dangling_cross_mem_edges: out.dangling_cross_mem_edges,
209 redactions: out.redactions,
210 })
211}
212
213/// Produce a portable `.mem` archive **as bytes** for a folder-backed
214/// mem. Same wire format as [`export_mem`] — same whitelist
215/// projection, same embedded schema source, same deterministic sort
216/// order and fixed mtime — but the output stays in memory so the
217/// bridge / WASM consumers can return it directly over HTTP.
218///
219/// `explicit_name` is the mem name the publish whitelist receives.
220/// Callers reaching this through [`crate::Engine::export_mem_to_bytes`]
221/// pass the mount's mem name; callers reaching it directly choose
222/// the disk basename or a config-supplied alias.
223///
224/// `ref_schema_source`: pre-collected schema source files from the
225/// workspace's `__MEMSTEAD:schemas/` ref (git-branch schema store).
226/// `Some` takes precedence over the disk/builtin chain — the same
227/// precedence the git-branch export path applies — so a folder mem in
228/// a mem-repo workspace seals the schema the loader resolved. `None`
229/// keeps the historical disk/builtin chain unchanged.
230pub fn export_mem_to_bytes(
231 mem_dir: &Path,
232 config: &MemConfig,
233 workspace_root: Option<&Path>,
234 workspace_schemas_dir: Option<&Path>,
235 explicit_name: &str,
236 ref_schema_source: Option<Vec<SchemaSourceFile>>,
237) -> Result<MemExportBytes, MemExportError> {
238 if !mem_dir.is_dir() {
239 return Err(MemExportError::DirNotFound(mem_dir.display().to_string()));
240 }
241
242 let mut md_files = Vec::new();
243 collect_markdown(mem_dir, &mut md_files)?;
244
245 let mut md_entries: Vec<(PathBuf, Vec<u8>)> = Vec::with_capacity(md_files.len());
246 for abs in &md_files {
247 let rel = abs
248 .strip_prefix(mem_dir)
249 .expect("markdown file must live under mem_dir");
250 md_entries.push((rel.to_path_buf(), fs::read(abs)?));
251 }
252
253 // Source the per-entity authoring provenance from the folder mem's
254 // own mutation log (`.memstead/changes.jsonl`), read through the folder
255 // backend so the JSONL parsing has one home. A mem with no changelog
256 // yields no records → no provenance member (absent, not empty).
257 use crate::backend::MemBackend;
258 let backend = crate::storage::FilesystemMemWriter::new(mem_dir.to_path_buf());
259 let (provenance, redactions) = backend
260 .read_provenance(None)
261 .ok()
262 .map(|records| build_redacted_archive_provenance(&records))
263 .unwrap_or((None, Vec::new()));
264
265 // Source the engine-owned anchors sidecar (`.memstead/anchors.json`)
266 // through the same backend so it travels inside the `.mem` archive.
267 // Absent (a mem with no anchors) → no member, distinct from an empty
268 // sidecar. The recognised-member set + canonical re-pack already accept
269 // and thread it verbatim; export is the producer half of that contract.
270 let anchors_bytes = backend.read_anchors_sidecar().ok().flatten();
271
272 export_entries_to_bytes(
273 config,
274 workspace_root,
275 workspace_schemas_dir,
276 explicit_name,
277 md_entries,
278 provenance.as_ref(),
279 anchors_bytes.as_deref(),
280 ref_schema_source,
281 )
282 .map(|mut r| {
283 r.redactions = redactions;
284 r
285 })
286}
287
288/// Seal already-collected entity bytes into a portable `.mem` archive —
289/// the storage-agnostic core shared by the folder exporter
290/// ([`export_mem_to_bytes`], which walks a directory) and the
291/// in-memory exporter (which lists entities from a
292/// [`crate::backend::MemBackend`] holding them in RAM). Same wire
293/// format either way: whitelist config projection, embedded schema
294/// source, deterministic path-sorted entries, fixed mtime, and the same
295/// pre-write lenient validation pass.
296///
297/// `md_entries` are `(mem-relative path, bytes)` pairs; paths are
298/// posix-normalised for the archive. Entries need not be pre-sorted — the
299/// archive sort makes the output deterministic regardless of input order.
300#[allow(clippy::too_many_arguments)]
301pub fn export_entries_to_bytes(
302 config: &MemConfig,
303 workspace_root: Option<&Path>,
304 workspace_schemas_dir: Option<&Path>,
305 explicit_name: &str,
306 md_entries: Vec<(PathBuf, Vec<u8>)>,
307 provenance: Option<&ArchiveProvenance>,
308 anchors_bytes: Option<&[u8]>,
309 ref_schema_source: Option<Vec<SchemaSourceFile>>,
310) -> Result<MemExportBytes, MemExportError> {
311 let published = published_config_from(config, explicit_name)?;
312 let config_bytes = canonical_json(&published)
313 .map_err(|e| MemExportError::Canonical(e.to_string()))?
314 .into_bytes();
315
316 // The git-branch schema store wins where the caller resolved it —
317 // the same precedence the git-branch export path applies — so a
318 // schema sealed by `memstead schema install` on the `__MEMSTEAD`
319 // ref exports for folder mems too, not only for branch mems.
320 let schema_files = match ref_schema_source {
321 Some(files) => files,
322 None => collect_schema_source(workspace_root, workspace_schemas_dir, &published.schema)?,
323 };
324
325 let entity_count = md_entries.len();
326 let mut all_entries: Vec<(String, Vec<u8>)> =
327 Vec::with_capacity(2 + schema_files.len() + md_entries.len());
328 all_entries.push((ARCHIVE_CONFIG_PATH.to_string(), config_bytes));
329 // Embed the authoring-provenance payload when present. Serialised
330 // canonically; the validator tolerates it as a recognised meta member
331 // and the consumer reads it back via `read_archive_provenance`.
332 if let Some(prov) = provenance
333 && let Ok(bytes) = prov.to_archive_bytes()
334 {
335 all_entries.push((ARCHIVE_PROVENANCE_PATH.to_string(), bytes));
336 }
337 // Embed the engine-owned anchors sidecar verbatim when the mem carries
338 // one. A recognised `.memstead/` member: the archive validator strictly
339 // validates it and the canonical re-pack threads it through unchanged.
340 if let Some(anchors) = anchors_bytes {
341 all_entries.push((ARCHIVE_ANCHORS_PATH.to_string(), anchors.to_vec()));
342 }
343 for sf in &schema_files {
344 all_entries.push((
345 format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path),
346 sf.bytes.clone(),
347 ));
348 }
349 for (rel, bytes) in md_entries {
350 all_entries.push((posix_path(&rel), bytes));
351 }
352 all_entries.sort_by(|a, b| a.0.cmp(&b.0));
353
354 let mut buf: Vec<u8> = Vec::new();
355 {
356 let cursor = Cursor::new(&mut buf);
357 let mut zip = zip::ZipWriter::new(cursor);
358 let options = SimpleFileOptions::default()
359 .compression_method(CompressionMethod::Deflated)
360 .last_modified_time(fixed_mtime())
361 .unix_permissions(0o644);
362
363 for (archive_path, bytes) in &all_entries {
364 zip.start_file(archive_path, options)?;
365 zip.write_all(bytes)?;
366 }
367 zip.finish()?;
368 }
369
370 // Strict
371 // archive validation runs against the in-memory bytes before they
372 // leave this function. Export and install share one validator
373 // pass — the trust boundary fires at export rather than letting an
374 // invalid archive land on disk and refuse only at the next install
375 // attempt. If the produced archive doesn't validate (legacy
376 // on-disk drift, hand-edited markdown, archive-imports from
377 // non-canonical sources), surface the typed refusal; the
378 // disk-shaped wrapper (`export_mem`) never writes a broken
379 // archive because validation happens here, pre-write.
380 //
381 // The *lenient* variant collects cross-mem edges (whose target
382 // won't travel inside this single-mem archive) instead of
383 // refusing on them — export warns and still produces, where install
384 // refuses. Every other strict check still refuses, so a
385 // genuinely-broken archive never lands.
386 let validated = crate::validator::validate_and_normalize_archive_lenient(&buf)
387 .map_err(|e| MemExportError::ArchiveValidationFailed(e.to_string()))?;
388
389 Ok(MemExportBytes {
390 bytes: buf,
391 name: published.name.clone(),
392 version: published.version.to_string(),
393 entity_count,
394 dangling_cross_mem_edges: validated.dangling_cross_mem_edges,
395 redactions: Vec::new(),
396 })
397}
398
399/// Zip's minimum representable timestamp — 1980-01-01 00:00:00. Used
400/// as a fixed mtime so archives are byte-stable across exports.
401fn fixed_mtime() -> DateTime {
402 DateTime::default()
403}
404
405fn posix_path(path: &Path) -> String {
406 path.components()
407 .filter_map(|c| c.as_os_str().to_str())
408 .collect::<Vec<_>>()
409 .join("/")
410}
411
412/// Recursively collect `.md` files. Skips hidden directories and
413/// `README.md` (same policy as the entity loaders — a folder mem living
414/// visibly in a repository tree carries a human-facing README beside its
415/// entity files, and what load skips, export must skip too).
416fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
417 let mut children: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
418 children.sort_by_key(|e| e.file_name());
419
420 for entry in children {
421 let path = entry.path();
422 let name = entry.file_name();
423 let name = name.to_string_lossy();
424
425 if path.is_dir() {
426 if name.starts_with('.') {
427 continue;
428 }
429 collect_markdown(&path, out)?;
430 } else if name.ends_with(".md") && name.as_ref() != "README.md" {
431 out.push(path);
432 }
433 }
434 Ok(())
435}