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
297pub 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 let entries = archive::extract_entries(bytes, limits)?;
356
357 let config = config::parse_config_bytes(&entries.config_bytes)?;
359
360 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
372
373 let fallback_schema = graph::resolve_fallback_type(None);
380
381 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 let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
421 ids::check_unique_ids(&parsed_entities)?;
422
423 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 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
472fn 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 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}