feat(analysis): stage bounded JSON codec and iterative expression ownership
This commit is contained in:
@@ -2,6 +2,7 @@ use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
mod json_codec;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
@@ -132,6 +133,19 @@ pub fn decode_limited(
|
||||
pub fn decode_with_report_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report_profile(
|
||||
bytes,
|
||||
limits,
|
||||
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
|
||||
json_codec::DEPTH_128_VALIDATED,
|
||||
)
|
||||
}
|
||||
fn decode_with_report_profile(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if bytes.len() > limits.max_source_bytes {
|
||||
return Err(FileError::Limit(format!(
|
||||
@@ -140,66 +154,43 @@ pub fn decode_with_report_limited(
|
||||
)));
|
||||
}
|
||||
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 6)
|
||||
{
|
||||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||||
json_codec::preflight(bytes, profile)?;
|
||||
let version = json_codec::version(bytes)?;
|
||||
if version.is_some_and(|version| version <= 6) {
|
||||
let header = json_codec::legacy(bytes)?;
|
||||
let result = check_legacy_header(&header, version);
|
||||
json_codec::discard_value(header);
|
||||
result?;
|
||||
}
|
||||
if header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.is_some_and(|v| v <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
match version {
|
||||
Some(1) => {
|
||||
let old = serde_json::from_slice(bytes)?;
|
||||
let old = json_codec::legacy(bytes)?;
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
|
||||
Some(6) => migrate_v6(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
|
||||
Some(7) => migrate_v7(serde_json::from_slice::<v7::Project7>(bytes)?.into_current()?, limits)?,
|
||||
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
|
||||
Some(5) => migrate_v5(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(6) => migrate_v6(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(7) => migrate_v7(
|
||||
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
||||
limits,
|
||||
)?,
|
||||
Some(8) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
project: json_codec::current(bytes, profile, depth_128)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -228,6 +219,38 @@ pub fn decode_with_report_limited(
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
|
||||
reject_source7_fields(header).map_err(FileError::Invalid)?;
|
||||
if version.is_some_and(|version| version <= 5)
|
||||
&& let Some(diagrams) = header
|
||||
.get("diagrams")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||||
for key in diagram.keys() {
|
||||
if key == "view_options" {
|
||||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||||
}
|
||||
if !matches!(
|
||||
key.as_str(),
|
||||
"id" | "alias"
|
||||
| "name"
|
||||
| "description"
|
||||
| "layer"
|
||||
| "projection"
|
||||
| "nodes"
|
||||
| "routes"
|
||||
) {
|
||||
return Err(FileError::Invalid(format!(
|
||||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Versions 4 and 5 share unchanged typed records, but their constructor set
|
||||
/// remains frozen. Apply this before normalizing the advertised version.
|
||||
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
|
||||
@@ -372,48 +395,49 @@ pub(crate) fn migrate_v5(
|
||||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||||
/// This is also applied to source's resolved structured values.
|
||||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
let mut pending = vec![value];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
serde_json::Value::Object(fields) => {
|
||||
if fields.contains_key("ContextualLink") {
|
||||
return Err("ContextualLink requires schema/source 7.".into());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str)
|
||||
== Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
pending.extend(fields.values().rev());
|
||||
}
|
||||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||||
{
|
||||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||||
}
|
||||
if fields.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"Operation"
|
||||
| "ContextOwner"
|
||||
| "ContextSource"
|
||||
| "ContextTarget"
|
||||
| "ContextPurpose"
|
||||
)
|
||||
}) {
|
||||
return Err(
|
||||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||||
);
|
||||
}
|
||||
if fields
|
||||
.get("Exchange")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|body| body.contains_key("operation"))
|
||||
{
|
||||
return Err(
|
||||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||||
);
|
||||
}
|
||||
for nested in fields.values() {
|
||||
reject_source7_fields(nested)?;
|
||||
serde_json::Value::Array(values) => {
|
||||
pending.extend(values.iter().rev());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
serde_json::Value::Array(values) => {
|
||||
for nested in values {
|
||||
reject_source7_fields(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -438,14 +462,23 @@ pub(crate) fn migrate_v7(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<DecodeResult, FileError> {
|
||||
if project.schema_version != 7 { return Err(FileError::Invalid("Expected schema_version 7.".into())); }
|
||||
if project.schema_version != 7 {
|
||||
return Err(FileError::Invalid("Expected schema_version 7.".into()));
|
||||
}
|
||||
reject_pre8_project(&project)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(DecodeResult { project, migration: Some(MigrationReport::data_upgrade(7)) })
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(MigrationReport::data_upgrade(7)),
|
||||
})
|
||||
}
|
||||
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
|
||||
if !project.definitions.is_empty() { return Err(FileError::Invalid("Definitions require schema/source 8.".into())); }
|
||||
if !project.definitions.is_empty() {
|
||||
return Err(FileError::Invalid(
|
||||
"Definitions require schema/source 8.".into(),
|
||||
));
|
||||
}
|
||||
v7::reject_elements(project.elements.values())
|
||||
}
|
||||
|
||||
@@ -866,6 +899,9 @@ pub(crate) mod v1 {
|
||||
}
|
||||
|
||||
fn validate_project(p: &Project) -> Result<(), FileError> {
|
||||
json_codec::document_stack(|| validate_project_inner(p))
|
||||
}
|
||||
fn validate_project_inner(p: &Project) -> Result<(), FileError> {
|
||||
if let Some(i) = validation::validate(p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == validation::Severity::Error)
|
||||
@@ -881,7 +917,10 @@ pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
load_with_report(path).map(|decoded| decoded.project)
|
||||
}
|
||||
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
|
||||
decode_with_report(&std::fs::read(path)?)
|
||||
decode_with_report(&json_codec::read_bounded(
|
||||
path,
|
||||
json_codec::MAX_DOCUMENT_BYTES,
|
||||
)?)
|
||||
}
|
||||
/// Serialize to a sibling temporary file, flush, then atomically replace the destination.
|
||||
pub fn save(path: &Path, p: &Project) -> Result<(), FileError> {
|
||||
@@ -894,6 +933,21 @@ pub fn save_checked(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
save_checked_profile(
|
||||
path,
|
||||
p,
|
||||
expected,
|
||||
json_codec::Profile::DOCUMENT,
|
||||
json_codec::DEPTH_128_VALIDATED,
|
||||
)
|
||||
}
|
||||
fn save_checked_profile(
|
||||
path: &Path,
|
||||
p: &Project,
|
||||
expected: Option<&[u8]>,
|
||||
profile: json_codec::Profile,
|
||||
depth_128: bool,
|
||||
) -> Result<Vec<u8>, FileError> {
|
||||
validate_project(p)?;
|
||||
let mut bytes = if path
|
||||
@@ -902,7 +956,7 @@ pub fn save_checked(
|
||||
{
|
||||
crate::language::format(p).into_bytes()
|
||||
} else {
|
||||
serde_json::to_vec_pretty(p)?
|
||||
json_codec::encode(p, profile, depth_128)?
|
||||
};
|
||||
if !bytes.ends_with(b"\n") {
|
||||
bytes.push(b'\n');
|
||||
@@ -915,8 +969,8 @@ pub fn save_checked(
|
||||
temp.write_all(&bytes)?;
|
||||
temp.as_file().sync_all()?;
|
||||
if let Some(expected) = expected {
|
||||
match std::fs::read(path) {
|
||||
Ok(current) if current == expected => {}
|
||||
match json_codec::matches_file(path, expected) {
|
||||
Ok(true) => {}
|
||||
Ok(_) => return Err(FileError::Conflict),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict),
|
||||
Err(e) => return Err(FileError::Io(e)),
|
||||
|
||||
Reference in New Issue
Block a user