use std::sync::OnceLock;
use noyalib::compat::serde_yaml as serde_yml;
use regex::Regex;
use super::CompileError;
use super::store::StoreEntryKind;
use super::trailer::{FORMAT_VERSION, FORMAT_VERSION_V2, TrailerError, TrailerKind};
pub const RUNTIME_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const MANIFEST_SCHEMA: u64 = 2;
pub const MANIFEST_SCHEMA_LEGACY: u64 = 1;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EmbeddedFile {
pub digest: String,
pub kind: StoreEntryKind,
pub length: u64,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Manifest {
pub manifest_schema: u64,
pub source_name: String,
pub runtime_version: String,
pub kind: TrailerKind,
pub components: Vec<String>,
pub env_names: Vec<String>,
pub listeners: Vec<String>,
pub embedded_files: Vec<EmbeddedFile>,
}
impl Manifest {
pub fn to_canonical_json(&self) -> String {
let value = serde_json::json!({
"components": self.components,
"embedded_files": self.embedded_files,
"env_names": self.env_names,
"kind": self.kind.as_str(),
"listeners": self.listeners,
"manifest_schema": self.manifest_schema,
"runtime_version": self.runtime_version,
"source_name": self.source_name,
});
serde_json::to_string(&value).expect("json! of strings and arrays cannot fail") }
pub fn to_legacy_json(&self) -> String {
let value = serde_json::json!({
"components": self.components,
"env_names": self.env_names,
"kind": self.kind.as_str(),
"listeners": self.listeners,
"runtime_version": self.runtime_version,
"source_name": self.source_name,
});
serde_json::to_string(&value).expect("json! of strings and arrays cannot fail") }
pub fn from_canonical_json(bytes: &[u8]) -> Result<Manifest, CompileError> {
let value: serde_json::Value = serde_json::from_slice(bytes)
.map_err(|e| CompileError::InvalidDocument(format!("manifest is not JSON: {e}")))?;
let schema = manifest_schema_of(&value).map_err(|_| {
CompileError::InvalidDocument("manifest schema is not an integer".to_string())
})?;
if schema != MANIFEST_SCHEMA && schema != MANIFEST_SCHEMA_LEGACY {
return Err(CompileError::InvalidDocument(format!(
"unsupported manifest schema {schema}"
)));
}
if schema == MANIFEST_SCHEMA {
check_schema2_fields(&value).map_err(CompileError::InvalidDocument)?;
}
let object = |field: &str| {
value.get(field).and_then(serde_json::Value::as_str).ok_or(
CompileError::InvalidDocument(format!("manifest carries no {field}")),
)
};
let strings = |field: &str| -> Vec<String> {
value
.get(field)
.and_then(serde_json::Value::as_array)
.map(|items| {
items
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
};
let kind = TrailerKind::from_name(object("kind")?).ok_or(CompileError::InvalidDocument(
"manifest carries no recognizable kind".to_string(),
))?;
let embedded_files = match value.get("embedded_files") {
Some(serde_json::Value::Array(items)) => items
.iter()
.map(|item| {
serde_json::from_value(item.clone()).map_err(|e| {
CompileError::InvalidDocument(format!("invalid embedded_files entry: {e}"))
})
})
.collect::<Result<Vec<EmbeddedFile>, CompileError>>()?,
Some(_) => {
return Err(CompileError::InvalidDocument(
"manifest embedded_files is not an array".to_string(),
));
}
None => Vec::new(),
};
Ok(Manifest {
manifest_schema: schema,
source_name: object("source_name")?.to_string(),
runtime_version: object("runtime_version")?.to_string(),
kind,
components: strings("components"),
env_names: strings("env_names"),
listeners: strings("listeners"),
embedded_files,
})
}
}
fn manifest_schema_of(value: &serde_json::Value) -> Result<u64, ()> {
match value.get("manifest_schema") {
None => Ok(MANIFEST_SCHEMA_LEGACY),
Some(v) => v.as_u64().ok_or(()),
}
}
const SCHEMA2_FIELDS: [&str; 8] = [
"components",
"embedded_files",
"env_names",
"kind",
"listeners",
"manifest_schema",
"runtime_version",
"source_name",
];
const EMBEDDED_FILE_FIELDS: [&str; 4] = ["digest", "kind", "length", "path"];
fn is_blake3_hex(digest: &str) -> bool {
digest.len() == 64
&& digest
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
fn check_schema2_fields(value: &serde_json::Value) -> Result<(), String> {
let Some(map) = value.as_object() else {
return Err("manifest is not a JSON object".to_string());
};
for key in map.keys() {
if !SCHEMA2_FIELDS.contains(&key.as_str()) {
return Err(format!("unknown schema-2 manifest field {key:?}"));
}
}
for field in ["kind", "source_name", "runtime_version"] {
if value
.get(field)
.and_then(serde_json::Value::as_str)
.is_none()
{
return Err(format!("schema-2 manifest carries no {field}"));
}
}
for field in ["components", "env_names", "listeners"] {
let Some(items) = value.get(field).and_then(serde_json::Value::as_array) else {
return Err(format!("schema-2 manifest field {field} is not an array"));
};
if items.iter().any(|item| item.as_str().is_none()) {
return Err(format!(
"schema-2 manifest array {field} carries a non-string entry"
));
}
}
let Some(serde_json::Value::Array(items)) = value.get("embedded_files") else {
return Err("schema-2 manifest carries no embedded_files array".to_string());
};
let mut paths = Vec::with_capacity(items.len());
for item in items {
paths.push(check_embedded_file(item)?);
}
for window in paths.windows(2) {
if window[0] >= window[1] {
return Err("embedded_files are not in canonical path order".to_string());
}
}
Ok(())
}
fn check_embedded_file(item: &serde_json::Value) -> Result<String, String> {
let Some(map) = item.as_object() else {
return Err("embedded_files entry is not a JSON object".to_string());
};
for key in map.keys() {
if !EMBEDDED_FILE_FIELDS.contains(&key.as_str()) {
return Err(format!("unknown embedded_files field {key:?}"));
}
}
let digest = map
.get("digest")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "embedded_files entry carries no digest string".to_string())?;
if !is_blake3_hex(digest) {
return Err(format!(
"embedded_files digest {digest:?} is not 64-character lowercase hex"
));
}
let kind = map
.get("kind")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "embedded_files entry carries no kind string".to_string())?;
if StoreEntryKind::from_name(kind).is_none() {
return Err(format!("embedded_files entry names unknown kind {kind:?}"));
}
if map
.get("length")
.and_then(serde_json::Value::as_u64)
.is_none()
{
return Err("embedded_files entry carries no length integer".to_string());
}
let path = map
.get("path")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "embedded_files entry carries no path string".to_string())?;
super::store::validate_path(path).map_err(|e| format!("embedded_files path invalid: {e}"))?;
Ok(path.to_string())
}
pub(crate) fn validate_manifest(
manifest: &[u8],
trailer_version: u16,
) -> Result<TrailerKind, TrailerError> {
let value: serde_json::Value =
serde_json::from_slice(manifest).map_err(|_| TrailerError::InvalidManifest)?;
let schema = manifest_schema_of(&value).map_err(|_| TrailerError::InvalidManifest)?;
match trailer_version {
FORMAT_VERSION => {
if schema != MANIFEST_SCHEMA_LEGACY {
return Err(TrailerError::InvalidManifestSchema(schema));
}
}
FORMAT_VERSION_V2 => {
if schema != MANIFEST_SCHEMA {
return Err(TrailerError::InvalidManifestSchema(schema));
}
check_schema2_fields(&value).map_err(TrailerError::InvalidManifestFields)?;
}
_ => return Err(TrailerError::InvalidVersion(trailer_version)),
}
let kind = value
.get("kind")
.and_then(serde_json::Value::as_str)
.and_then(TrailerKind::from_name)
.ok_or(TrailerError::InvalidManifest)?;
Ok(kind)
}
pub fn derive(
source_name: &str,
kind: TrailerKind,
document: &str,
) -> Result<Manifest, CompileError> {
let root = parse_document(document)
.map_err(|e| CompileError::InvalidDocument(format!("not a YAML/JSON document: {e}")))?;
let mut components = Vec::new();
let mut listeners = Vec::new();
walk_document(&root, &mut components, &mut listeners);
let env_names = scan_env_names(document);
Ok(Manifest {
manifest_schema: MANIFEST_SCHEMA,
source_name: source_name.to_string(),
runtime_version: RUNTIME_VERSION.to_string(),
kind,
components,
env_names,
listeners,
embedded_files: vec![EmbeddedFile {
digest: blake3::hash(document.as_bytes()).to_hex().to_string(),
kind: StoreEntryKind::from(kind),
length: document.len() as u64,
path: source_name.to_string(),
}],
})
}
pub fn derive_for_store(
store: &super::store::VirtualDocumentStore,
kind: TrailerKind,
route_documents: &[(String, String)],
) -> Result<Manifest, CompileError> {
let mut components = Vec::new();
let mut listeners = Vec::new();
let mut env_names = Vec::new();
for (path, text) in route_documents {
let root = parse_document(text).map_err(|e| {
CompileError::InvalidDocument(format!("{path}: not a YAML/JSON document: {e}"))
})?;
walk_document(&root, &mut components, &mut listeners);
for name in scan_env_names(text) {
if !env_names.contains(&name) {
env_names.push(name);
}
}
}
for entry in &store.index.entries {
if !matches!(
entry.kind,
StoreEntryKind::Config | StoreEntryKind::Include | StoreEntryKind::Profile
) {
continue;
}
let text = store.read_text(&entry.path).ok_or_else(|| {
CompileError::InvalidDocument(format!(
"store entry '{}' is not valid UTF-8",
entry.path
))
})?;
let (entry_components, entry_listeners) = scan_config_entry(text);
for name in scan_env_names(text) {
if !env_names.contains(&name) {
env_names.push(name);
}
}
for name in entry_components {
if !components.contains(&name) {
components.push(name);
}
}
for listener in entry_listeners {
if !listeners.contains(&listener) {
listeners.push(listener);
}
}
}
let mut embedded_files = Vec::with_capacity(store.index.entries.len());
for entry in &store.index.entries {
let range = entry.offset as usize..(entry.offset + entry.length) as usize;
let bytes = store.content.get(range).ok_or_else(|| {
CompileError::InvalidDocument(format!(
"store entry '{}' range is out of content bounds",
entry.path
))
})?;
embedded_files.push(EmbeddedFile {
digest: blake3::hash(bytes).to_hex().to_string(),
kind: entry.kind,
length: entry.length,
path: entry.path.clone(),
});
}
Ok(Manifest {
manifest_schema: MANIFEST_SCHEMA,
source_name: store.index.entry_point.clone(),
runtime_version: RUNTIME_VERSION.to_string(),
kind,
components,
env_names,
listeners,
embedded_files,
})
}
fn scan_config_entry(text: &str) -> (Vec<String>, Vec<String>) {
let Ok(root) = toml::from_str::<toml::Value>(text) else {
return (Vec::new(), Vec::new());
};
let mut components = Vec::new();
let mut listeners = Vec::new();
walk_config_tables(&root, &mut components, &mut listeners);
components.sort();
components.dedup();
listeners.sort();
listeners.dedup();
(components, listeners)
}
fn walk_config_tables(
value: &toml::Value,
components: &mut Vec<String>,
listeners: &mut Vec<String>,
) {
match value {
toml::Value::Table(table) => {
for (key, val) in table {
if key == "components"
&& let Some(blocks) = val.as_table()
{
for name in blocks.keys() {
if !components.iter().any(|s| s == name) {
components.push(name.clone());
}
}
}
if key == "observability"
&& let Some(sections) = val.as_table()
{
if let Some(entry) = sections.get("health") {
push_config_listener(listeners, entry, "8081");
}
if let Some(entry) = sections.get("prometheus") {
push_config_listener(listeners, entry, "9090");
}
}
walk_config_tables(val, components, listeners);
}
}
toml::Value::Array(items) => {
for item in items {
walk_config_tables(item, components, listeners);
}
}
_ => {}
}
}
fn push_config_listener(listeners: &mut Vec<String>, entry: &toml::Value, default_port: &str) {
let Some(table) = entry.as_table() else {
return;
};
if table.get("enabled").and_then(toml::Value::as_bool) != Some(true) {
return; }
let host = table
.get("host")
.and_then(toml::Value::as_str)
.unwrap_or("0.0.0.0");
let port = match table.get("port") {
Some(toml::Value::Integer(n)) => n.to_string(),
Some(toml::Value::String(s)) => s.clone(),
_ => default_port.to_string(),
};
listeners.push(format!("{host}:{port}"));
}
fn parse_document(document: &str) -> Result<serde_yml::Value, String> {
serde_yml::from_str(document).map_err(|e| e.to_string())
}
fn walk_document(
value: &serde_yml::Value,
components: &mut Vec<String>,
listeners: &mut Vec<String>,
) {
match value {
serde_yml::Value::Mapping(map) => {
for (key, val) in map {
let key = key.as_str();
if (key == "from" || key == "to")
&& let Some(uri) = val.as_str()
{
push_scheme(components, uri);
}
if key == "rest"
&& let Some(entries) = val.as_sequence()
{
for entry in entries {
push_rest_listener(listeners, entry);
}
}
if key == "mcp"
&& let Some(entries) = val.as_sequence()
{
for entry in entries {
push_mcp_listener(listeners, entry);
}
}
walk_document(val, components, listeners);
}
}
serde_yml::Value::Sequence(seq) => {
for item in seq {
walk_document(item, components, listeners);
}
}
_ => {}
}
}
fn push_scheme(components: &mut Vec<String>, uri: &str) {
let Some((scheme, _rest)) = uri.split_once(':') else {
return;
};
if !is_uri_scheme(scheme) {
return;
}
if !components.iter().any(|s| s == scheme) {
components.push(scheme.to_string());
}
}
pub(crate) fn is_uri_scheme(scheme: &str) -> bool {
let mut chars = scheme.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn push_rest_listener(listeners: &mut Vec<String>, entry: &serde_yml::Value) {
let Some(map) = entry.as_mapping() else {
return;
};
let host = map
.get("host")
.and_then(serde_yml::Value::as_str)
.unwrap_or("0.0.0.0");
let port = match map.get("port") {
Some(v) => match v {
serde_yml::Value::Number(n) => n.to_string(),
serde_yml::Value::String(s) => s.clone(),
_ => "8080".to_string(),
},
None => "8080".to_string(),
};
listeners.push(format!("{host}:{port}"));
}
fn push_mcp_listener(listeners: &mut Vec<String>, entry: &serde_yml::Value) {
let Some(map) = entry.as_mapping() else {
return;
};
let Some(server) = map.get("server").and_then(serde_yml::Value::as_mapping) else {
return;
};
let Some(bind) = server.get("bind").and_then(serde_yml::Value::as_str) else {
return;
};
listeners.push(bind.to_string());
}
fn env_token_re() -> &'static Regex {
static ENV_RE: OnceLock<Regex> = OnceLock::new();
ENV_RE.get_or_init(|| {
Regex::new(r"(\$\$\{env:[^}]*\})|(\$\$)|(\$\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\})")
.unwrap() })
}
fn scan_env_names(document: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for caps in env_token_re().captures_iter(document) {
if caps.get(1).is_some() || caps.get(2).is_some() {
continue; }
if caps.get(5).is_some() {
continue; }
let name = &caps[4];
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
#[test]
fn compile_manifest_lists_components_env_and_listeners() {
let document = "\
routes:
- id: tick
from: timer:tick
steps:
- to: kafka:events
- id: ingest
from: ${env:INPUT}
steps:
- to: file:out?dir=${env:OUT_DIR:-/tmp/spool}
rest:
- port: 8080
host: 0.0.0.0
operations:
- get: /health
to: direct:health
mcp:
- server:
name: tools
bind: \"127.0.0.1:${env:MCP_PORT}\"
tools: []
";
let manifest = derive("routes/app.yaml", TrailerKind::Route, document)
.expect("supported document must derive a manifest");
assert_eq!(manifest.source_name, "routes/app.yaml");
assert_eq!(manifest.kind, TrailerKind::Route);
assert_eq!(manifest.runtime_version, RUNTIME_VERSION);
assert_eq!(manifest.manifest_schema, MANIFEST_SCHEMA);
assert_eq!(
manifest.embedded_files,
vec![EmbeddedFile {
digest: blake3::hash(document.as_bytes()).to_hex().to_string(),
kind: StoreEntryKind::Route,
length: document.len() as u64,
path: "routes/app.yaml".to_string(),
}]
);
assert_eq!(
manifest.components,
vec!["timer", "kafka", "file", "direct"]
);
assert_eq!(manifest.env_names, vec!["INPUT", "MCP_PORT"]);
assert_eq!(
manifest.listeners,
vec!["0.0.0.0:8080", "127.0.0.1:${env:MCP_PORT}"]
);
let json = manifest.to_canonical_json();
assert_eq!(
json,
format!(
"{{\"components\":[\"timer\",\"kafka\",\"file\",\"direct\"],\
\"embedded_files\":[{{\"digest\":\"{}\",\"kind\":\"route\",\"length\":{},\
\"path\":\"routes/app.yaml\"}}],\
\"env_names\":[\"INPUT\",\"MCP_PORT\"],\"kind\":\"route\",\
\"listeners\":[\"0.0.0.0:8080\",\"127.0.0.1:${{env:MCP_PORT}}\"],\
\"manifest_schema\":{MANIFEST_SCHEMA},\
\"runtime_version\":\"{RUNTIME_VERSION}\",\
\"source_name\":\"routes/app.yaml\"}}",
blake3::hash(document.as_bytes()).to_hex(),
document.len()
)
);
let reparsed: serde_json::Value =
serde_json::from_str(&json).expect("canonical manifest JSON must parse");
assert_eq!(
reparsed.get("kind").and_then(serde_json::Value::as_str),
Some("route")
);
assert_eq!(
reparsed
.get("manifest_schema")
.and_then(serde_json::Value::as_u64),
Some(MANIFEST_SCHEMA)
);
assert!(
!json.contains("OUT_DIR"),
"defaulted env tokens must not appear in the manifest"
);
}
#[test]
fn manifest_env_scan_ignores_arg_tokens() {
let document = "\
args:
value:
default: hello
execute:
mode: one-shot
timeout: 60s
send:
to: direct:transform
body: \"${arg:value}\"
routes:
- id: job-arg
from: direct:lit
steps:
- set_body:
value: \"$${arg:lit} ${env:HOST}\"
";
let manifest = derive("args.job.yaml", TrailerKind::Job, document)
.expect("declared job document must derive a manifest");
assert_eq!(
manifest.env_names,
vec!["HOST"],
"arg tokens (bare and escaped) must contribute nothing to env_names"
);
}
#[test]
fn compile_manifest_store_derivation_scans_config_entries() {
use super::store::{StoreDocument, VirtualDocumentStore};
let route_text = "\
routes:
- id: a
from: timer:a
steps:
- to: direct:a
";
let config_text = "\
[components.kafka]
brokers = \"localhost:9092\"
[observability.health]
enabled = true
port = \"${env:HEALTH_PORT}\"
";
let store = VirtualDocumentStore::build(
"app.yaml",
&[
StoreDocument {
path: "Camel.toml".to_string(),
kind: StoreEntryKind::Config,
bytes: config_text.as_bytes().to_vec(),
},
StoreDocument {
path: "app.yaml".to_string(),
kind: StoreEntryKind::Route,
bytes: route_text.as_bytes().to_vec(),
},
],
&["Camel.toml".to_string()],
&["app.yaml".to_string()],
)
.expect("valid store builds");
let manifest = derive_for_store(
&store,
TrailerKind::Route,
&[("app.yaml".to_string(), route_text.to_string())],
)
.expect("store manifest must derive");
assert_eq!(manifest.env_names, vec!["HEALTH_PORT"]);
assert_eq!(manifest.listeners, vec!["0.0.0.0:${env:HEALTH_PORT}"]);
assert_eq!(manifest.components, vec!["timer", "direct", "kafka"]);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_derive_rejects_unparsable_document() {
let err = derive("x.yaml", TrailerKind::Route, "key: [unclosed")
.expect_err("unparsable document must be named");
assert!(matches!(err, CompileError::InvalidDocument(_)));
}
#[test]
fn manifest_rest_defaults_applied_when_fields_absent() {
let document = "rest:\n - operations:\n - get: /x\n to: direct:a\n";
let manifest = derive("a.yaml", TrailerKind::Route, document).expect("derives");
assert_eq!(manifest.listeners, vec!["0.0.0.0:8080"]);
}
#[test]
fn manifest_env_scan_skips_escapes_and_defaults() {
let names = scan_env_names(
"$${env:LIT} $$ ${env:REQUIRED} ${env:WITH_DEFAULT:-x} ${env:EMPTY_DEFAULT:-} \
${env:REQUIRED}",
);
assert_eq!(names, vec!["REQUIRED"]);
}
fn schema2_json() -> serde_json::Value {
serde_json::json!({
"components": ["direct"],
"embedded_files": [{
"digest": blake3::hash(b"doc").to_hex().to_string(),
"kind": "route",
"length": 3,
"path": "routes/a.yaml",
}],
"env_names": [],
"kind": "route",
"listeners": [],
"manifest_schema": MANIFEST_SCHEMA,
"runtime_version": RUNTIME_VERSION,
"source_name": "routes/a.yaml",
})
}
fn parse(value: &serde_json::Value) -> Result<Manifest, CompileError> {
Manifest::from_canonical_json(value.to_string().as_bytes())
}
#[test]
fn schema2_manifest_rejects_unknown_fields_wrong_types_and_shapes() {
assert!(parse(&schema2_json()).is_ok());
let mut value = schema2_json();
value["signed"] = serde_json::json!(true);
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("unknown schema-2 manifest field \"signed\"")
));
let mut value = schema2_json();
value["components"] = serde_json::json!("direct");
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("field components is not an array")
));
let mut value = schema2_json();
value["env_names"] = serde_json::json!(["NAME", 7]);
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("array env_names carries a non-string entry")
));
let mut value = schema2_json();
value["kind"] = serde_json::json!(1);
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("carries no kind")
));
let mut value = schema2_json();
value["embedded_files"][0]["digest_algorithm"] = serde_json::json!("blake3");
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("unknown embedded_files field \"digest_algorithm\"")
));
for bad in ["ABCD", &"g".repeat(64), &"a".repeat(63)] {
let mut value = schema2_json();
value["embedded_files"][0]["digest"] = serde_json::json!(bad);
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("not 64-character lowercase hex")
));
}
for bad in ["../escape.yaml", "routes\\a.yaml", "C:/routes/a.yaml"] {
let mut value = schema2_json();
value["embedded_files"][0]["path"] = serde_json::json!(bad);
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("embedded_files path invalid")
));
}
let mut value = schema2_json();
value["embedded_files"][0]["path"] = serde_json::json!("routes/a.yaml");
value["embedded_files"][0]["kind"] = serde_json::json!("asset");
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("unknown kind \"asset\"")
));
let mut value = schema2_json();
value["embedded_files"][0]["kind"] = serde_json::json!("route");
value["embedded_files"][0]["length"] = serde_json::json!("3");
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("carries no length integer")
));
let mut value = schema2_json();
let second = schema2_json()["embedded_files"][0].clone();
value["embedded_files"] = serde_json::json!([
schema2_json()["embedded_files"][0],
{ "digest": blake3::hash(b"other").to_hex().to_string(),
"kind": "route", "length": 5, "path": "routes/00.yaml" },
second,
]);
assert!(matches!(
parse(&value),
Err(CompileError::InvalidDocument(reason))
if reason.contains("not in canonical path order")
));
}
#[test]
fn legacy_json_omits_schema2_fields_and_validates_as_v1() {
let document = "routes:\n- id: a\n from: direct:a\n";
let manifest = derive("routes/app.yaml", TrailerKind::Route, document).expect("derives");
let legacy = manifest.to_legacy_json();
assert!(!legacy.contains("manifest_schema"));
assert!(!legacy.contains("embedded_files"));
assert!(legacy.contains(r#""kind":"route""#));
assert!(legacy.contains(r#""source_name":"routes/app.yaml""#));
assert_eq!(
validate_manifest(legacy.as_bytes(), FORMAT_VERSION),
Ok(TrailerKind::Route),
"the transitional v1 writer must produce decodable v1 artifacts"
);
assert_eq!(
validate_manifest(legacy.as_bytes(), FORMAT_VERSION_V2),
Err(TrailerError::InvalidManifestSchema(MANIFEST_SCHEMA_LEGACY))
);
}
}