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 pub anchors_bytes: Option<Vec<u8>>,
143}
144
145#[derive(Debug, thiserror::Error)]
149pub enum ValidationError {
150 #[error("zip error: {0}")]
152 Zip(String),
153 #[error("symlink entry is not allowed: {0}")]
154 Symlink(String),
155 #[error("unknown file type in archive: {0}")]
156 UnknownFile(String),
157 #[error("duplicate entry path: {0}")]
158 DuplicateEntry(String),
159 #[error("path too long ({len} > {limit}): {path}")]
160 PathTooLong {
161 path: String,
162 len: usize,
163 limit: usize,
164 },
165 #[error("path too deep ({depth} > {limit}): {path}")]
166 PathTooDeep {
167 path: String,
168 depth: usize,
169 limit: usize,
170 },
171 #[error("size cap exceeded ({kind:?}): {got} > {limit}")]
172 SizeCapExceeded {
173 kind: SizeCapKind,
174 got: u64,
175 limit: u64,
176 },
177 #[error("invalid UTF-8 at {path} offset {offset}")]
178 Utf8 { path: String, offset: usize },
179
180 #[error("archive is missing .memstead/config.json")]
182 MissingConfig,
183 #[error("invalid anchors sidecar (.memstead/anchors.json): {reason}")]
184 InvalidAnchorsMember { reason: String },
185 #[error("invalid config: {reason}")]
186 InvalidConfig { reason: String },
187 #[error("invalid name: {reason}")]
188 InvalidName { reason: String },
189 #[error("invalid version: {reason}")]
190 InvalidVersion { reason: String },
191 #[error("unsupported format: {got} (expected {expected})")]
192 UnsupportedFormat { got: u32, expected: u32 },
193 #[error("unknown schema '{name}@{version}' — not registered")]
194 UnknownSchema {
195 name: String,
196 version: semver::Version,
197 },
198 #[error("unknown type: {name}")]
199 UnknownType { name: String },
200
201 #[error("embedded schema failed validation: {reason}")]
202 EmbeddedSchemaInvalid { reason: String },
203 #[error(
204 "embedded schema pins '{embedded}' but `.memstead/config.json` declares '{declared}' — archive is inconsistent"
205 )]
206 EmbeddedSchemaMismatch { embedded: String, declared: String },
207
208 #[error("missing frontmatter at {path}")]
210 MissingFrontmatter { path: String },
211 #[error("invalid frontmatter at {path}: {reason}")]
212 InvalidFrontmatter { path: String, reason: String },
213 #[error("unknown frontmatter key at {path}: {key}")]
214 UnknownFrontmatterKey { path: String, key: String },
215 #[error("missing required field at {path}: {field}")]
216 MissingRequiredField { path: String, field: String },
217 #[error("field type mismatch at {path}: {field} (expected {expected})")]
218 FieldTypeMismatch {
219 path: String,
220 field: String,
221 expected: String,
222 },
223 #[error("enum violation at {path}: {field} = {got}")]
224 EnumViolation {
225 path: String,
226 field: String,
227 got: String,
228 },
229 #[error("missing `# Title` at {path}")]
230 MissingTitle { path: String },
231 #[error("missing required section at {path}: {section}")]
232 MissingRequiredSection { path: String, section: String },
233 #[error("unknown section at {path}: {section}")]
234 UnknownSection { path: String, section: String },
235 #[error("malformed relationship line at {path}: {line}")]
236 InvalidRelationshipLine { path: String, line: String },
237 #[error("invalid relationship type at {path}: {rel_type}")]
238 InvalidRelationshipType { path: String, rel_type: String },
239 #[error("invalid wiki-link at {path}: {link} ({reason})")]
240 InvalidWikiLink {
241 path: String,
242 link: String,
243 reason: String,
244 },
245 #[error("unbalanced brackets at {path}")]
246 UnbalancedBrackets { path: String },
247
248 #[error("duplicate entity id {id} from {} and {}", paths.0, paths.1)]
250 DuplicateEntityId { id: String, paths: (String, String) },
251 #[error("cross-mem relationship at {path}: target {target}")]
252 CrossMemRelationship { path: String, target: String },
253 #[error("graph construction failed: {0}")]
254 GraphConstructionFailed(String),
255 #[error("community detection failed: {0}")]
256 CommunityDetectionFailed(String),
257}
258
259pub fn validate_and_normalize_archive(bytes: &[u8]) -> Result<ValidatedMem, ValidationError> {
268 validate_and_normalize_archive_with_limits(bytes, &ValidatorLimits::DEFAULT)
269}
270
271pub fn validate_and_normalize_archive_with_limits(
272 bytes: &[u8],
273 limits: &ValidatorLimits,
274) -> Result<ValidatedMem, ValidationError> {
275 validate_impl(bytes, limits, true)
278}
279
280pub fn validate_and_normalize_archive_lenient(
290 bytes: &[u8],
291) -> Result<ValidatedMem, ValidationError> {
292 validate_impl(bytes, &ValidatorLimits::DEFAULT, false)
293}
294
295pub fn collect_dangling_cross_mem_edges_from_bytes(
308 bytes: &[u8],
309) -> Result<Vec<graph::DanglingCrossMemEdge>, ValidationError> {
310 let limits = &ValidatorLimits::DEFAULT;
311 let entries = archive::extract_entries(bytes, limits)?;
312 let config = config::parse_config_bytes(&entries.config_bytes)?;
313 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
314 let fallback_schema = graph::resolve_fallback_type(None);
315
316 let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
317 for md in &entries.markdown_files {
318 let raw = md.content.as_str();
319 let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
320 let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
321 let peeked_schema = type_name
322 .as_deref()
323 .and_then(|n| {
324 embedded_schema
325 .as_ref()
326 .and_then(|s| s.get_type(n))
327 .or_else(|| memstead_schema::type_by_name(n))
328 })
329 .unwrap_or_else(|| fallback_schema.clone());
330
331 let parse_result = crate::entity::parser::parse_markdown(
332 raw_stripped,
333 &md.path,
334 &peeked_schema,
335 &config.name,
336 )
337 .map_err(|e| map_parse_error(&md.path, &e))?;
338 parse_results.push(parse_result);
339 }
340
341 Ok(graph::dangling_cross_mem_edges_in(
342 &parse_results,
343 &config.name,
344 ))
345}
346
347fn validate_impl(
348 bytes: &[u8],
349 limits: &ValidatorLimits,
350 cross_mem_as_error: bool,
351) -> Result<ValidatedMem, ValidationError> {
352 let entries = archive::extract_entries(bytes, limits)?;
354
355 let config = config::parse_config_bytes(&entries.config_bytes)?;
357
358 let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
370
371 let fallback_schema = graph::resolve_fallback_type(None);
378
379 let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
387 for md in &entries.markdown_files {
388 let raw = md.content.as_str();
389 let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
390
391 let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
392 let peeked_schema = type_name
393 .as_deref()
394 .and_then(|n| {
395 embedded_schema
396 .as_ref()
397 .and_then(|s| s.get_type(n))
398 .or_else(|| memstead_schema::type_by_name(n))
399 })
400 .unwrap_or_else(|| fallback_schema.clone());
401
402 let parse_result = crate::entity::parser::parse_markdown(
403 raw_stripped,
404 &md.path,
405 &peeked_schema,
406 &config.name,
407 )
408 .map_err(|e| map_parse_error(&md.path, &e))?;
409
410 strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
411
412 parse_results.push(parse_result);
413 }
414
415 let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
419 ids::check_unique_ids(&parsed_entities)?;
420
421 let graph_result = graph::build_and_check(
423 parse_results,
424 &fallback_schema,
425 &config.name,
426 cross_mem_as_error,
427 )?;
428
429 let (entity_count, edge_count) = graph::tally(&graph_result.store);
430
431 let stats = MemStats {
432 entities: entity_count,
433 edges: edge_count,
434 communities: graph_result.communities.count,
435 schema: config.schema.clone(),
436 };
437
438 let entities_for_canonical: Vec<Entity> = graph_result
442 .store
443 .all_entities()
444 .filter(|e| !e.stub)
445 .cloned()
446 .collect();
447 let canonical_bytes = canonical::canonical_bytes(
448 &config,
449 &entities_for_canonical,
450 &entries.schema_files,
451 embedded_schema.as_ref(),
452 entries.provenance_bytes.as_deref(),
453 entries.anchors_bytes.as_deref(),
454 )?;
455
456 Ok(ValidatedMem {
457 config,
458 entities: parsed_entities,
459 store: graph_result.store,
460 communities: graph_result.communities,
461 stats,
462 canonical_bytes,
463 schema_files: entries.schema_files,
464 dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
465 provenance_bytes: entries.provenance_bytes,
466 anchors_bytes: entries.anchors_bytes,
467 })
468}
469
470fn check_embedded_schema(
479 schema_files: &[archive::SchemaFile],
480 config: &PublishedMemConfig,
481) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
482 if schema_files.is_empty() {
483 return Ok(None);
484 }
485 let schema = memstead_schema::load_sealed_package(&archive::to_package_files(schema_files))
489 .map_err(|e| match e {
490 memstead_schema::SchemaLoadError::SealedPackageMissingManifest => {
491 ValidationError::EmbeddedSchemaInvalid {
492 reason:
493 "`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
494 .into(),
495 }
496 }
497 other => ValidationError::EmbeddedSchemaInvalid {
498 reason: other.to_string(),
499 },
500 })?;
501
502 let (embedded_name, embedded_version) = schema.id();
503 if embedded_name != config.schema.name || embedded_version != config.schema.version {
504 return Err(ValidationError::EmbeddedSchemaMismatch {
505 embedded: format!("{embedded_name}@{embedded_version}"),
506 declared: config.schema.as_display(),
507 });
508 }
509 Ok(Some(std::sync::Arc::new(schema)))
510}
511
512fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
513 use crate::entity::parser::ParseError;
514 match e {
515 ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
516 path: path.to_string(),
517 },
518 ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
519 path: path.to_string(),
520 reason: reason.clone(),
521 },
522 ParseError::MissingTitle => ValidationError::MissingTitle {
523 path: path.to_string(),
524 },
525 ParseError::Io(err) => ValidationError::InvalidFrontmatter {
526 path: path.to_string(),
527 reason: err.to_string(),
528 },
529 }
530}