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    /// Raw bytes of the archive's engine-owned anchors sidecar
138    /// (`.memstead/anchors.json`, E3a), or `None` when the archive carries
139    /// none. Structurally validated at extract time and threaded verbatim
140    /// through the canonical re-pack so publish/normalize does not strip
141    /// provenance anchors.
142    pub anchors_bytes: Option<Vec<u8>>,
143}
144
145/// Every reason the validator can reject an archive. Each variant
146/// carries enough context (file path, reason string, size numbers) to
147/// give the caller an actionable error.
148#[derive(Debug, thiserror::Error)]
149pub enum ValidationError {
150    // Archive-level
151    #[error("zip error: {0}")]
152    Zip(String),
153    #[error("symlink entry is not allowed: {0}")]
154    Symlink(String),
155    #[error("unknown file type in archive: {0}")]
156    UnknownFile(String),
157    #[error("duplicate entry path: {0}")]
158    DuplicateEntry(String),
159    #[error("path too long ({len} > {limit}): {path}")]
160    PathTooLong {
161        path: String,
162        len: usize,
163        limit: usize,
164    },
165    #[error("path too deep ({depth} > {limit}): {path}")]
166    PathTooDeep {
167        path: String,
168        depth: usize,
169        limit: usize,
170    },
171    #[error("size cap exceeded ({kind:?}): {got} > {limit}")]
172    SizeCapExceeded {
173        kind: SizeCapKind,
174        got: u64,
175        limit: u64,
176    },
177    #[error("invalid UTF-8 at {path} offset {offset}")]
178    Utf8 { path: String, offset: usize },
179
180    // Config-level
181    #[error("archive is missing .memstead/config.json")]
182    MissingConfig,
183    #[error("invalid anchors sidecar (.memstead/anchors.json): {reason}")]
184    InvalidAnchorsMember { reason: String },
185    #[error("invalid config: {reason}")]
186    InvalidConfig { reason: String },
187    #[error("invalid name: {reason}")]
188    InvalidName { reason: String },
189    #[error("invalid version: {reason}")]
190    InvalidVersion { reason: String },
191    #[error("unsupported format: {got} (expected {expected})")]
192    UnsupportedFormat { got: u32, expected: u32 },
193    #[error("unknown schema '{name}@{version}' — not registered")]
194    UnknownSchema {
195        name: String,
196        version: semver::Version,
197    },
198    #[error("unknown type: {name}")]
199    UnknownType { name: String },
200
201    #[error("embedded schema failed validation: {reason}")]
202    EmbeddedSchemaInvalid { reason: String },
203    #[error(
204        "embedded schema pins '{embedded}' but `.memstead/config.json` declares '{declared}' — archive is inconsistent"
205    )]
206    EmbeddedSchemaMismatch { embedded: String, declared: String },
207
208    // Entity-level
209    #[error("missing frontmatter at {path}")]
210    MissingFrontmatter { path: String },
211    #[error("invalid frontmatter at {path}: {reason}")]
212    InvalidFrontmatter { path: String, reason: String },
213    #[error("unknown frontmatter key at {path}: {key}")]
214    UnknownFrontmatterKey { path: String, key: String },
215    #[error("missing required field at {path}: {field}")]
216    MissingRequiredField { path: String, field: String },
217    #[error("field type mismatch at {path}: {field} (expected {expected})")]
218    FieldTypeMismatch {
219        path: String,
220        field: String,
221        expected: String,
222    },
223    #[error("enum violation at {path}: {field} = {got}")]
224    EnumViolation {
225        path: String,
226        field: String,
227        got: String,
228    },
229    #[error("missing `# Title` at {path}")]
230    MissingTitle { path: String },
231    #[error("missing required section at {path}: {section}")]
232    MissingRequiredSection { path: String, section: String },
233    #[error("unknown section at {path}: {section}")]
234    UnknownSection { path: String, section: String },
235    #[error("malformed relationship line at {path}: {line}")]
236    InvalidRelationshipLine { path: String, line: String },
237    #[error("invalid relationship type at {path}: {rel_type}")]
238    InvalidRelationshipType { path: String, rel_type: String },
239    #[error("invalid wiki-link at {path}: {link} ({reason})")]
240    InvalidWikiLink {
241        path: String,
242        link: String,
243        reason: String,
244    },
245    #[error("unbalanced brackets at {path}")]
246    UnbalancedBrackets { path: String },
247
248    // Cross-archive / graph
249    #[error("duplicate entity id {id} from {} and {}", paths.0, paths.1)]
250    DuplicateEntityId { id: String, paths: (String, String) },
251    #[error("cross-mem relationship at {path}: target {target}")]
252    CrossMemRelationship { path: String, target: String },
253    #[error("graph construction failed: {0}")]
254    GraphConstructionFailed(String),
255    #[error("community detection failed: {0}")]
256    CommunityDetectionFailed(String),
257}
258
259/// The single ingress entry point. Callers (registry publish, CLI
260/// install, MCP read-mem attach, macOS drop-to-install) hand in
261/// bytes and receive either a `ValidatedMem` with canonical bytes
262/// to install, or a typed `ValidationError`.
263///
264/// Runs every check with `ValidatorLimits::DEFAULT`. Use
265/// `validate_and_normalize_archive_with_limits` to supply custom caps
266/// (the registry may allow larger archives; the CLI keeps defaults).
267pub fn validate_and_normalize_archive(bytes: &[u8]) -> Result<ValidatedMem, ValidationError> {
268    validate_and_normalize_archive_with_limits(bytes, &ValidatorLimits::DEFAULT)
269}
270
271pub fn validate_and_normalize_archive_with_limits(
272    bytes: &[u8],
273    limits: &ValidatorLimits,
274) -> Result<ValidatedMem, ValidationError> {
275    // Strict posture: a cross-mem edge refuses the archive — the
276    // install / archive-load contract.
277    validate_impl(bytes, limits, true)
278}
279
280/// Export-side validation: identical strict checks, except a cross-mem
281/// edge whose target won't travel inside this single-mem archive is
282/// **collected** onto [`ValidatedMem::dangling_cross_mem_edges`]
283/// instead of refused. Lets `export` warn
284/// (`DANGLING_CROSS_MEM_EDGE_IN_EXPORT`) and still produce the archive,
285/// while `install` keeps refusing the same edge — one predicate, two
286/// postures. Every other strict
287/// check (schema drift, malformed markdown, …) still refuses, so export
288/// never emits an otherwise-invalid archive.
289pub fn validate_and_normalize_archive_lenient(
290    bytes: &[u8],
291) -> Result<ValidatedMem, ValidationError> {
292    validate_impl(bytes, &ValidatorLimits::DEFAULT, false)
293}
294
295/// Cross-mem-only scan over an archive's bytes: extract + tolerant
296/// parse + the shared cross-mem predicate
297/// ([`graph::dangling_cross_mem_edges_in`]), with **no** strict
298/// section/field validation and no store construction. Returns every
299/// edge whose target won't travel inside this single-mem archive.
300///
301/// This is the lightweight export-side detector for backends that don't
302/// otherwise run the full archive validator (the git-branch export):
303/// it surfaces exactly the edges `install` will refuse on, without
304/// taking on the strict-validation refusal posture (which is a separate,
305/// pre-existing concern). Tolerant parse means section/field drift does
306/// not refuse here — only genuinely-unparseable markdown does.
307pub fn collect_dangling_cross_mem_edges_from_bytes(
308    bytes: &[u8],
309) -> Result<Vec<graph::DanglingCrossMemEdge>, ValidationError> {
310    let limits = &ValidatorLimits::DEFAULT;
311    let entries = archive::extract_entries(bytes, limits)?;
312    let config = config::parse_config_bytes(&entries.config_bytes)?;
313    let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
314    let fallback_schema = graph::resolve_fallback_type(None);
315
316    let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
317    for md in &entries.markdown_files {
318        let raw = md.content.as_str();
319        let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
320        let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
321        let peeked_schema = type_name
322            .as_deref()
323            .and_then(|n| {
324                embedded_schema
325                    .as_ref()
326                    .and_then(|s| s.get_type(n))
327                    .or_else(|| memstead_schema::type_by_name(n))
328            })
329            .unwrap_or_else(|| fallback_schema.clone());
330
331        let parse_result = crate::entity::parser::parse_markdown(
332            raw_stripped,
333            &md.path,
334            &peeked_schema,
335            &config.name,
336        )
337        .map_err(|e| map_parse_error(&md.path, &e))?;
338        parse_results.push(parse_result);
339    }
340
341    Ok(graph::dangling_cross_mem_edges_in(
342        &parse_results,
343        &config.name,
344    ))
345}
346
347fn validate_impl(
348    bytes: &[u8],
349    limits: &ValidatorLimits,
350    cross_mem_as_error: bool,
351) -> Result<ValidatedMem, ValidationError> {
352    // 1. Archive-level: unzip, enforce caps + whitelist, UTF-8 decode.
353    let entries = archive::extract_entries(bytes, limits)?;
354
355    // 2. Config: strict-parse the meta-dir config bytes.
356    let config = config::parse_config_bytes(&entries.config_bytes)?;
357
358    // 2b. Embedded schema integrity. Any `.memstead/schema/` tree in the
359    //     archive must (a) parse via the full loader and (b) declare
360    //     the same `(name, version)` as `config.schema`. An archive
361    //     whose embedded schema doesn't match its declared pin would
362    //     extract schema-a into the cache while loading entities
363    //     against schema-b — exactly the silent corruption the strict
364    //     ingress boundary exists to prevent. The returned schema
365    //     (when an embedded `.memstead/schema/` tree was present) is reused below as the
366    //     authoritative source for per-entity type resolution so a
367    //     user-defined schema's types validate against its own
368    //     metadata rules, not against the builtin `default` fallback.
369    let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
370
371    // 3. Decide the fallback type the Store will use for
372    //    inline-link relationships and edge weights. Every listed
373    //    type in the config has already resolved — graph.rs picks
374    //    the first one; the runtime does the same during bulk-load
375    //    via `engine_fallback_type` (spec), but we prefer the
376    //    author's declared choice when available.
377    let fallback_schema = graph::resolve_fallback_type(None);
378
379    // 4. Per-entity: tolerant-parse + strict-check against raw bytes.
380    //    The same parse_markdown call the runtime uses; strict layer
381    //    catches what the tolerant parser papers over. When the
382    //    archive carries an embedded schema, its type table wins over
383    //    the builtin-default lookup; this lets a `recipe` archive
384    //    validate its `recipe` entities against the `recipe` type
385    //    definition instead of silently falling back to `spec`.
386    let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
387    for md in &entries.markdown_files {
388        let raw = md.content.as_str();
389        let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
390
391        let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
392        let peeked_schema = type_name
393            .as_deref()
394            .and_then(|n| {
395                embedded_schema
396                    .as_ref()
397                    .and_then(|s| s.get_type(n))
398                    .or_else(|| memstead_schema::type_by_name(n))
399            })
400            .unwrap_or_else(|| fallback_schema.clone());
401
402        let parse_result = crate::entity::parser::parse_markdown(
403            raw_stripped,
404            &md.path,
405            &peeked_schema,
406            &config.name,
407        )
408        .map_err(|e| map_parse_error(&md.path, &e))?;
409
410        strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
411
412        parse_results.push(parse_result);
413    }
414
415    // 5. Entity-ID uniqueness (after all files parsed, before store
416    //    construction — a duplicate would silently overwrite in
417    //    upsert).
418    let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
419    ids::check_unique_ids(&parsed_entities)?;
420
421    // 6. Graph: build store, detect communities, cross-mem guard.
422    let graph_result = graph::build_and_check(
423        parse_results,
424        &fallback_schema,
425        &config.name,
426        cross_mem_as_error,
427    )?;
428
429    let (entity_count, edge_count) = graph::tally(&graph_result.store);
430
431    let stats = MemStats {
432        entities: entity_count,
433        edges: edge_count,
434        communities: graph_result.communities.count,
435        schema: config.schema.clone(),
436    };
437
438    // 7. Canonical re-pack: regenerate markdown + canonical JSON,
439    //    propagate schema files verbatim, write sorted zip with fixed
440    //    mtime. Pinned by golden tests.
441    let entities_for_canonical: Vec<Entity> = graph_result
442        .store
443        .all_entities()
444        .filter(|e| !e.stub)
445        .cloned()
446        .collect();
447    let canonical_bytes = canonical::canonical_bytes(
448        &config,
449        &entities_for_canonical,
450        &entries.schema_files,
451        embedded_schema.as_ref(),
452        entries.provenance_bytes.as_deref(),
453        entries.anchors_bytes.as_deref(),
454    )?;
455
456    Ok(ValidatedMem {
457        config,
458        entities: parsed_entities,
459        store: graph_result.store,
460        communities: graph_result.communities,
461        stats,
462        canonical_bytes,
463        schema_files: entries.schema_files,
464        dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
465        provenance_bytes: entries.provenance_bytes,
466        anchors_bytes: entries.anchors_bytes,
467    })
468}
469
470/// Run the embedded schema through the full loader and confirm its
471/// manifest identity matches the config's `schema` pin. Returns the
472/// loaded schema so downstream passes (entity parse/strict/canonical
473/// repack) can resolve user-defined types against it. Empty
474/// `schema_files` yields `Ok(None)` — the Engine's load-side
475/// extraction pass then looks up the pin in the existing registry.
476/// The `format: 3` publish path always embeds under `.memstead/schema/`,
477/// so post-migration archives always hit the integrity branch.
478fn check_embedded_schema(
479    schema_files: &[archive::SchemaFile],
480    config: &PublishedMemConfig,
481) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
482    if schema_files.is_empty() {
483        return Ok(None);
484    }
485    let mut manifest: Option<&str> = None;
486    let mut types: Vec<(String, String)> = Vec::with_capacity(schema_files.len());
487    for sf in schema_files {
488        if sf.archive_path == ".memstead/schema/schema.yaml" {
489            manifest = Some(sf.content.as_str());
490        } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
491            && let Some(stem) = rest.strip_suffix(".yaml")
492        {
493            types.push((stem.to_string(), sf.content.clone()));
494        }
495    }
496    let Some(manifest_yaml) = manifest else {
497        return Err(ValidationError::EmbeddedSchemaInvalid {
498            reason:
499                "`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
500                    .into(),
501        });
502    };
503
504    let marker_path = format!(
505        "{}{}",
506        memstead_schema::ARCHIVE_SCHEMA_PREFIX,
507        memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE
508    );
509    let format = if schema_files.iter().any(|sf| sf.archive_path == marker_path) {
510        memstead_schema::MetadataPolarityFormat::RequiredOptIn
511    } else {
512        memstead_schema::MetadataPolarityFormat::Legacy
513    };
514    let schema =
515        memstead_schema::load_schema_from_memory_with_format(manifest_yaml, &types, format)
516            .map_err(|e| ValidationError::EmbeddedSchemaInvalid {
517                reason: e.to_string(),
518            })?;
519
520    let (embedded_name, embedded_version) = schema.id();
521    if embedded_name != config.schema.name || embedded_version != config.schema.version {
522        return Err(ValidationError::EmbeddedSchemaMismatch {
523            embedded: format!("{embedded_name}@{embedded_version}"),
524            declared: config.schema.as_display(),
525        });
526    }
527    Ok(Some(std::sync::Arc::new(schema)))
528}
529
530fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
531    use crate::entity::parser::ParseError;
532    match e {
533        ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
534            path: path.to_string(),
535        },
536        ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
537            path: path.to_string(),
538            reason: reason.clone(),
539        },
540        ParseError::MissingTitle => ValidationError::MissingTitle {
541            path: path.to_string(),
542        },
543        ParseError::Io(err) => ValidationError::InvalidFrontmatter {
544            path: path.to_string(),
545            reason: err.to_string(),
546        },
547    }
548}