Skip to main content

hadris_iso/
directory.rs

1use super::io::{self, Read, Write};
2use bytemuck::Zeroable;
3
4use super::io::LogicalSector;
5use crate::types::{U16LsbMsb, U32LsbMsb};
6
7/// The header of a directory record, because the identifier is variable length
8/// (ECMA-119 9.1 fixed fields).
9///
10/// @hadris-spec ECMA-119:9.1
11/// @hadris-compliance partial
12/// @hadris-note Fixed fields round-trip, but all identifier, flag, and semantic constraints are not yet validated.
13/// @hadris-tests directory::tests::directory_record_parse_roundtrip
14/// @hadris-fuzz iso_read
15#[repr(C)]
16#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
17pub struct DirectoryRecordHeader {
18    /// The `len` field.
19    pub len: u8,
20    /// The `extended_attr_record` field.
21    pub extended_attr_record: u8,
22    /// The LBA of the record
23    pub extent: U32LsbMsb,
24    /// The length of the data in bytes
25    pub data_len: U32LsbMsb,
26    /// The `date_time` field.
27    pub date_time: DirDateTime,
28    /// The `flags` field.
29    pub flags: u8,
30    /// The `file_unit_size` field.
31    pub file_unit_size: u8,
32    /// The `interleave_gap_size` field.
33    pub interleave_gap_size: u8,
34    /// The `volume_sequence_number` field.
35    pub volume_sequence_number: U16LsbMsb,
36    /// The `file_identifier_len` field.
37    pub file_identifier_len: u8,
38}
39
40impl Default for DirectoryRecordHeader {
41    fn default() -> Self {
42        Self {
43            len: 0,
44            extended_attr_record: 0,
45            extent: U32LsbMsb::new(0),
46            data_len: U32LsbMsb::new(0),
47            date_time: DirDateTime::now(),
48            flags: 0,
49            file_unit_size: 0,
50            interleave_gap_size: 0,
51            volume_sequence_number: U16LsbMsb::new(0),
52            file_identifier_len: 0,
53        }
54    }
55}
56
57impl DirectoryRecordHeader {
58    /// Performs the `from_bytes` operation.
59    pub fn from_bytes(bytes: &[u8]) -> &Self {
60        bytemuck::from_bytes(bytes)
61    }
62
63    /// Performs the `to_bytes` operation.
64    pub fn to_bytes(&self) -> &[u8] {
65        bytemuck::bytes_of(self)
66    }
67
68    /// Performs the `is_directory` operation.
69    pub fn is_directory(&self) -> bool {
70        FileFlags::from_bits_retain(self.flags).contains(FileFlags::DIRECTORY)
71    }
72}
73
74/// Directory Record (ECMA-119 9.1) — header plus variable identifier / system use.
75///
76/// @hadris-spec ECMA-119:9.1
77/// @hadris-compliance partial
78/// @hadris-tests directory::tests::directory_record_parse_roundtrip
79/// @hadris-fuzz iso_read
80/// @hadris-note Joliet+RRIP coexistence on read may hide one namespace; see crate Known Limitations
81#[repr(transparent)]
82#[derive(Debug, Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
83pub struct DirectoryRecord {
84    data: [u8; 256],
85}
86
87impl Default for DirectoryRecord {
88    fn default() -> Self {
89        bytemuck::Zeroable::zeroed()
90    }
91}
92
93/// Error returned when trying to access a file as a directory
94#[derive(Debug, Clone, Copy)]
95pub struct NotADirectoryError;
96
97impl core::fmt::Display for NotADirectoryError {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        write!(f, "not a directory")
100    }
101}
102
103#[cfg(feature = "std")]
104impl std::error::Error for NotADirectoryError {}
105
106impl DirectoryRecord {
107    const DATA_START: usize = size_of::<DirectoryRecordHeader>();
108
109    #[inline]
110    /// Performs the `header` operation.
111    pub fn header(&self) -> &DirectoryRecordHeader {
112        bytemuck::from_bytes(&self.data[0..Self::DATA_START])
113    }
114
115    #[inline]
116    /// Performs the `header_mut` operation.
117    pub fn header_mut(&mut self) -> &mut DirectoryRecordHeader {
118        bytemuck::from_bytes_mut(&mut self.data[0..size_of::<DirectoryRecordHeader>()])
119    }
120
121    #[inline]
122    /// Performs the `name` operation.
123    pub fn name(&self) -> &[u8] {
124        let len = self.header().file_identifier_len as usize;
125        &self.data[Self::DATA_START..Self::DATA_START + len]
126    }
127
128    /// Get the filename decoded from Joliet (UTF-16 BE) encoding
129    ///
130    /// This is useful when reading from a Joliet supplementary volume descriptor.
131    /// Returns the decoded Unicode string.
132    #[cfg(feature = "alloc")]
133    pub fn joliet_name(&self) -> alloc::string::String {
134        crate::joliet::decode_joliet_name(self.name())
135    }
136
137    /// Check if this entry's name appears to be Joliet-encoded (UTF-16 BE)
138    #[cfg(feature = "alloc")]
139    pub fn is_joliet_name(&self) -> bool {
140        crate::joliet::is_likely_joliet_name(self.name())
141    }
142
143    #[inline]
144    /// Performs the `system_use` operation.
145    pub fn system_use(&self) -> &[u8] {
146        let header = self.header();
147        // ISO 9660 requires a padding byte after the file identifier when its
148        // length is even, so the system use area always starts at an even offset.
149        let su_start = (Self::DATA_START + header.file_identifier_len as usize + 1) & !1;
150        if su_start >= header.len as usize {
151            return &[];
152        }
153        &self.data[su_start..header.len as usize]
154    }
155
156    sync_only! {
157        /// Returns the mutable system-use area needed by the synchronous writer.
158        #[cfg(feature = "write")]
159        pub(crate) fn system_use_mut(&mut self) -> &mut [u8] {
160            let name_len = self.header().file_identifier_len as usize;
161            let start = (Self::DATA_START + name_len + 1) & !1;
162            let end = self.header().len as usize;
163            &mut self.data[start..end]
164        }
165    }
166
167    #[inline]
168    /// Performs the `is_special` operation.
169    pub fn is_special(&self) -> bool {
170        self.name() == b"\x00" || self.name() == b"\x01"
171    }
172
173    #[inline]
174    /// Performs the `is_directory` operation.
175    pub fn is_directory(&self) -> bool {
176        self.header().is_directory()
177    }
178
179    /// Performs the `is_file` operation.
180    pub fn is_file(&self) -> bool {
181        !self.header().is_directory()
182    }
183
184    /// Performs the `as_dir_ref` operation.
185    pub fn as_dir_ref(&self) -> Result<DirectoryRef, NotADirectoryError> {
186        if !self.is_directory() {
187            return Err(NotADirectoryError);
188        }
189
190        let header = self.header();
191        Ok(DirectoryRef {
192            extent: LogicalSector(header.extent.read() as usize),
193            size: header.data_len.read() as usize,
194        })
195    }
196
197    /// Performs the `size` operation.
198    pub fn size(&self) -> usize {
199        self.header().len as usize
200    }
201
202    /// Performs the `to_bytes` operation.
203    pub fn to_bytes(&self) -> &[u8] {
204        &self.data[0..self.size()]
205    }
206
207    /// Performs the `new` operation.
208    pub fn new(name: &[u8], system_use: &[u8], directory: DirectoryRef, flags: FileFlags) -> Self {
209        let mut sel = Self::zeroed();
210        assert!(
211            !name.is_empty() && name.len() <= u8::MAX as usize,
212            "ISO directory-record identifier must contain 1..=255 bytes"
213        );
214        // ISO 9660 (ECMA-119 9.1): a padding byte follows the file identifier
215        // when its length is even, so the system use area starts at an even offset.
216        let su_start = (Self::DATA_START + name.len() + 1) & !1;
217        let total = su_start + system_use.len();
218        // Record length must be even (ECMA-119 7.1.1).
219        let record_len = (total + 1) & !1;
220        assert!(
221            record_len <= 255,
222            "DirectoryRecord too large: {} bytes (name={}, su={})",
223            record_len,
224            name.len(),
225            system_use.len()
226        );
227        *sel.header_mut() = DirectoryRecordHeader {
228            len: record_len as u8,
229            extended_attr_record: 0,
230            extent: U32LsbMsb::new(directory.extent.0 as u32),
231            data_len: U32LsbMsb::new(directory.size as u32),
232            date_time: DirDateTime::now(),
233            flags: flags.bits(),
234            file_unit_size: 0,
235            interleave_gap_size: 0,
236            volume_sequence_number: U16LsbMsb::new(1),
237            file_identifier_len: name.len() as u8,
238        };
239        sel.data[Self::DATA_START..Self::DATA_START + name.len()].copy_from_slice(name);
240        // Padding byte (if any) is already zero from zeroed().
241        sel.data[su_start..su_start + system_use.len()].copy_from_slice(system_use);
242        sel
243    }
244
245    /// Performs the `with_len` operation.
246    pub fn with_len(name_len: usize, su_len: usize) -> Self {
247        let mut sel = Self::zeroed();
248        assert!(
249            (1..=u8::MAX as usize).contains(&name_len),
250            "ISO directory-record identifier must contain 1..=255 bytes"
251        );
252        let su_start = (Self::DATA_START + name_len + 1) & !1;
253        let total = su_start + su_len;
254        let record_len = (total + 1) & !1;
255        assert!(record_len <= u8::MAX as usize, "DirectoryRecord too large");
256        sel.header_mut().len = record_len as u8;
257        sel
258    }
259}
260
261io_transform! {
262impl DirectoryRecord {
263    /// Performs the `parse` operation.
264    pub async fn parse<R: Read>(reader: &mut R) -> io::Result<Self> {
265        let mut sel = Self::zeroed();
266        reader.read_exact(&mut sel.data[0..Self::DATA_START]).await?;
267        let size = sel.size();
268        if size == 0 {
269            return Ok(sel);
270        }
271        let name_len = sel.header().file_identifier_len as usize;
272        if size < Self::DATA_START + 1
273            || !size.is_multiple_of(2)
274            || Self::DATA_START + name_len > size
275            || sel.header().flags & 0b0110_0000 != 0
276            || !sel.header().extent.is_consistent()
277            || !sel.header().data_len.is_consistent()
278            || !sel.header().volume_sequence_number.is_consistent()
279        {
280            return Err(io::Error::new(
281                io::ErrorKind::InvalidData,
282                "invalid ISO directory record",
283            ));
284        }
285        if size > Self::DATA_START {
286            reader.read_exact(&mut sel.data[Self::DATA_START..size]).await?;
287        }
288        if name_len.is_multiple_of(2) {
289            let padding = Self::DATA_START + name_len;
290            if padding >= size || sel.data[padding] != 0 {
291                return Err(io::Error::new(
292                    io::ErrorKind::InvalidData,
293                    "invalid ISO directory-record identifier padding",
294                ));
295            }
296        }
297        Ok(sel)
298    }
299
300    /// Performs the `write` operation.
301    pub async fn write<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
302        let size = self.size();
303        writer.write_all(&self.data[0..size]).await?;
304        Ok(size)
305    }
306}
307} // io_transform!
308
309/// The root directory entry
310#[repr(C)]
311#[derive(Default, Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
312pub struct RootDirectoryEntry {
313    /// The `header` field.
314    pub header: DirectoryRecordHeader,
315    /// There is no name on the root directory, so this is always empty
316    pub padding: u8,
317}
318
319#[repr(C)]
320#[derive(Default, Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
321/// Represents DirDateTime.
322pub struct DirDateTime {
323    /// Number of years since 1900
324    year: u8,
325    month: u8,
326    day: u8,
327    hour: u8,
328    minute: u8,
329    second: u8,
330    offset: u8,
331}
332
333impl DirDateTime {
334    #[cfg(feature = "std")]
335    /// Performs the `now` operation.
336    pub fn now() -> Self {
337        use chrono::{Datelike, Timelike, Utc};
338        let now = Utc::now();
339        Self {
340            year: (now.year() - 1900) as u8,
341            month: now.month() as u8,
342            day: now.day() as u8,
343            hour: now.hour() as u8,
344            minute: now.minute() as u8,
345            second: now.second() as u8,
346            // UTC offset is always 0
347            offset: 0,
348        }
349    }
350
351    /// Creates a zeroed datetime for no-std environments
352    #[cfg(not(feature = "std"))]
353    pub fn now() -> Self {
354        Self::default()
355    }
356}
357
358#[derive(Default, Debug, Clone, Copy)]
359/// Represents DirectoryRef.
360pub struct DirectoryRef {
361    /// The `extent` field.
362    pub extent: LogicalSector,
363    /// The `size` field.
364    pub size: usize,
365}
366
367sync_only! {
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use alloc::vec;
372    use alloc::vec::Vec;
373    use std::io::Cursor;
374
375    /// Vertical slice for ECMA-119:9.1 — parse `.` / `..` records from a sector.
376    #[test]
377    fn directory_record_parse_roundtrip() {
378        // Minimal root directory sector (same layout as comprehensive_iso helper)
379        let mut dir = vec![0u8; 2048];
380        let mut offset = 0usize;
381        for (id, flags) in [(0x00u8, 0x02u8), (0x01u8, 0x02u8)] {
382            dir[offset] = 34;
383            dir[offset + 2..offset + 6].copy_from_slice(&20u32.to_le_bytes());
384            dir[offset + 6..offset + 10].copy_from_slice(&20u32.to_be_bytes());
385            dir[offset + 10..offset + 14].copy_from_slice(&2048u32.to_le_bytes());
386            dir[offset + 14..offset + 18].copy_from_slice(&2048u32.to_be_bytes());
387            dir[offset + 25] = flags;
388            dir[offset + 28..offset + 30].copy_from_slice(&1u16.to_le_bytes());
389            dir[offset + 30..offset + 32].copy_from_slice(&1u16.to_be_bytes());
390            dir[offset + 32] = 1;
391            dir[offset + 33] = id;
392            offset += 34;
393        }
394
395        let mut cursor = Cursor::new(&dir[..]);
396        let dot = DirectoryRecord::parse(&mut cursor).expect("parse .");
397        assert_eq!(dot.size(), 34);
398        assert_eq!(core::mem::size_of::<DirectoryRecordHeader>(), 33);
399        assert!(dot.is_directory());
400        assert_eq!(dot.name(), b"\x00");
401        assert_eq!(dot.header().extent.read(), 20);
402        assert_eq!(dot.header().data_len.read(), 2048);
403
404        let dotdot = DirectoryRecord::parse(&mut cursor).expect("parse ..");
405        assert_eq!(dotdot.name(), b"\x01");
406        assert!(dotdot.is_special());
407
408        // new() + write/parse roundtrip for a file identifier
409        let made = DirectoryRecord::new(
410            b"README.;1",
411            &[],
412            DirectoryRef {
413                extent: LogicalSector(42),
414                size: 100,
415            },
416            FileFlags::empty(),
417        );
418        assert!(made.size() >= 33 + b"README.;1".len());
419        assert!(!made.is_directory());
420        assert_eq!(made.name(), b"README.;1");
421        assert_eq!(made.header().extent.read(), 42);
422
423        let mut out = Vec::new();
424        made.write(&mut out).expect("write");
425        let mut round = Cursor::new(&out[..]);
426        let parsed = DirectoryRecord::parse(&mut round).expect("re-parse");
427        assert_eq!(parsed.name(), made.name());
428        assert_eq!(parsed.size(), made.size());
429        assert_eq!(parsed.header().extent.read(), 42);
430    }
431
432    #[test]
433    fn directory_record_rejects_invalid_bounds_and_endian_copy() {
434        let mut short = vec![0_u8; 33];
435        short[0] = 32;
436        short[32] = 1;
437        assert_eq!(
438            DirectoryRecord::parse(&mut Cursor::new(short))
439                .unwrap_err()
440                .kind(),
441            io::ErrorKind::InvalidData
442        );
443
444        let mut mismatched = vec![0_u8; 34];
445        mismatched[0] = 34;
446        mismatched[2..6].copy_from_slice(&20_u32.to_le_bytes());
447        mismatched[6..10].copy_from_slice(&21_u32.to_be_bytes());
448        mismatched[28..30].copy_from_slice(&1_u16.to_le_bytes());
449        mismatched[30..32].copy_from_slice(&1_u16.to_be_bytes());
450        mismatched[32] = 1;
451        assert_eq!(
452            DirectoryRecord::parse(&mut Cursor::new(mismatched))
453                .unwrap_err()
454                .kind(),
455            io::ErrorKind::InvalidData
456        );
457    }
458}
459}
460
461bitflags::bitflags! {
462    /// Flags stored in an ISO 9660 directory record.
463    #[derive(Clone, Copy)]
464    pub struct FileFlags: u8 {
465        /// Entry is hidden from ordinary directory listings.
466        const HIDDEN = 0b0000_0001;
467        /// Entry identifies a directory.
468        const DIRECTORY = 0b0000_0010;
469        /// Entry is an associated file.
470        const ASSOCIATED_FILE = 0b0000_0100;
471        /// Entry includes an extended-attribute record.
472        const EXTENDED_ATTRIBUTES = 0b0000_1000;
473        /// Entry has extended permission information.
474        const EXTENDED_PERMISSIONS = 0b0001_0000;
475        /// Entry continues in another directory record.
476        const NOT_FINAL = 0b1000_0000;
477    }
478}