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