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