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