1use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
2use std::{fmt, str::FromStr};
3
4#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
18pub struct StableKey(String);
19
20impl<'de> Deserialize<'de> for StableKey {
21 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
22 #[derive(Deserialize)]
24 #[serde(rename = "StableKey")]
25 struct Text(String);
26
27 let Text(value) = Text::deserialize(deserializer)?;
28 validate(&value).map_err(D::Error::custom)?;
29 Ok(Self(value))
30 }
31}
32
33impl StableKey {
34 pub fn parse(value: impl AsRef<str>) -> Result<Self, StableKeyError> {
39 let value = value.as_ref();
40 validate(value)?;
41 Ok(Self(value.to_string()))
42 }
43
44 #[must_use]
46 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49
50 #[must_use]
52 pub fn into_string(self) -> String {
53 self.0
54 }
55}
56
57impl AsRef<str> for StableKey {
58 fn as_ref(&self) -> &str {
59 self.as_str()
60 }
61}
62
63impl fmt::Display for StableKey {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 formatter.write_str(self.as_str())
66 }
67}
68
69impl FromStr for StableKey {
70 type Err = StableKeyError;
71
72 fn from_str(value: &str) -> Result<Self, Self::Err> {
73 Self::parse(value)
74 }
75}
76
77#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
82#[error("stable key '{stable_key}' is invalid: {reason}")]
83pub struct StableKeyError {
84 pub stable_key: String,
86 pub reason: &'static str,
88}
89
90pub fn validate(stable_key: &str) -> Result<(), StableKeyError> {
91 if stable_key.is_empty() {
92 return invalid(stable_key, "must not be empty");
93 }
94 if stable_key.len() > 128 {
95 return invalid(stable_key, "must be at most 128 bytes");
96 }
97 if !stable_key.is_ascii() {
98 return invalid(stable_key, "must be ASCII");
99 }
100 if stable_key.bytes().any(|byte| byte.is_ascii_uppercase()) {
101 return invalid(stable_key, "must be lowercase");
102 }
103 if stable_key.contains(char::is_whitespace) {
104 return invalid(stable_key, "must not contain whitespace");
105 }
106 if stable_key.contains('/') || stable_key.contains('-') {
107 return invalid(stable_key, "must not contain slashes or hyphens");
108 }
109 if stable_key.starts_with('.') || stable_key.ends_with('.') {
110 return invalid(stable_key, "must not start or end with a dot");
111 }
112
113 let Some(version_index) = stable_key.rfind(".v") else {
114 return invalid(stable_key, "must end with .vN");
115 };
116 let version = &stable_key[version_index + 2..];
117 if version.is_empty()
118 || version.starts_with('0')
119 || !version.bytes().all(|byte| byte.is_ascii_digit())
120 {
121 return invalid(stable_key, "version suffix must be nonzero .vN");
122 }
123
124 let prefix = &stable_key[..version_index];
125 if prefix.is_empty() {
126 return invalid(
127 stable_key,
128 "must contain at least one segment before version",
129 );
130 }
131
132 for segment in prefix.split('.') {
133 validate_segment(stable_key, segment)?;
134 }
135
136 Ok(())
137}
138
139fn validate_segment(stable_key: &str, segment: &str) -> Result<(), StableKeyError> {
140 let mut bytes = segment.bytes();
141 let Some(first) = bytes.next() else {
142 return invalid(stable_key, "must not contain empty segments");
143 };
144 if !first.is_ascii_lowercase() {
145 return invalid(stable_key, "segments must start with a lowercase letter");
146 }
147 if !bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') {
148 return invalid(
149 stable_key,
150 "segments may contain only lowercase letters, digits, and underscores",
151 );
152 }
153 Ok(())
154}
155
156fn invalid<T>(stable_key: &str, reason: &'static str) -> Result<T, StableKeyError> {
157 Err(StableKeyError {
158 stable_key: stable_key.to_string(),
159 reason,
160 })
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn parse_stores_the_string_it_validates() {
169 struct ChangingKey(std::cell::Cell<bool>);
170
171 impl AsRef<str> for ChangingKey {
172 fn as_ref(&self) -> &str {
173 if self.0.replace(true) {
174 "INVALID"
175 } else {
176 "app.rows.v1"
177 }
178 }
179 }
180
181 let key = StableKey::parse(ChangingKey(std::cell::Cell::new(false))).unwrap();
182 assert_eq!(key.as_str(), "app.rows.v1");
183 }
184
185 #[test]
186 fn accepts_canonical_keys() {
187 assert_eq!(
188 StableKey::parse("app.users.primary.v1")
189 .expect("valid key")
190 .as_str(),
191 "app.users.primary.v1"
192 );
193 assert!(StableKey::parse("framework.core.auth_state.v12").is_ok());
194 }
195
196 #[test]
197 fn key_decode_enforces_grammar_and_preserves_current_encoding() {
198 let key = StableKey::parse("app.users.v1").unwrap();
199 let json = serde_json::to_string(&key).unwrap();
200 assert_eq!(json, "\"app.users.v1\"");
201 assert_eq!(serde_json::from_str::<StableKey>(&json).unwrap(), key);
202 let bytes = crate::test_cbor::to_vec(&key).unwrap();
203 assert_eq!(bytes, b"\x6capp.users.v1");
204 assert_eq!(
205 crate::test_cbor::from_slice::<StableKey>(&bytes).unwrap(),
206 key
207 );
208
209 for invalid in [
210 "App.users.v1",
211 "app.users.v0",
212 "app..users.v1",
213 &"x".repeat(129),
214 ] {
215 let json = serde_json::to_string(invalid).unwrap();
216 assert!(serde_json::from_str::<StableKey>(&json).is_err());
217 let bytes = crate::test_cbor::to_vec(&invalid).unwrap();
218 assert!(crate::test_cbor::from_slice::<StableKey>(&bytes).is_err());
219 }
220 }
221
222 #[test]
223 fn rejects_noncanonical_keys() {
224 for key in [
225 "",
226 "App.users.v1",
227 "app.users",
228 "app.users.v0",
229 "app..users.v1",
230 ".app.users.v1",
231 "app.users.v1.",
232 "app-users.v1",
233 "app/users.v1",
234 "app.1users.v1",
235 ] {
236 assert!(StableKey::parse(key).is_err(), "{key} should fail");
237 }
238 }
239}