geode_client/quote.rs
1//! GQL quoting and sanitization helpers.
2//!
3//! These mirror the Go reference client's `SanitizeGraphName`,
4//! `QuoteIdent`, and `QuoteString` so that user-supplied names and string
5//! literals can be inlined into GQL admin statements without opening an
6//! injection vector.
7
8use crate::error::{Error, Result};
9
10/// Returns `name` stripped of any character that is not alphanumeric,
11/// underscore, or hyphen, so it can be inlined safely into a `USE GRAPH`
12/// statement without opening an injection vector.
13///
14/// # Example
15///
16/// ```
17/// use geode_client::sanitize_graph_name;
18///
19/// assert_eq!(sanitize_graph_name("my graph!"), "mygraph");
20/// assert_eq!(sanitize_graph_name("prod-db_01"), "prod-db_01");
21/// ```
22pub fn sanitize_graph_name(name: &str) -> String {
23 let mut out = String::with_capacity(name.len());
24 for c in name.chars() {
25 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
26 out.push(c);
27 }
28 }
29 out
30}
31
32/// Validates and returns `name` in a form safe to inline into a GQL admin
33/// statement that takes an identifier (`CREATE USER`, `ALTER USER`,
34/// `CREATE MIGRATION`, etc.).
35///
36/// Valid identifiers consist of ASCII letters, digits, underscore, and
37/// hyphen, and must not start with a hyphen. Digits are permitted at any
38/// position, including the first character, because some upstream catalogs
39/// use digit-prefixed names (e.g. migration names like `005_add_index`).
40///
41/// # Errors
42///
43/// Returns [`Error::Validation`] for empty strings or inputs containing
44/// characters outside this set (including a leading hyphen).
45///
46/// # Example
47///
48/// ```
49/// use geode_client::quote_ident;
50///
51/// assert_eq!(quote_ident("user_01").unwrap(), "user_01");
52/// assert_eq!(quote_ident("005_add_index").unwrap(), "005_add_index");
53/// assert!(quote_ident("-bad").is_err());
54/// assert!(quote_ident("").is_err());
55/// ```
56pub fn quote_ident(name: &str) -> Result<String> {
57 if name.is_empty() {
58 return Err(Error::validation(
59 "identifier contains forbidden characters",
60 ));
61 }
62 let mut first = true;
63 for c in name.chars() {
64 match c {
65 'a'..='z' | 'A'..='Z' | '_' => {
66 // Always allowed.
67 }
68 '0'..='9' => {
69 // Digits allowed anywhere, including the first character
70 // (digit-prefixed migration/catalog names).
71 }
72 '-' => {
73 // Hyphen allowed except as the first character to avoid
74 // option-confusion if a downstream emitter splits args.
75 if first {
76 return Err(Error::validation(
77 "identifier contains forbidden characters",
78 ));
79 }
80 }
81 _ => {
82 return Err(Error::validation(
83 "identifier contains forbidden characters",
84 ));
85 }
86 }
87 first = false;
88 }
89 Ok(name.to_string())
90}
91
92/// Returns `s` wrapped in GQL single quotes, with backslashes and single
93/// quotes properly escaped.
94///
95/// Escape order matches the Go reference: backslash first (`\` → `\\`),
96/// then single quotes (`'` becomes two single quotes `''`).
97///
98/// ASCII control characters (`< 0x20` or `0x7F`) are rejected rather than
99/// escaped; this is intentional because `quote_string` is meant for
100/// user-supplied data where control characters are almost always an error
101/// (injection risk).
102///
103/// # Errors
104///
105/// Returns [`Error::Validation`] if `s` contains ASCII control characters.
106///
107/// # Example
108///
109/// ```
110/// use geode_client::quote_string;
111///
112/// assert_eq!(quote_string("hello").unwrap(), "'hello'");
113/// assert_eq!(quote_string("O'Brien").unwrap(), "'O''Brien'");
114/// assert_eq!(quote_string("a\\b").unwrap(), "'a\\\\b'");
115/// assert!(quote_string("bad\nline").is_err());
116/// ```
117pub fn quote_string(s: &str) -> Result<String> {
118 for c in s.chars() {
119 if (c as u32) < 0x20 || c == '\u{7f}' {
120 return Err(Error::validation(
121 "string literal contains forbidden characters",
122 ));
123 }
124 }
125 let escaped = s.replace('\\', "\\\\").replace('\'', "''");
126 Ok(format!("'{}'", escaped))
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_sanitize_graph_name() {
135 assert_eq!(sanitize_graph_name("mygraph"), "mygraph");
136 assert_eq!(sanitize_graph_name("my graph!"), "mygraph");
137 assert_eq!(sanitize_graph_name("prod-db_01"), "prod-db_01");
138 assert_eq!(sanitize_graph_name("a;DROP GRAPH x"), "aDROPGRAPHx");
139 assert_eq!(sanitize_graph_name(""), "");
140 // Non-ASCII letters are stripped (matches Go's ASCII-only ranges).
141 assert_eq!(sanitize_graph_name("café"), "caf");
142 }
143
144 #[test]
145 fn test_quote_ident_valid() {
146 assert_eq!(quote_ident("user").unwrap(), "user");
147 assert_eq!(quote_ident("User_01").unwrap(), "User_01");
148 assert_eq!(quote_ident("a-b-c").unwrap(), "a-b-c");
149 assert_eq!(quote_ident("_private").unwrap(), "_private");
150 }
151
152 #[test]
153 fn test_quote_ident_digit_prefix_allowed() {
154 // Digit-prefixed identifiers are allowed (migration/catalog names).
155 assert_eq!(quote_ident("123abc").unwrap(), "123abc");
156 assert_eq!(quote_ident("005_add_index").unwrap(), "005_add_index");
157 assert_eq!(quote_ident("9").unwrap(), "9");
158 }
159
160 #[test]
161 fn test_quote_ident_leading_dash_rejected() {
162 assert!(quote_ident("-bad").is_err());
163 assert!(quote_ident("-").is_err());
164 }
165
166 #[test]
167 fn test_quote_ident_empty_rejected() {
168 assert!(quote_ident("").is_err());
169 }
170
171 #[test]
172 fn test_quote_ident_invalid_chars_rejected() {
173 assert!(quote_ident("has space").is_err());
174 assert!(quote_ident("semi;colon").is_err());
175 assert!(quote_ident("quote'here").is_err());
176 // Control character.
177 assert!(quote_ident("bad\u{0}").is_err());
178 // Non-ASCII letter.
179 assert!(quote_ident("café").is_err());
180 }
181
182 #[test]
183 fn test_quote_string_basic() {
184 assert_eq!(quote_string("hello").unwrap(), "'hello'");
185 assert_eq!(quote_string("").unwrap(), "''");
186 }
187
188 #[test]
189 fn test_quote_string_escapes_single_quote() {
190 assert_eq!(quote_string("O'Brien").unwrap(), "'O''Brien'");
191 assert_eq!(quote_string("''").unwrap(), "''''''");
192 }
193
194 #[test]
195 fn test_quote_string_escapes_backslash() {
196 assert_eq!(quote_string("a\\b").unwrap(), "'a\\\\b'");
197 // Backslash is escaped first, then quotes: \' -> \\''
198 assert_eq!(quote_string("\\'").unwrap(), "'\\\\'''");
199 }
200
201 #[test]
202 fn test_quote_string_rejects_control_chars() {
203 assert!(quote_string("bad\nline").is_err());
204 assert!(quote_string("tab\there").is_err());
205 assert!(quote_string("null\u{0}byte").is_err());
206 assert!(quote_string("del\u{7f}").is_err());
207 }
208}