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 type_name = crate::entity::parser::peek_type_from_frontmatter(raw);
393 let peeked_schema = type_name
394 .as_deref()
395 .and_then(|n| {
396 embedded_schema
397 .as_ref()
398 .and_then(|s| s.get_type(n))
399 .or_else(|| memstead_schema::type_by_name(n))
400 })
401 .unwrap_or_else(|| fallback_schema.clone());
402
403 let parse_result =
404 crate::entity::parser::parse_markdown(raw, &md.path, &peeked_schema, &config.name)
405 .map_err(|e| map_parse_error(&md.path, &e))?;
406 parse_results.push(parse_result);
407 }
408
409 Ok(graph::dangling_cross_mem_edges_in(
410 &parse_results,
411 &config.name,
412 ))
413}
414
415fn validate_impl(
416 bytes: &[u8],
417 limits: &ValidatorLimits,
418 cross_mem_as_error: bool,
419) -> Result<ValidatedMem, ValidationError> {
420 let entries = archive::extract_entries(bytes, limits)?;
422
423 let config = config::parse_config_bytes(&entries.config_bytes)?;
425
426 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
438
439 let fallback_schema = graph::resolve_fallback_type(None);
446
447 let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
455 for md in &entries.markdown_files {
456 let raw = md.content.as_str();
457
458 let type_name = crate::entity::parser::peek_type_from_frontmatter(raw);
459 let peeked_schema = type_name
460 .as_deref()
461 .and_then(|n| {
462 embedded_schema
463 .as_ref()
464 .and_then(|s| s.get_type(n))
465 .or_else(|| memstead_schema::type_by_name(n))
466 })
467 .unwrap_or_else(|| fallback_schema.clone());
468
469 let parse_result =
470 crate::entity::parser::parse_markdown(raw, &md.path, &peeked_schema, &config.name)
471 .map_err(|e| map_parse_error(&md.path, &e))?;
472
473 strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
474
475 parse_results.push(parse_result);
476 }
477
478 let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
482 ids::check_unique_ids(&parsed_entities)?;
483
484 let graph_result = graph::build_and_check(
486 parse_results,
487 &fallback_schema,
488 &config.name,
489 cross_mem_as_error,
490 )?;
491
492 let (entity_count, edge_count) = graph::tally(&graph_result.store);
493
494 let stats = MemStats {
495 entities: entity_count,
496 edges: edge_count,
497 communities: graph_result.communities.count,
498 schema: config.schema.clone(),
499 };
500
501 let entities_for_canonical: Vec<Entity> = graph_result
505 .store
506 .all_entities()
507 .filter(|e| !e.stub)
508 .cloned()
509 .collect();
510 let canonical_bytes = canonical::canonical_bytes(
511 &config,
512 &entities_for_canonical,
513 &entries.schema_files,
514 embedded_schema.as_ref(),
515 entries.provenance_bytes.as_deref(),
516 entries.anchors_bytes.as_deref(),
517 )?;
518
519 Ok(ValidatedMem {
520 config,
521 entities: parsed_entities,
522 store: graph_result.store,
523 communities: graph_result.communities,
524 stats,
525 canonical_bytes,
526 schema_files: entries.schema_files,
527 dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
528 provenance_bytes: entries.provenance_bytes,
529 anchors_bytes: entries.anchors_bytes,
530 })
531}
532
533fn check_embedded_schema(
542 schema_files: &[archive::SchemaFile],
543 config: &PublishedMemConfig,
544) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
545 if schema_files.is_empty() {
546 return Ok(None);
547 }
548 let schema = memstead_schema::load_sealed_package(&archive::to_package_files(schema_files))
552 .map_err(|e| match e {
553 memstead_schema::SchemaLoadError::SealedPackageMissingManifest => {
554 ValidationError::EmbeddedSchemaInvalid {
555 reason:
556 "`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
557 .into(),
558 }
559 }
560 other => ValidationError::EmbeddedSchemaInvalid {
561 reason: other.to_string(),
562 },
563 })?;
564
565 let (embedded_name, embedded_version) = schema.id();
566 if embedded_name != config.schema.name || embedded_version != config.schema.version {
567 return Err(ValidationError::EmbeddedSchemaMismatch {
568 embedded: format!("{embedded_name}@{embedded_version}"),
569 declared: config.schema.as_display(),
570 });
571 }
572 Ok(Some(std::sync::Arc::new(schema)))
573}
574
575fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
576 use crate::entity::parser::ParseError;
577 match e {
578 ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
579 path: path.to_string(),
580 },
581 ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
582 path: path.to_string(),
583 reason: reason.clone(),
584 },
585 ParseError::MissingTitle => ValidationError::MissingTitle {
586 path: path.to_string(),
587 },
588 ParseError::Io(err) => ValidationError::InvalidFrontmatter {
589 path: path.to_string(),
590 reason: err.to_string(),
591 },
592 }
593}