1use 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#[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
61pub(crate) enum BoundedZipRead {
63 Within(Vec<u8>),
64 ExceedsCap,
68}
69
70pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SizeCapKind {
92 CompressedArchive,
93 UncompressedArchive,
94 UncompressedEntry,
95 ConfigFile,
96 EntryCount,
97}
98
99#[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#[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 pub schema_files: Vec<archive::SchemaFile>,
126 pub dangling_cross_mem_edges: Vec<graph::DanglingCrossMemEdge>,
134 pub provenance_bytes: Option<Vec<u8>>,
139 pub anchors_bytes: Option<Vec<u8>>,
145}
146
147#[derive(Debug, thiserror::Error)]
151pub enum ValidationError {
152 #[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 #[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 #[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 #[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
261pub 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 validate_impl(bytes, limits, true)
280}
281
282pub fn validate_and_normalize_archive_lenient(
292 bytes: &[u8],
293) -> Result<ValidatedMem, ValidationError> {
294 validate_impl(bytes, &ValidatorLimits::DEFAULT, false)
295}
296
297#[derive(Debug)]
301pub struct SelfContainedArchive {
302 pub bytes: Vec<u8>,
304 pub dropped: Vec<graph::DanglingCrossMemEdge>,
308}
309
310pub 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 let strict = validate_impl(&repacked, &ValidatorLimits::DEFAULT, true)?;
362 Ok(SelfContainedArchive {
363 bytes: strict.canonical_bytes,
364 dropped,
365 })
366}
367
368pub 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 let entries = archive::extract_entries(bytes, limits)?;
427
428 let config = config::parse_config_bytes(&entries.config_bytes)?;
430
431 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
443
444 let fallback_schema = graph::resolve_fallback_type(None);
451
452 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 let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
492 ids::check_unique_ids(&parsed_entities)?;
493
494 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 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
543fn 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 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}