Skip to main content

kshana/
permalink.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Shareable scenario permalinks (self-contained Base64, RFC 4648).
3//!
4//! A scenario in the playground is a TOML document. To share it as a URL, the TOML is
5//! Base64-encoded into a `?s=` query parameter; opening that URL decodes it back. This
6//! module provides the codec — a dependency-free RFC 4648 Base64 (the standard `+/`
7//! alphabet for interop, and the URL-safe `-_` alphabet without padding for clean query
8//! strings) and the `encode_scenario` / `decode_scenario` wrappers.
9//!
10//! Keeping it in the engine (rather than only in JavaScript) means the same encoding is
11//! available to the Rust, CLI, Python, and WebAssembly surfaces, and is unit-tested
12//! against the canonical RFC 4648 vectors.
13
14const STD: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
15const URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
16
17/// Base64-encode `bytes` with the given 64-symbol `alphabet`, padding with `=` when
18/// `pad` is set (standard) or leaving the output unpadded (URL-safe query strings).
19fn encode(bytes: &[u8], alphabet: &[u8; 64], pad: bool) -> String {
20    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
21    for chunk in bytes.chunks(3) {
22        let b0 = chunk[0] as u32;
23        let b1 = *chunk.get(1).unwrap_or(&0) as u32;
24        let b2 = *chunk.get(2).unwrap_or(&0) as u32;
25        let n = (b0 << 16) | (b1 << 8) | b2;
26        out.push(alphabet[(n >> 18 & 63) as usize] as char);
27        out.push(alphabet[(n >> 12 & 63) as usize] as char);
28        if chunk.len() > 1 {
29            out.push(alphabet[(n >> 6 & 63) as usize] as char);
30        } else if pad {
31            out.push('=');
32        }
33        if chunk.len() > 2 {
34            out.push(alphabet[(n & 63) as usize] as char);
35        } else if pad {
36            out.push('=');
37        }
38    }
39    out
40}
41
42/// Decode a Base64 string under the given `alphabet`, accepting either alphabet's
43/// padding and ignoring `=` and whitespace. Returns `None` on an invalid symbol or a
44/// truncated (single-character) final group.
45fn decode(s: &str, alphabet: &[u8; 64]) -> Option<Vec<u8>> {
46    let mut rev = [255u8; 256];
47    for (i, &c) in alphabet.iter().enumerate() {
48        rev[c as usize] = i as u8;
49    }
50    let chars: Vec<u8> = s
51        .bytes()
52        .filter(|&c| c != b'=' && !c.is_ascii_whitespace())
53        .collect();
54    let mut out = Vec::with_capacity(chars.len() / 4 * 3);
55    for chunk in chars.chunks(4) {
56        if chunk.len() < 2 {
57            return None;
58        }
59        let mut vals = [0u32; 4];
60        for (i, &c) in chunk.iter().enumerate() {
61            let v = rev[c as usize];
62            if v == 255 {
63                return None;
64            }
65            vals[i] = v as u32;
66        }
67        let n = (vals[0] << 18) | (vals[1] << 12) | (vals[2] << 6) | vals[3];
68        out.push((n >> 16 & 0xff) as u8);
69        if chunk.len() >= 3 {
70            out.push((n >> 8 & 0xff) as u8);
71        }
72        if chunk.len() >= 4 {
73            out.push((n & 0xff) as u8);
74        }
75    }
76    Some(out)
77}
78
79/// Standard RFC 4648 Base64 encode (alphabet `A–Za–z0–9+/`, `=` padding).
80pub fn base64_encode(bytes: &[u8]) -> String {
81    encode(bytes, STD, true)
82}
83
84/// Standard RFC 4648 Base64 decode.
85pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
86    decode(s, STD)
87}
88
89/// Encode a scenario TOML document into a URL-safe, unpadded permalink token suitable
90/// for a `?s=` query parameter (no `+`, `/`, or `=` to escape).
91pub fn encode_scenario(toml: &str) -> String {
92    encode(toml.as_bytes(), URL, false)
93}
94
95/// Decode a permalink token back into the scenario TOML, or `None` if the token is not
96/// valid Base64 or not valid UTF-8.
97pub fn decode_scenario(token: &str) -> Option<String> {
98    let bytes = decode(token, URL)?;
99    String::from_utf8(bytes).ok()
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn rfc4648_standard_vectors() {
108        // The canonical RFC 4648 §10 test vectors.
109        let cases = [
110            ("", ""),
111            ("f", "Zg=="),
112            ("fo", "Zm8="),
113            ("foo", "Zm9v"),
114            ("foob", "Zm9vYg=="),
115            ("fooba", "Zm9vYmE="),
116            ("foobar", "Zm9vYmFy"),
117        ];
118        for (plain, b64) in cases {
119            assert_eq!(base64_encode(plain.as_bytes()), b64, "encode {plain}");
120            assert_eq!(
121                base64_decode(b64).unwrap(),
122                plain.as_bytes(),
123                "decode {b64}"
124            );
125        }
126    }
127
128    #[test]
129    fn url_safe_round_trips_a_scenario() {
130        let toml =
131            "kind = \"clock\"\n[time]\nstep_s = 1.0\n[gnss]\nshape = \"outage\"\n# share me 𝛂β\n";
132        let token = encode_scenario(toml);
133        // URL-safe and unpadded: nothing that needs percent-escaping in a query string.
134        assert!(!token.contains('+') && !token.contains('/') && !token.contains('='));
135        assert_eq!(decode_scenario(&token).as_deref(), Some(toml));
136    }
137
138    #[test]
139    fn decode_rejects_invalid_symbols() {
140        // A standard-alphabet '+' is not in the URL-safe alphabet.
141        assert!(decode_scenario("abc+def").is_none());
142        // Non-base64 punctuation is rejected outright.
143        assert!(base64_decode("not valid!@#").is_none());
144    }
145
146    #[test]
147    fn empty_and_binary_round_trip() {
148        assert_eq!(base64_encode(&[]), "");
149        assert_eq!(base64_decode("").unwrap(), Vec::<u8>::new());
150        // All byte values survive a round trip through the standard codec.
151        let bytes: Vec<u8> = (0..=255u8).collect();
152        assert_eq!(base64_decode(&base64_encode(&bytes)).unwrap(), bytes);
153    }
154}