Skip to main content

memstead_base/validator/
mod.rs

1//! Strict ingress validator for sealed `.mem` archives.
2//!
3//! Single trust boundary: every byte that enters any cache passes
4//! through `validate_and_normalize_archive`. Parses the zip, validates
5//! each markdown file against its declared schema over the **raw
6//! bytes** (tolerant-parser fallbacks are the wrong default at
7//! ingress), builds a `Store`, runs community detection, and
8//! canonically re-packs. Fails hard on any violation.
9//!
10//! Pure function: takes `&[u8]`, returns `Result<ValidatedMem,
11//! ValidationError>`, performs no I/O.
12
13use crate::entity::Entity;
14use crate::graph::LouvainOutput;
15use crate::store::Store;
16
17pub mod archive;
18pub mod canonical;
19pub mod config;
20pub mod graph;
21pub mod ids;
22pub mod strict;
23
24pub use graph::DanglingCrossMemEdge;
25pub use memstead_schema::PublishedMemConfig;
26
27/// Numeric limits enforced by archive-level checks. Callers that need
28/// different caps (e.g. enterprise registry) construct a custom
29/// instance; the default matches the published registry limits.
30#[derive(Debug, Clone, Copy)]
31pub struct ValidatorLimits {
32    pub max_compressed_archive: u64,
33    pub max_uncompressed_archive: u64,
34    pub max_uncompressed_entry: u64,
35    pub max_config_file: u64,
36    pub max_file_count: u32,
37    pub max_path_length: usize,
38    pub max_path_depth: usize,
39}
40
41impl ValidatorLimits {
42    pub const DEFAULT: Self = Self {
43        max_compressed_archive: 2 * 1024 * 1024,
44        max_uncompressed_archive: 20 * 1024 * 1024,
45        max_uncompressed_entry: 1024 * 1024,
46        max_config_file: 64 * 1024,
47        max_file_count: 10_000,
48        max_path_length: 512,
49        max_path_depth: 16,
50    };
51}
52
53impl Default for ValidatorLimits {
54    fn default() -> Self {
55        Self::DEFAULT
56    }
57}
58
59/// Outcome of a bounded zip-entry read.
60pub(crate) enum BoundedZipRead {
61    Within(Vec<u8>),
62    /// The entry's decompressed content exceeds the cap. The actual
63    /// size is unknowable without reading it all — which is the attack —
64    /// so only the cap is reported.
65    ExceedsCap,
66}
67
68/// Read a zip entry with a hard cap on decompressed bytes.
69///
70/// Every direct archive read path shares this so decompression is never
71/// sized by an attacker-declared header: the buffer grows only with
72/// bytes actually decompressed, and reading stops at `cap + 1`. The same
73/// idiom `archive::extract_entries` uses for the ingress validator.
74pub(crate) fn read_zip_entry_bounded(
75    reader: &mut impl std::io::Read,
76    cap: u64,
77) -> std::io::Result<BoundedZipRead> {
78    use std::io::Read as _;
79    let mut buf = Vec::new();
80    reader.take(cap + 1).read_to_end(&mut buf)?;
81    if buf.len() as u64 > cap {
82        return Ok(BoundedZipRead::ExceedsCap);
83    }
84    Ok(BoundedZipRead::Within(buf))
85}
86
87/// Which size cap a given `SizeCapExceeded` refers to.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum SizeCapKind {
90    CompressedArchive,
91    UncompressedArchive,
92    UncompressedEntry,
93    ConfigFile,
94    EntryCount,
95}
96
97/// Aggregate statistics reported alongside a successful validation.
98#[derive(Debug, Clone)]
99pub struct MemStats {
100    pub entities: usize,
101    pub edges: usize,
102    pub communities: usize,
103    pub schema: memstead_schema::SchemaRef,
104}
105
106/// The successful output of validation: strict-checked entities, a
107/// built graph, community assignments, and the canonical bytes that
108/// should replace the input in any cache.
109#[derive(Debug)]
110pub struct ValidatedMem {
111    pub config: PublishedMemConfig,
112    pub entities: Vec<Entity>,
113    pub store: Store,
114    pub communities: LouvainOutput,
115    pub stats: MemStats,
116    pub canonical_bytes: Vec<u8>,
117    /// Schema source files found under `.memstead/schema/` in the
118    /// archive.
119    /// Empty only if the archive was never meant to carry a schema
120    /// (callers typically short-circuit before reaching here).
121    /// `format: 2` archives (top-level `schema/` tree) are rejected
122    /// upstream in `check_format` and never materialize here.
123    pub schema_files: Vec<archive::SchemaFile>,
124    /// Relationships whose target lives outside this mem — edges that
125    /// can't resolve inside the single-mem archive. Always empty when
126    /// produced via the strict (`validate_and_normalize_archive`) path:
127    /// that path refuses on the first such edge. Populated only via
128    /// [`validate_and_normalize_archive_lenient`] (the export side),
129    /// which collects them so `export` can warn
130    /// (`DANGLING_CROSS_MEM_EDGE_IN_EXPORT`) rather than refuse.
131    pub dangling_cross_mem_edges: Vec<graph::DanglingCrossMemEdge>,
132    /// Raw bytes of the archive's authoring-provenance payload
133    /// (`.memstead/provenance.json`), or `None` when the archive carries
134    /// none. Surfaced so the registry and other validation consumers can
135    /// expose provenance without re-extracting the archive.
136    pub provenance_bytes: Option<Vec<u8>>,
137}
138
139/// Every reason the validator can reject an archive. Each variant
140/// carries enough context (file path, reason string, size numbers) to
141/// give the caller an actionable error.
142#[derive(Debug, thiserror::Error)]
143pub enum ValidationError {
144    // Archive-level
145    #[error("zip error: {0}")]
146    Zip(String),
147    #[error("symlink entry is not allowed: {0}")]
148    Symlink(String),
149    #[error("unknown file type in archive: {0}")]
150    UnknownFile(String),
151    #[error("duplicate entry path: {0}")]
152    DuplicateEntry(String),
153    #[error("path too long ({len} > {limit}): {path}")]
154    PathTooLong {
155        path: String,
156        len: usize,
157        limit: usize,
158    },
159    #[error("path too deep ({depth} > {limit}): {path}")]
160    PathTooDeep {
161        path: String,
162        depth: usize,
163        limit: usize,
164    },
165    #[error("size cap exceeded ({kind:?}): {got} > {limit}")]
166    SizeCapExceeded {
167        kind: SizeCapKind,
168        got: u64,
169        limit: u64,
170    },
171    #[error("invalid UTF-8 at {path} offset {offset}")]
172    Utf8 { path: String, offset: usize },
173
174    // Config-level
175    #[error("archive is missing .memstead/config.json")]
176    MissingConfig,
177    #[error("invalid config: {reason}")]
178    InvalidConfig { reason: String },
179    #[error("invalid name: {reason}")]
180    InvalidName { reason: String },
181    #[error("invalid version: {reason}")]
182    InvalidVersion { reason: String },
183    #[error("unsupported format: {got} (expected {expected})")]
184    UnsupportedFormat { got: u32, expected: u32 },
185    #[error("unknown schema '{name}@{version}' — not registered")]
186    UnknownSchema {
187        name: String,
188        version: semver::Version,
189    },
190    #[error("unknown type: {name}")]
191    UnknownType { name: String },
192
193    #[error("embedded schema failed validation: {reason}")]
194    EmbeddedSchemaInvalid { reason: String },
195    #[error(
196        "embedded schema pins '{embedded}' but `.memstead/config.json` declares '{declared}' — archive is inconsistent"
197    )]
198    EmbeddedSchemaMismatch { embedded: String, declared: String },
199
200    // Entity-level
201    #[error("missing frontmatter at {path}")]
202    MissingFrontmatter { path: String },
203    #[error("invalid frontmatter at {path}: {reason}")]
204    InvalidFrontmatter { path: String, reason: String },
205    #[error("unknown frontmatter key at {path}: {key}")]
206    UnknownFrontmatterKey { path: String, key: String },
207    #[error("missing required field at {path}: {field}")]
208    MissingRequiredField { path: String, field: String },
209    #[error("field type mismatch at {path}: {field} (expected {expected})")]
210    FieldTypeMismatch {
211        path: String,
212        field: String,
213        expected: String,
214    },
215    #[error("enum violation at {path}: {field} = {got}")]
216    EnumViolation {
217        path: String,
218        field: String,
219        got: String,
220    },
221    #[error("missing `# Title` at {path}")]
222    MissingTitle { path: String },
223    #[error("missing required section at {path}: {section}")]
224    MissingRequiredSection { path: String, section: String },
225    #[error("unknown section at {path}: {section}")]
226    UnknownSection { path: String, section: String },
227    #[error("malformed relationship line at {path}: {line}")]
228    InvalidRelationshipLine { path: String, line: String },
229    #[error("invalid relationship type at {path}: {rel_type}")]
230    InvalidRelationshipType { path: String, rel_type: String },
231    #[error("invalid wiki-link at {path}: {link} ({reason})")]
232    InvalidWikiLink {
233        path: String,
234        link: String,
235        reason: String,
236    },
237    #[error("unbalanced brackets at {path}")]
238    UnbalancedBrackets { path: String },
239
240    // Cross-archive / graph
241    #[error("duplicate entity id {id} from {} and {}", paths.0, paths.1)]
242    DuplicateEntityId { id: String, paths: (String, String) },
243    #[error("cross-mem relationship at {path}: target {target}")]
244    CrossMemRelationship { path: String, target: String },
245    #[error("graph construction failed: {0}")]
246    GraphConstructionFailed(String),
247    #[error("community detection failed: {0}")]
248    CommunityDetectionFailed(String),
249}
250
251/// The single ingress entry point. Callers (registry publish, CLI
252/// install, MCP read-mem attach, macOS drop-to-install) hand in
253/// bytes and receive either a `ValidatedMem` with canonical bytes
254/// to install, or a typed `ValidationError`.
255///
256/// Runs every check with `ValidatorLimits::DEFAULT`. Use
257/// `validate_and_normalize_archive_with_limits` to supply custom caps
258/// (the registry may allow larger archives; the CLI keeps defaults).
259pub fn validate_and_normalize_archive(bytes: &[u8]) -> Result<ValidatedMem, ValidationError> {
260    validate_and_normalize_archive_with_limits(bytes, &ValidatorLimits::DEFAULT)
261}
262
263pub fn validate_and_normalize_archive_with_limits(
264    bytes: &[u8],
265    limits: &ValidatorLimits,
266) -> Result<ValidatedMem, ValidationError> {
267    // Strict posture: a cross-mem edge refuses the archive — the
268    // install / archive-load contract.
269    validate_impl(bytes, limits, true)
270}
271
272/// Export-side validation: identical strict checks, except a cross-mem
273/// edge whose target won't travel inside this single-mem archive is
274/// **collected** onto [`ValidatedMem::dangling_cross_mem_edges`]
275/// instead of refused. Lets `export` warn
276/// (`DANGLING_CROSS_MEM_EDGE_IN_EXPORT`) and still produce the archive,
277/// while `install` keeps refusing the same edge — one predicate, two
278/// postures. Every other strict
279/// check (schema drift, malformed markdown, …) still refuses, so export
280/// never emits an otherwise-invalid archive.
281pub fn validate_and_normalize_archive_lenient(
282    bytes: &[u8],
283) -> Result<ValidatedMem, ValidationError> {
284    validate_impl(bytes, &ValidatorLimits::DEFAULT, false)
285}
286
287/// Cross-mem-only scan over an archive's bytes: extract + tolerant
288/// parse + the shared cross-mem predicate
289/// ([`graph::dangling_cross_mem_edges_in`]), with **no** strict
290/// section/field validation and no store construction. Returns every
291/// edge whose target won't travel inside this single-mem archive.
292///
293/// This is the lightweight export-side detector for backends that don't
294/// otherwise run the full archive validator (the git-branch export):
295/// it surfaces exactly the edges `install` will refuse on, without
296/// taking on the strict-validation refusal posture (which is a separate,
297/// pre-existing concern). Tolerant parse means section/field drift does
298/// not refuse here — only genuinely-unparseable markdown does.
299pub fn collect_dangling_cross_mem_edges_from_bytes(
300    bytes: &[u8],
301) -> Result<Vec<graph::DanglingCrossMemEdge>, ValidationError> {
302    let limits = &ValidatorLimits::DEFAULT;
303    let entries = archive::extract_entries(bytes, limits)?;
304    let config = config::parse_config_bytes(&entries.config_bytes)?;
305    let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
306    let fallback_schema = graph::resolve_fallback_type(None);
307
308    let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
309    for md in &entries.markdown_files {
310        let raw = md.content.as_str();
311        let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
312        let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
313        let peeked_schema = type_name
314            .as_deref()
315            .and_then(|n| {
316                embedded_schema
317                    .as_ref()
318                    .and_then(|s| s.get_type(n))
319                    .or_else(|| memstead_schema::type_by_name(n))
320            })
321            .unwrap_or_else(|| fallback_schema.clone());
322
323        let parse_result = crate::entity::parser::parse_markdown(
324            raw_stripped,
325            &md.path,
326            &peeked_schema,
327            &config.name,
328        )
329        .map_err(|e| map_parse_error(&md.path, &e))?;
330        parse_results.push(parse_result);
331    }
332
333    Ok(graph::dangling_cross_mem_edges_in(
334        &parse_results,
335        &config.name,
336    ))
337}
338
339fn validate_impl(
340    bytes: &[u8],
341    limits: &ValidatorLimits,
342    cross_mem_as_error: bool,
343) -> Result<ValidatedMem, ValidationError> {
344    // 1. Archive-level: unzip, enforce caps + whitelist, UTF-8 decode.
345    let entries = archive::extract_entries(bytes, limits)?;
346
347    // 2. Config: strict-parse the meta-dir config bytes.
348    let config = config::parse_config_bytes(&entries.config_bytes)?;
349
350    // 2b. Embedded schema integrity. Any `.memstead/schema/` tree in the
351    //     archive must (a) parse via the full loader and (b) declare
352    //     the same `(name, version)` as `config.schema`. An archive
353    //     whose embedded schema doesn't match its declared pin would
354    //     extract schema-a into the cache while loading entities
355    //     against schema-b — exactly the silent corruption the strict
356    //     ingress boundary exists to prevent. The returned schema
357    //     (when an embedded `.memstead/schema/` tree was present) is reused below as the
358    //     authoritative source for per-entity type resolution so a
359    //     user-defined schema's types validate against its own
360    //     metadata rules, not against the builtin `default` fallback.
361    let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
362
363    // 3. Decide the fallback type the Store will use for
364    //    inline-link relationships and edge weights. Every listed
365    //    type in the config has already resolved — graph.rs picks
366    //    the first one; the runtime does the same during bulk-load
367    //    via `engine_fallback_type` (spec), but we prefer the
368    //    author's declared choice when available.
369    let fallback_schema = graph::resolve_fallback_type(None);
370
371    // 4. Per-entity: tolerant-parse + strict-check against raw bytes.
372    //    The same parse_markdown call the runtime uses; strict layer
373    //    catches what the tolerant parser papers over. When the
374    //    archive carries an embedded schema, its type table wins over
375    //    the builtin-default lookup; this lets a `recipe` archive
376    //    validate its `recipe` entities against the `recipe` type
377    //    definition instead of silently falling back to `spec`.
378    let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
379    for md in &entries.markdown_files {
380        let raw = md.content.as_str();
381        let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
382
383        let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
384        let peeked_schema = type_name
385            .as_deref()
386            .and_then(|n| {
387                embedded_schema
388                    .as_ref()
389                    .and_then(|s| s.get_type(n))
390                    .or_else(|| memstead_schema::type_by_name(n))
391            })
392            .unwrap_or_else(|| fallback_schema.clone());
393
394        let parse_result = crate::entity::parser::parse_markdown(
395            raw_stripped,
396            &md.path,
397            &peeked_schema,
398            &config.name,
399        )
400        .map_err(|e| map_parse_error(&md.path, &e))?;
401
402        strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
403
404        parse_results.push(parse_result);
405    }
406
407    // 5. Entity-ID uniqueness (after all files parsed, before store
408    //    construction — a duplicate would silently overwrite in
409    //    upsert).
410    let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
411    ids::check_unique_ids(&parsed_entities)?;
412
413    // 6. Graph: build store, detect communities, cross-mem guard.
414    let graph_result = graph::build_and_check(
415        parse_results,
416        &fallback_schema,
417        &config.name,
418        cross_mem_as_error,
419    )?;
420
421    let (entity_count, edge_count) = graph::tally(&graph_result.store);
422
423    let stats = MemStats {
424        entities: entity_count,
425        edges: edge_count,
426        communities: graph_result.communities.count,
427        schema: config.schema.clone(),
428    };
429
430    // 7. Canonical re-pack: regenerate markdown + canonical JSON,
431    //    propagate schema files verbatim, write sorted zip with fixed
432    //    mtime. Pinned by golden tests.
433    let entities_for_canonical: Vec<Entity> = graph_result
434        .store
435        .all_entities()
436        .filter(|e| !e.stub)
437        .cloned()
438        .collect();
439    let canonical_bytes = canonical::canonical_bytes(
440        &config,
441        &entities_for_canonical,
442        &entries.schema_files,
443        embedded_schema.as_ref(),
444        entries.provenance_bytes.as_deref(),
445    )?;
446
447    Ok(ValidatedMem {
448        config,
449        entities: parsed_entities,
450        store: graph_result.store,
451        communities: graph_result.communities,
452        stats,
453        canonical_bytes,
454        schema_files: entries.schema_files,
455        dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
456        provenance_bytes: entries.provenance_bytes,
457    })
458}
459
460/// Run the embedded schema through the full loader and confirm its
461/// manifest identity matches the config's `schema` pin. Returns the
462/// loaded schema so downstream passes (entity parse/strict/canonical
463/// repack) can resolve user-defined types against it. Empty
464/// `schema_files` yields `Ok(None)` — the Engine's load-side
465/// extraction pass then looks up the pin in the existing registry.
466/// The `format: 3` publish path always embeds under `.memstead/schema/`,
467/// so post-migration archives always hit the integrity branch.
468fn check_embedded_schema(
469    schema_files: &[archive::SchemaFile],
470    config: &PublishedMemConfig,
471) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
472    if schema_files.is_empty() {
473        return Ok(None);
474    }
475    let mut manifest: Option<&str> = None;
476    let mut types: Vec<(String, String)> = Vec::with_capacity(schema_files.len());
477    for sf in schema_files {
478        if sf.archive_path == ".memstead/schema/schema.yaml" {
479            manifest = Some(sf.content.as_str());
480        } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
481            && let Some(stem) = rest.strip_suffix(".yaml")
482        {
483            types.push((stem.to_string(), sf.content.clone()));
484        }
485    }
486    let Some(manifest_yaml) = manifest else {
487        return Err(ValidationError::EmbeddedSchemaInvalid {
488            reason:
489                "`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
490                    .into(),
491        });
492    };
493
494    let schema = memstead_schema::load_schema_from_memory(manifest_yaml, &types).map_err(|e| {
495        ValidationError::EmbeddedSchemaInvalid {
496            reason: e.to_string(),
497        }
498    })?;
499
500    let (embedded_name, embedded_version) = schema.id();
501    if embedded_name != config.schema.name || embedded_version != config.schema.version {
502        return Err(ValidationError::EmbeddedSchemaMismatch {
503            embedded: format!("{embedded_name}@{embedded_version}"),
504            declared: config.schema.as_display(),
505        });
506    }
507    Ok(Some(std::sync::Arc::new(schema)))
508}
509
510fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
511    use crate::entity::parser::ParseError;
512    match e {
513        ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
514            path: path.to_string(),
515        },
516        ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
517            path: path.to_string(),
518            reason: reason.clone(),
519        },
520        ParseError::MissingTitle => ValidationError::MissingTitle {
521            path: path.to_string(),
522        },
523        ParseError::Io(err) => ValidationError::InvalidFrontmatter {
524            path: path.to_string(),
525            reason: err.to_string(),
526        },
527    }
528}