use crate::ast::{
EntitySection, IdlEnumVariantFieldSnapshot, IdlFieldSnapshot, IdlSnapshot,
IdlTypeDefKindSnapshot, InstructionDef, SerializableStackSpec, SerializableStreamSpec,
};
use serde_json::Value;
const FREE_TEXT_KEYS: &[&str] = &["docs", "msg", "description"];
const AUTHOR_KEYED_MAPS: &[&str] = &["pdas", "field_mappings", "required_fields"];
const RUNTIME_ONLY_ENTITY_PATHS: &[&str] = &[
"resolver_specs",
"resolver_hooks",
"instruction_hooks",
"computed_field_specs",
"views.*.pipeline",
"handlers.*.conditions",
"handlers.*.key_resolution",
"handlers.*.mappings.*.condition",
"handlers.*.mappings.*.when",
"handlers.*.mappings.*.stop",
"handlers.*.mappings.*.source.Constant",
"handlers.*.mappings.*.source.FromSource.default",
"handlers.*.mappings.*.source.Format",
"handlers.*.mappings.*.source.Custom",
];
pub fn validate_stack_spec(spec: &SerializableStackSpec) -> Result<(), String> {
let value = serde_json::to_value(spec)
.map_err(|error| format!("stack spec cannot be checked for SDK generation: {error}"))?;
check_inert_value(&value, "stack spec", &mut Vec::new())?;
for (index, idl) in spec.idls.iter().enumerate() {
check_idl_names(idl, &format!("IDL #{index}"))?;
}
for (index, program) in spec.program_specs.iter().enumerate() {
check_idl_names(
&program.idl_snapshot.clone().into_legacy_snapshot(),
&format!("program spec #{index}"),
)?;
}
for instruction in &spec.instructions {
check_instruction_def(instruction)?;
}
for (program, pdas) in &spec.pdas {
for (name, pda) in pdas {
require_identifier(&format!("program '{program}' PDA"), name)?;
require_identifier(&format!("program '{program}' PDA"), &pda.name)?;
}
}
for entity in &spec.entities {
check_entity_names(entity)?;
}
Ok(())
}
pub fn validate_entity_spec(spec: &SerializableStreamSpec) -> Result<(), String> {
let value = serde_json::to_value(spec)
.map_err(|error| format!("entity spec cannot be checked for SDK generation: {error}"))?;
check_inert_value(&value, "entity spec", &mut Vec::new())?;
check_entity_names(spec)
}
pub fn validate_text(what: &str, value: &str) -> Result<(), String> {
match inert_violation(value) {
Some(problem) => Err(format!(
"{what} {value:?} contains {problem}; names in a stack, LiveSpec or IDL may not contain quotes, backticks, backslashes, braces, comment delimiters or control characters"
)),
None => Ok(()),
}
}
pub(crate) fn comment_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut previous = '\0';
for character in text.chars() {
if character == '/' && previous == '*' {
out.push('\\');
}
if is_line_breaking(character) {
out.push(' ');
} else {
out.push(character);
}
previous = character;
}
out
}
fn is_line_breaking(character: char) -> bool {
(character.is_control() && character != '\t') || matches!(character, '\u{2028}' | '\u{2029}')
}
fn is_bidi_format(character: char) -> bool {
matches!(
character,
'\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)
}
fn inert_violation(value: &str) -> Option<String> {
if let Some(character) = value.chars().find(|character| {
matches!(character, '\'' | '"' | '`' | '\\' | '{' | '}')
|| character.is_control()
|| matches!(character, '\u{2028}' | '\u{2029}')
|| is_bidi_format(*character)
}) {
return Some(format!("the character {character:?}"));
}
["/*", "*/"]
.into_iter()
.find(|delimiter| value.contains(delimiter))
.map(|delimiter| format!("the comment delimiter `{delimiter}`"))
}
enum Step<'a> {
Key(&'a str),
Index(usize),
}
fn render_path(root: &str, path: &[Step<'_>]) -> String {
let mut rendered = root.to_string();
for step in path {
match step {
Step::Key(key) => {
rendered.push('.');
rendered.push_str(key);
}
Step::Index(index) => rendered.push_str(&format!("[{index}]")),
}
}
rendered
}
fn is_runtime_only(path: &[Step<'_>]) -> bool {
let relative = match path {
[Step::Key("entities"), Step::Index(_), rest @ ..] => rest,
_ => path,
};
RUNTIME_ONLY_ENTITY_PATHS.iter().any(|pattern| {
pattern.split('.').count() == relative.len()
&& pattern
.split('.')
.zip(relative)
.all(|(expected, step)| match step {
Step::Key(key) => expected != "*" && expected == *key,
Step::Index(_) => expected == "*",
})
})
}
fn is_free_text(key: &str, item: &Value, path: &[Step<'_>]) -> bool {
let shaped = match key {
"docs" => item
.as_array()
.is_some_and(|lines| lines.iter().all(Value::is_string)),
"msg" | "description" => item.is_string() || item.is_null(),
_ => false,
};
FREE_TEXT_KEYS.contains(&key)
&& shaped
&& !path
.iter()
.any(|step| matches!(step, Step::Key(key) if AUTHOR_KEYED_MAPS.contains(key)))
}
fn check_inert_value<'a>(
value: &'a Value,
root: &str,
path: &mut Vec<Step<'a>>,
) -> Result<(), String> {
match value {
Value::String(text) => {
if inert_violation(text).is_some() {
return validate_text(&render_path(root, path), text);
}
Ok(())
}
Value::Array(items) => {
for (index, item) in items.iter().enumerate() {
path.push(Step::Index(index));
check_inert_value(item, root, path)?;
path.pop();
}
Ok(())
}
Value::Object(entries) => {
for (key, item) in entries {
if inert_violation(key).is_some() {
return validate_text(&format!("key of {}", render_path(root, path)), key);
}
if is_free_text(key, item, path) {
continue;
}
path.push(Step::Key(key));
if !is_runtime_only(path) {
check_inert_value(item, root, path)?;
}
path.pop();
}
Ok(())
}
Value::Null | Value::Bool(_) | Value::Number(_) => Ok(()),
}
}
fn is_identifier(value: &str) -> bool {
let mut characters = value.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
}
fn require(valid: bool, what: &str, value: &str, expected: &str) -> Result<(), String> {
if valid {
Ok(())
} else {
Err(format!(
"{what} {value:?} cannot be used in a generated SDK; it must be {expected}"
))
}
}
fn require_identifier(what: &str, value: &str) -> Result<(), String> {
require(
is_identifier(value),
what,
value,
"an identifier (ASCII letters, digits and `_`, not starting with a digit)",
)
}
fn require_qualified(what: &str, value: &str) -> Result<(), String> {
require(
value.split("::").all(is_identifier),
what,
value,
"an identifier, or identifiers joined by `::`",
)
}
fn require_account_name(what: &str, value: &str) -> Result<(), String> {
let sysvar = value
.strip_prefix("$(")
.and_then(|rest| rest.strip_suffix(')'))
.is_some_and(is_identifier);
require(
sysvar || is_identifier(value),
what,
value,
"an identifier (ASCII letters, digits and `_`, not starting with a digit) or `$(Identifier)`",
)
}
fn check_fields(fields: &[IdlFieldSnapshot], owner: &str) -> Result<(), String> {
fields
.iter()
.try_for_each(|field| require_identifier(&format!("{owner} field"), &field.name))
}
fn check_idl_names(idl: &IdlSnapshot, idl_label: &str) -> Result<(), String> {
let label = format!("{idl_label} ('{}')", idl.name);
for account in &idl.accounts {
require_qualified(&format!("{label} account"), &account.name)?;
check_fields(
&account.fields,
&format!("{label} account '{}'", account.name),
)?;
if let Some(type_def) = &account.type_def {
check_fields(
&type_def.fields,
&format!("{label} account '{}'", account.name),
)?;
}
}
for instruction in &idl.instructions {
require_identifier(&format!("{label} instruction"), &instruction.name)?;
let owner = format!("{label} instruction '{}'", instruction.name);
for account in &instruction.accounts {
require_account_name(&format!("{owner} account"), &account.name)?;
}
for arg in &instruction.args {
require_identifier(&format!("{owner} argument"), &arg.name)?;
}
}
for type_def in &idl.types {
require_qualified(&format!("{label} type"), &type_def.name)?;
let owner = format!("{label} type '{}'", type_def.name);
match &type_def.type_def {
IdlTypeDefKindSnapshot::Struct { fields, .. } => check_fields(fields, &owner)?,
IdlTypeDefKindSnapshot::TupleStruct { .. } => {}
IdlTypeDefKindSnapshot::Enum { variants, .. } => {
for variant in variants {
require_identifier(&format!("{owner} variant"), &variant.name)?;
for field in &variant.fields {
if let IdlEnumVariantFieldSnapshot::Named(field) = field {
require_identifier(
&format!("{owner} variant '{}' field", variant.name),
&field.name,
)?;
}
}
}
}
}
}
for event in &idl.events {
require_qualified(&format!("{label} event"), &event.name)?;
check_fields(&event.fields, &format!("{label} event '{}'", event.name))?;
}
for error in &idl.errors {
require_identifier(&format!("{label} error"), &error.name)?;
}
Ok(())
}
fn check_instruction_def(instruction: &InstructionDef) -> Result<(), String> {
require_identifier("instruction", &instruction.name)?;
let owner = format!("instruction '{}'", instruction.name);
for account in &instruction.accounts {
require_account_name(&format!("{owner} account"), &account.name)?;
}
for arg in &instruction.args {
require_identifier(&format!("{owner} argument"), &arg.name)?;
}
Ok(())
}
fn is_field_path(value: &str) -> bool {
value.split('.').all(is_identifier)
}
fn is_view_id(value: &str) -> bool {
value.split('/').all(|segment| {
!segment.is_empty()
&& segment.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-')
})
})
}
fn check_section(section: &EntitySection, owner: &str) -> Result<(), String> {
require_identifier(&format!("{owner} section"), §ion.name)?;
for field in §ion.fields {
require(
is_field_path(&field.field_name),
&format!("{owner} section '{}' field", section.name),
&field.field_name,
"identifiers joined by `.`",
)?;
}
Ok(())
}
fn check_entity_names(entity: &SerializableStreamSpec) -> Result<(), String> {
require_identifier("entity", &entity.state_name)?;
let owner = format!("entity '{}'", entity.state_name);
if let Some(idl) = &entity.idl {
check_idl_names(idl, &format!("{owner} IDL"))?;
}
for section in &entity.sections {
check_section(section, &owner)?;
}
for path in entity.field_mappings.keys() {
require(
is_field_path(path),
&format!("{owner} field path"),
path,
"identifiers joined by `.`",
)?;
}
for view in &entity.views {
require(
is_view_id(&view.id),
&format!("{owner} view id"),
&view.id,
"`/`-separated segments of ASCII letters, digits, `_`, `.` and `-`",
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inert_text_rejects_every_breakout_character() {
for hostile in [
"a'b",
"a\"b",
"a`b",
"a\\b",
"a{b",
"a}b",
"${x}",
"a*/b",
"a/*b",
"a\nb",
"a\rb",
"a\u{0}b",
"a\u{85}b",
"a\u{2028}b",
"a\u{2029}b",
"a\u{202E}b",
"a\u{2066}b",
"\"\"\"",
"'''",
] {
assert!(validate_text("name", hostile).is_err(), "{hostile:?}");
}
for fine in [
"OreRound/latest",
"results.expires_at_slot_hash",
"Option < Vec < f64 > >",
"[u8; 32]",
"ore::TreasuryState",
"$(SysVarRentPubkey)",
"token-balances",
"/seed?samples=",
"https://example.com/var/",
"value != ZERO_32",
"a / b * c",
] {
assert!(validate_text("name", fine).is_ok(), "{fine:?}");
}
}
#[test]
fn inert_errors_name_the_entry_and_skip_free_text() {
let value = serde_json::json!({
"entities": [{"docs": ["free \" text */"], "views": [{"id": "a'b"}]}]
});
let error = check_inert_value(&value, "stack spec", &mut Vec::new()).unwrap_err();
assert!(
error.starts_with("stack spec.entities[0].views[0].id \"a'b\" contains"),
"{error}"
);
}
#[test]
fn author_named_map_entries_are_never_free_text() {
let seed = serde_json::json!({"seeds": [{"Literal": {"value": "' + run() + '"}}]});
for value in [
serde_json::json!({"pdas": {"ore": {"docs": seed.clone()}}}),
serde_json::json!({"pdas": {"ore": {"msg": "' + run() + '"}}}),
serde_json::json!({"entities": [{"field_mappings": {"docs": {"field_name": "a'b"}}}]}),
serde_json::json!({"entities": [{"docs": {"nested": "a'b"}}]}),
] {
assert!(
check_inert_value(&value, "stack spec", &mut Vec::new()).is_err(),
"{value}"
);
}
}
#[test]
fn runtime_only_data_is_not_restricted() {
let constant = serde_json::json!({"mappings": [{
"target_path": "status.label",
"source": {"Constant": "don't"}
}]});
let value = serde_json::json!({"entities": [{
"handlers": [constant.clone(), {"mappings": [{
"target_path": "status.note",
"source": {"Format": "{a}'s {b}"},
"when": "it's"
}]}],
"resolver_specs": [{"input_value": "it's"}],
"views": [{"id": "E/list", "pipeline": [{"Filter": {"Literal": "a'b"}}]}]
}]});
assert!(check_inert_value(&value, "stack spec", &mut Vec::new()).is_ok());
let entity = serde_json::json!({"handlers": [constant]});
assert!(check_inert_value(&entity, "entity spec", &mut Vec::new()).is_ok());
let bad = serde_json::json!({"entities": [{"handlers": [{"mappings": [{
"target_path": "status.label'",
"source": {"Constant": "fine"}
}]}]}]});
assert!(check_inert_value(&bad, "stack spec", &mut Vec::new()).is_err());
}
#[test]
fn name_rules() {
assert!(is_identifier("_padding0"));
assert!(!is_identifier("9lives"));
assert!(!is_identifier("a-b"));
assert!(!is_identifier(""));
assert!(require_account_name("a", "$(SysVarRentPubkey)").is_ok());
assert!(require_account_name("a", "$(x); y").is_err());
assert!(is_field_path("id.round_id"));
assert!(require_qualified("t", "sb_on_demand::pull_feed::Submission").is_ok());
assert!(require_qualified("t", "a:::b").is_err());
assert!(require_qualified("t", "::b").is_err());
assert!(!is_field_path("id..round_id"));
assert!(is_view_id("OreRound/latest"));
assert!(is_view_id("token-balances/by.slot"));
assert!(!is_view_id("OreRound//latest"));
assert!(!is_view_id("OreRound/a b"));
}
#[test]
fn comment_text_neutralizes_comment_terminators() {
assert_eq!(comment_text("plain text"), "plain text");
assert_eq!(comment_text("a */ b"), "a *\\/ b");
assert_eq!(comment_text("a\nb\r\nc\u{2028}d"), "a b c d");
assert_eq!(comment_text("tab\tkept"), "tab\tkept");
}
}