Skip to main content

hadris_iso/
file.rs

1use hadris_fixed::FixedBytes;
2
3#[cfg(feature = "alloc")]
4use crate::joliet::JolietLevel;
5
6#[cfg(feature = "write")]
7use crate::types::{Charset, CharsetD, CharsetD1};
8
9/// The type of directory entry, indicating the ISO interchange level and features
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum EntryType {
12    /// The `Level1` variant.
13    Level1 {
14        /// The `supports_lowercase` field.
15        supports_lowercase: bool,
16        /// The `supports_rrip` field.
17        supports_rrip: bool,
18    },
19    /// The `Level2` variant.
20    Level2 {
21        /// The `supports_lowercase` field.
22        supports_lowercase: bool,
23        /// The `supports_rrip` field.
24        supports_rrip: bool,
25    },
26    /// The `Level3` variant.
27    Level3 {
28        /// The `supports_lowercase` field.
29        supports_lowercase: bool,
30        /// The `supports_rrip` field.
31        supports_rrip: bool,
32    },
33    #[cfg(feature = "alloc")]
34    /// The `Joliet` variant.
35    Joliet {
36        /// The `level` field.
37        level: JolietLevel,
38        /// The `supports_rrip` field.
39        supports_rrip: bool,
40    },
41}
42
43impl Default for EntryType {
44    fn default() -> Self {
45        Self::Level1 {
46            supports_lowercase: false,
47            supports_rrip: false,
48        }
49    }
50}
51
52impl EntryType {
53    // Usefulness coefficient:
54    // bits 0-3 = base level (lowercase = 4,5,6 Joliet = level 12, 13, 14)
55    // bit 4 = rrip
56    /// Performs the `supports_rrip` operation.
57    pub fn supports_rrip(&self) -> bool {
58        match self {
59            Self::Level1 { supports_rrip, .. } => *supports_rrip,
60            Self::Level2 { supports_rrip, .. } => *supports_rrip,
61            Self::Level3 { supports_rrip, .. } => *supports_rrip,
62            #[cfg(feature = "alloc")]
63            Self::Joliet { supports_rrip, .. } => *supports_rrip,
64        }
65    }
66
67    // Usefulness coefficient:
68    // bits 0-3 = base level (lowercase = 4,5,6 Joliet = level 12, 13, 14)
69    // bit 4 = rrip
70    fn usefulness(self) -> u8 {
71        match self {
72            Self::Level1 {
73                supports_lowercase,
74                supports_rrip,
75            } => (supports_lowercase as u8) << 2 | (supports_rrip as u8) << 4,
76            Self::Level2 {
77                supports_lowercase,
78                supports_rrip,
79            } => 0x01 | (supports_lowercase as u8) << 2 | (supports_rrip as u8) << 4,
80            Self::Level3 {
81                supports_lowercase,
82                supports_rrip,
83            } => 0x02 | (supports_lowercase as u8) << 2 | (supports_rrip as u8) << 4,
84            #[cfg(feature = "alloc")]
85            Self::Joliet {
86                level,
87                supports_rrip,
88            } => (level as u8 + 11) | (supports_rrip as u8) << 4,
89        }
90    }
91}
92
93impl Ord for EntryType {
94    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
95        self.usefulness().cmp(&other.usefulness())
96    }
97}
98
99impl PartialOrd for EntryType {
100    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
101        Some(self.cmp(other))
102    }
103}
104
105#[cfg(feature = "alloc")]
106impl From<JolietLevel> for EntryType {
107    fn from(value: JolietLevel) -> Self {
108        Self::Joliet {
109            level: value,
110            supports_rrip: false,
111        }
112    }
113}
114
115/// Type alias for FilenameL1.
116pub type FilenameL1 = FixedBytes<14>;
117/// Type alias for FilenameL2.
118pub type FilenameL2 = FixedBytes<32>;
119/// Type alias for FilenameL3.
120pub type FilenameL3 = FixedBytes<207>;
121
122#[cfg(feature = "write")]
123/// Identifies a ConvertedName value.
124pub enum ConvertedName {
125    /// The `Level1` variant.
126    Level1(FilenameL1),
127    /// The `Level2` variant.
128    Level2(FilenameL2),
129    /// The `Level3` variant.
130    Level3(FilenameL3),
131    /// The `Joliet` variant.
132    Joliet(FixedBytes<207>),
133}
134
135#[cfg(feature = "write")]
136impl ConvertedName {
137    /// Performs the `as_bytes` operation.
138    pub fn as_bytes(&self) -> &[u8] {
139        match self {
140            Self::Level1(name) => name.as_bytes(),
141            Self::Level2(name) => name.as_bytes(),
142            Self::Level3(name) => name.as_bytes(),
143            Self::Joliet(name) => name.as_bytes(),
144        }
145    }
146}
147
148#[cfg(feature = "write")]
149impl EntryType {
150    /// Performs the `convert_name` operation.
151    pub fn convert_name(self, name: &str) -> ConvertedName {
152        match self {
153            Self::Level1 {
154                supports_lowercase, ..
155            } => ConvertedName::Level1(convert_l1(name, supports_lowercase)),
156            Self::Level2 {
157                supports_lowercase, ..
158            } => ConvertedName::Level2(convert_l2(name, supports_lowercase)),
159            Self::Level3 {
160                supports_lowercase, ..
161            } => ConvertedName::Level3(convert_l3(name, supports_lowercase)),
162            Self::Joliet { level, .. } => match level {
163                // All Joliet levels use UTF-16 BE encoding
164                JolietLevel::Level1 | JolietLevel::Level2 | JolietLevel::Level3 => {
165                    ConvertedName::Joliet(convert_joliet3(name))
166                }
167            },
168        }
169    }
170
171    /// Converts a directory name without a file extension or version suffix.
172    pub fn convert_directory_name(self, name: &str) -> ConvertedName {
173        fn primary<const N: usize>(
174            name: &str,
175            max: usize,
176            supports_lowercase: bool,
177        ) -> FixedBytes<N> {
178            let mut converted = FixedBytes::empty();
179            let end = name
180                .char_indices()
181                .map(|(offset, _)| offset)
182                .take_while(|offset| *offset <= max)
183                .last()
184                .unwrap_or(0);
185            let end = if name.len() <= max { name.len() } else { end };
186            let range = converted.push_slice(&name.as_bytes()[..end]);
187            let bytes = converted.as_bytes_mut()[range].iter_mut();
188            if supports_lowercase {
189                CharsetD1::substitute_invalid(bytes);
190            } else {
191                CharsetD::substitute_invalid(bytes);
192            }
193            converted
194        }
195
196        match self {
197            Self::Level1 {
198                supports_lowercase, ..
199            } => ConvertedName::Level1(primary(name, 8, supports_lowercase)),
200            Self::Level2 {
201                supports_lowercase, ..
202            } => ConvertedName::Level2(primary(name, 31, supports_lowercase)),
203            Self::Level3 {
204                supports_lowercase, ..
205            } => ConvertedName::Level3(primary(name, 31, supports_lowercase)),
206            Self::Joliet { .. } => ConvertedName::Joliet(convert_joliet3(name)),
207        }
208    }
209}
210
211#[cfg(feature = "write")]
212/// Performs the `convert_l1` operation.
213pub fn convert_l1(name: &str, supports_lowercase: bool) -> FixedBytes<14> {
214    let mut l1 = FixedBytes::empty();
215    let name_bytes = name.as_bytes();
216    match name.find('.') {
217        Some(index) => {
218            // We copy the basename, at most 8 bytes
219            let basename = l1.push_slice(&name_bytes[0..index.min(8)]);
220            let basename = l1.as_bytes_mut()[basename].iter_mut();
221            if supports_lowercase {
222                CharsetD1::substitute_invalid(basename);
223            } else {
224                CharsetD::substitute_invalid(basename);
225            }
226            // Extension length excluding the dot character
227            let ext_len = (name.len() - index - 1).min(3);
228            l1.push_byte(b'.');
229            let ext = l1.push_slice(&name_bytes[index + 1..(index + 1 + ext_len).min(name.len())]);
230            let ext = l1.as_bytes_mut()[ext].iter_mut();
231            if supports_lowercase {
232                CharsetD1::substitute_invalid(ext);
233            } else {
234                CharsetD::substitute_invalid(ext);
235            }
236        }
237        None => {
238            let len = name.len().min(8);
239            let basename = l1.push_slice(&name_bytes[0..len]);
240            let basename = l1.as_bytes_mut()[basename].iter_mut();
241            if supports_lowercase {
242                CharsetD1::substitute_invalid(basename);
243            } else {
244                CharsetD::substitute_invalid(basename);
245            }
246        }
247    }
248    l1.push_slice(b";1");
249    l1
250}
251
252#[cfg(feature = "write")]
253/// Performs the `convert_l2` operation.
254pub fn convert_l2(name: &str, supports_lowercase: bool) -> FilenameL2 {
255    let mut l2 = FilenameL2::empty();
256    let name_bytes = name.as_bytes();
257    // Max: 30 bytes for name (reserve 2 for ";1")
258    const MAX_NAME_LEN: usize = 30;
259
260    match name.find('.') {
261        Some(index) => {
262            let basename_end = index.min(MAX_NAME_LEN);
263            let basename = l2.push_slice(&name_bytes[0..basename_end]);
264            let basename = l2.as_bytes_mut()[basename].iter_mut();
265            if supports_lowercase {
266                CharsetD1::substitute_invalid(basename);
267            } else {
268                CharsetD::substitute_invalid(basename);
269            }
270
271            // Calculate remaining space for extension (subtract basename length and 1 for dot)
272            let remaining = MAX_NAME_LEN.saturating_sub(basename_end + 1);
273            if remaining > 0 {
274                l2.push_byte(b'.');
275                let ext_end = (index + 1 + remaining).min(name.len());
276                let ext = l2.push_slice(&name_bytes[index + 1..ext_end]);
277                let ext = l2.as_bytes_mut()[ext].iter_mut();
278                if supports_lowercase {
279                    CharsetD1::substitute_invalid(ext);
280                } else {
281                    CharsetD::substitute_invalid(ext);
282                }
283            }
284        }
285        None => {
286            let len = name.len().min(MAX_NAME_LEN);
287            let basename = l2.push_slice(&name_bytes[0..len]);
288            let basename = l2.as_bytes_mut()[basename].iter_mut();
289            if supports_lowercase {
290                CharsetD1::substitute_invalid(basename);
291            } else {
292                CharsetD::substitute_invalid(basename);
293            }
294        }
295    }
296    l2.push_slice(b";1");
297    l2
298}
299
300#[cfg(feature = "write")]
301/// Performs the `convert_l3` operation.
302pub fn convert_l3(name: &str, supports_lowercase: bool) -> FilenameL3 {
303    let mut l3 = FilenameL3::empty();
304    let name_bytes = name.as_bytes();
305    // Max: 207 bytes for name (no version suffix in L3)
306    const MAX_NAME_LEN: usize = 207;
307
308    match name.find('.') {
309        Some(index) => {
310            let basename_end = index.min(MAX_NAME_LEN);
311            let basename = l3.push_slice(&name_bytes[0..basename_end]);
312            let basename = l3.as_bytes_mut()[basename].iter_mut();
313            if supports_lowercase {
314                CharsetD1::substitute_invalid(basename);
315            } else {
316                CharsetD::substitute_invalid(basename);
317            }
318
319            // Calculate remaining space for extension (subtract basename length and 1 for dot)
320            let remaining = MAX_NAME_LEN.saturating_sub(basename_end + 1);
321            if remaining > 0 {
322                l3.push_byte(b'.');
323                let ext_end = (index + 1 + remaining).min(name.len());
324                let ext = l3.push_slice(&name_bytes[index + 1..ext_end]);
325                let ext = l3.as_bytes_mut()[ext].iter_mut();
326                if supports_lowercase {
327                    CharsetD1::substitute_invalid(ext);
328                } else {
329                    CharsetD::substitute_invalid(ext);
330                }
331            }
332        }
333        None => {
334            let len = name.len().min(MAX_NAME_LEN);
335            let basename = l3.push_slice(&name_bytes[0..len]);
336            let basename = l3.as_bytes_mut()[basename].iter_mut();
337            if supports_lowercase {
338                CharsetD1::substitute_invalid(basename);
339            } else {
340                CharsetD::substitute_invalid(basename);
341            }
342        }
343    }
344    l3
345}
346
347/// Maximum number of characters in a Joliet file identifier.
348///
349/// The Joliet specification limits a file identifier to 64 UCS-2 characters.
350/// (A 255-byte directory record could physically hold up to 103, but 64 is the
351/// conformant limit produced by default by reference tools.)
352#[cfg(feature = "write")]
353pub const JOLIET_MAX_NAME_CHARS: usize = 64;
354
355#[cfg(feature = "write")]
356/// Encode `name` as a big-endian UCS-2 Joliet file identifier.
357///
358/// Joliet identifiers are UCS-2 (Basic Multilingual Plane only), not UTF-16:
359/// a character outside the BMP cannot be represented as a single UCS-2 code
360/// unit, so it is substituted with `_` rather than emitting a surrogate pair
361/// into the field. The identifier is limited to [`JOLIET_MAX_NAME_CHARS`]
362/// characters; longer names are truncated.
363pub fn convert_joliet3(name: &str) -> FixedBytes<207> {
364    let mut j1 = FixedBytes::empty();
365    for c in name.chars().take(JOLIET_MAX_NAME_CHARS) {
366        let unit = if (c as u32) <= 0xFFFF {
367            c as u16
368        } else {
369            b'_' as u16
370        };
371        j1.push_slice(&unit.to_be_bytes());
372    }
373
374    j1
375}
376
377#[cfg(all(test, feature = "write", feature = "alloc"))]
378mod joliet_tests {
379    use super::*;
380    use crate::joliet::decode_joliet_name;
381
382    #[test]
383    fn joliet_name_is_truncated_to_the_conformant_limit() {
384        let long = "a".repeat(200);
385        let encoded = convert_joliet3(&long);
386        // Two bytes per UCS-2 unit, capped at 64 characters.
387        assert_eq!(encoded.as_bytes().len(), JOLIET_MAX_NAME_CHARS * 2);
388        assert_eq!(decode_joliet_name(encoded.as_bytes()).chars().count(), 64);
389    }
390
391    #[test]
392    fn joliet_substitutes_non_bmp_instead_of_emitting_surrogates() {
393        // U+1F600 GRINNING FACE is outside the BMP and cannot be UCS-2 encoded.
394        let encoded = convert_joliet3("a\u{1F600}b.txt");
395        let decoded = decode_joliet_name(encoded.as_bytes());
396        assert_eq!(decoded, "a_b.txt");
397        // No byte pair may fall in the UTF-16 surrogate range 0xD800..=0xDFFF.
398        for pair in encoded.as_bytes().chunks_exact(2) {
399            let unit = u16::from_be_bytes([pair[0], pair[1]]);
400            assert!(
401                !(0xD800..=0xDFFF).contains(&unit),
402                "surrogate leaked into UCS-2"
403            );
404        }
405    }
406
407    #[test]
408    fn joliet_preserves_bmp_unicode() {
409        let encoded = convert_joliet3("日本語.txt");
410        assert_eq!(decode_joliet_name(encoded.as_bytes()), "日本語.txt");
411    }
412}