gatekeep_sqlx/fragment/
tenant.rs1pub const MAX_TENANT_IDENTIFIER_BYTES: usize = 63;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum TenantIdentifierPart {
7 Table,
9 Column,
11}
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
15pub enum TenantColumnError {
16 #[error("tenant {part:?} identifier must be 1..={max} ASCII bytes")]
18 InvalidLength {
19 part: TenantIdentifierPart,
21 max: usize,
23 },
24 #[error("tenant {part:?} identifier {value:?} is not a safe unquoted SQL identifier")]
26 InvalidCharacters {
27 part: TenantIdentifierPart,
29 value: &'static str,
31 },
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct TenantColumn {
42 table: &'static str,
43 column: &'static str,
44}
45
46impl TenantColumn {
47 pub const fn new(table: &'static str, column: &'static str) -> Result<Self, TenantColumnError> {
54 match validate_tenant_identifier(table, TenantIdentifierPart::Table) {
55 Ok(()) => {}
56 Err(error) => return Err(error),
57 }
58
59 match validate_tenant_identifier(column, TenantIdentifierPart::Column) {
60 Ok(()) => {}
61 Err(error) => return Err(error),
62 }
63 Ok(Self { table, column })
64 }
65
66 #[must_use]
68 pub const fn table(self) -> &'static str {
69 self.table
70 }
71
72 #[must_use]
74 pub const fn column(self) -> &'static str {
75 self.column
76 }
77
78 #[must_use]
80 pub fn qualified(self) -> String {
81 format!("{}.{}", self.table, self.column)
82 }
83}
84
85const fn validate_tenant_identifier(
86 value: &'static str,
87 part: TenantIdentifierPart,
88) -> Result<(), TenantColumnError> {
89 let bytes = value.as_bytes();
90 if bytes.is_empty() || bytes.len() > MAX_TENANT_IDENTIFIER_BYTES {
91 return Err(TenantColumnError::InvalidLength {
92 part,
93 max: MAX_TENANT_IDENTIFIER_BYTES,
94 });
95 }
96
97 let Some((first, mut remaining)) = bytes.split_first() else {
98 return Err(TenantColumnError::InvalidLength {
99 part,
100 max: MAX_TENANT_IDENTIFIER_BYTES,
101 });
102 };
103
104 if !(first.is_ascii_alphabetic() || *first == b'_') {
105 return Err(TenantColumnError::InvalidCharacters { part, value });
106 }
107
108 while let [byte, rest @ ..] = remaining {
109 if !(byte.is_ascii_alphanumeric() || *byte == b'_') {
110 return Err(TenantColumnError::InvalidCharacters { part, value });
111 }
112 remaining = rest;
113 }
114
115 if is_reserved_tenant_identifier(value) {
116 return Err(TenantColumnError::InvalidCharacters { part, value });
117 }
118
119 Ok(())
120}
121
122const fn is_reserved_tenant_identifier(value: &str) -> bool {
123 matches_ascii_case_insensitive(
124 value,
125 &[
126 "all", "and", "as", "between", "by", "case", "else", "end", "exists", "false", "from",
127 "group", "having", "in", "is", "join", "like", "limit", "not", "null", "offset", "on",
128 "or", "order", "select", "then", "true", "union", "when", "where",
129 ],
130 )
131}
132
133const fn matches_ascii_case_insensitive(value: &str, mut candidates: &[&str]) -> bool {
134 while let [candidate, rest @ ..] = candidates {
135 if value.eq_ignore_ascii_case(candidate) {
136 return true;
137 }
138 candidates = rest;
139 }
140 false
141}