1use crate::RecordKey;
6
7const HEX: &[u8; 16] = b"0123456789ABCDEF";
8
9pub fn segment(s: &str) -> String {
22 let mut out = String::with_capacity(s.len());
23 for &b in s.as_bytes() {
24 match b {
25 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' => out.push(b as char),
26 _ => {
27 out.push('%');
28 out.push(HEX[(b >> 4) as usize] as char);
29 out.push(HEX[(b & 0x0f) as usize] as char);
30 }
31 }
32 }
33 out
34}
35
36pub fn decode_segment(s: &str) -> String {
41 let bytes = s.as_bytes();
42 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
43 let mut i = 0;
44 while i < bytes.len() {
45 if bytes[i] == b'%'
46 && i + 2 < bytes.len()
47 && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
48 {
49 out.push((hi << 4) | lo);
50 i += 3;
51 } else {
52 out.push(bytes[i]);
53 i += 1;
54 }
55 }
56 String::from_utf8_lossy(&out).into_owned()
57}
58
59fn hex_val(b: u8) -> Option<u8> {
60 match b {
61 b'0'..=b'9' => Some(b - b'0'),
62 b'A'..=b'F' => Some(b - b'A' + 10),
63 b'a'..=b'f' => Some(b - b'a' + 10),
64 _ => None,
65 }
66}
67
68pub fn record_components(key: &RecordKey) -> (String, String, String) {
72 (
73 segment(&key.namespace),
74 segment(&key.collection),
75 format!("{}.json", segment(&key.id)),
76 )
77}
78
79pub fn object_key(key: &RecordKey) -> String {
81 let (ns, col, file) = record_components(key);
82 format!("{ns}/{col}/{file}")
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn segment_neutralizes_traversal() {
91 assert_eq!(segment(".."), "%2E%2E");
94 assert_eq!(segment("../etc"), "%2E%2E%2Fetc");
95 assert_eq!(segment("a.b"), "a%2Eb");
96 assert!(!segment("../../x").contains(".."));
97 assert!(!segment("a/b").contains('/'));
98 }
99
100 #[test]
101 fn segment_leaves_wellformed_keys_untouched() {
102 for s in ["rust", "caliban", "topics", "a_b-c9", "UPPER"] {
104 assert_eq!(segment(s), s);
105 }
106 }
107
108 #[test]
109 fn segment_roundtrips_and_is_injective() {
110 let cases = [
113 "",
114 "..",
115 "v1.0",
116 "v1_0",
117 "a/b",
118 "a.b",
119 "50% off",
120 "spaces here",
121 "café/x",
122 "emoji🚀",
123 "%2E", "a%2Fb",
125 ];
126 let mut encoded = std::collections::BTreeSet::new();
127 for s in cases {
128 let enc = segment(s);
129 assert_eq!(decode_segment(&enc), s, "round-trip failed for {s:?}");
130 assert!(encoded.insert(enc), "encoding collision at {s:?}");
131 }
132 assert_ne!(segment("v1.0"), segment("v1_0"));
134 assert_ne!(segment("a/b"), segment("a_b"));
135 }
136
137 #[test]
138 fn object_key_is_slash_joined_json() {
139 let k = RecordKey::new("caliban", "topics", "rust");
140 assert_eq!(object_key(&k), "caliban/topics/rust.json");
141 }
142}