Skip to main content

hadris_iso/write/
writer.rs

1use super::super::io::{self, Write};
2use alloc::vec;
3use alloc::{collections::BTreeMap, collections::VecDeque, string::String, sync::Arc, vec::Vec};
4use hadris_common::types::endian::EndianType;
5use hadris_path::{Component, Separators, VPath};
6
7use super::super::io::LogicalSector;
8use super::super::path::PathTableEntryHeader;
9use super::super::read::PathSeparator;
10use crate::file::EntryType;
11#[cfg(test)]
12use crate::file::{convert_joliet3, convert_l1, convert_l2, convert_l3};
13
14use super::super::directory::DirectoryRef;
15use super::{InputEntryKind, InputMetadata};
16
17/// Path to a directory in the written file tree.
18///
19/// Used to track the current directory during tree traversal.
20/// Each index represents a child directory index at that level.
21///
22/// # Example
23/// ```text
24/// // Root directory: []
25/// // Child at index 2: [2]
26/// // Grandchild at index 5: [2, 5]
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
29pub struct DirectoryId {
30    indices: Vec<usize>,
31}
32
33impl DirectoryId {
34    /// Creates a new root directory ID.
35    pub(crate) const fn new() -> Self {
36        Self { indices: vec![] }
37    }
38
39    /// Pushes a child index onto the path.
40    ///
41    /// # Arguments
42    /// * `index` - Child directory index
43    pub fn push(&mut self, index: usize) {
44        self.indices.push(index);
45    }
46
47    /// Pops the last child index from the path.
48    ///
49    /// # Panics
50    /// Panics if the path is empty (root directory).
51    ///
52    /// # Returns
53    /// The popped index
54    pub fn pop(&mut self) -> usize {
55        self.indices.pop().expect("directory underflow")
56    }
57}
58
59#[derive(Debug)]
60/// Represents WrittenFiles.
61pub struct WrittenFiles {
62    root: WrittenDirectory,
63}
64
65impl Default for WrittenFiles {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl WrittenFiles {
72    /// Performs the `new` operation.
73    pub fn new() -> Self {
74        Self {
75            root: WrittenDirectory::new(Arc::new(String::new())),
76        }
77    }
78
79    /// Performs the `find_file` operation.
80    pub fn find_file(&self, name: &str, _sep: PathSeparator) -> Option<DirectoryRef> {
81        let mut current_dir = DirectoryId::new();
82        let mut parts = VPath::with_separators(name, Separators::SlashOrBackslash)
83            .components()
84            .filter_map(|component| match component {
85                Component::Root | Component::Current => None,
86                Component::Parent => Some(None),
87                Component::Normal(component) => Some(Some(component)),
88            })
89            .peekable();
90        'parts: while let Some(part) = parts.next() {
91            let part = part?;
92            if parts.peek().is_none() {
93                let dir = self.get(&current_dir);
94                return dir
95                    .files
96                    .iter()
97                    .find(|file| file.name.as_str() == part)
98                    .map(|file| file.entry);
99            }
100            let dir = self.get(&current_dir);
101            for (idx, dir) in dir.dirs.iter().enumerate() {
102                if dir.name.as_str() == part {
103                    current_dir.push(idx);
104                    continue 'parts;
105                }
106            }
107            return None;
108        }
109        None
110    }
111
112    /// Performs the `root_dir` operation.
113    pub fn root_dir(&self) -> DirectoryId {
114        DirectoryId::new()
115    }
116
117    /// Performs the `root_refs` operation.
118    pub fn root_refs(&self) -> &BTreeMap<EntryType, DirectoryRef> {
119        &self.root.entries
120    }
121
122    /// Performs the `get` operation.
123    pub fn get(&self, id: &DirectoryId) -> &WrittenDirectory {
124        let mut dir = &self.root;
125        for index in &id.indices {
126            dir = &dir.dirs[*index];
127        }
128        dir
129    }
130
131    /// Performs the `get_mut` operation.
132    pub fn get_mut(&mut self, id: &DirectoryId) -> &mut WrittenDirectory {
133        let mut dir = &mut self.root;
134        for index in &id.indices {
135            dir = &mut dir.dirs[*index];
136        }
137        dir
138    }
139}
140
141/// A directory that has been written to the ISO image.
142///
143/// Contains child directories, files, and their locations on disk.
144#[derive(Debug)]
145pub struct WrittenDirectory {
146    /// Unique identifier for this directory.
147    pub(crate) id: usize,
148
149    /// Directory name (ISO 9660 formatted).
150    pub name: Arc<String>,
151
152    /// Original name for RRIP (Rock Ridge) extensions.
153    pub(crate) rrip_name: Arc<String>,
154
155    /// Directory references for each entry type (ISO levels, Joliet, etc.).
156    pub entries: BTreeMap<EntryType, DirectoryRef>,
157
158    /// Child directories.
159    pub dirs: Vec<WrittenDirectory>,
160
161    /// Files in this directory.
162    pub files: Vec<WrittenFile>,
163
164    /// File metadata (timestamps, permissions, etc.).
165    pub metadata: InputMetadata,
166
167    /// Relocation status for deep directory support (RRIP).
168    pub(crate) relocation: DirectoryRelocation,
169}
170
171/// Directory relocation state for RRIP deep directory support.
172///
173/// When ISO 9660 path depth limits are exceeded, directories are relocated
174/// to the root and tracked via Rock Ridge extensions.
175#[derive(Debug, Clone, Copy, Default)]
176pub(crate) enum DirectoryRelocation {
177    /// Not relocated.
178    #[default]
179    None,
180
181    /// Placeholder entry pointing to the actual relocated directory.
182    Placeholder {
183        /// ID of the target directory.
184        target: usize,
185    },
186
187    /// Directory that was moved to the root.
188    Moved {
189        /// New directory ID.
190        id: usize,
191        /// Original parent ID (logical location).
192        logical_parent: usize,
193    },
194}
195
196impl WrittenDirectory {
197    /// Creates a new empty directory.
198    ///
199    /// # Arguments
200    /// * `name` - Directory name
201    pub fn new(name: Arc<String>) -> Self {
202        Self {
203            id: 0,
204            rrip_name: name.clone(),
205            name,
206            entries: BTreeMap::new(),
207            dirs: Vec::new(),
208            files: Vec::new(),
209            metadata: InputMetadata::default(),
210            relocation: DirectoryRelocation::None,
211        }
212    }
213
214    /// Returns the directory reference for a given entry type, handling relocation.
215    ///
216    /// For placeholder directories, this returns the target's reference from the
217    /// relocation map. For normal directories, it returns the entry from `entries`.
218    pub(crate) fn get_dir_ref(
219        &self,
220        ty: EntryType,
221        relocation_refs: &BTreeMap<(usize, EntryType), DirectoryRef>,
222    ) -> DirectoryRef {
223        match self.relocation {
224            DirectoryRelocation::Placeholder { target } => relocation_refs
225                .get(&(target, ty))
226                .copied()
227                .unwrap_or_default(),
228            _ => *self.entries.get(&ty).unwrap(),
229        }
230    }
231
232    /// Adds a file to this directory.
233    ///
234    /// # Arguments
235    /// * `file` - The file to add
236    pub(crate) fn push_file(&mut self, file: WrittenFile) {
237        self.files.push(file);
238    }
239
240    /// Adds a child directory and returns its index.
241    ///
242    /// # Arguments
243    /// * `name` - Directory name
244    /// * `metadata` - Directory metadata
245    ///
246    /// # Returns
247    /// Index of the new directory in `self.dirs`
248    pub fn push_dir(&mut self, name: Arc<String>, metadata: InputMetadata) -> usize {
249        let mut directory = Self::new(name);
250        directory.metadata = metadata;
251        self.dirs.push(directory);
252        self.dirs.len() - 1
253    }
254}
255
256/// A file that has been written to the ISO image.
257///
258/// Contains the file's name, first extent, contents, and metadata.
259#[derive(Debug)]
260pub struct WrittenFile {
261    /// Original filename.
262    pub name: Arc<String>,
263
264    /// First extent (location and size) of the file data.
265    ///
266    /// For most files, this is the only extent.
267    /// For multi-extent files (>4 GiB), this is the first chunk.
268    pub entry: DirectoryRef,
269
270    /// File contents or directory children.
271    pub kind: InputEntryKind,
272
273    /// File metadata (timestamps, permissions, etc.).
274    pub metadata: InputMetadata,
275}
276
277impl WrittenFile {
278    /// Creates a new file with default (empty) extent.
279    ///
280    /// Used during tree walking before extents are assigned.
281    pub(crate) fn new(name: Arc<String>, kind: InputEntryKind, metadata: InputMetadata) -> Self {
282        Self {
283            name,
284            entry: DirectoryRef::default(),
285            kind,
286            metadata,
287        }
288    }
289
290    /// Creates a new file with a single extent.
291    ///
292    /// Used when the file size is known and fits in one extent (<= 4 GiB).
293    pub(crate) fn with_extent(
294        name: Arc<String>,
295        kind: InputEntryKind,
296        metadata: InputMetadata,
297        extent: DirectoryRef,
298    ) -> Self {
299        Self {
300            name,
301            entry: extent,
302            kind,
303            metadata,
304        }
305    }
306}
307
308pub(crate) struct PathTableWriter<'a> {
309    pub written_files: &'a WrittenFiles,
310    pub ty: EntryType,
311    pub endian: EndianType,
312}
313
314io_transform! {
315
316/// Write a single path table record.
317async fn write_pt_record<DATA: Write>(
318    data: &mut DATA,
319    endian: &EndianType,
320    parent_number: u16,
321    extent: LogicalSector,
322    name: &[u8],
323) -> io::Result<()> {
324    let header = PathTableEntryHeader {
325        len: name.len() as u8,
326        extended_attr_record: 0,
327        parent_directory_number: endian.u16_bytes(parent_number),
328        parent_lba: endian.u32_bytes(extent.0 as u32),
329    };
330    data.write_all(bytemuck::bytes_of(&header)).await?;
331    data.write_all(name).await?;
332    if !name.len().is_multiple_of(2) {
333        data.write_all(&[0x00]).await?; // padding to even
334    }
335    Ok(())
336}
337
338impl PathTableWriter<'_> {
339    pub async fn write<DATA: Write>(&mut self, data: &mut DATA) -> io::Result<()> {
340        // BFS queue: (directory_ref, parent_number)
341        // ISO 9660 requires path table entries in breadth-first order.
342        let mut queue: VecDeque<(&WrittenDirectory, u16)> = VecDeque::new();
343        let mut current_number: u16 = 1;
344
345        // Root entry (parent = 1, i.e. itself)
346        let root = &self.written_files.root;
347        let root_extent = *root.entries.get(&self.ty).unwrap();
348        write_pt_record(data, &self.endian, 1, root_extent.extent, &[0x00]).await?;
349        queue.push_back((root, 1));
350
351        while let Some((dir, parent_num)) = queue.pop_front() {
352            let my_number = parent_num;
353            let mut children: Vec<_> = dir
354                .dirs
355                .iter()
356                .filter(|child| {
357                    !matches!(
358                        child.relocation,
359                        DirectoryRelocation::Placeholder { .. }
360                    )
361                })
362                .map(|child| (child, self.ty.convert_directory_name(&child.name)))
363                .collect();
364            children.sort_by(|(_, left), (_, right)| left.as_bytes().cmp(right.as_bytes()));
365            for (child_dir, name) in children {
366                current_number += 1;
367                let name_bytes = name.as_bytes();
368                let extent = child_dir.entries.get(&self.ty).unwrap().extent;
369                write_pt_record(data, &self.endian, my_number, extent, name_bytes).await?;
370                queue.push_back((child_dir, current_number));
371            }
372        }
373        Ok(())
374    }
375}
376
377} // io_transform!
378
379#[cfg(all(test, feature = "std"))]
380mod tests {
381    use super::*;
382    use alloc::format;
383
384    #[test]
385    fn test_convert_l1() {
386        let orig = "this-is-the-original-file.@very-long-ext";
387        let converted = convert_l1(orig, false);
388        assert_eq!(converted.as_str(), "THIS_IS_._VE;1");
389        let converted = convert_l1(orig, true);
390        assert_eq!(converted.as_str(), "this_is_._ve;1");
391    }
392
393    #[test]
394    fn test_convert_l2_short_name() {
395        let orig = "readme.txt";
396        let converted = convert_l2(orig, false);
397        assert_eq!(converted.as_str(), "README.TXT;1");
398    }
399
400    #[test]
401    fn test_convert_l2_long_name_truncation() {
402        // Max is 30 bytes for name + 2 for ";1" = 32 total
403        let orig = "this-is-a-very-long-filename-that-should-be-truncated.extension";
404        let converted = convert_l2(orig, false);
405        // Should be truncated to 30 bytes total (basename + dot + ext) + ";1"
406        assert!(
407            converted.len() <= 32,
408            "L2 name too long: {}",
409            converted.len()
410        );
411        assert!(converted.as_str().ends_with(";1"));
412    }
413
414    #[test]
415    fn test_convert_l2_no_extension() {
416        let orig = "this-is-a-very-long-directory-name-without-extension";
417        let converted = convert_l2(orig, false);
418        assert!(
419            converted.len() <= 32,
420            "L2 name too long: {}",
421            converted.len()
422        );
423        assert!(converted.as_str().ends_with(";1"));
424        // First 30 characters + ";1"
425        assert_eq!(converted.as_str(), "THIS_IS_A_VERY_LONG_DIRECTORY_;1");
426    }
427
428    #[test]
429    fn test_convert_l3_short_name() {
430        let orig = "readme.txt";
431        let converted = convert_l3(orig, false);
432        assert_eq!(converted.as_str(), "README.TXT");
433    }
434
435    #[test]
436    fn test_convert_l3_long_name_truncation() {
437        // Max is 207 bytes for L3
438        let long_name = "a".repeat(250);
439        let converted = convert_l3(&long_name, false);
440        assert!(
441            converted.len() <= 207,
442            "L3 name too long: {}",
443            converted.len()
444        );
445        assert_eq!(converted.len(), 207);
446    }
447
448    #[test]
449    fn test_convert_l3_with_extension() {
450        // Create a name that exceeds 207 bytes with extension
451        let basename = "a".repeat(200);
452        let orig = format!("{basename}.txt");
453        let converted = convert_l3(&orig, false);
454        assert!(
455            converted.len() <= 207,
456            "L3 name too long: {}",
457            converted.len()
458        );
459    }
460
461    // Edge-case tests for convert_l1
462
463    #[test]
464    fn test_convert_l1_empty_extension() {
465        let converted = convert_l1("file.", false);
466        assert_eq!(converted.as_str(), "FILE.;1");
467    }
468
469    #[test]
470    fn test_convert_l1_dot_only() {
471        let converted = convert_l1(".", false);
472        assert_eq!(converted.as_str(), ".;1");
473    }
474
475    #[test]
476    fn test_convert_l1_dot_dot() {
477        // ".." → basename empty, dot, ext "." substituted to "_"
478        let converted = convert_l1("..", false);
479        assert_eq!(converted.as_str(), "._;1");
480    }
481
482    #[test]
483    fn test_convert_l1_no_dot() {
484        let converted = convert_l1("README", false);
485        assert_eq!(converted.as_str(), "README;1");
486    }
487
488    #[test]
489    fn test_convert_l1_no_dot_long() {
490        let converted = convert_l1("LONGFILENAME", false);
491        assert_eq!(converted.as_str(), "LONGFILE;1");
492    }
493
494    #[test]
495    fn test_convert_l1_exact_8_3() {
496        let converted = convert_l1("12345678.abc", false);
497        assert_eq!(converted.as_str(), "12345678.ABC;1");
498    }
499
500    #[test]
501    fn test_convert_l1_oversized() {
502        let converted = convert_l1("longname1.longext", false);
503        assert_eq!(converted.as_str(), "LONGNAME.LON;1");
504    }
505
506    #[test]
507    fn test_convert_l1_single_char() {
508        let converted = convert_l1("a.b", false);
509        assert_eq!(converted.as_str(), "A.B;1");
510    }
511
512    #[test]
513    fn test_convert_l1_multibyte_utf8() {
514        // "café.txt" — 'é' is 2 bytes in UTF-8, basename "café" = 5 bytes
515        let converted = convert_l1("café.txt", false);
516        // Should not panic; multi-byte chars get substituted by CharsetD
517        assert!(converted.len() <= 14, "L1 overflow: {}", converted.len());
518        assert!(converted.as_str().ends_with(";1"));
519    }
520
521    // Edge-case tests for convert_l2
522
523    #[test]
524    fn test_convert_l2_empty_extension() {
525        let converted = convert_l2("file.", false);
526        assert_eq!(converted.as_str(), "FILE.;1");
527    }
528
529    #[test]
530    fn test_convert_l2_no_dot() {
531        let converted = convert_l2("README", false);
532        assert_eq!(converted.as_str(), "README;1");
533    }
534
535    #[test]
536    fn test_convert_l2_single_char() {
537        let converted = convert_l2("a.b", false);
538        assert_eq!(converted.as_str(), "A.B;1");
539    }
540
541    // Edge-case tests for convert_l3
542
543    #[test]
544    fn test_convert_l3_empty_extension() {
545        let converted = convert_l3("file.", false);
546        assert_eq!(converted.as_str(), "FILE.");
547    }
548
549    #[test]
550    fn test_convert_l3_no_dot() {
551        let converted = convert_l3("README", false);
552        assert_eq!(converted.as_str(), "README");
553    }
554
555    #[test]
556    fn test_convert_l3_single_char() {
557        let converted = convert_l3("a.b", false);
558        assert_eq!(converted.as_str(), "A.B");
559    }
560
561    // Edge-case tests for convert_joliet3
562
563    #[test]
564    fn test_convert_joliet3_short_name() {
565        let converted = convert_joliet3("readme.txt");
566        // UTF-16 BE: each char is 2 bytes, "readme.txt" = 10 chars = 20 bytes
567        assert_eq!(converted.len(), 20);
568    }
569
570    #[test]
571    fn test_convert_joliet3_long_name_truncation() {
572        // Joliet identifiers are capped at the conformant 64-character limit.
573        let long_name = "a".repeat(150);
574        let converted = convert_joliet3(&long_name);
575        // 64 UCS-2 code units * 2 bytes = 128 bytes.
576        assert_eq!(converted.len(), 128);
577    }
578
579    #[test]
580    fn test_convert_joliet3_multibyte_utf8() {
581        // "café.txt" — 'é' is one UTF-16 code unit, 8 code units total
582        let converted = convert_joliet3("café.txt");
583        // 8 code units * 2 bytes = 16 bytes
584        assert_eq!(converted.len(), 16);
585    }
586}