Skip to main content

ic_memory/
key.rs

1use serde::{Deserialize, Serialize};
2use std::{fmt, str::FromStr};
3
4///
5/// StableKey
6///
7/// Canonical durable logical allocation identity.
8///
9/// A stable key names the logical store, not the current storage backend or
10/// `MemoryManager` ID. Once committed, the key is permanently bound to its
11/// physical allocation slot; changing the key declares a new logical store.
12///
13
14#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
15pub struct StableKey(String);
16
17impl StableKey {
18    /// Parse and validate a canonical stable key string.
19    ///
20    /// Keys are bounded lowercase ASCII dot-separated names ending in a
21    /// nonzero `.vN` suffix.
22    pub fn parse(value: impl AsRef<str>) -> Result<Self, StableKeyError> {
23        validate(value.as_ref())?;
24        Ok(Self(value.as_ref().to_string()))
25    }
26
27    /// Borrow the canonical stable-key string.
28    #[must_use]
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32
33    /// Consume the key and return the canonical stable-key string.
34    #[must_use]
35    pub fn into_string(self) -> String {
36        self.0
37    }
38
39    /// Validate constructor invariants after decode.
40    pub fn validate(&self) -> Result<(), StableKeyError> {
41        validate(&self.0)
42    }
43}
44
45impl AsRef<str> for StableKey {
46    fn as_ref(&self) -> &str {
47        self.as_str()
48    }
49}
50
51impl fmt::Display for StableKey {
52    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53        formatter.write_str(self.as_str())
54    }
55}
56
57impl FromStr for StableKey {
58    type Err = StableKeyError;
59
60    fn from_str(value: &str) -> Result<Self, Self::Err> {
61        Self::parse(value)
62    }
63}
64
65///
66/// StableKeyError
67///
68/// Stable-key grammar validation failure.
69#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
70#[error("stable key '{stable_key}' is invalid: {reason}")]
71pub struct StableKeyError {
72    /// Rejected stable-key string.
73    pub stable_key: String,
74    /// Stable-key grammar failure.
75    pub reason: &'static str,
76}
77
78fn validate(stable_key: &str) -> Result<(), StableKeyError> {
79    if stable_key.is_empty() {
80        return invalid(stable_key, "must not be empty");
81    }
82    if stable_key.len() > 128 {
83        return invalid(stable_key, "must be at most 128 bytes");
84    }
85    if !stable_key.is_ascii() {
86        return invalid(stable_key, "must be ASCII");
87    }
88    if stable_key.bytes().any(|byte| byte.is_ascii_uppercase()) {
89        return invalid(stable_key, "must be lowercase");
90    }
91    if stable_key.contains(char::is_whitespace) {
92        return invalid(stable_key, "must not contain whitespace");
93    }
94    if stable_key.contains('/') || stable_key.contains('-') {
95        return invalid(stable_key, "must not contain slashes or hyphens");
96    }
97    if stable_key.starts_with('.') || stable_key.ends_with('.') {
98        return invalid(stable_key, "must not start or end with a dot");
99    }
100
101    let Some(version_index) = stable_key.rfind(".v") else {
102        return invalid(stable_key, "must end with .vN");
103    };
104    let version = &stable_key[version_index + 2..];
105    if version.is_empty()
106        || version.starts_with('0')
107        || !version.bytes().all(|byte| byte.is_ascii_digit())
108    {
109        return invalid(stable_key, "version suffix must be nonzero .vN");
110    }
111
112    let prefix = &stable_key[..version_index];
113    if prefix.is_empty() {
114        return invalid(
115            stable_key,
116            "must contain at least one segment before version",
117        );
118    }
119
120    for segment in prefix.split('.') {
121        validate_segment(stable_key, segment)?;
122    }
123
124    Ok(())
125}
126
127fn validate_segment(stable_key: &str, segment: &str) -> Result<(), StableKeyError> {
128    let mut bytes = segment.bytes();
129    let Some(first) = bytes.next() else {
130        return invalid(stable_key, "must not contain empty segments");
131    };
132    if !first.is_ascii_lowercase() {
133        return invalid(stable_key, "segments must start with a lowercase letter");
134    }
135    if !bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') {
136        return invalid(
137            stable_key,
138            "segments may contain only lowercase letters, digits, and underscores",
139        );
140    }
141    Ok(())
142}
143
144fn invalid<T>(stable_key: &str, reason: &'static str) -> Result<T, StableKeyError> {
145    Err(StableKeyError {
146        stable_key: stable_key.to_string(),
147        reason,
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn accepts_canonical_keys() {
157        assert_eq!(
158            StableKey::parse("app.users.primary.v1")
159                .expect("valid key")
160                .as_str(),
161            "app.users.primary.v1"
162        );
163        assert!(StableKey::parse("framework.core.auth_state.v12").is_ok());
164    }
165
166    #[test]
167    fn rejects_noncanonical_keys() {
168        for key in [
169            "",
170            "App.users.v1",
171            "app.users",
172            "app.users.v0",
173            "app..users.v1",
174            ".app.users.v1",
175            "app.users.v1.",
176            "app-users.v1",
177            "app/users.v1",
178            "app.1users.v1",
179        ] {
180            assert!(StableKey::parse(key).is_err(), "{key} should fail");
181        }
182    }
183}