Skip to main content

gonzalo_core/
paths.rs

1//! Shared, filesystem/object-key path mapping used by every storage
2//! substrate so a record lands at the same logical location regardless of
3//! backend.
4
5use crate::RecordKey;
6
7const HEX: &[u8; 16] = b"0123456789ABCDEF";
8
9/// Encode one key component as a single safe path/key segment.
10///
11/// This is a **reversible** percent-style encoding: the unreserved set
12/// `[A-Za-z0-9_-]` survives verbatim, and every other byte is escaped as
13/// `%XX` (uppercase hex of the UTF-8 byte). Because `.` and `/` are escaped,
14/// `..` and path separators cannot escape a component; because the mapping is
15/// injective ([`decode_segment`] is its exact inverse), two distinct keys can
16/// never collide onto one path/object key — closing the silent cross-key
17/// overwrite and OCC-bypass that the old lossy `_`-collapse allowed.
18///
19/// Well-formed keys (only `[A-Za-z0-9_-]`) encode to themselves, so existing
20/// stores need no migration.
21pub 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
36/// Decode a segment produced by [`segment`] back to the original component.
37/// Exact inverse of [`segment`]; on our own output the round-trip is lossless.
38/// A stray `%` not followed by two hex digits is passed through literally so
39/// decoding never panics on unexpected input.
40pub 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
68/// The three sanitized path components for a record:
69/// `(namespace_dir, collection_dir, "<id>.json")`. Backends join these with
70/// their own separator (`PathBuf` for fs/git, `/` for object keys).
71pub 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
79/// The object-key form `namespace/collection/id.json` for object stores.
80pub 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        // `.` and `/` are escaped, so no component can contain `..` or a
92        // separator after encoding.
93        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        // No migration for clean keys: they encode to themselves.
103        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        // decode ∘ segment == identity, so segment is injective: distinct keys
111        // never share an encoding (the core anti-collision property).
112        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 literal percent must round-trip too
124            "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        // The classic collision pair now maps to distinct segments.
133        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}