1use 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#[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
59pub(crate) enum BoundedZipRead {
61 Within(Vec<u8>),
62 ExceedsCap,
66}
67
68pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum SizeCapKind {
90 CompressedArchive,
91 UncompressedArchive,
92 UncompressedEntry,
93 ConfigFile,
94 EntryCount,
95}
96
97#[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#[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 pub schema_files: Vec<archive::SchemaFile>,
124 pub dangling_cross_mem_edges: Vec<graph::DanglingCrossMemEdge>,
132 pub provenance_bytes: Option<Vec<u8>>,
137}
138
139#[derive(Debug, thiserror::Error)]
143pub enum ValidationError {
144 #[error("zip error: {0}")]
146 Zip(String),
147 #[error("symlink entry is not allowed: {0}")]
148 Symlink(String),
149 #[error("unknown file type in archive: {0}")]
150 UnknownFile(String),
151 #[error("duplicate entry path: {0}")]
152 DuplicateEntry(String),
153 #[error("path too long ({len} > {limit}): {path}")]
154 PathTooLong {
155 path: String,
156 len: usize,
157 limit: usize,
158 },
159 #[error("path too deep ({depth} > {limit}): {path}")]
160 PathTooDeep {
161 path: String,
162 depth: usize,
163 limit: usize,
164 },
165 #[error("size cap exceeded ({kind:?}): {got} > {limit}")]
166 SizeCapExceeded {
167 kind: SizeCapKind,
168 got: u64,
169 limit: u64,
170 },
171 #[error("invalid UTF-8 at {path} offset {offset}")]
172 Utf8 { path: String, offset: usize },
173
174 #[error("archive is missing .memstead/config.json")]
176 MissingConfig,
177 #[error("invalid config: {reason}")]
178 InvalidConfig { reason: String },
179 #[error("invalid name: {reason}")]
180 InvalidName { reason: String },
181 #[error("invalid version: {reason}")]
182 InvalidVersion { reason: String },
183 #[error("unsupported format: {got} (expected {expected})")]
184 UnsupportedFormat { got: u32, expected: u32 },
185 #[error("unknown schema '{name}@{version}' — not registered")]
186 UnknownSchema {
187 name: String,
188 version: semver::Version,
189 },
190 #[error("unknown type: {name}")]
191 UnknownType { name: String },
192
193 #[error("embedded schema failed validation: {reason}")]
194 EmbeddedSchemaInvalid { reason: String },
195 #[error(
196 "embedded schema pins '{embedded}' but `.memstead/config.json` declares '{declared}' — archive is inconsistent"
197 )]
198 EmbeddedSchemaMismatch { embedded: String, declared: String },
199
200 #[error("missing frontmatter at {path}")]
202 MissingFrontmatter { path: String },
203 #[error("invalid frontmatter at {path}: {reason}")]
204 InvalidFrontmatter { path: String, reason: String },
205 #[error("unknown frontmatter key at {path}: {key}")]
206 UnknownFrontmatterKey { path: String, key: String },
207 #[error("missing required field at {path}: {field}")]
208 MissingRequiredField { path: String, field: String },
209 #[error("field type mismatch at {path}: {field} (expected {expected})")]
210 FieldTypeMismatch {
211 path: String,
212 field: String,
213 expected: String,
214 },
215 #[error("enum violation at {path}: {field} = {got}")]
216 EnumViolation {
217 path: String,
218 field: String,
219 got: String,
220 },
221 #[error("missing `# Title` at {path}")]
222 MissingTitle { path: String },
223 #[error("missing required section at {path}: {section}")]
224 MissingRequiredSection { path: String, section: String },
225 #[error("unknown section at {path}: {section}")]
226 UnknownSection { path: String, section: String },
227 #[error("malformed relationship line at {path}: {line}")]
228 InvalidRelationshipLine { path: String, line: String },
229 #[error("invalid relationship type at {path}: {rel_type}")]
230 InvalidRelationshipType { path: String, rel_type: String },
231 #[error("invalid wiki-link at {path}: {link} ({reason})")]
232 InvalidWikiLink {
233 path: String,
234 link: String,
235 reason: String,
236 },
237 #[error("unbalanced brackets at {path}")]
238 UnbalancedBrackets { path: String },
239
240 #[error("duplicate entity id {id} from {} and {}", paths.0, paths.1)]
242 DuplicateEntityId { id: String, paths: (String, String) },
243 #[error("cross-mem relationship at {path}: target {target}")]
244 CrossMemRelationship { path: String, target: String },
245 #[error("graph construction failed: {0}")]
246 GraphConstructionFailed(String),
247 #[error("community detection failed: {0}")]
248 CommunityDetectionFailed(String),
249}
250
251pub fn validate_and_normalize_archive(bytes: &[u8]) -> Result<ValidatedMem, ValidationError> {
260 validate_and_normalize_archive_with_limits(bytes, &ValidatorLimits::DEFAULT)
261}
262
263pub fn validate_and_normalize_archive_with_limits(
264 bytes: &[u8],
265 limits: &ValidatorLimits,
266) -> Result<ValidatedMem, ValidationError> {
267 validate_impl(bytes, limits, true)
270}
271
272pub fn validate_and_normalize_archive_lenient(
282 bytes: &[u8],
283) -> Result<ValidatedMem, ValidationError> {
284 validate_impl(bytes, &ValidatorLimits::DEFAULT, false)
285}
286
287pub fn collect_dangling_cross_mem_edges_from_bytes(
300 bytes: &[u8],
301) -> Result<Vec<graph::DanglingCrossMemEdge>, ValidationError> {
302 let limits = &ValidatorLimits::DEFAULT;
303 let entries = archive::extract_entries(bytes, limits)?;
304 let config = config::parse_config_bytes(&entries.config_bytes)?;
305 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
306 let fallback_schema = graph::resolve_fallback_type(None);
307
308 let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
309 for md in &entries.markdown_files {
310 let raw = md.content.as_str();
311 let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
312 let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
313 let peeked_schema = type_name
314 .as_deref()
315 .and_then(|n| {
316 embedded_schema
317 .as_ref()
318 .and_then(|s| s.get_type(n))
319 .or_else(|| memstead_schema::type_by_name(n))
320 })
321 .unwrap_or_else(|| fallback_schema.clone());
322
323 let parse_result = crate::entity::parser::parse_markdown(
324 raw_stripped,
325 &md.path,
326 &peeked_schema,
327 &config.name,
328 )
329 .map_err(|e| map_parse_error(&md.path, &e))?;
330 parse_results.push(parse_result);
331 }
332
333 Ok(graph::dangling_cross_mem_edges_in(
334 &parse_results,
335 &config.name,
336 ))
337}
338
339fn validate_impl(
340 bytes: &[u8],
341 limits: &ValidatorLimits,
342 cross_mem_as_error: bool,
343) -> Result<ValidatedMem, ValidationError> {
344 let entries = archive::extract_entries(bytes, limits)?;
346
347 let config = config::parse_config_bytes(&entries.config_bytes)?;
349
350 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
362
363 let fallback_schema = graph::resolve_fallback_type(None);
370
371 let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
379 for md in &entries.markdown_files {
380 let raw = md.content.as_str();
381 let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
382
383 let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
384 let peeked_schema = type_name
385 .as_deref()
386 .and_then(|n| {
387 embedded_schema
388 .as_ref()
389 .and_then(|s| s.get_type(n))
390 .or_else(|| memstead_schema::type_by_name(n))
391 })
392 .unwrap_or_else(|| fallback_schema.clone());
393
394 let parse_result = crate::entity::parser::parse_markdown(
395 raw_stripped,
396 &md.path,
397 &peeked_schema,
398 &config.name,
399 )
400 .map_err(|e| map_parse_error(&md.path, &e))?;
401
402 strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
403
404 parse_results.push(parse_result);
405 }
406
407 let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
411 ids::check_unique_ids(&parsed_entities)?;
412
413 let graph_result = graph::build_and_check(
415 parse_results,
416 &fallback_schema,
417 &config.name,
418 cross_mem_as_error,
419 )?;
420
421 let (entity_count, edge_count) = graph::tally(&graph_result.store);
422
423 let stats = MemStats {
424 entities: entity_count,
425 edges: edge_count,
426 communities: graph_result.communities.count,
427 schema: config.schema.clone(),
428 };
429
430 let entities_for_canonical: Vec<Entity> = graph_result
434 .store
435 .all_entities()
436 .filter(|e| !e.stub)
437 .cloned()
438 .collect();
439 let canonical_bytes = canonical::canonical_bytes(
440 &config,
441 &entities_for_canonical,
442 &entries.schema_files,
443 embedded_schema.as_ref(),
444 entries.provenance_bytes.as_deref(),
445 )?;
446
447 Ok(ValidatedMem {
448 config,
449 entities: parsed_entities,
450 store: graph_result.store,
451 communities: graph_result.communities,
452 stats,
453 canonical_bytes,
454 schema_files: entries.schema_files,
455 dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
456 provenance_bytes: entries.provenance_bytes,
457 })
458}
459
460fn check_embedded_schema(
469 schema_files: &[archive::SchemaFile],
470 config: &PublishedMemConfig,
471) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
472 if schema_files.is_empty() {
473 return Ok(None);
474 }
475 let mut manifest: Option<&str> = None;
476 let mut types: Vec<(String, String)> = Vec::with_capacity(schema_files.len());
477 for sf in schema_files {
478 if sf.archive_path == ".memstead/schema/schema.yaml" {
479 manifest = Some(sf.content.as_str());
480 } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
481 && let Some(stem) = rest.strip_suffix(".yaml")
482 {
483 types.push((stem.to_string(), sf.content.clone()));
484 }
485 }
486 let Some(manifest_yaml) = manifest else {
487 return Err(ValidationError::EmbeddedSchemaInvalid {
488 reason:
489 "`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
490 .into(),
491 });
492 };
493
494 let schema = memstead_schema::load_schema_from_memory(manifest_yaml, &types).map_err(|e| {
495 ValidationError::EmbeddedSchemaInvalid {
496 reason: e.to_string(),
497 }
498 })?;
499
500 let (embedded_name, embedded_version) = schema.id();
501 if embedded_name != config.schema.name || embedded_version != config.schema.version {
502 return Err(ValidationError::EmbeddedSchemaMismatch {
503 embedded: format!("{embedded_name}@{embedded_version}"),
504 declared: config.schema.as_display(),
505 });
506 }
507 Ok(Some(std::sync::Arc::new(schema)))
508}
509
510fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
511 use crate::entity::parser::ParseError;
512 match e {
513 ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
514 path: path.to_string(),
515 },
516 ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
517 path: path.to_string(),
518 reason: reason.clone(),
519 },
520 ParseError::MissingTitle => ValidationError::MissingTitle {
521 path: path.to_string(),
522 },
523 ParseError::Io(err) => ValidationError::InvalidFrontmatter {
524 path: path.to_string(),
525 reason: err.to_string(),
526 },
527 }
528}