Merge 'sim: fix singlequote escaping and unescaping' from Jussi Saurio

Fixes https://github.com/tursodatabase/limbo/issues/1801 by preventing
the generation of strings like `'foo''` that contain an improper
unescaped sequence of quotes

Closes #1819
This commit is contained in:
Pekka Enberg
2025-06-24 16:07:43 +03:00

View File

@@ -185,10 +185,58 @@ impl From<ast::Literal> for SimValue {
}
}
/// Sanitaizes a string literal by removing single quote at front and back
/// and escaping double single quotes
fn sanitize_string(input: &str) -> String {
input[1..input.len() - 1].replace("''", "'").to_string()
/// Converts a SQL string literal with already-escaped single quotes to a regular string by:
/// - Removing the enclosing single quotes
/// - Converting sequences of 2N single quotes ('''''') to N single quotes (''')
///
/// Assumes:
/// - The input starts and ends with a single quote
/// - The input contains a valid amount of single quotes inside the enclosing quotes;
/// i.e. any ' is escaped as a double ''
fn unescape_singlequotes(input: &str) -> String {
assert!(
input.starts_with('\'') && input.ends_with('\''),
"Input string must be wrapped in single quotes"
);
// Skip first and last characters (the enclosing quotes)
let inner = &input[1..input.len() - 1];
let mut result = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
// Count consecutive single quotes
let mut quote_count = 1;
while chars.peek() == Some(&'\'') {
quote_count += 1;
chars.next();
}
assert!(
quote_count % 2 == 0,
"Expected even number of quotes, got {} in string {}",
quote_count,
input
);
// For every pair of quotes, output one quote
for _ in 0..(quote_count / 2) {
result.push('\'');
}
} else {
result.push(c);
}
}
result
}
/// Escapes a string by doubling contained single quotes and then wrapping it in single quotes.
fn escape_singlequotes(input: &str) -> String {
let mut result = String::with_capacity(input.len() + 2);
result.push('\'');
result.push_str(&input.replace("'", "''"));
result.push('\'');
result
}
impl From<&ast::Literal> for SimValue {
@@ -196,8 +244,7 @@ impl From<&ast::Literal> for SimValue {
let new_value = match value {
ast::Literal::Null => types::Value::Null,
ast::Literal::Numeric(number) => Numeric::from(number).into(),
// TODO: see how to avoid sanitizing here
ast::Literal::String(string) => types::Value::build_text(sanitize_string(string)),
ast::Literal::String(string) => types::Value::build_text(unescape_singlequotes(string)),
ast::Literal::Blob(blob) => types::Value::Blob(
blob.as_bytes()
.chunks_exact(2)
@@ -227,7 +274,7 @@ impl From<&SimValue> for ast::Literal {
types::Value::Null => Self::Null,
types::Value::Integer(i) => Self::Numeric(i.to_string()),
types::Value::Float(f) => Self::Numeric(f.to_string()),
text @ types::Value::Text(..) => Self::String(format!("'{}'", text)),
text @ types::Value::Text(..) => Self::String(escape_singlequotes(&text.to_string())),
types::Value::Blob(blob) => Self::Blob(hex::encode(blob)),
}
}
@@ -266,3 +313,32 @@ impl From<&limbo_core::types::Value> for SimValue {
Self(value.clone())
}
}
#[cfg(test)]
mod tests {
use crate::model::table::{escape_singlequotes, unescape_singlequotes};
#[test]
fn test_unescape_singlequotes() {
assert_eq!(unescape_singlequotes("'hello'"), "hello");
assert_eq!(unescape_singlequotes("'O''Reilly'"), "O'Reilly");
assert_eq!(
unescape_singlequotes("'multiple''single''quotes'"),
"multiple'single'quotes"
);
assert_eq!(unescape_singlequotes("'test''''test'"), "test''test");
assert_eq!(unescape_singlequotes("'many''''''quotes'"), "many'''quotes");
}
#[test]
fn test_escape_singlequotes() {
assert_eq!(escape_singlequotes("hello"), "'hello'");
assert_eq!(escape_singlequotes("O'Reilly"), "'O''Reilly'");
assert_eq!(
escape_singlequotes("multiple'single'quotes"),
"'multiple''single''quotes'"
);
assert_eq!(escape_singlequotes("test''test"), "'test''''test'");
assert_eq!(escape_singlequotes("many'''quotes"), "'many''''''quotes'");
}
}