Skip to main content

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}