use crate::entity::Entity;
use crate::graph::LouvainOutput;
use crate::store::Store;
pub mod archive;
pub mod canonical;
pub mod config;
pub mod graph;
pub mod ids;
pub mod strict;
pub use graph::DanglingCrossMemEdge;
pub use memstead_schema::PublishedMemConfig;
#[derive(Debug, Clone, Copy)]
pub struct ValidatorLimits {
pub max_compressed_archive: u64,
pub max_uncompressed_archive: u64,
pub max_uncompressed_entry: u64,
pub max_config_file: u64,
pub max_file_count: u32,
pub max_path_length: usize,
pub max_path_depth: usize,
}
impl ValidatorLimits {
pub const DEFAULT: Self = Self {
max_compressed_archive: 2 * 1024 * 1024,
max_uncompressed_archive: 20 * 1024 * 1024,
max_uncompressed_entry: 1024 * 1024,
max_config_file: 64 * 1024,
max_file_count: 10_000,
max_path_length: 512,
max_path_depth: 16,
};
}
impl Default for ValidatorLimits {
fn default() -> Self {
Self::DEFAULT
}
}
pub(crate) enum BoundedZipRead {
Within(Vec<u8>),
ExceedsCap,
}
pub(crate) fn read_zip_entry_bounded(
reader: &mut impl std::io::Read,
cap: u64,
) -> std::io::Result<BoundedZipRead> {
use std::io::Read as _;
let mut buf = Vec::new();
reader.take(cap + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > cap {
return Ok(BoundedZipRead::ExceedsCap);
}
Ok(BoundedZipRead::Within(buf))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SizeCapKind {
CompressedArchive,
UncompressedArchive,
UncompressedEntry,
ConfigFile,
EntryCount,
}
#[derive(Debug, Clone)]
pub struct MemStats {
pub entities: usize,
pub edges: usize,
pub communities: usize,
pub schema: memstead_schema::SchemaRef,
}
#[derive(Debug)]
pub struct ValidatedMem {
pub config: PublishedMemConfig,
pub entities: Vec<Entity>,
pub store: Store,
pub communities: LouvainOutput,
pub stats: MemStats,
pub canonical_bytes: Vec<u8>,
pub schema_files: Vec<archive::SchemaFile>,
pub dangling_cross_mem_edges: Vec<graph::DanglingCrossMemEdge>,
pub provenance_bytes: Option<Vec<u8>>,
pub anchors_bytes: Option<Vec<u8>>,
}
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("zip error: {0}")]
Zip(String),
#[error("symlink entry is not allowed: {0}")]
Symlink(String),
#[error("unknown file type in archive: {0}")]
UnknownFile(String),
#[error("duplicate entry path: {0}")]
DuplicateEntry(String),
#[error("path too long ({len} > {limit}): {path}")]
PathTooLong {
path: String,
len: usize,
limit: usize,
},
#[error("path too deep ({depth} > {limit}): {path}")]
PathTooDeep {
path: String,
depth: usize,
limit: usize,
},
#[error("size cap exceeded ({kind:?}): {got} > {limit}")]
SizeCapExceeded {
kind: SizeCapKind,
got: u64,
limit: u64,
},
#[error("invalid UTF-8 at {path} offset {offset}")]
Utf8 { path: String, offset: usize },
#[error("archive is missing .memstead/config.json")]
MissingConfig,
#[error("invalid anchors sidecar (.memstead/anchors.json): {reason}")]
InvalidAnchorsMember { reason: String },
#[error("invalid config: {reason}")]
InvalidConfig { reason: String },
#[error("invalid name: {reason}")]
InvalidName { reason: String },
#[error("invalid version: {reason}")]
InvalidVersion { reason: String },
#[error("unsupported format: {got} (expected {expected})")]
UnsupportedFormat { got: u32, expected: u32 },
#[error("unknown schema '{name}@{version}' — not registered")]
UnknownSchema {
name: String,
version: semver::Version,
},
#[error("unknown type: {name}")]
UnknownType { name: String },
#[error("embedded schema failed validation: {reason}")]
EmbeddedSchemaInvalid { reason: String },
#[error(
"embedded schema pins '{embedded}' but `.memstead/config.json` declares '{declared}' — archive is inconsistent"
)]
EmbeddedSchemaMismatch { embedded: String, declared: String },
#[error("missing frontmatter at {path}")]
MissingFrontmatter { path: String },
#[error("invalid frontmatter at {path}: {reason}")]
InvalidFrontmatter { path: String, reason: String },
#[error("unknown frontmatter key at {path}: {key}")]
UnknownFrontmatterKey { path: String, key: String },
#[error("missing required field at {path}: {field}")]
MissingRequiredField { path: String, field: String },
#[error("field type mismatch at {path}: {field} (expected {expected})")]
FieldTypeMismatch {
path: String,
field: String,
expected: String,
},
#[error("enum violation at {path}: {field} = {got}")]
EnumViolation {
path: String,
field: String,
got: String,
},
#[error("missing `# Title` at {path}")]
MissingTitle { path: String },
#[error("missing required section at {path}: {section}")]
MissingRequiredSection { path: String, section: String },
#[error("unknown section at {path}: {section}")]
UnknownSection { path: String, section: String },
#[error("malformed relationship line at {path}: {line}")]
InvalidRelationshipLine { path: String, line: String },
#[error("invalid relationship type at {path}: {rel_type}")]
InvalidRelationshipType { path: String, rel_type: String },
#[error("invalid wiki-link at {path}: {link} ({reason})")]
InvalidWikiLink {
path: String,
link: String,
reason: String,
},
#[error("unbalanced brackets at {path}")]
UnbalancedBrackets { path: String },
#[error("duplicate entity id {id} from {} and {}", paths.0, paths.1)]
DuplicateEntityId { id: String, paths: (String, String) },
#[error("cross-mem relationship at {path}: target {target}")]
CrossMemRelationship { path: String, target: String },
#[error("graph construction failed: {0}")]
GraphConstructionFailed(String),
#[error("community detection failed: {0}")]
CommunityDetectionFailed(String),
}
pub fn validate_and_normalize_archive(bytes: &[u8]) -> Result<ValidatedMem, ValidationError> {
validate_and_normalize_archive_with_limits(bytes, &ValidatorLimits::DEFAULT)
}
pub fn validate_and_normalize_archive_with_limits(
bytes: &[u8],
limits: &ValidatorLimits,
) -> Result<ValidatedMem, ValidationError> {
validate_impl(bytes, limits, true)
}
pub fn validate_and_normalize_archive_lenient(
bytes: &[u8],
) -> Result<ValidatedMem, ValidationError> {
validate_impl(bytes, &ValidatorLimits::DEFAULT, false)
}
pub fn collect_dangling_cross_mem_edges_from_bytes(
bytes: &[u8],
) -> Result<Vec<graph::DanglingCrossMemEdge>, ValidationError> {
let limits = &ValidatorLimits::DEFAULT;
let entries = archive::extract_entries(bytes, limits)?;
let config = config::parse_config_bytes(&entries.config_bytes)?;
let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
let fallback_schema = graph::resolve_fallback_type(None);
let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
for md in &entries.markdown_files {
let raw = md.content.as_str();
let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
let peeked_schema = type_name
.as_deref()
.and_then(|n| {
embedded_schema
.as_ref()
.and_then(|s| s.get_type(n))
.or_else(|| memstead_schema::type_by_name(n))
})
.unwrap_or_else(|| fallback_schema.clone());
let parse_result = crate::entity::parser::parse_markdown(
raw_stripped,
&md.path,
&peeked_schema,
&config.name,
)
.map_err(|e| map_parse_error(&md.path, &e))?;
parse_results.push(parse_result);
}
Ok(graph::dangling_cross_mem_edges_in(
&parse_results,
&config.name,
))
}
fn validate_impl(
bytes: &[u8],
limits: &ValidatorLimits,
cross_mem_as_error: bool,
) -> Result<ValidatedMem, ValidationError> {
let entries = archive::extract_entries(bytes, limits)?;
let config = config::parse_config_bytes(&entries.config_bytes)?;
let embedded_schema = check_embedded_schema(&entries.schema_files, &config)?;
let fallback_schema = graph::resolve_fallback_type(None);
let mut parse_results = Vec::with_capacity(entries.markdown_files.len());
for md in &entries.markdown_files {
let raw = md.content.as_str();
let raw_stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
let type_name = crate::entity::parser::peek_type_from_frontmatter(raw_stripped);
let peeked_schema = type_name
.as_deref()
.and_then(|n| {
embedded_schema
.as_ref()
.and_then(|s| s.get_type(n))
.or_else(|| memstead_schema::type_by_name(n))
})
.unwrap_or_else(|| fallback_schema.clone());
let parse_result = crate::entity::parser::parse_markdown(
raw_stripped,
&md.path,
&peeked_schema,
&config.name,
)
.map_err(|e| map_parse_error(&md.path, &e))?;
strict::validate_strict(raw, &parse_result.entity, &peeked_schema, &md.path)?;
parse_results.push(parse_result);
}
let parsed_entities: Vec<Entity> = parse_results.iter().map(|pr| pr.entity.clone()).collect();
ids::check_unique_ids(&parsed_entities)?;
let graph_result = graph::build_and_check(
parse_results,
&fallback_schema,
&config.name,
cross_mem_as_error,
)?;
let (entity_count, edge_count) = graph::tally(&graph_result.store);
let stats = MemStats {
entities: entity_count,
edges: edge_count,
communities: graph_result.communities.count,
schema: config.schema.clone(),
};
let entities_for_canonical: Vec<Entity> = graph_result
.store
.all_entities()
.filter(|e| !e.stub)
.cloned()
.collect();
let canonical_bytes = canonical::canonical_bytes(
&config,
&entities_for_canonical,
&entries.schema_files,
embedded_schema.as_ref(),
entries.provenance_bytes.as_deref(),
entries.anchors_bytes.as_deref(),
)?;
Ok(ValidatedMem {
config,
entities: parsed_entities,
store: graph_result.store,
communities: graph_result.communities,
stats,
canonical_bytes,
schema_files: entries.schema_files,
dangling_cross_mem_edges: graph_result.dangling_cross_mem_edges,
provenance_bytes: entries.provenance_bytes,
anchors_bytes: entries.anchors_bytes,
})
}
fn check_embedded_schema(
schema_files: &[archive::SchemaFile],
config: &PublishedMemConfig,
) -> Result<Option<std::sync::Arc<memstead_schema::Schema>>, ValidationError> {
if schema_files.is_empty() {
return Ok(None);
}
let schema = memstead_schema::load_sealed_package(&archive::to_package_files(schema_files))
.map_err(|e| match e {
memstead_schema::SchemaLoadError::SealedPackageMissingManifest => {
ValidationError::EmbeddedSchemaInvalid {
reason:
"`.memstead/schema/` tree present but `.memstead/schema/schema.yaml` is missing"
.into(),
}
}
other => ValidationError::EmbeddedSchemaInvalid {
reason: other.to_string(),
},
})?;
let (embedded_name, embedded_version) = schema.id();
if embedded_name != config.schema.name || embedded_version != config.schema.version {
return Err(ValidationError::EmbeddedSchemaMismatch {
embedded: format!("{embedded_name}@{embedded_version}"),
declared: config.schema.as_display(),
});
}
Ok(Some(std::sync::Arc::new(schema)))
}
fn map_parse_error(path: &str, e: &crate::entity::parser::ParseError) -> ValidationError {
use crate::entity::parser::ParseError;
match e {
ParseError::MissingFrontmatter => ValidationError::MissingFrontmatter {
path: path.to_string(),
},
ParseError::InvalidFrontmatter(reason) => ValidationError::InvalidFrontmatter {
path: path.to_string(),
reason: reason.clone(),
},
ParseError::MissingTitle => ValidationError::MissingTitle {
path: path.to_string(),
},
ParseError::Io(err) => ValidationError::InvalidFrontmatter {
path: path.to_string(),
reason: err.to_string(),
},
}
}