From 7108a4b4a5698984273d5cbc144e8a6d9d4f4905 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 21:31:55 +0700 Subject: [PATCH 1/2] fix(linux-safe-mode): run MySQL executable comments and read them as each server does --- .../app/src/services/history_service.rs | 8 +- .../app/src/ui/editor/catalog_change.rs | 25 +- .../ui/editor/completion/completion_scope.rs | 15 +- .../app/src/ui/editor/safe_mode_gate.rs | 35 ++ .../app/src/ui/editor/significant_tokens.rs | 75 +++- .../core/src/sql_syntax/history_recording.rs | 59 ++- .../core/src/sql_syntax/script/lexer.rs | 120 +++++- .../crates/core/src/sql_syntax/script/mod.rs | 1 + .../core/src/sql_syntax/script/script_plan.rs | 58 ++- .../src/sql_syntax/script/script_rules.rs | 7 + .../sql_syntax/script/script_token_kind.rs | 3 + .../src/sql_syntax/script/server_reading.rs | 399 ++++++++++++++++++ .../core/src/sql_syntax/statement_access.rs | 211 +++++++-- .../core/src/sql_syntax/statement_tokens.rs | 40 +- linux/crates/core/tests/script_corpus.rs | 59 +++ .../crates/drivers/mysql/src/script_split.rs | 18 +- .../mysql/tests/integration/flavour_suite.rs | 56 +++ linux/crates/storage/src/query_history/mod.rs | 2 +- linux/docs/drivers/mysql.md | 14 +- linux/docs/storage.md | 6 +- 20 files changed, 1087 insertions(+), 124 deletions(-) create mode 100644 linux/crates/core/src/sql_syntax/script/server_reading.rs diff --git a/linux/crates/app/src/services/history_service.rs b/linux/crates/app/src/services/history_service.rs index 191d356e40..b7b48571f0 100644 --- a/linux/crates/app/src/services/history_service.rs +++ b/linux/crates/app/src/services/history_service.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tablepro_core::dialect::SqlDialect; use tablepro_core::sql_syntax::HistoryRecording; use tablepro_session::runtime::Tasks; -use tablepro_storage::query_history::NewEntry; +use tablepro_storage::query_history::{MAX_QUERY_BYTES, NewEntry}; use tablepro_storage::{QueryHistory, StoragePaths}; use tokio::sync::watch; @@ -97,6 +97,12 @@ impl HistoryService { tracing::debug!(connection = %entry.connection_id, "no dialect, so the run is not recorded"); return; }; + // The store refuses it anyway, and reading it for credentials + // lexes it once per way the server may read it. + if entry.query.len() > MAX_QUERY_BYTES { + tracing::debug!(connection = %entry.connection_id, "a run too large for the history was not recorded"); + return; + } if let HistoryRecording::Withhold(reason) = dialect.history_recording(&entry.query) { tracing::debug!(connection = %entry.connection_id, ?reason, "a run was kept out of the history"); return; diff --git a/linux/crates/app/src/ui/editor/catalog_change.rs b/linux/crates/app/src/ui/editor/catalog_change.rs index 54c6e75fb3..8a43a94d7c 100644 --- a/linux/crates/app/src/ui/editor/catalog_change.rs +++ b/linux/crates/app/src/ui/editor/catalog_change.rs @@ -1,5 +1,5 @@ use tablepro_core::sql_syntax::SqlGrammar; -use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; +use tablepro_core::sql_syntax::script::{ScriptPlan, ScriptTokenKind}; /// Words that add, rename or remove what the sidebar lists. They count /// anywhere in a statement: a SQL Server batch needs no `;` between its @@ -10,11 +10,15 @@ const SCHEMA_VERBS: [&str; 7] = ["CREATE", "ALTER", "DROP", "RENAME", "ATTACH", /// once it commits. const TRANSACTION_ENDS: [&str; 2] = ["COMMIT", "END"]; -/// Whether running `text` can change the tables the catalog lists. A -/// commit counts as the statement's first word, and `SELECT … INTO` -/// counts too, since PostgreSQL and SQL Server create its target table. +/// Whether running `text` can change the tables the catalog lists, read +/// every way the server may read it. A commit counts as the statement's +/// first word, and `SELECT … INTO` counts too, since PostgreSQL and SQL +/// Server create its target table. pub(super) fn changes_catalog(text: &str, grammar: SqlGrammar) -> bool { - let plan = ScriptPlan::build(text, grammar, LexicalSettings::default_for(grammar)); + ScriptPlan::server_readings(text, grammar).is_none_or(|mut plans| plans.any(|plan| plan_changes(text, &plan))) +} + +fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { let is = |word: &str, set: &[&str]| set.iter().any(|verb| word.eq_ignore_ascii_case(verb)); plan.statements().iter().any(|statement| { let words: Vec<&str> = plan @@ -102,6 +106,17 @@ mod tests { assert!(!changes_catalog("SELECT id FROM orders", SqlGrammar::PostgreSql)); } + #[test] + fn ddl_only_some_mysql_server_versions_run_counts() { + for sql in [ + "/*!50001 CREATE VIEW v AS SELECT 1 */", + "SELECT 1 /*!99999 /* */ ' */ ; DROP TABLE orders; -- '", + r"SELECT 'C:\'; DROP TABLE orders; -- '", + ] { + assert!(changes_catalog(sql, SqlGrammar::MySql), "{sql}"); + } + } + #[test] fn sqlite_attach_and_rename_count() { assert!(changes_catalog("ATTACH DATABASE 'x.db' AS x", SqlGrammar::Sqlite)); diff --git a/linux/crates/app/src/ui/editor/completion/completion_scope.rs b/linux/crates/app/src/ui/editor/completion/completion_scope.rs index 944252f483..7e2b6d79a2 100644 --- a/linux/crates/app/src/ui/editor/completion/completion_scope.rs +++ b/linux/crates/app/src/ui/editor/completion/completion_scope.rs @@ -63,9 +63,10 @@ pub fn analyze(text: &str, cursor: usize, grammar: SqlGrammar, settings: Lexical tables_in_statement: Vec::new(), }); }; + // What a MySQL executable comment holds is code, its markers are not. let lexemes: Vec> = plan .tokens(statement) - .filter(|token| !token.kind.is_trivia()) + .filter(|token| !token.kind.is_trivia() && token.kind != ScriptTokenKind::ExecutableMarker) .filter_map(|token| Lexeme::new(text, token)) .collect(); let tables = TableMap::collect(&lexemes, grammar); @@ -359,7 +360,8 @@ fn encloses(token: &ScriptToken, text: &str, cursor: usize) -> bool { ScriptTokenKind::StringLiteral | ScriptTokenKind::QuotedIdentifier | ScriptTokenKind::DollarQuoted - | ScriptTokenKind::BlockComment => inside, + | ScriptTokenKind::BlockComment + | ScriptTokenKind::ExecutableMarker => inside, _ => false, } } @@ -577,6 +579,15 @@ mod tests { ); } + #[test] + fn a_mysql_executable_comment_is_code() { + assert_eq!( + scope_in(SqlGrammar::MySql, "SELECT * FROM orders /*! AS */ o WHERE o.|"), + columns(vec![table(None, "orders")]) + ); + assert_eq!(scope_in(SqlGrammar::MySql, "SELECT /*!40|1 x */ 1"), None); + } + #[test] fn strings_and_comments_are_not_completed() { assert_eq!(scope("SELECT 'FROM |'"), None); diff --git a/linux/crates/app/src/ui/editor/safe_mode_gate.rs b/linux/crates/app/src/ui/editor/safe_mode_gate.rs index 2494f2af6c..663c570341 100644 --- a/linux/crates/app/src/ui/editor/safe_mode_gate.rs +++ b/linux/crates/app/src/ui/editor/safe_mode_gate.rs @@ -105,6 +105,41 @@ mod tests { ); } + #[test] + fn a_mysql_executable_comment_is_sent_and_read_as_the_server_reads_it() { + // MySQL 8.0 skips the second comment past its nested `/* */` and + // runs the DROP after it. + let script = "/*!40103 SET TIME_ZONE='+00:00' */;\nSELECT 1 /*!99999 /* */ ' */ ; DROP TABLE orders; -- '"; + + let off = gated(script, SqlGrammar::MySql, SafeModeLevel::Off); + let read_only = gated(script, SqlGrammar::MySql, SafeModeLevel::ReadOnly); + + let texts: Vec<&str> = off.batches.iter().map(|batch| batch.text.as_str()).collect(); + assert_eq!( + texts, + [ + "/*!40103 SET TIME_ZONE='+00:00' */", + "SELECT 1 /*!99999 /* */ ' */ ; DROP TABLE orders; -- '" + ] + ); + assert_eq!(off.confirm, ["SELECT 1 /*!99999 /* */ ' */ ; DROP TABLE orders; -- '"]); + assert!(off.destructive); + assert!(read_only.batches.iter().all(|batch| batch.refused)); + } + + #[test] + fn a_batch_the_driver_splits_again_is_read_piece_by_piece() { + // The driver reads the batch's second line as a DELIMITER line and + // sends the last line alone, where MySQL 8.0 runs the DROP. + let script = "DELIMITER //\nSELECT 1;\nDELIMITER ; /*!99999 '*/ `' */\n\ + SELECT 1 /*!99999 ' */ ; DROP TABLE t; -- ` ' */\n//"; + + let run = gated(script, SqlGrammar::MySql, SafeModeLevel::Off); + + assert_eq!(run.batches.len(), 1); + assert!(run.destructive); + } + #[test] fn read_only_keeps_back_every_write_and_asks_nothing() { let run = gated( diff --git a/linux/crates/app/src/ui/editor/significant_tokens.rs b/linux/crates/app/src/ui/editor/significant_tokens.rs index 6d4586bfd0..f75c9a17d3 100644 --- a/linux/crates/app/src/ui/editor/significant_tokens.rs +++ b/linux/crates/app/src/ui/editor/significant_tokens.rs @@ -10,8 +10,26 @@ pub fn significant_tokens(text: &str, grammar: SqlGrammar, settings: LexicalSett let plan = ScriptPlan::build(text, grammar, settings); let mut tokens: Vec = Vec::new(); let mut spacing = Spacing::default(); + // A MySQL executable comment is compared as written: the formatter + // takes it for a comment, and which of its text is a string depends + // on the server. + let mut executable: Option = None; for token in plan.tokens(0..text.len()) { let slice = text.get(token.range.clone()).unwrap_or_default(); + if token.kind == ScriptTokenKind::ExecutableMarker || executable.is_some() { + let start = *executable.get_or_insert(token.range.start); + if token.kind == ScriptTokenKind::ExecutableMarker && slice == "*/" { + executable = None; + push( + &mut tokens, + &mut spacing, + grammar, + token.kind, + text.get(start..token.range.end).unwrap_or_default(), + ); + } + continue; + } if token.kind == ScriptTokenKind::Whitespace { spacing.spaced = true; spacing.line_break |= slice.contains('\n'); @@ -23,19 +41,39 @@ pub fn significant_tokens(text: &str, grammar: SqlGrammar, settings: LexicalSett ScriptTokenKind::LineComment | ScriptTokenKind::BlockComment => slice.trim_end(), _ => slice, }; - let gap = tokens.last().map_or(Gap::Free, |previous| { - gap_between(grammar, previous, token.kind, body, spacing) - }); - tokens.push(SignificantToken { - kind: token.kind, - text: body.to_owned(), - gap, - }); - spacing = Spacing::default(); + push(&mut tokens, &mut spacing, grammar, token.kind, body); + } + if let Some(start) = executable { + let rest = text.get(start..).unwrap_or_default(); + push( + &mut tokens, + &mut spacing, + grammar, + ScriptTokenKind::ExecutableMarker, + rest, + ); } tokens } +fn push( + tokens: &mut Vec, + spacing: &mut Spacing, + grammar: SqlGrammar, + kind: ScriptTokenKind, + body: &str, +) { + let gap = tokens.last().map_or(Gap::Free, |previous| { + gap_between(grammar, previous, kind, body, *spacing) + }); + tokens.push(SignificantToken { + kind, + text: body.to_owned(), + gap, + }); + *spacing = Spacing::default(); +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SignificantToken { kind: ScriptTokenKind, @@ -109,7 +147,8 @@ fn spacing_is_free(kind: ScriptTokenKind, text: &str) -> bool { | ScriptTokenKind::CloseParen | ScriptTokenKind::Semicolon | ScriptTokenKind::LineComment - | ScriptTokenKind::BlockComment => true, + | ScriptTokenKind::BlockComment + | ScriptTokenKind::ExecutableMarker => true, ScriptTokenKind::Punct => matches!( text, "," | "." | "=" | "<" | ">" | "+" | "-" | "*" | "/" | "%" | "^" | "|" | "!" | "~" | "[" | "]" @@ -130,6 +169,22 @@ mod tests { tokens_for(text, SqlGrammar::PostgreSql) } + #[test] + fn a_mysql_executable_comment_must_stay_as_written() { + // Under NO_BACKSLASH_ESCAPES the indented line is inside a string. + let source = "SELECT 1 /*! , 'a\\' AS x, '\n foo\n' */"; + let reindented = "SELECT 1 /*! , 'a\\' AS x, '\n foo\n' */"; + + assert_ne!( + tokens_for(source, SqlGrammar::MySql), + tokens_for(reindented, SqlGrammar::MySql) + ); + assert_eq!( + tokens_for(source, SqlGrammar::MySql), + tokens_for(&source.replace("1 /*!", "1\n/*!"), SqlGrammar::MySql) + ); + } + #[test] fn spacing_alone_does_not_change_a_statement() { assert_eq!(tokens("select a ,b from t"), tokens("select a, b\nfrom t")); diff --git a/linux/crates/core/src/sql_syntax/history_recording.rs b/linux/crates/core/src/sql_syntax/history_recording.rs index ef0f369e16..fc23aaa81b 100644 --- a/linux/crates/core/src/sql_syntax/history_recording.rs +++ b/linux/crates/core/src/sql_syntax/history_recording.rs @@ -1,6 +1,8 @@ use super::SqlGrammar; use super::credential_rules::CredentialRules; -use super::statement_tokens::{StatementToken, lex_statements}; +use super::script::ScriptPlan; +use super::statement_access::CLIENT_SPLITS; +use super::statement_tokens::{StatementToken, lex_statements, statements_of}; /// Whether what the user ran may go into the query history. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -23,17 +25,43 @@ pub enum WithholdReason { /// nest. const MAX_BODY_DEPTH: u8 = 4; -/// Whether `sql` may be kept, split the way the editor splits it for -/// `grammar`. The text is never rewritten: an entry is kept whole or not +/// Whether `sql` may be kept, read every way a server of `grammar` may +/// read it. The text is never rewritten: an entry is kept whole or not /// at all, so History can neither show nor run a statement with a secret /// cut out of it. pub fn classify_for_history(grammar: SqlGrammar, rules: &CredentialRules, sql: &str) -> HistoryRecording { - let script = lex_statements(sql, grammar); - if script.unterminated { + classify_as_sent(grammar, rules, sql, CLIENT_SPLITS) +} + +/// The whole text, then each piece a `DELIMITER` line cuts it into, which +/// a client sends apart. +fn classify_as_sent(grammar: SqlGrammar, rules: &CredentialRules, sql: &str, splits: u8) -> HistoryRecording { + let whole = classify_every_way(grammar, rules, sql); + if whole != HistoryRecording::Record || splits == 0 { + return whole; + } + ScriptPlan::client_pieces(sql, grammar) + .into_iter() + .map(|piece| classify_as_sent(grammar, rules, piece, splits - 1)) + .find(|recording| *recording != HistoryRecording::Record) + .unwrap_or(HistoryRecording::Record) +} + +fn classify_every_way(grammar: SqlGrammar, rules: &CredentialRules, sql: &str) -> HistoryRecording { + let Some(plans) = ScriptPlan::server_readings(sql, grammar) else { return HistoryRecording::Withhold(WithholdReason::Untokenizable); + }; + let mut unterminated = false; + for plan in plans { + let script = statements_of(sql, &plan); + if script.unterminated { + unterminated = true; + } else if carries_credentials(grammar, rules, &script.statements, 0) { + return HistoryRecording::Withhold(WithholdReason::CredentialStatement); + } } - match carries_credentials(grammar, rules, &script.statements, 0) { - true => HistoryRecording::Withhold(WithholdReason::CredentialStatement), + match unterminated { + true => HistoryRecording::Withhold(WithholdReason::Untokenizable), false => HistoryRecording::Record, } } @@ -395,6 +423,23 @@ mod tests { ); } + #[test] + fn a_credential_any_server_reading_runs_is_withheld() { + for sql in [ + "SELECT 1 /*!, '*/'; CREATE USER u IDENTIFIED BY 'p'; SELECT ' */", + "SELECT 1 /*!50000 # */ '\n; CREATE USER u IDENTIFIED BY 'p'; -- '\n*/", + // A server older than 99999 skips the comment past its nested + // `/* */`, so the CREATE USER after it runs. + "SELECT 1 /*!99999 /* */ ' */ ; CREATE USER u IDENTIFIED BY 'p'; -- ' */", + // Split again at its DELIMITER line, the last line goes alone. + "SELECT 1;\nDELIMITER ; /*!99999 '*/ `' */\nSELECT 1 /*!99999 ' */ ; CREATE USER u IDENTIFIED BY 'p'; -- ` ' */", + // Under NO_BACKSLASH_ESCAPES the first string ends at `\'`. + r"INSERT INTO paths VALUES ('C:\temp\'); CREATE USER u IDENTIFIED BY 'p'; -- it's done", + ] { + assert_ne!(classify(SqlGrammar::MySql, sql), HistoryRecording::Record, "{sql}"); + } + } + proptest! { #[test] fn any_text_classifies_without_panicking(sql in "\\PC{0,120}", grammar in 0usize..5) { diff --git a/linux/crates/core/src/sql_syntax/script/lexer.rs b/linux/crates/core/src/sql_syntax/script/lexer.rs index b090a8badf..82df74f541 100644 --- a/linux/crates/core/src/sql_syntax/script/lexer.rs +++ b/linux/crates/core/src/sql_syntax/script/lexer.rs @@ -4,8 +4,12 @@ use std::ops::Range; use crate::sql_syntax::script::script_rules::{ BackslashPolicy, BracketIdentifier, DollarQuote, HashComment, QuoteKind, QuoteRule, ScriptSyntax, }; +use crate::sql_syntax::script::server_reading::{CommentRead, NameQuotes, ServerReading, dollar_tag_end, read_at}; use crate::sql_syntax::script::{LexicalSettings, OpenConstruct, ScriptTokenKind}; +/// MariaDB's MSSQL mode ends a `[name]` at a `]` that is not doubled. +const MARIADB_BRACKETS: BracketIdentifier = BracketIdentifier::Doubling; + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LexedToken { pub kind: ScriptTokenKind, @@ -18,19 +22,29 @@ pub(crate) struct Lexer<'a> { bytes: &'a [u8], syntax: &'static ScriptSyntax, settings: LexicalSettings, + reading: ServerReading, pos: usize, missing_heredoc_closers: HashSet<&'a str>, + /// Where the executable comment the lexer is inside opened. + executable: Option, } impl<'a> Lexer<'a> { - pub fn new(text: &'a str, syntax: &'static ScriptSyntax, settings: LexicalSettings) -> Self { + pub fn new( + text: &'a str, + syntax: &'static ScriptSyntax, + settings: LexicalSettings, + reading: ServerReading, + ) -> Self { Self { text, bytes: text.as_bytes(), syntax, settings, + reading, pos: 0, missing_heredoc_closers: HashSet::new(), + executable: None, } } @@ -38,6 +52,10 @@ impl<'a> Lexer<'a> { self.pos } + pub fn open_executable_comment(&self) -> Option { + self.executable + } + pub fn at_line_start(&self) -> bool { self.pos == 0 || self.bytes.get(self.pos - 1) == Some(&b'\n') } @@ -50,6 +68,12 @@ impl<'a> Lexer<'a> { let start = self.pos; let first = *self.bytes.get(start)?; let token = match delimiter { + // The server ends an executable comment at the first `*/` + // that stands where a token could start. + _ if self.executable.is_some() && self.text[start..].starts_with("*/") => { + self.executable = None; + self.plain(ScriptTokenKind::ExecutableMarker, start + 2) + } Some(delimiter) if !delimiter.is_empty() && self.text[start..].starts_with(delimiter) => { self.plain(ScriptTokenKind::Semicolon, start + delimiter.len()) } @@ -65,10 +89,10 @@ impl<'a> Lexer<'a> { b if is_space(b) => self.plain(ScriptTokenKind::Whitespace, self.whitespace_end(start)), b'-' if next == Some(b'-') && self.dash_comment_starts(start) => self.line_comment(start), b'#' if self.hash_comment_starts(next) => self.line_comment(start), - b'/' if next == Some(b'*') => self.block_comment(start), + b'/' if next == Some(b'*') => self.comment(start), b'/' if next == Some(b'/') && self.syntax.slash_slash_comment => self.line_comment(start), b'\'' => self.quoted(start, self.syntax.single_quote), - b'"' => self.quoted(start, self.syntax.double_quote), + b'"' => self.quoted(start, self.double_quote()), b'`' => match self.syntax.backtick { Some(rule) => self.quoted(start, rule), None => self.plain(ScriptTokenKind::Punct, start + 1), @@ -143,6 +167,47 @@ impl<'a> Lexer<'a> { self.plain(ScriptTokenKind::LineComment, end) } + fn comment(&mut self, start: usize) -> LexedToken { + let read = match self.syntax.executable_comments { + true => read_at(&self.bytes[start..], self.reading), + false => None, + }; + match read { + None => self.block_comment(start), + Some(CommentRead::Runs { content_start }) => { + self.executable.get_or_insert(start); + self.plain(ScriptTokenKind::ExecutableMarker, start + content_start) + } + Some(CommentRead::Skipped) => self.skipped_comment(start), + } + } + + /// A comment a server's version skips, read as its `consume_comment` + /// reads it: bytes to the first `*/`, past one level of `/* */`. + fn skipped_comment(&self, start: usize) -> LexedToken { + let close = |from: usize| { + self.text + .get(from..) + .and_then(|rest| rest.find("*/")) + .map(|offset| from + offset + 2) + }; + let mut index = start + 2; + while let Some(&b) = self.bytes.get(index) { + let next = self.bytes.get(index + 1).copied(); + if b == b'/' && next == Some(b'*') { + match close(index + 2) { + Some(end) => index = end, + None => break, + } + } else if b == b'*' && next == Some(b'/') { + return self.plain(ScriptTokenKind::BlockComment, index + 2); + } else { + index += 1; + } + } + self.unterminated(start, OpenConstruct::BlockComment) + } + fn block_comment(&self, start: usize) -> LexedToken { let mut depth = 1u32; let mut index = start + 2; @@ -206,8 +271,22 @@ impl<'a> Lexer<'a> { } } + fn double_quote(&self) -> QuoteRule { + match self.reading.name_quotes() { + NameQuotes::Default => self.syntax.double_quote, + NameQuotes::Ansi | NameQuotes::Brackets => QuoteRule { + kind: QuoteKind::QuotedIdentifier, + backslash: BackslashPolicy::Never, + }, + } + } + fn bracketed(&self, start: usize) -> LexedToken { - let doubling = match self.syntax.bracket_identifier { + let rule = match self.reading.name_quotes() { + NameQuotes::Brackets => MARIADB_BRACKETS, + NameQuotes::Default | NameQuotes::Ansi => self.syntax.bracket_identifier, + }; + let doubling = match rule { BracketIdentifier::None => return self.plain(ScriptTokenKind::Punct, start + 1), BracketIdentifier::NoDoubling => false, BracketIdentifier::Doubling => true, @@ -224,6 +303,11 @@ impl<'a> Lexer<'a> { } fn dollar(&mut self, start: usize, delimiter: Option<&str>) -> LexedToken { + if self.reading.quotes_dollar_tags() + && let Some(token) = self.mysql_dollar_quote(start) + { + return token; + } match self.syntax.dollar_quote { DollarQuote::Tagged => { if let Some(opener_end) = self.tagged_opener_end(start) { @@ -247,6 +331,34 @@ impl<'a> Lexer<'a> { self.plain(ScriptTokenKind::Punct, start + 1) } + /// MySQL 8.1 and later quote text in `$tag$ … $tag$` where a token + /// starts, as `get_dollar_quoted_text` reads it. A `$` that breaks a + /// partial match of the closer cannot start the next one. + fn mysql_dollar_quote(&self, start: usize) -> Option { + // After `.` or `@` the server is inside a name. + if start + .checked_sub(1) + .and_then(|before| self.bytes.get(before)) + .is_some_and(|b| matches!(b, b'.' | b'@')) + { + return None; + } + let opener_end = dollar_tag_end(self.bytes, start)?; + let closer = &self.bytes[start..opener_end]; + let mut matched = 0; + for (index, &b) in self.bytes.iter().enumerate().skip(opener_end) { + if matched > 0 && b == closer[matched] { + matched += 1; + if matched == closer.len() { + return Some(self.plain(ScriptTokenKind::DollarQuoted, index + 1)); + } + } else { + matched = usize::from(matched == 0 && b == b'$'); + } + } + Some(self.unterminated(start, OpenConstruct::DollarQuoted)) + } + fn tagged_opener_end(&self, start: usize) -> Option { let mut index = start + 1; let first = *self.bytes.get(index)?; diff --git a/linux/crates/core/src/sql_syntax/script/mod.rs b/linux/crates/core/src/sql_syntax/script/mod.rs index 90773ecf49..651bb4d3f7 100644 --- a/linux/crates/core/src/sql_syntax/script/mod.rs +++ b/linux/crates/core/src/sql_syntax/script/mod.rs @@ -11,6 +11,7 @@ mod script_rules; mod script_statement; mod script_token; mod script_token_kind; +pub(crate) mod server_reading; pub mod text_offsets; pub use batch_error_policy::BatchErrorPolicy; diff --git a/linux/crates/core/src/sql_syntax/script/script_plan.rs b/linux/crates/core/src/sql_syntax/script/script_plan.rs index a0e008b2b5..4c12370048 100644 --- a/linux/crates/core/src/sql_syntax/script/script_plan.rs +++ b/linux/crates/core/src/sql_syntax/script/script_plan.rs @@ -6,8 +6,10 @@ use crate::sql_syntax::script::compound_tracker::CompoundTracker; use crate::sql_syntax::script::directive_scan::{delimiter_line, go_line}; use crate::sql_syntax::script::lexer::{LexedToken, Lexer}; use crate::sql_syntax::script::script_rules::{Boundary, ScriptRules, rules_for}; +use crate::sql_syntax::script::server_reading::{self, ServerReading}; use crate::sql_syntax::script::{ - BatchErrorPolicy, LexicalSettings, ScriptBatch, ScriptDiagnostic, ScriptStatement, ScriptToken, ScriptTokenKind, + BatchErrorPolicy, LexicalSettings, OpenConstruct, ScriptBatch, ScriptDiagnostic, ScriptStatement, ScriptToken, + ScriptTokenKind, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -21,7 +23,43 @@ pub struct ScriptPlan { impl ScriptPlan { pub fn build(text: &str, grammar: SqlGrammar, settings: LexicalSettings) -> ScriptPlan { - Builder::new(text, rules_for(grammar), settings).run() + Self::read_as(text, grammar, settings, ServerReading::Client) + } + + pub(crate) fn read_as( + text: &str, + grammar: SqlGrammar, + settings: LexicalSettings, + reading: ServerReading, + ) -> ScriptPlan { + Builder::new(text, rules_for(grammar), settings, reading).run() + } + + /// Every plan the client or a server may make of `text`: as the client + /// splits it and, on MySQL, as each server reads what it is sent, at + /// each version its executable comments name. Each with backslash + /// escapes on and off, since a session setting decides. Built one at a + /// time. `None` when the text names too many versions to try. + pub fn server_readings(text: &str, grammar: SqlGrammar) -> Option + '_> { + let readings = server_reading::server_readings(text, grammar)?; + Some(readings.into_iter().flat_map(move |reading| { + [false, true] + .map(|backslash_escapes| Self::read_as(text, grammar, LexicalSettings { backslash_escapes }, reading)) + })) + } + + /// The pieces a client sends one at a time when a `DELIMITER` or `GO` + /// line splits `text`, each of which a server reads anew. Empty when + /// no such line does. + pub(crate) fn client_pieces(text: &str, grammar: SqlGrammar) -> Vec<&str> { + let plan = Self::build(text, grammar, LexicalSettings::default_for(grammar)); + if !plan.tokens.iter().any(|token| token.kind == ScriptTokenKind::Directive) { + return Vec::new(); + } + plan.statements + .iter() + .filter_map(|statement| text.get(statement.range.clone())) + .collect() } pub fn batches(&self) -> &[ScriptBatch] { @@ -62,6 +100,7 @@ struct Builder<'a> { text: &'a str, rules: &'static ScriptRules, lexer: Lexer<'a>, + reads_directives: bool, tracker: CompoundTracker, delimiter: &'a str, open: Option>, @@ -72,11 +111,12 @@ struct Builder<'a> { } impl<'a> Builder<'a> { - fn new(text: &'a str, rules: &'static ScriptRules, settings: LexicalSettings) -> Self { + fn new(text: &'a str, rules: &'static ScriptRules, settings: LexicalSettings, reading: ServerReading) -> Self { Self { text, rules, - lexer: Lexer::new(text, &rules.syntax, settings), + lexer: Lexer::new(text, &rules.syntax, settings, reading), + reads_directives: reading.reads_directives(), tracker: CompoundTracker::new(rules.compound, rules.parens_hold_semicolons), delimiter: ";", open: None, @@ -89,7 +129,7 @@ impl<'a> Builder<'a> { fn run(mut self) -> ScriptPlan { while self.lexer.position() < self.text.len() { - if self.lexer.at_line_start() && self.accept_directive() { + if self.reads_directives && self.lexer.at_line_start() && self.accept_directive() { continue; } let delimiter = (self.rules.boundary == Boundary::DelimiterDirectives).then_some(self.delimiter); @@ -98,6 +138,14 @@ impl<'a> Builder<'a> { }; self.accept(token); } + if let Some(start) = self.lexer.open_executable_comment() + && self.diagnostics.is_empty() + { + self.diagnostics.push(ScriptDiagnostic::Unterminated { + construct: OpenConstruct::BlockComment, + start, + }); + } self.finish_statement(None, NonZeroU32::MIN); self.extend_extents(); ScriptPlan { diff --git a/linux/crates/core/src/sql_syntax/script/script_rules.rs b/linux/crates/core/src/sql_syntax/script/script_rules.rs index a3ffa20dcb..7eae866b20 100644 --- a/linux/crates/core/src/sql_syntax/script/script_rules.rs +++ b/linux/crates/core/src/sql_syntax/script/script_rules.rs @@ -70,6 +70,8 @@ pub(crate) struct ScriptSyntax { pub hash_comment: HashComment, pub slash_slash_comment: bool, pub nested_block_comments: bool, + /// MySQL and MariaDB run what `/*!` and `/*M!` comments hold. + pub executable_comments: bool, pub dollar_quote: DollarQuote, pub word_start_extra: &'static [u8], pub word_part_extra: &'static [u8], @@ -104,6 +106,7 @@ const POSTGRESQL: ScriptRules = ScriptRules { hash_comment: HashComment::None, slash_slash_comment: false, nested_block_comments: true, + executable_comments: false, dollar_quote: DollarQuote::Tagged, word_start_extra: b"", word_part_extra: b"$", @@ -126,6 +129,7 @@ const MYSQL: ScriptRules = ScriptRules { hash_comment: HashComment::Always, slash_slash_comment: false, nested_block_comments: false, + executable_comments: true, dollar_quote: DollarQuote::None, word_start_extra: b"$", word_part_extra: b"$", @@ -148,6 +152,7 @@ const SQLITE: ScriptRules = ScriptRules { hash_comment: HashComment::None, slash_slash_comment: false, nested_block_comments: false, + executable_comments: false, dollar_quote: DollarQuote::None, word_start_extra: b"$", word_part_extra: b"$", @@ -170,6 +175,7 @@ const MSSQL: ScriptRules = ScriptRules { hash_comment: HashComment::None, slash_slash_comment: false, nested_block_comments: true, + executable_comments: false, dollar_quote: DollarQuote::None, word_start_extra: b"@#", word_part_extra: b"@#$", @@ -192,6 +198,7 @@ const CLICKHOUSE: ScriptRules = ScriptRules { hash_comment: HashComment::SpaceOrBang, slash_slash_comment: true, nested_block_comments: true, + executable_comments: false, dollar_quote: DollarQuote::Heredoc, word_start_extra: b"", word_part_extra: b"", diff --git a/linux/crates/core/src/sql_syntax/script/script_token_kind.rs b/linux/crates/core/src/sql_syntax/script/script_token_kind.rs index 81519e2a24..a83e0b177b 100644 --- a/linux/crates/core/src/sql_syntax/script/script_token_kind.rs +++ b/linux/crates/core/src/sql_syntax/script/script_token_kind.rs @@ -13,6 +13,9 @@ pub enum ScriptTokenKind { CloseParen, Punct, Directive, + /// The `/*!50100` that opens a MySQL executable comment, or the `*/` + /// that closes it. What stands between the two is code. + ExecutableMarker, Unterminated, } diff --git a/linux/crates/core/src/sql_syntax/script/server_reading.rs b/linux/crates/core/src/sql_syntax/script/server_reading.rs new file mode 100644 index 0000000000..552ae1cefd --- /dev/null +++ b/linux/crates/core/src/sql_syntax/script/server_reading.rs @@ -0,0 +1,399 @@ +use std::collections::{BTreeSet, HashSet}; +use std::ops::RangeInclusive; + +use crate::sql_syntax::SqlGrammar; + +/// How the lexer reads the MySQL text whose meaning hangs on who reads +/// it: an executable comment (`/*! … */`, `/*!50100 … */`, MariaDB's +/// `/*M!100100 … */`), a `$tag$` quote and a `DELIMITER` line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ServerReading { + /// As the mysql client reads a script to split it: every executable + /// comment is code, `$` is a name character, and a `DELIMITER` line + /// is the client's. + Client, + /// What one server session runs of the text it is sent: the comments + /// its version allows, names quoted as its sql_mode says, and no + /// `DELIMITER` line, which only a client reads. + Server { + server: Server, + version: u32, + quotes: NameQuotes, + }, +} + +impl ServerReading { + pub(crate) fn reads_directives(self) -> bool { + self == Self::Client + } + + pub(crate) fn quotes_dollar_tags(self) -> bool { + matches!( + self, + Self::Server { + server: Server::MySql81, + .. + } + ) + } + + pub(crate) fn name_quotes(self) -> NameQuotes { + match self { + Self::Client => NameQuotes::Default, + Self::Server { quotes, .. } => quotes, + } + } +} + +/// What a session's sql_mode makes of `"` and `[`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum NameQuotes { + /// `"` quotes a string and `[` is punctuation. + Default, + /// ANSI_QUOTES: `"` quotes a name, where a backslash escapes nothing. + Ansi, + /// MariaDB's MSSQL mode: ANSI_QUOTES, and `[name]` quotes a name. + Brackets, +} + +/// The servers that read the same text differently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Server { + /// Up to 8.0: five version digits, and `$` is a name character. + MySql80, + /// 8.1 and later: six version digits when whitespace follows them, + /// and `$tag$` opens a quote. + MySql81, + MariaDb, + /// A Galera node also runs `/*!99997` comments. + MariaDbGalera, +} + +impl Server { + const ALL: [Server; 4] = [Self::MySql80, Self::MySql81, Self::MariaDb, Self::MariaDbGalera]; + + fn mariadb(self) -> bool { + matches!(self, Self::MariaDb | Self::MariaDbGalera) + } +} + +/// What one executable comment is under a reading. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum CommentRead { + /// Code from `content_start` to the `*/` that ends it. + Runs { content_start: usize }, + /// Skipped to its first `*/`, past one `/* */` of its own. + Skipped, +} + +/// More versions than this in one text are more readings than worth +/// trying, so the text counts as unreadable. +const MAX_VERSIONS: usize = 16; + +/// The bytes all readings of one text may lex between them, about a +/// second's work, since Safe Mode reads a statement before it runs. +const MAX_LEXED_BYTES: usize = 32 * 1024 * 1024; + +/// MariaDB skips `/*!` comments for these MySQL versions, whose syntax +/// it may lack. +const MARIADB_SKIPS: RangeInclusive = 50_700..=99_999; + +const GALERA_RUNS: u32 = 99_997; + +/// `bytes` starts at a `/*`. `None` when the reading takes it for a +/// plain comment. +pub(crate) fn read_at(bytes: &[u8], reading: ServerReading) -> Option { + let (mariadb_marker, marker_len) = match bytes.get(2..)? { + [b'!', ..] => (false, 3), + [b'M', b'!', ..] => (true, 4), + _ => return None, + }; + let after = bytes.get(marker_len..).unwrap_or_default(); + let ServerReading::Server { server, version, .. } = reading else { + let digits = after.iter().take_while(|b| b.is_ascii_digit()).count(); + return Some(CommentRead::Runs { + content_start: marker_len + digits, + }); + }; + // MySQL knows no `/*M!`. + if mariadb_marker && !server.mariadb() { + return None; + } + let Some((gate, digits)) = version_gate(after, server) else { + return Some(CommentRead::Runs { + content_start: marker_len, + }); + }; + let skipped = gate > version || (server.mariadb() && !mariadb_marker && MARIADB_SKIPS.contains(&gate)); + Some(match skipped { + // Galera runs a skipped 99997 past five digits only, a sixth + // left as code. + true if server == Server::MariaDbGalera && gate == GALERA_RUNS => CommentRead::Runs { + content_start: marker_len + 5, + }, + true => CommentRead::Skipped, + false => CommentRead::Runs { + content_start: marker_len + digits, + }, + }) +} + +/// The version `after`'s leading digits gate the comment on, and how many +/// digits that takes. With fewer than five the comment always runs and +/// the digits are code. +fn version_gate(after: &[u8], server: Server) -> Option<(u32, usize)> { + let digit = |at: usize| after.get(at).is_some_and(u8::is_ascii_digit); + if !(0..5).all(digit) { + return None; + } + let six = digit(5) + && match server { + Server::MySql80 => false, + Server::MySql81 => after + .get(6) + .is_some_and(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)), + Server::MariaDb | Server::MariaDbGalera => true, + }; + let len = if six { 6 } else { 5 }; + let version = after[..len] + .iter() + .fold(0u32, |value, digit| value * 10 + u32::from(digit - b'0')); + Some((version, len)) +} + +/// Where the `$tag$` opener that starts at `at` ends, when one does: a +/// tag of letters, digits, `_` or multibyte characters, maybe empty. +pub(crate) fn dollar_tag_end(bytes: &[u8], at: usize) -> Option { + let tag = bytes + .get(at + 1..)? + .iter() + .take_while(|&&b| b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80) + .count(); + let end = at + tag + 2; + (bytes.get(end - 1) == Some(&b'$')).then_some(end) +} + +/// The client's reading of `text`, then each distinct one the servers it +/// may meet give it: each kind of server at version 0 and at each version +/// the text's comments name, since only those change which comments run. +/// `None` when there are more than are worth trying. +pub(crate) fn server_readings(text: &str, grammar: SqlGrammar) -> Option> { + let mut readings = vec![ServerReading::Client]; + if grammar != SqlGrammar::MySql { + return Some(readings); + } + let bytes = text.as_bytes(); + // Every marker counts, even one inside a string, since another reading + // may find it outside one. + let markers: Vec = ["/*!", "/*M!"] + .into_iter() + .flat_map(|marker| text.match_indices(marker).map(|(at, _)| at)) + .collect(); + let dollars = bytes + .iter() + .enumerate() + .any(|(at, &b)| b == b'$' && dollar_tag_end(bytes, at).is_some()); + let directives = text.lines().any(|line| { + let line = line.trim_start(); + line.get(..9).is_some_and(|word| word.eq_ignore_ascii_case("delimiter")) || line.starts_with("\\d") + }); + let mut quotes = vec![NameQuotes::Default]; + // Without a backslash, a `"` name and a `"` string end alike. + if text.contains('"') && text.contains('\\') { + quotes.push(NameQuotes::Ansi); + } + if text.contains('[') { + quotes.push(NameQuotes::Brackets); + } + if markers.is_empty() && !dollars && !directives && quotes.len() == 1 { + return Some(readings); + } + // A marker's reading turns on its opener, six digits and the byte + // after them, so markers alike there read alike. + let shapes: BTreeSet<&[u8]> = markers + .iter() + .filter_map(|&at| bytes.get(at..(at + 11).min(bytes.len()))) + .collect(); + let mut versions = BTreeSet::from([0]); + for shape in &shapes { + let marker_len = if shape.get(2) == Some(&b'M') { 4 } else { 3 }; + let after = shape.get(marker_len..).unwrap_or_default(); + versions.extend( + Server::ALL + .into_iter() + .filter_map(|server| version_gate(after, server).map(|(version, _)| version)), + ); + if versions.len() > MAX_VERSIONS { + return None; + } + } + // Two readings that read every marker, `$tag$` and name quote alike + // lex the text alike. + let mut seen = HashSet::new(); + for server in Server::ALL { + for &version in &versions { + for "es in "es { + if quotes == NameQuotes::Brackets && !server.mariadb() { + continue; + } + let reading = ServerReading::Server { + server, + version, + quotes, + }; + let decisions: Vec> = shapes.iter().map(|shape| read_at(shape, reading)).collect(); + if seen.insert((reading.quotes_dollar_tags() && dollars, quotes, decisions)) { + readings.push(reading); + // Each reading is lexed with backslash escapes on and off. + if readings.len() * 2 * text.len() > MAX_LEXED_BYTES { + return None; + } + } + } + } + } + Some(readings) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(comment: &str, server: Server, version: u32) -> Option { + read_at( + comment.as_bytes(), + ServerReading::Server { + server, + version, + quotes: NameQuotes::Default, + }, + ) + } + + #[test] + fn a_version_comment_runs_on_a_server_at_least_that_new() { + assert_eq!( + read("/*!80047 x */", Server::MySql80, 80_046), + Some(CommentRead::Skipped) + ); + assert_eq!( + read("/*!80047 x */", Server::MySql81, 80_411), + Some(CommentRead::Runs { content_start: 8 }) + ); + assert_eq!( + read("/*!8004 x */", Server::MySql80, 0), + Some(CommentRead::Runs { content_start: 3 }) + ); + assert_eq!( + read("/*! x */", Server::MariaDb, 0), + Some(CommentRead::Runs { content_start: 3 }) + ); + } + + #[test] + fn each_server_reads_a_sixth_digit_its_own_way() { + let comment = "/*!101119 x */"; + assert_eq!( + read(comment, Server::MySql80, 99_999), + Some(CommentRead::Runs { content_start: 8 }) + ); + assert_eq!(read(comment, Server::MySql81, 99_999), Some(CommentRead::Skipped)); + assert_eq!( + read("/*!101119,x */", Server::MySql81, 99_999), + Some(CommentRead::Runs { content_start: 8 }) + ); + assert_eq!( + read("/*!101119,x */", Server::MariaDb, 101_118), + Some(CommentRead::Skipped) + ); + } + + #[test] + fn mariadb_skips_mysql_57_comments_and_mysql_knows_no_mariadb_ones() { + assert_eq!( + read("/*!50700 x */", Server::MariaDb, 101_119), + Some(CommentRead::Skipped) + ); + assert_eq!( + read("/*M!50700 x */", Server::MariaDb, 101_119), + Some(CommentRead::Runs { content_start: 9 }) + ); + assert_eq!(read("/*M! x */", Server::MySql81, 90_700), None); + assert_eq!(read("/* x */", Server::MariaDb, 0), None); + } + + #[test] + fn a_galera_node_runs_99997_comments_mariadb_skips() { + for (comment, galera) in [ + ("/*!99997 x */", Some(CommentRead::Runs { content_start: 8 })), + ("/*!099997 x */", Some(CommentRead::Runs { content_start: 8 })), + ("/*!999970 x */", Some(CommentRead::Skipped)), + ] { + assert_eq!(read(comment, Server::MariaDbGalera, 101_119), galera, "{comment}"); + assert_eq!( + read(comment, Server::MariaDb, 101_119), + Some(CommentRead::Skipped), + "{comment}" + ); + } + } + + #[test] + fn each_distinct_reading_is_tried_once() { + let text = "/*!40101 a */ /*!101119 b */"; + let readings = server_readings(text, SqlGrammar::MySql).unwrap_or_default(); + let decisions: Vec>> = readings + .iter() + .map(|&reading| { + [0, 14] + .map(|at| read_at(text.as_bytes().get(at..).unwrap_or_default(), reading)) + .to_vec() + }) + .collect(); + let runs = |content_start| Some(CommentRead::Runs { content_start }); + let skipped = Some(CommentRead::Skipped); + + assert_eq!( + decisions, + [ + vec![runs(8), runs(9)], + vec![skipped, skipped], + vec![skipped, runs(8)], + vec![runs(8), runs(8)], + vec![runs(8), skipped], + vec![runs(8), runs(9)], + ] + ); + assert_eq!( + server_readings("SELECT 1", SqlGrammar::MySql), + Some(vec![ServerReading::Client]) + ); + // A server reads a DELIMITER line as SQL. + assert_eq!( + server_readings("SELECT 1;\n delimiter //", SqlGrammar::MySql).map(|readings| readings.len()), + Some(2) + ); + assert_eq!( + server_readings("SELECT $$x$$", SqlGrammar::MySql).map(|readings| readings.len()), + Some(3) + ); + assert_eq!( + server_readings("SELECT data->'$.a', $x FROM t", SqlGrammar::MySql), + Some(vec![ServerReading::Client]) + ); + assert_eq!( + server_readings("SELECT /*! 1 */", SqlGrammar::PostgreSql), + Some(vec![ServerReading::Client]) + ); + } + + #[test] + fn too_many_readings_are_unreadable() { + let versions: String = (0..20).map(|n| format!("/*!{} x */", 40_000 + n)).collect(); + let long = format!("/*!40000 x */ /*M!40001 y */ '\"\\[' {}", "x".repeat(2 * 1024 * 1024)); + + assert_eq!(server_readings(&versions, SqlGrammar::MySql), None); + assert_eq!(server_readings(&long, SqlGrammar::MySql), None); + assert!(server_readings(&"x".repeat(64 * 1024 * 1024), SqlGrammar::MySql).is_some()); + } +} diff --git a/linux/crates/core/src/sql_syntax/statement_access.rs b/linux/crates/core/src/sql_syntax/statement_access.rs index e35bffe4f8..9fefce9d92 100644 --- a/linux/crates/core/src/sql_syntax/statement_access.rs +++ b/linux/crates/core/src/sql_syntax/statement_access.rs @@ -1,5 +1,7 @@ +use std::collections::HashMap; + use crate::sql_syntax::SqlGrammar; -use crate::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptToken, ScriptTokenKind}; +use crate::sql_syntax::script::{ScriptPlan, ScriptToken, ScriptTokenKind}; /// What running a piece of SQL can do, from its own words. Ordered, so /// the strictest of several statements is their maximum. @@ -148,34 +150,58 @@ const CLAUSE_WORDS: [&str; 15] = [ /// The strictest access among the statements of `sql`. /// /// A statement reads only when its words show it does, so anything -/// unrecognized counts as a write. Where a string ends can hang on a -/// session setting the app cannot see, such as `NO_BACKSLASH_ESCAPES`, -/// so the text is read both ways and the stricter answer stands. +/// unrecognized counts as a write. The text is read every way a server +/// may read it and the strictest answer stands: where a string ends can +/// hang on a session setting the app cannot see, such as +/// `NO_BACKSLASH_ESCAPES`, and which MySQL executable comments run hangs +/// on the server's version. Each piece a `DELIMITER` line cuts it into is +/// read on its own too, since a client sends them apart. pub fn statement_access(sql: &str, grammar: SqlGrammar) -> StatementAccess { - [false, true] + access_as_sent(sql, grammar, CLIENT_SPLITS) +} + +/// How many times a script can be split by `DELIMITER` lines on its way: +/// once by the editor, once more by the driver. +pub(crate) const CLIENT_SPLITS: u8 = 2; + +fn access_as_sent(sql: &str, grammar: SqlGrammar, splits: u8) -> StatementAccess { + let whole = access_every_way(sql, grammar); + if whole == StatementAccess::Destructive || splits == 0 { + return whole; + } + ScriptPlan::client_pieces(sql, grammar) .into_iter() - .map(|backslash_escapes| access_under(sql, grammar, LexicalSettings { backslash_escapes })) - .max() - .unwrap_or(StatementAccess::Write) + .map(|piece| access_as_sent(piece, grammar, splits - 1)) + .fold(whole, StatementAccess::max) +} + +fn access_every_way(sql: &str, grammar: SqlGrammar) -> StatementAccess { + let Some(plans) = ScriptPlan::server_readings(sql, grammar) else { + return StatementAccess::Destructive; + }; + let mut strictest = None; + for plan in plans { + let access = access_in(sql, grammar, &plan); + if access == StatementAccess::Destructive { + return access; + } + strictest = strictest.max(Some(access)); + } + strictest.unwrap_or(StatementAccess::Write) } -fn access_under(sql: &str, grammar: SqlGrammar, settings: LexicalSettings) -> StatementAccess { - let plan = ScriptPlan::build(sql, grammar, settings); +fn access_in(sql: &str, grammar: SqlGrammar, plan: &ScriptPlan) -> StatementAccess { let unreadable = match plan.diagnostics().is_empty() { true => StatementAccess::Read, false => StatementAccess::Write, }; - // A comment between statements is sent too, so every token is - // searched, not only those inside a statement. - let all: Vec = plan.tokens(0..sql.len()).collect(); - let hidden = executable_comments(sql, grammar, &all); plan.statements() .iter() .map(|statement| { let tokens: Vec = plan.tokens(statement.range.clone()).collect(); access_of(sql, grammar, &tokens) }) - .fold(unreadable.max(hidden), StatementAccess::max) + .fold(unreadable, StatementAccess::max) } fn access_of(sql: &str, grammar: SqlGrammar, tokens: &[ScriptToken]) -> StatementAccess { @@ -254,27 +280,6 @@ fn opens_as_read(part: &[Code<'_>], grammar: SqlGrammar) -> bool { } } -/// MySQL and MariaDB run the text inside `/*! … */` and `/*M! … */`, so -/// a statement holding one is at least a write, and as harmful as what -/// the comment holds. -fn executable_comments(sql: &str, grammar: SqlGrammar, tokens: &[ScriptToken]) -> StatementAccess { - if grammar != SqlGrammar::MySql { - return StatementAccess::Read; - } - tokens - .iter() - .filter(|token| token.kind == ScriptTokenKind::BlockComment) - .filter_map(|token| sql.get(token.range.clone())) - .filter_map(|comment| comment.strip_prefix("/*!").or_else(|| comment.strip_prefix("/*M!"))) - .map(|inner| { - let inner = inner.strip_suffix("*/").unwrap_or(inner); - let inner = inner.trim_start_matches(|c: char| c.is_ascii_digit()); - statement_access(inner, grammar).max(StatementAccess::Write) - }) - .max() - .unwrap_or(StatementAccess::Read) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Kind { Word, @@ -308,7 +313,13 @@ fn code_of<'a>(sql: &'a str, tokens: &[ScriptToken]) -> Vec> { let mut depth = 0usize; tokens .iter() - .filter(|token| !token.kind.is_trivia() && token.kind != ScriptTokenKind::Directive) + .filter(|token| { + !token.kind.is_trivia() + && !matches!( + token.kind, + ScriptTokenKind::Directive | ScriptTokenKind::ExecutableMarker + ) + }) .map(|token| { let text = sql.get(token.range.clone()).unwrap_or_default(); let at = depth; @@ -377,15 +388,21 @@ fn defines_routine(statement: &[Code<'_>]) -> bool { /// data-modifying CTE. fn statement_destroys(code: &[Code<'_>], grammar: SqlGrammar) -> bool { let starts = statement_starts(code); - starts.iter().enumerate().any(|(nth, &start)| { + // Each start's segment runs to the next start at its depth, found in + // one pass from the end. + let mut next_at_depth: HashMap = HashMap::new(); + let mut ends = vec![code.len(); starts.len()]; + for (nth, &start) in starts.iter().enumerate().rev() { let depth = code[start].depth; - let end = starts[nth + 1..] - .iter() - .copied() - .find(|&next| code[next].depth == depth) - .unwrap_or(code.len()); - segment_destroys(&code[start..end], grammar) - }) + if let Some(&next) = next_at_depth.get(&depth) { + ends[nth] = next; + } + next_at_depth.insert(depth, start); + } + starts + .iter() + .zip(ends) + .any(|(&start, end)| segment_destroys(&code[start..end], grammar)) } fn statement_starts(code: &[Code<'_>]) -> Vec { @@ -780,12 +797,114 @@ mod tests { access("SELECT 1 /*! ; DROP TABLE orders */", SqlGrammar::MySql), StatementAccess::Destructive ); + for sql in [ + "SELECT 1 /*M!100000 , 2 */", + "SELECT /*!40001 SQL_NO_CACHE */ * FROM orders", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Read, "{sql}"); + } + // A server older than 99999 skips the SELECT and runs the SET. assert_eq!( - access("SELECT 1 /*M!100000 , 2 */", SqlGrammar::MySql), + access("/*!99999 SELECT */ SET GLOBAL read_only = 0", SqlGrammar::MySql), StatementAccess::Write ); } + #[test] + fn an_executable_comment_ends_where_the_server_ends_it() { + for sql in [ + "SELECT 1 /*!, '*/'; DROP TABLE orders; SELECT ' */", + "SELECT 1 /*! -- */ '\n; DROP TABLE orders; -- '\n*/", + "SELECT 1 /*! # */ '\n; DROP TABLE orders; -- '\n*/", + "SELECT 1 /*! AS `*/`; DROP TABLE orders; SELECT ` */", + "SELECT 1;\n/*! DROP TABLE orders;", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Destructive, "{sql}"); + } + } + + #[test] + fn a_skipped_executable_comment_ends_where_the_server_ends_it() { + for sql in [ + "/*!99999 ' */ DROP TABLE orders -- '", + "SELECT 1 /*!99999 /* */ ' */ ; DROP TABLE orders; -- '", + "SELECT 1 /*M!999999 /* */ ' */ ; DROP TABLE orders; -- '", + // MySQL reads `/*M!` as a plain comment, with no nesting. + "SELECT 1 /*M! ' /* x */ ; DROP TABLE orders; -- '", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Destructive, "{sql}"); + } + } + + #[test] + fn executable_comments_are_read_for_every_server_version() { + for sql in [ + "/*!40000 DELETE FROM orders */ /*!99999 WHERE id = 1 */", + "/*!99999 ON */ DROP TABLE orders", + "DELETE FROM orders /*!50700 WHERE id = 1 */", + "DELETE FROM orders /*M!100000 WHERE id = 1 */", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Destructive, "{sql}"); + } + } + + #[test] + fn a_mysql_81_dollar_quote_ends_where_the_server_ends_it() { + for sql in [ + "CREATE PROCEDURE p() LANGUAGE JAVASCRIPT AS $$ /* $$; DROP TABLE orders; -- */", + // A `$` that breaks a partial match of `$a$` cannot start one. + "CREATE FUNCTION f() RETURNS int LANGUAGE JAVASCRIPT AS $a$ $$a$ /* $a$; DROP TABLE orders; -- */", + "CREATE PROCEDURE p() LANGUAGE JAVASCRIPT /*!80100 AS $$ */ ' $$ */; DROP TABLE orders; -- '", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Destructive, "{sql}"); + } + for sql in ["SELECT a$b$c, t.$x$ FROM t", "SELECT '$$; DROP TABLE orders' FROM t"] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Read, "{sql}"); + } + } + + #[test] + fn a_line_only_a_client_reads_as_delimiter_is_sql_to_the_server() { + for sql in [ + "BEGIN NOT ATOMIC SELECT 1 /*!99999 ' */ ;\ndelimiter : LOOP DROP TABLE t; LEAVE delimiter; END LOOP;\nEND -- ' */", + "BEGIN NOT ATOMIC SELECT 'a\\' ;\ndelimiter : LOOP DROP TABLE t; LEAVE delimiter; END LOOP;\nEND -- '", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Destructive, "{sql}"); + } + } + + #[test] + fn a_name_quote_the_session_sql_mode_sets_takes_the_stricter_reading() { + for sql in [ + // ANSI_QUOTES: `"y\"` is a name, so the DROP stands outside it. + r#"SELECT 'a\' , ' AS "y\" ; DROP TABLE t ; -- " AS b, '\''"#, + // MariaDB's MSSQL mode reads `[a']` as a name. + "SELECT 1 AS [a'] ; DROP TABLE t ; -- ']", + ] { + assert_eq!(access(sql, SqlGrammar::MySql), StatementAccess::Destructive, "{sql}"); + } + } + + #[test] + fn each_piece_a_client_sends_is_read_on_its_own() { + // The editor reads the third line as part of the statement; split + // again with `;` as the delimiter, it is a DELIMITER line, and the + // last line goes alone. + let batch = "SELECT 1;\nDELIMITER ; /*!99999 '*/ `' */\nSELECT 1 /*!99999 ' */ ; DROP TABLE t; -- ` ' */"; + assert_eq!(access(batch, SqlGrammar::MySql), StatementAccess::Destructive); + } + + #[test] + fn a_galera_node_runs_99997_comments() { + assert_eq!( + access( + "/*M! /*!99997 DROP TABLE orders -- */ SELECT 1 -- \n*/", + SqlGrammar::MySql + ), + StatementAccess::Destructive + ); + } + #[test] fn a_quote_that_only_one_reading_closes_takes_the_stricter_reading() { // With backslash escapes the DELETE sits inside the string; the diff --git a/linux/crates/core/src/sql_syntax/statement_tokens.rs b/linux/crates/core/src/sql_syntax/statement_tokens.rs index 4584aa60dc..5fca53a6ae 100644 --- a/linux/crates/core/src/sql_syntax/statement_tokens.rs +++ b/linux/crates/core/src/sql_syntax/statement_tokens.rs @@ -51,11 +51,14 @@ pub(crate) struct LexedScript<'a> { } pub(crate) fn lex_statements(sql: &str, grammar: SqlGrammar) -> LexedScript<'_> { - let plan = ScriptPlan::build(sql, grammar, LexicalSettings::default_for(grammar)); - // Split at the terminators rather than taken statement by statement, - // so a comment the plan leaves outside every statement still counts: - // MySQL runs a `/*!` comment with whatever stands between the same - // two terminators, and the history keeps the whole text. + statements_of( + sql, + &ScriptPlan::build(sql, grammar, LexicalSettings::default_for(grammar)), + ) +} + +/// The statements of `sql` as `plan` reads them. +pub(crate) fn statements_of<'a>(sql: &'a str, plan: &ScriptPlan) -> LexedScript<'a> { let mut statements = Vec::new(); let mut current = Vec::new(); for token in plan.tokens(0..sql.len()) { @@ -72,7 +75,7 @@ pub(crate) fn lex_statements(sql: &str, grammar: SqlGrammar) -> LexedScript<'_> } continue; } - push_significant(sql, grammar, token.kind, token.range, &mut current); + push_significant(sql, token.kind, token.range, &mut current); } if !current.is_empty() { statements.push(current); @@ -85,22 +88,17 @@ pub(crate) fn lex_statements(sql: &str, grammar: SqlGrammar) -> LexedScript<'_> fn push_significant<'a>( sql: &'a str, - grammar: SqlGrammar, kind: ScriptTokenKind, range: Range, tokens: &mut Vec>, ) { let text = sql.get(range).unwrap_or_default(); let token = match kind { - ScriptTokenKind::BlockComment => { - // MySQL and MariaDB run what a `/*!` comment holds as part of - // the statement around it. - if let Some(inner) = executable_comment(grammar, text) { - tokens.extend(lex_statements(inner, grammar).statements.into_iter().flatten()); - } - return; - } - ScriptTokenKind::Whitespace | ScriptTokenKind::LineComment | ScriptTokenKind::Directive => return, + ScriptTokenKind::Whitespace + | ScriptTokenKind::LineComment + | ScriptTokenKind::BlockComment + | ScriptTokenKind::Directive + | ScriptTokenKind::ExecutableMarker => return, ScriptTokenKind::Word => StatementToken::Word(text), ScriptTokenKind::QuotedIdentifier => StatementToken::QuotedName(unquoted(text)), ScriptTokenKind::DollarQuoted => StatementToken::DollarBody(dollar_body(text)), @@ -113,16 +111,6 @@ fn push_significant<'a>( tokens.push(token); } -/// `/*!50100 text */` or MariaDB's `/*M!100100 text */` gives `text`. -fn executable_comment(grammar: SqlGrammar, text: &str) -> Option<&str> { - if grammar != SqlGrammar::MySql { - return None; - } - let body = text.strip_prefix("/*!").or_else(|| text.strip_prefix("/*M!"))?; - let body = body.strip_suffix("*/")?; - Some(body.trim_start_matches(|c: char| c.is_ascii_digit())) -} - /// Every quote the lexer accepts for a name (`"`, `` ` ``, `[` and `]`) /// is one byte, so dropping the first and last byte leaves the name. fn unquoted(text: &str) -> &str { diff --git a/linux/crates/core/tests/script_corpus.rs b/linux/crates/core/tests/script_corpus.rs index d094e33613..d9e7474cf2 100644 --- a/linux/crates/core/tests/script_corpus.rs +++ b/linux/crates/core/tests/script_corpus.rs @@ -212,6 +212,65 @@ fn backtick_identifier_with_semicolon() { assert_eq!(statements(SqlGrammar::MySql, text), ["SELECT `a;b` FROM t", "SELECT 2"]); } +#[test] +fn mysql_executable_comments_are_statements() { + let text = "/*!40101 SET NAMES utf8mb4 */;\n/*!40103 SET TIME_ZONE='+00:00' */;\n\ + CREATE TABLE t (id int) /*!50100 PARTITION BY HASH (id) */;\n/* plain */;\nSELECT 1;"; + assert_eq!( + statements(SqlGrammar::MySql, text), + [ + "/*!40101 SET NAMES utf8mb4 */", + "/*!40103 SET TIME_ZONE='+00:00' */", + "CREATE TABLE t (id int) /*!50100 PARTITION BY HASH (id) */", + "SELECT 1" + ] + ); +} + +#[test] +fn a_mysql_executable_comment_ends_where_the_server_ends_it() { + let text = "SELECT 1 /*! AS '*/' */; SELECT 2 /*!50100 -- */\n*/; SELECT 3;"; + assert_eq!( + statements(SqlGrammar::MySql, text), + ["SELECT 1 /*! AS '*/' */", "SELECT 2 /*!50100 -- */\n*/", "SELECT 3"] + ); +} + +#[test] +fn a_dumped_mysql_trigger_is_one_batch() { + let text = "DELIMITER ;;\n/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`%`*/ /*!50003 TRIGGER t1 BEFORE INSERT ON t \ + FOR EACH ROW BEGIN SET NEW.a = 1; END */;;\nDELIMITER ;\n"; + assert_eq!( + batch_texts(&plan(SqlGrammar::MySql, text), text), + [ + "/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`%`*/ /*!50003 TRIGGER t1 BEFORE INSERT ON t FOR EACH ROW BEGIN SET NEW.a = 1; END */" + ] + ); +} + +#[test] +fn an_open_mysql_executable_comment_is_reported() { + let plan = plan(SqlGrammar::MySql, "SELECT 1; /*!40101 SET @a = 1;"); + assert_eq!( + plan.diagnostics(), + [ScriptDiagnostic::Unterminated { + construct: OpenConstruct::BlockComment, + start: 10, + }] + ); +} + +#[test] +fn other_engines_read_a_bang_comment_as_a_comment() { + for grammar in [SqlGrammar::PostgreSql, SqlGrammar::Sqlite, SqlGrammar::ClickHouse] { + assert_eq!( + statements(grammar, "/*! SELECT 1 */; SELECT 2;"), + ["SELECT 2"], + "{grammar:?}" + ); + } +} + #[test] fn backslash_escaped_quote() { let text = r"SELECT 'it\'s; fine'; SELECT 2;"; diff --git a/linux/crates/drivers/mysql/src/script_split.rs b/linux/crates/drivers/mysql/src/script_split.rs index cd8d2d2ec1..43b24d58fe 100644 --- a/linux/crates/drivers/mysql/src/script_split.rs +++ b/linux/crates/drivers/mysql/src/script_split.rs @@ -16,7 +16,7 @@ pub(crate) fn texts(sql: &str) -> Vec<&str> { return vec![sql]; } let tokens: Vec = plan.tokens(0..sql.len()).collect(); - let units = units(sql, &tokens); + let units = units(&tokens); let directives = tokens.iter().any(|token| token.kind == ScriptTokenKind::Directive); // One statement goes as written: the client's view of where it ends // can differ from the server's, as under NO_BACKSLASH_ESCAPES. @@ -27,15 +27,14 @@ pub(crate) fn texts(sql: &str) -> Vec<&str> { } /// The text between each two terminators, less the whitespace and plain -/// comments at its edges. The server runs what a `/*!` comment holds, so -/// such a comment is code, even standing alone. -fn units(sql: &str, tokens: &[ScriptToken]) -> Vec> { +/// comments at its edges. +fn units(tokens: &[ScriptToken]) -> Vec> { let mut units = Vec::new(); let mut code: Option> = None; for token in tokens { if matches!(token.kind, ScriptTokenKind::Semicolon | ScriptTokenKind::Directive) { units.extend(code.take()); - } else if !token.kind.is_trivia() || executable(sql, token) { + } else if !token.kind.is_trivia() { code = Some(code.map_or(token.range.clone(), |code| code.start..token.range.end)); } } @@ -43,13 +42,6 @@ fn units(sql: &str, tokens: &[ScriptToken]) -> Vec> { units } -fn executable(sql: &str, token: &ScriptToken) -> bool { - token.kind == ScriptTokenKind::BlockComment - && sql - .get(token.range.clone()) - .is_some_and(|text| text.starts_with("/*!") || text.starts_with("/*M!")) -} - /// The client's split holds only for statements it reads as the server /// does. A statement it does not know may have a body, which only the /// server's grammar can end, and the session's sql_mode decides how some @@ -59,7 +51,7 @@ fn whole_by_server(sql: &str, tokens: &[ScriptToken], units: &[Range]) -> let read_by_the_server = |token: &ScriptToken| { let text = sql.get(token.range.clone()).unwrap_or_default(); match token.kind { - ScriptTokenKind::BlockComment => executable(sql, token), + ScriptTokenKind::ExecutableMarker => true, ScriptTokenKind::StringLiteral => text.contains('\\'), ScriptTokenKind::Word => text.contains('$'), ScriptTokenKind::Punct => !PLAIN_PUNCTUATION.contains(text), diff --git a/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs b/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs index df49f7d3ae..e289adfaf2 100644 --- a/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs +++ b/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs @@ -3,6 +3,8 @@ use std::time::Duration; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan}; use tablepro_core::{ DriverError, ReadOnlyRefusal, RowLimit, RunEnd, RunOptions, TimeoutPhase, Value, WorkspaceSettings, WriteKind, }; @@ -169,6 +171,60 @@ async fn create_trigger_and_procedure_in_session() { } } +/// A mysqldump file run as the editor splits it: each `/*!` line on its +/// own, a trigger under `DELIMITER ;;` as one statement, a partition +/// clause with its table, and a comment the server's version skips as a +/// statement that does nothing. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_dump_runs_its_executable_comments() { + const DUMP: &str = "/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;\n\ + /*!40103 SET TIME_ZONE='+00:00' */;\n\ + /*!99999 SET @never = 1 */;\n\ + CREATE TABLE orders (id int) /*!50100 PARTITION BY HASH (id) PARTITIONS 2 */;\n\ + CREATE TABLE audit (id int);\n\ + DELIMITER ;;\n\ + /*!50003 CREATE*/ /*!50003 TRIGGER trg AFTER INSERT ON orders FOR EACH ROW \ + BEGIN INSERT INTO audit VALUES (NEW.id); INSERT INTO audit VALUES (NEW.id + 100); END */;;\n\ + DELIMITER ;\n\ + /*!50001 CREATE ALGORITHM=UNDEFINED */ /*!50001 VIEW v AS SELECT id FROM orders */;\n\ + INSERT INTO orders VALUES (1);"; + let grammar = SqlGrammar::MySql; + let plan = ScriptPlan::build(DUMP, grammar, LexicalSettings::default_for(grammar)); + let statements: Vec<&str> = plan + .batches() + .iter() + .filter_map(|batch| DUMP.get(batch.range.clone())) + .collect(); + assert_eq!(statements.len(), 8); + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let connection = connect(&fixture, false).await; + let editor = session(connection.as_ref()).await; + + for statement in &statements { + run_ok(editor.as_ref(), statement).await; + } + + let scalar = async |sql: &str| session_scalar(editor.as_ref(), sql).await; + assert_eq!(scalar("SELECT @@session.time_zone").await, "+00:00", "{flavour:?}"); + assert_eq!(scalar("SELECT @never IS NULL").await, "1", "{flavour:?}"); + assert_eq!( + scalar( + "SELECT COUNT(*) FROM information_schema.PARTITIONS \ + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'orders'" + ) + .await, + "2", + "{flavour:?}" + ); + assert_eq!(scalar("SELECT COUNT(*) FROM audit").await, "2", "{flavour:?}"); + assert_eq!(scalar("SELECT COUNT(*) FROM v").await, "1", "{flavour:?}"); + editor.close().await; + connection.close().await.expect("a close"); + } +} + /// A cancelled SLEEP leaves the editor's thread idle on the server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] diff --git a/linux/crates/storage/src/query_history/mod.rs b/linux/crates/storage/src/query_history/mod.rs index fec605694f..0dff0d141d 100644 --- a/linux/crates/storage/src/query_history/mod.rs +++ b/linux/crates/storage/src/query_history/mod.rs @@ -37,7 +37,7 @@ use export::{history_csv, history_sql}; use history_query::TermMatch; use search_fold::fold; -const MAX_QUERY_BYTES: usize = 1024 * 1024; +pub const MAX_QUERY_BYTES: usize = 1024 * 1024; /// Unpinned entries kept regardless of the retention window. History is /// a convenience, not an archive, and an unbounded table makes every diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 6f27e22385..cb1f415978 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -96,7 +96,17 @@ A call that timed out or lost its connection is checked before the link is decla `ReadOnlyEnforcement::SessionDefault`: `SET SESSION TRANSACTION READ ONLY` on every session. Saves are refused before anything is sent, and a write through generated SQL fails with 1792. At Safe Mode Read-Only the app refuses every editor statement that does not read before it is sent, `SET TRANSACTION READ WRITE` included; a function a `SELECT` calls is the server's to refuse. For a guarantee, connect with a login that has no write privileges. -An export, on any connection, runs in `START TRANSACTION READ ONLY` and refuses user SQL that Safe Mode would not count as a read (`tablepro_core::sql_syntax::statement_access`), which reads the text of a `/*! */` comment as the code the server runs. +An export, on any connection, runs in `START TRANSACTION READ ONLY` and refuses user SQL that Safe Mode would not count as a read (`tablepro_core::sql_syntax::statement_access`). + +Safe Mode reads text every way the client or a server may read it, and the strictest reading stands (`tablepro_core::sql_syntax::script::ScriptPlan::server_readings`): + +- A `/*!`, `/*!NNNNN` or `/*M!` comment as MySQL up to 8.0, MySQL 8.1 and later, MariaDB and a MariaDB Galera node run or skip it, at every version the text names. A comment that runs is code up to the first `*/` outside a string, a quoted name or a line comment. One that a version skips ends at the first `*/` past one nested `/* */`. MySQL reads `/*M!` as a plain comment, MariaDB skips `/*!50700` to `/*!99999`, and Galera runs `/*!99997`. +- `$tag$ ... $tag$` as a quote, as MySQL 8.1 and later read it. +- A `DELIMITER` line as SQL, since only a client reads it as a directive. +- With backslash escapes on and off, `"` as a name (ANSI_QUOTES), and `[name]` as a name (MariaDB's MSSQL mode). +- Whole, and as each piece a `DELIMITER` line cuts it into, since the editor and the driver send those apart. + +Text with more readings than are worth lexing before a run, 16 versions or about 32 MiB of lexing in all, counts as destructive. ## Writes @@ -118,7 +128,7 @@ Whether a write can be undone depends on the table's storage engine, not on the ## Editor sessions -- The editor sends one statement per run. Text with several statements is split where the mysql client splits it, at each `;` or at the delimiter a `DELIMITER` line sets, and goes one statement per round trip. Sent whole, the server starts each next statement as soon as the last one's result is written, read or not, so a stop could not keep it from running. A `/*!` comment is code there, so it is sent. +- The editor sends one statement per run. Text with several statements is split where the mysql client splits it, at each `;` or at the delimiter a `DELIMITER` line sets, and goes one statement per round trip. Sent whole, the server starts each next statement as soon as the last one's result is written, read or not, so a stop could not keep it from running. A `/*!` or `/*M!` comment is code to the split, as it is to the mysql client, so each `/*!40101 ... */;` line of a dump is a statement of its own and a `/*!50100 PARTITION BY ... */` stays with its table. - Text without `DELIMITER` lines goes as written, as the server's own batch, when it holds one statement, or when only the server can tell where its statements end: a statement that does not open with a word known to start one with no body, such as a stored program or a compound statement, a `/*!` comment, or text the session's `sql_mode` reads its own way (a backslash in a string, `[name]`, `$$` quotes). - The row limit is `SET SESSION sql_select_limit = `, sent when the limit changes. The server stops a plain `SELECT` there, and the set ends with `Truncation::ServerStopped` and no total. A `SELECT` with a `LIMIT` of its own, or one inside a routine, is read to its end: the rows past the limit are counted, never decoded, and the set ends with `Truncation::ReadPastLimit` and its total. A set that ends exactly one row past the limit reads as `ServerStopped` either way. - A stop sends `KILL QUERY` on the session's own control connection and gives the statement the cancel grace to end, and nothing after it is sent, whatever the `KILL` did. A statement that has not ended by then ends the session, because its answer is still on the way, and so does a `KILL` that has not answered, because it may still land on whatever runs next. The row limit's `SET` is watched the same way. diff --git a/linux/docs/storage.md b/linux/docs/storage.md index ebf039d879..9feeed2998 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -236,8 +236,10 @@ script lexer: only with a given word (`GRANT ... IDENTIFIED`), and functions or engines that take a password (`dblink`, `OPENROWSET`, `s3`). A routine body is read for the statements inside it, whether dollar-quoted or, on - PostgreSQL, a plain string after `DO` or `AS`. So is a MySQL `/*!` - comment, which the server runs. + PostgreSQL, a plain string after `DO` or `AS`. The text is read every + way the server may read it: with and without backslash escapes, and + with each MySQL `/*!` comment run or skipped as each server version + would. - A string, quoted name or comment that never closes withholds it too, since what is inside a literal is then a guess. From 3a03bfe125861f8de3bb15d23a2049603bfbb6a9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 22 Oct 2026 21:32:20 +0700 Subject: [PATCH 2/2] docs(linux): changelog for MySQL executable comments --- linux/changelog.d/3790.fixed.md | 1 + linux/changelog.d/3790.security.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 linux/changelog.d/3790.fixed.md create mode 100644 linux/changelog.d/3790.security.md diff --git a/linux/changelog.d/3790.fixed.md b/linux/changelog.d/3790.fixed.md new file mode 100644 index 0000000000..f286ae37ff --- /dev/null +++ b/linux/changelog.d/3790.fixed.md @@ -0,0 +1 @@ +The SQL editor skipped MySQL `/*! */` statements, so a dump's SET NAMES and time zone lines never ran. diff --git a/linux/changelog.d/3790.security.md b/linux/changelog.d/3790.security.md new file mode 100644 index 0000000000..87552d5ddd --- /dev/null +++ b/linux/changelog.d/3790.security.md @@ -0,0 +1 @@ +Safe Mode reads MySQL and MariaDB executable comments and dollar quotes the way each server version runs them.