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/// The output of [`make_archive_self_contained`]: the canonical bytes of
298/// an archive that now passes strict validation, and every cross-mem
299/// edge that was dropped to get there.
300#[derive(Debug)]
301pub struct SelfContainedArchive {
302    /// Strictly validated, canonically packed bytes, ready for `install`.
303    pub bytes: Vec<u8>,
304    /// The relationship rows removed: each named the entity that carried
305    /// it and the target in the other mem. Empty when the input was
306    /// already self-contained (then `bytes` is its canonical form).
307    pub dropped: Vec<graph::DanglingCrossMemEdge>,
308}
309
310/// Make an archive self-contained: drop every `## Relationships` row
311/// whose target lives in another mem, re-pack canonically, and prove the
312/// result with the strict validator (the same pass `install` runs).
313///
314/// WHY: `export --format mem` admits cross-mem edges (it warns
315/// `DANGLING_CROSS_MEM_EDGE_IN_EXPORT` and still produces the archive),
316/// while `install` refuses them, so a mem that references its sibling
317/// mems could be exported but never installed anywhere, not even back
318/// into the workspace it came from. The dogfood workspace's retired
319/// `features` mem carried fifty such rows, every one an alias row
320/// synthesised from a body wiki-link, so dropping the rows loses
321/// nothing the body does not still say. Rows that are NOT backed by a
322/// body link are dropped too; the caller reports every dropped edge so
323/// the author can see what the package no longer carries as a typed
324/// edge. Section text (body wiki-links included) is never touched.
325pub fn make_archive_self_contained(bytes: &[u8]) -> Result<SelfContainedArchive, ValidationError> {
326    let lenient = validate_impl(bytes, &ValidatorLimits::DEFAULT, false)?;
327    if lenient.dangling_cross_mem_edges.is_empty() {
328        return Ok(SelfContainedArchive {
329            bytes: lenient.canonical_bytes,
330            dropped: Vec::new(),
331        });
332    }
333    let mem_name = lenient.config.name.clone();
334    let mut dropped = Vec::new();
335    let mut entities = lenient.entities;
336    for entity in &mut entities {
337        let path = entity.file_path.clone();
338        entity.relationships.retain(|rel| {
339            let keep = rel.target.mem() == mem_name;
340            if !keep {
341                dropped.push(graph::DanglingCrossMemEdge {
342                    entity_path: path.clone(),
343                    target_id: rel.target.as_ref().to_string(),
344                    target_mem: rel.target.mem().to_string(),
345                });
346            }
347            keep
348        });
349    }
350    let embedded_schema = check_embedded_schema(&lenient.schema_files, &lenient.config)?;
351    let repacked = canonical::canonical_bytes(
352        &lenient.config,
353        &entities,
354        &lenient.schema_files,
355        embedded_schema.as_ref(),
356        lenient.provenance_bytes.as_deref(),
357        lenient.anchors_bytes.as_deref(),
358    )?;
359    // The strict pass is the proof: what comes out is exactly what
360    // `install` accepts, or this returns the typed refusal.
361    let strict = validate_impl(&repacked, &ValidatorLimits::DEFAULT, true)?;
362    Ok(SelfContainedArchive {
363        bytes: strict.canonical_bytes,
364        dropped,
365    })
366}
367
368/// Cross-mem-only scan over an archive's bytes: extract + tolerant
369/// parse + the shared cross-mem predicate
370/// ([`graph::dangling_cross_mem_edges_in`]), with **no** strict
371/// section/field validation and no store construction. Returns every
372/// edge whose target won't travel inside this single-mem archive.
373///
374/// This is the lightweight export-side detector for backends that don't
375/// otherwise run the full archive validator (the git-branch export):
376/// it surfaces exactly the edges `install` will refuse on, without
377/// taking on the strict-validation refusal posture (which is a separate,
378/// pre-existing concern). Tolerant parse means section/field drift does
379/// not refuse here — only genuinely-unparseable markdown does.
380pub fn collect_dangling_cross_mem_edges_from_bytes(
381    bytes: &[u8],
382) -> Result<Vec<graph::DanglingCrossMemEdge>, ValidationError> {
383    let limits = &ValidatorLimits::DEFAULT;
384    let entries = archive::extract_entries(bytes, limits)?;
385    let config = config::parse_config_bytes(&entries.config_bytes)?;
386    let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
387    let fallback_schema = graph::resolve_fallback_type(None);
388
389    let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
390    for md in &entries.markdown_files {
391        let raw = md.content.as_str();
392        let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
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        parse_results.push(parse_result);
412    }
413
414    Ok(graph::dangling_cross_mem_edges_in(
415        &parse_results,
416        &config.name,
417    ))
418}
419
420fn validate_impl(
421    bytes: &[u8],
422    limits: &ValidatorLimits,
423    cross_mem_as_error: bool,
424) -> Result<ValidatedMem, ValidationError> {
425    // 1. Archive-level: unzip, enforce caps + whitelist, UTF-8 decode.
426    let entries = archive::extract_entries(bytes, limits)?;
427
428    // 2. Config: strict-parse the meta-dir config bytes.
429    let config = config::parse_config_bytes(&entries.config_bytes)?;
430
431    // 2b. Embedded schema integrity. Any `.memstead/schema/` tree in the
432    //     archive must (a) parse via the full loader and (b) declare
433    //     the same `(name, version)` as `config.schema`. An archive
434    //     whose embedded schema doesn't match its declared pin would
435    //     extract schema-a into the cache while loading entities
436    //     against schema-b — exactly the silent corruption the strict
437    //     ingress boundary exists to prevent. The returned schema
438    //     (when an embedded `.memstead/schema/` tree was present) is reused below as the
439    //     authoritative source for per-entity type resolution so a
440    //     user-defined schema's types validate against its own
441    //     metadata rules, not against the builtin `default` fallback.
442    let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
443
444    // 3. Decide the fallback type the Store will use for
445    //    inline-link relationships and edge weights. Every listed
446    //    type in the config has already resolved — graph.rs picks
447    //    the first one; the runtime does the same during bulk-load
448    //    via `engine_fallback_type` (spec), but we prefer the
449    //    author's declared choice when available.
450    let fallback_schema = graph::resolve_fallback_type(None);
451
452    // 4. Per-entity: tolerant-parse + strict-check against raw bytes.
453    //    The same parse_markdown call the runtime uses; strict layer
454    //    catches what the tolerant parser papers over. When the
455    //    archive carries an embedded schema, its type table wins over
456    //    the builtin-default lookup; this lets a `recipe` archive
457    //    validate its `recipe` entities against the `recipe` type
458    //    definition instead of silently falling back to `spec`.
459    let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
460    for md in &entries.markdown_files {
461        let raw = md.content.as_str();
462        let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
463
464        let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
465        let peeked_schema = type_name
466            .as_deref()
467            .and_then(|n| {
468                embedded_schema
469                    .as_ref()
470                    .and_then(|s| s.get_type(n))
471                    .or_else(|| memstead_schema::type_by_name(n))
472            })
473            .unwrap_or_else(|| fallback_schema.clone());
474
475        let parse_result = crate::entity::parser::parse_markdown(
476            raw_stripped,
477            &md.path,
478            &peeked_schema,
479            &config.name,
480        )
481        .map_err(|e| map_parse_error(&md.path, &e))?;
482
483        strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
484
485        parse_results.push(parse_result);
486    }
487
488    // 5. Entity-ID uniqueness (after all files parsed, before store
489    //    construction — a duplicate would silently overwrite in
490    //    upsert).
491    let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
492    ids::check_unique_ids(&parsed_entities)?;
493
494    // 6. Graph: build store, detect communities, cross-mem guard.
495    let graph_result = graph::build_and_check(
496        parse_results,
497        &fallback_schema,
498        &config.name,
499        cross_mem_as_error,
500    )?;
501
502    let (entity_count, edge_count) = graph::tally(&graph_result.store);
503
504    let stats = MemStats {
505        entities: entity_count,
506        edges: edge_count,
507        communities: graph_result.communities.count,
508        schema: config.schema.clone(),
509    };
510
511    // 7. Canonical re-pack: regenerate markdown + canonical JSON,
512    //    propagate schema files verbatim, write sorted zip with fixed
513    //    mtime. Pinned by golden tests.
514    let entities_for_canonical: Vec<Entity> = graph_result
515        .store
516        .all_entities()
517        .filter(|e| !e.stub)
518        .cloned()
519        .collect();
520    let canonical_bytes = canonical::canonical_bytes(
521        &config,
522        &entities_for_canonical,
523        &entries.schema_files,
524        embedded_schema.as_ref(),
525        entries.provenance_bytes.as_deref(),
526        entries.anchors_bytes.as_deref(),
527    )?;
528
529    Ok(ValidatedMem {
530        config,
531        entities: parsed_entities,
532        store: graph_result.store,
533        communities: graph_result.communities,
534        stats,
535        canonical_bytes,
536        schema_files: entries.schema_files,
537        dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
538        provenance_bytes: entries.provenance_bytes,
539        anchors_bytes: entries.anchors_bytes,
540    })
541}
542
543/// Run the embedded schema through the full loader and confirm its
544/// manifest identity matches the config's `schema` pin. Returns the
545/// loaded schema so downstream passes (entity parse/strict/canonical
546/// repack) can resolve user-defined types against it. Empty
547/// `schema_files` yields `Ok(None)` — the Engine's load-side
548/// extraction pass then looks up the pin in the existing registry.
549/// The `format: 3` publish path always embeds under `.memstead/schema/`,
550/// so post-migration archives always hit the integrity branch.
551fn check_embedded_schema(
552    schema_files: &[archive::SchemaFile],
553    config: &PublishedMemConfig,
554) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
555    if schema_files.is_empty() {
556        return Ok(None);
557    }
558    // The SAME reader the install-time staging and the local schema
559    // source use on the way back in — an archive whose schema is
560    // admitted here is an archive whose schema loads there.
561    let schema = memstead_schema::load_sealed_package(&archive::to_package_files(schema_files))
562        .map_err(|e| match e {
563            memstead_schema::SchemaLoadError::SealedPackageMissingManifest => {
564                ValidationError::EmbeddedSchemaInvalid {
565                    reason:
566                        "`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
567                            .into(),
568                }
569            }
570            other => ValidationError::EmbeddedSchemaInvalid {
571                reason: other.to_string(),
572            },
573        })?;
574
575    let (embedded_name, embedded_version) = schema.id();
576    if embedded_name != config.schema.name || embedded_version != config.schema.version {
577        return Err(ValidationError::EmbeddedSchemaMismatch {
578            embedded: format!("{embedded_name}@{embedded_version}"),
579            declared: config.schema.as_display(),
580        });
581    }
582    Ok(Some(std::sync::Arc::new(schema)))
583}
584
585fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
586    use crate::entity::parser::ParseError;
587    match e {
588        ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
589            path: path.to_string(),
590        },
591        ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
592            path: path.to_string(),
593            reason: reason.clone(),
594        },
595        ParseError::MissingTitle => ValidationError::MissingTitle {
596            path: path.to_string(),
597        },
598        ParseError::Io(err) => ValidationError::InvalidFrontmatter {
599            path: path.to_string(),
600            reason: err.to_string(),
601        },
602    }
603}