Skip to main content

systemprompt_database/admin/
identifier.rs

1//! Validated `PostgreSQL` identifier wrapper used wherever a raw table name or
2//! column name flows from user input into a SQL string.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::fmt;
8
9use thiserror::Error;
10
11const MAX_IDENTIFIER_LEN: usize = 63;
12
13#[derive(Debug, Clone, Copy, Error)]
14pub enum IdentifierError {
15    #[error("identifier is empty")]
16    Empty,
17    #[error("identifier length {0} exceeds `PostgreSQL` limit of {MAX_IDENTIFIER_LEN}")]
18    TooLong(usize),
19    #[error("identifier must start with an ASCII letter or underscore")]
20    BadLead,
21    #[error("identifier contains invalid character {0:?}")]
22    InvalidChar(char),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SafeIdentifier(String);
27
28impl SafeIdentifier {
29    pub fn parse(raw: &str) -> Result<Self, IdentifierError> {
30        if raw.is_empty() {
31            return Err(IdentifierError::Empty);
32        }
33        if raw.len() > MAX_IDENTIFIER_LEN {
34            return Err(IdentifierError::TooLong(raw.len()));
35        }
36        let mut chars = raw.chars();
37        let first = chars.next().ok_or(IdentifierError::Empty)?;
38        if !(first.is_ascii_alphabetic() || first == '_') {
39            return Err(IdentifierError::BadLead);
40        }
41        for c in chars {
42            if !(c.is_ascii_alphanumeric() || c == '_') {
43                return Err(IdentifierError::InvalidChar(c));
44            }
45        }
46        Ok(Self(raw.to_owned()))
47    }
48
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52
53    // Why: quoting lives on the validated type so it cannot be reached with an
54    // unvalidated string. It was previously a free function duplicated in the
55    // admin and services/postgres introspection modules; the services copy took
56    // a bare `&str` and relied on its identifier coming from a catalog query
57    // rather than on anything checkable. Doubling embedded quotes is correct
58    // Postgres escaping on its own, but "correct because of where the caller
59    // got it" is a property that survives only until the next caller.
60    #[must_use]
61    pub fn quoted(&self) -> String {
62        let escaped = self.0.replace('"', "\"\"");
63        format!("\"{escaped}\"")
64    }
65}
66
67impl fmt::Display for SafeIdentifier {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(&self.0)
70    }
71}