Skip to main content

hadris_iso/
joliet.rs

1//! Joliet extension support for ISO 9660
2//!
3//! Joliet allows Unicode filenames (up to 64 characters) encoded as UTF-16 Big Endian.
4
5/// Public `ESCAPE_SEQUNCES` API.
6pub static ESCAPE_SEQUNCES: [[u8; 3]; 3] = [*b"%/@", *b"%/C", *b"%/E"];
7
8#[repr(u8)]
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10/// Identifies a JolietLevel value.
11pub enum JolietLevel {
12    /// Level 1 - UCS-2 Level 1 (escape sequence %/@)
13    Level1 = 1,
14    /// Level 2 - UCS-2 Level 2 (escape sequence %/C)
15    Level2 = 2,
16    /// Level 3 - UCS-2 Level 3 (escape sequence %/E) - most common
17    Level3 = 3,
18}
19
20impl JolietLevel {
21    /// Performs the `all` operation.
22    pub fn all() -> &'static [JolietLevel] {
23        static LEVELS: [JolietLevel; 3] = [
24            JolietLevel::Level1,
25            JolietLevel::Level2,
26            JolietLevel::Level3,
27        ];
28        &LEVELS
29    }
30
31    /// Get the escape sequence for this Joliet level
32    pub fn escape_sequence(self) -> [u8; 32] {
33        let mut output = [b' '; 32];
34        match self {
35            Self::Level1 => output[0..3].copy_from_slice(b"%/@"),
36            Self::Level2 => output[0..3].copy_from_slice(b"%/C"),
37            Self::Level3 => output[0..3].copy_from_slice(b"%/E"),
38        }
39        output
40    }
41
42    /// Try to detect the Joliet level from escape sequences
43    pub fn from_escape_sequence(escape: &[u8; 32]) -> Option<Self> {
44        if escape[0..3] == *b"%/@" {
45            Some(Self::Level1)
46        } else if escape[0..3] == *b"%/C" {
47            Some(Self::Level2)
48        } else if escape[0..3] == *b"%/E" {
49            Some(Self::Level3)
50        } else {
51            None
52        }
53    }
54}
55
56/// Decode a Joliet filename from UTF-16 Big Endian bytes
57///
58/// Joliet uses UTF-16 BE encoding for filenames. This function decodes
59/// the raw bytes into a String.
60#[cfg(feature = "alloc")]
61pub fn decode_joliet_name(bytes: &[u8]) -> alloc::string::String {
62    // UTF-16 BE: each character is 2 bytes, high byte first
63    if bytes.len() < 2 {
64        return alloc::string::String::new();
65    }
66
67    // Remove trailing ;1 version suffix if present
68    let bytes = strip_version_suffix(bytes);
69
70    // Convert pairs of bytes to u16 code units
71    let code_units: alloc::vec::Vec<u16> = bytes
72        .chunks_exact(2)
73        .map(|pair| u16::from_be_bytes([pair[0], pair[1]]))
74        .collect();
75
76    // Decode UTF-16 to String
77    alloc::string::String::from_utf16_lossy(&code_units)
78}
79
80/// Strip the version suffix (;1) from a Joliet filename if present
81#[cfg(feature = "alloc")]
82fn strip_version_suffix(bytes: &[u8]) -> &[u8] {
83    // Look for ";1" at the end (0x00 0x3B 0x00 0x31 in UTF-16 BE)
84    if bytes.len() >= 4 {
85        let suffix = &bytes[bytes.len() - 4..];
86        if suffix == [0x00, b';', 0x00, b'1'] {
87            return &bytes[..bytes.len() - 4];
88        }
89    }
90    bytes
91}
92
93/// Encode a string as a Joliet filename (big-endian UCS-2).
94///
95/// Joliet uses UCS-2, which covers only the Basic Multilingual Plane. A
96/// character outside the BMP cannot be represented as a single UCS-2 code unit,
97/// so it is substituted with `_` rather than emitting a UTF-16 surrogate pair.
98#[cfg(feature = "alloc")]
99pub fn encode_joliet_name(name: &str) -> alloc::vec::Vec<u8> {
100    let mut result = alloc::vec::Vec::with_capacity(name.chars().count() * 2);
101    for c in name.chars() {
102        let unit = if (c as u32) <= 0xFFFF {
103            c as u16
104        } else {
105            b'_' as u16
106        };
107        result.extend_from_slice(&unit.to_be_bytes());
108    }
109    result
110}
111
112/// Check if a byte slice looks like a Joliet (UTF-16 BE) filename
113///
114/// Returns true if the bytes appear to be valid UTF-16 BE text
115pub fn is_likely_joliet_name(bytes: &[u8]) -> bool {
116    // Must be even length for UTF-16
117    if !bytes.len().is_multiple_of(2) || bytes.is_empty() {
118        return false;
119    }
120
121    // Check for common ASCII characters in UTF-16 BE (0x00 followed by ASCII char)
122    // This is a heuristic - Joliet names often contain ASCII which appears as 0x00 XX
123    let mut ascii_count = 0;
124    for pair in bytes.chunks_exact(2) {
125        if pair[0] == 0x00 && pair[1].is_ascii_graphic() {
126            ascii_count += 1;
127        }
128    }
129
130    // If more than half the characters are ASCII, likely Joliet
131    ascii_count * 2 > bytes.len() / 2
132}
133
134#[cfg(all(feature = "std", test))]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_escape_sequences() {
140        let level1 = b"%/@";
141        assert_eq!(level1, &ESCAPE_SEQUNCES[0]);
142
143        let level2 = b"%/C";
144        assert_eq!(level2, &ESCAPE_SEQUNCES[1]);
145
146        let level3 = b"%/E";
147        assert_eq!(level3, &ESCAPE_SEQUNCES[2]);
148    }
149
150    #[test]
151    fn test_joliet_level_escape_sequence() {
152        let level1 = JolietLevel::Level1.escape_sequence();
153        assert_eq!(&level1[0..3], b"%/@");
154
155        let level2 = JolietLevel::Level2.escape_sequence();
156        assert_eq!(&level2[0..3], b"%/C");
157
158        let level3 = JolietLevel::Level3.escape_sequence();
159        assert_eq!(&level3[0..3], b"%/E");
160    }
161
162    #[test]
163    fn test_joliet_level_from_escape_sequence() {
164        let mut seq = [b' '; 32];
165
166        seq[0..3].copy_from_slice(b"%/@");
167        assert_eq!(
168            JolietLevel::from_escape_sequence(&seq),
169            Some(JolietLevel::Level1)
170        );
171
172        seq[0..3].copy_from_slice(b"%/C");
173        assert_eq!(
174            JolietLevel::from_escape_sequence(&seq),
175            Some(JolietLevel::Level2)
176        );
177
178        seq[0..3].copy_from_slice(b"%/E");
179        assert_eq!(
180            JolietLevel::from_escape_sequence(&seq),
181            Some(JolietLevel::Level3)
182        );
183
184        seq[0..3].copy_from_slice(b"XXX");
185        assert_eq!(JolietLevel::from_escape_sequence(&seq), None);
186    }
187
188    #[test]
189    fn test_joliet_level_all() {
190        let levels = JolietLevel::all();
191        assert_eq!(levels.len(), 3);
192        assert_eq!(levels[0], JolietLevel::Level1);
193        assert_eq!(levels[1], JolietLevel::Level2);
194        assert_eq!(levels[2], JolietLevel::Level3);
195    }
196
197    #[test]
198    fn test_encode_joliet_name_ascii() {
199        let encoded = encode_joliet_name("test.txt");
200        // Each ASCII char becomes 2 bytes: 0x00, char
201        assert_eq!(encoded.len(), 16); // 8 chars * 2 bytes
202        assert_eq!(&encoded[0..2], &[0x00, b't']);
203        assert_eq!(&encoded[2..4], &[0x00, b'e']);
204        assert_eq!(&encoded[4..6], &[0x00, b's']);
205        assert_eq!(&encoded[6..8], &[0x00, b't']);
206    }
207
208    #[test]
209    fn test_encode_joliet_name_unicode() {
210        let encoded = encode_joliet_name("日本語");
211        // 3 characters, each is a single BMP code point
212        assert_eq!(encoded.len(), 6); // 3 chars * 2 bytes
213
214        // 日 = U+65E5 = [0x65, 0xE5] in UTF-16 BE
215        assert_eq!(&encoded[0..2], &[0x65, 0xE5]);
216    }
217
218    #[test]
219    fn test_decode_joliet_name_ascii() {
220        // "test" in UTF-16 BE
221        let bytes: &[u8] = &[0x00, b't', 0x00, b'e', 0x00, b's', 0x00, b't'];
222        let decoded = decode_joliet_name(bytes);
223        assert_eq!(decoded, "test");
224    }
225
226    #[test]
227    fn test_decode_joliet_name_unicode() {
228        // "日本" in UTF-16 BE
229        let bytes: &[u8] = &[0x65, 0xE5, 0x67, 0x2C];
230        let decoded = decode_joliet_name(bytes);
231        assert_eq!(decoded, "日本");
232    }
233
234    #[test]
235    fn test_decode_joliet_name_with_version_suffix() {
236        // "test;1" in UTF-16 BE
237        let bytes: &[u8] = &[
238            0x00, b't', 0x00, b'e', 0x00, b's', 0x00, b't', 0x00, b';', 0x00, b'1',
239        ];
240        let decoded = decode_joliet_name(bytes);
241        assert_eq!(decoded, "test"); // Version suffix should be stripped
242    }
243
244    #[test]
245    fn test_decode_joliet_name_empty() {
246        let decoded = decode_joliet_name(&[]);
247        assert_eq!(decoded, "");
248
249        let decoded = decode_joliet_name(&[0x00]); // Single byte (invalid)
250        assert_eq!(decoded, "");
251    }
252
253    #[test]
254    fn test_encode_decode_roundtrip() {
255        let original = "test_file.txt";
256        let encoded = encode_joliet_name(original);
257        let decoded = decode_joliet_name(&encoded);
258        assert_eq!(decoded, original);
259    }
260
261    #[test]
262    fn test_encode_decode_roundtrip_unicode() {
263        let original = "文档_2024.txt";
264        let encoded = encode_joliet_name(original);
265        let decoded = decode_joliet_name(&encoded);
266        assert_eq!(decoded, original);
267    }
268
269    #[test]
270    fn test_is_likely_joliet_name_ascii() {
271        // ASCII text in UTF-16 BE looks like 0x00, char
272        let joliet: &[u8] = &[0x00, b't', 0x00, b'e', 0x00, b's', 0x00, b't'];
273        assert!(is_likely_joliet_name(joliet));
274    }
275
276    #[test]
277    fn test_is_likely_joliet_name_odd_length() {
278        // Odd length bytes can't be UTF-16
279        let bytes: &[u8] = &[0x00, b't', 0x00];
280        assert!(!is_likely_joliet_name(bytes));
281    }
282
283    #[test]
284    fn test_is_likely_joliet_name_empty() {
285        assert!(!is_likely_joliet_name(&[]));
286    }
287
288    #[test]
289    fn test_is_likely_joliet_name_non_ascii() {
290        // Pure ISO 8859-1 / ASCII without high bytes
291        let iso_name: &[u8] = b"TEST.TXT";
292        assert!(!is_likely_joliet_name(iso_name));
293    }
294}