brewery
notashelf /
589c26f2105746065b3ac8d954eb768b930f9dff

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git
crates/beer-protocols/src/codec.rsRust154 lines4.9 KB
1
//! Byte codecs the escape-sequence layer leans on: base64 (OSC 52 clipboard),
2
//! hex (XTGETTCAP capability names), and percent-decoding of `file://` URIs
3
//! (OSC 7 working-directory reports).
4
5
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
6
7
/// Standard base64 encode (used for OSC 52 query replies).
8
pub fn base64_encode(data: &[u8]) -> String {
9
    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
10
    for chunk in data.chunks(3) {
11
        let b = [
12
            chunk[0],
13
            *chunk.get(1).unwrap_or(&0),
14
            *chunk.get(2).unwrap_or(&0),
15
        ];
16
        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
17
        out.push(B64[(n >> 18 & 63) as usize] as char);
18
        out.push(B64[(n >> 12 & 63) as usize] as char);
19
        out.push(if chunk.len() > 1 {
20
            B64[(n >> 6 & 63) as usize] as char
21
        } else {
22
            '='
23
        });
24
        out.push(if chunk.len() > 2 {
25
            B64[(n & 63) as usize] as char
26
        } else {
27
            '='
28
        });
29
    }
30
    out
31
}
32
33
/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad
34
/// character.
35
pub fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
36
    let val = |c: u8| -> Option<u32> {
37
        match c {
38
            b'A'..=b'Z' => Some(u32::from(c - b'A')),
39
            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
40
            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
41
            b'+' => Some(62),
42
            b'/' => Some(63),
43
            _ => None,
44
        }
45
    };
46
    let filtered: Vec<u8> = data
47
        .iter()
48
        .copied()
49
        .filter(|&c| c != b'=' && !c.is_ascii_whitespace())
50
        .collect();
51
    let mut out = Vec::with_capacity(filtered.len() / 4 * 3);
52
    for chunk in filtered.chunks(4) {
53
        if chunk.len() == 1 {
54
            return None; // a lone sextet cannot form a byte
55
        }
56
        let mut n = 0u32;
57
        for &c in chunk {
58
            n = (n << 6) | val(c)?;
59
        }
60
        n <<= 6 * (4 - chunk.len() as u32);
61
        out.push((n >> 16) as u8);
62
        if chunk.len() >= 3 {
63
            out.push((n >> 8) as u8);
64
        }
65
        if chunk.len() >= 4 {
66
            out.push(n as u8);
67
        }
68
    }
69
    Some(out)
70
}
71
72
/// Decode an even-length lowercase/uppercase hex string into bytes (XTGETTCAP
73
/// names arrive hex-encoded).
74
pub fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
75
    if s.is_empty() || !s.len().is_multiple_of(2) {
76
        return None;
77
    }
78
    let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);
79
    s.chunks_exact(2)
80
        .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))
81
        .collect()
82
}
83
84
/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.
85
pub fn percent_decode(s: &[u8]) -> Vec<u8> {
86
    let mut out = Vec::with_capacity(s.len());
87
    let mut i = 0;
88
    while i < s.len() {
89
        if s[i] == b'%' && i + 2 < s.len() {
90
            let hi = (s[i + 1] as char).to_digit(16);
91
            let lo = (s[i + 2] as char).to_digit(16);
92
            if let (Some(hi), Some(lo)) = (hi, lo) {
93
                out.push((hi * 16 + lo) as u8);
94
                i += 3;
95
                continue;
96
            }
97
        }
98
        out.push(s[i]);
99
        i += 1;
100
    }
101
    out
102
}
103
104
/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding
105
/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns
106
/// `None` if it is not a usable absolute path.
107
pub fn file_uri_path(uri: &[u8]) -> Option<String> {
108
    let rest = uri.strip_prefix(b"file://").unwrap_or(uri);
109
    // Skip the authority (host) up to the first '/', which begins the path.
110
    let slash = rest.iter().position(|&b| b == b'/')?;
111
    let path_bytes = percent_decode(&rest[slash..]);
112
    let path = String::from_utf8(path_bytes).ok()?;
113
    path.starts_with('/').then_some(path)
114
}
115
116
#[cfg(test)]
117
mod tests {
118
    use super::*;
119
120
    #[test]
121
    fn base64_round_trips() {
122
        for s in [
123
            "",
124
            "f",
125
            "fo",
126
            "foo",
127
            "foob",
128
            "fooba",
129
            "foobar",
130
            "hi there\n",
131
        ] {
132
            let enc = base64_encode(s.as_bytes());
133
            assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));
134
        }
135
        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
136
        assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));
137
    }
138
139
    #[test]
140
    fn decode_hex_rejects_odd_and_nonhex() {
141
        assert_eq!(decode_hex(b"544e").as_deref(), Some(&b"TN"[..]));
142
        assert_eq!(decode_hex(b"54e"), None);
143
        assert_eq!(decode_hex(b"zz"), None);
144
    }
145
146
    #[test]
147
    fn file_uri_decodes_percent_and_drops_host() {
148
        assert_eq!(
149
            file_uri_path(b"file://hermes/home/user/my%20dir").as_deref(),
150
            Some("/home/user/my dir")
151
        );
152
        assert_eq!(file_uri_path(b"file://host").as_deref(), None);
153
    }
154
}