use super::error::GraphError;
const BLOCKED: &[&str] = &[
"CREATE", "SET", "DELETE", "DETACH", "REMOVE", "MERGE", "DROP", "CALL", "LOAD",
];
pub fn reject_mutations(cypher: &str) -> Result<(), GraphError> {
let upper = cypher.to_ascii_uppercase();
let bytes = upper.as_bytes();
let mut earliest: Option<(&'static str, usize)> = None;
for keyword in BLOCKED {
let mut from = 0;
while let Some(pos) = upper[from..].find(keyword) {
let start = from + pos;
let end = start + keyword.len();
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
if boundary_before && boundary_after {
if earliest.is_none_or(|(_, best)| start < best) {
earliest = Some((keyword, start));
}
break; }
from = end;
}
}
match earliest {
Some((keyword, offset)) => Err(GraphError::MutationRejected { keyword, offset }),
None => Ok(()),
}
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_keywords_are_rejected_case_insensitively() {
for q in [
"CREATE (n)",
"match (n) set n.x = 1",
"MATCH (n) DETACH DELETE n",
"merge (n:X)",
"DROP GRAPH g",
"CALL db.labels()",
"LOAD CSV FROM 'https://x' AS row RETURN row",
] {
let err = reject_mutations(q).unwrap_err();
assert!(
matches!(err, GraphError::MutationRejected { .. }),
"{q}: {err}"
);
}
}
#[test]
fn word_boundaries_spare_identifiers() {
for q in [
"MATCH (n) WHERE n.created_at > 0 RETURN n", "MATCH (n) RETURN n.dataset", "MATCH (n) RETURN n.calls", "MATCH (n) RETURN n.reload", ] {
assert!(reject_mutations(q).is_ok(), "{q}");
}
}
#[test]
fn keyword_shaped_literals_false_positive_by_design() {
assert!(reject_mutations("MATCH (n) WHERE n.op = 'DELETE' RETURN n").is_err());
}
#[test]
fn the_error_names_keyword_and_offset_not_the_query() {
let err = reject_mutations("MATCH (n) SET n.secret = 'v'").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("'SET'"), "{msg}");
assert!(!msg.contains("secret"), "query text never leaks: {msg}");
}
#[test]
fn the_first_keyword_in_the_query_wins_not_the_first_in_the_list() {
let err = reject_mutations("MATCH (n) DETACH DELETE n").unwrap_err();
let GraphError::MutationRejected { keyword, offset } = err else {
panic!("mutation");
};
assert_eq!(keyword, "DETACH");
assert_eq!(offset, 10);
}
}