libfreemkv 0.31.5

Open source raw disc access library for optical drives
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
//! UDF filesystem reader โ€” read files from Blu-ray discs.
//!
//! Blu-ray discs use UDF 2.50 with metadata partitions.
//! The read sequence follows pointers through the disc structure:
//!
//!   Sector 256 (AVDP)
//!     โ†’ VDS (Partition Descriptor + Logical Volume Descriptor)
//!       โ†’ Metadata Partition (virtual partition stored as a file)
//!         โ†’ File Set Descriptor (FSD)
//!           โ†’ Root Directory ICB
//!             โ†’ Directory data (File Identifier Descriptors)
//!               โ†’ BDMV/PLAYLIST/*.mpls, BDMV/CLIPINF/*.clpi
//!
//! Each step reads one or two sectors. No bulk reads needed.
//!
//! References:
//!   ECMA-167 (UDF base)
//!   UDF 2.50 (OSTA) โ€” metadata partition extension
//!   BD-ROM Part 3 โ€” Blu-ray filesystem profile

use crate::error::{Error, Result};
use crate::sector::SectorSource;

/// Upper bound on a single metadata file read (`read_file`). BD-ROM
/// metadata files (.mpls/.clpi/.inf/.bdmv) are a few KiB to tens of MiB;
/// 64 MiB is a generous ceiling. Caps the allocation so a crafted ICB
/// info_length / extent length cannot force a huge zeroed reservation
/// before any data is read.
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;

/// Upper bound on a single directory's on-disc data. Real BD-ROM
/// directories are a few KiB; 1 MiB is well above any legitimate value.
/// Caps the allocation so a corrupt 30-bit directory ICB allocation
/// length cannot force a ~1 GiB zeroed allocation per recursion level.
const MAX_DIR_BYTES: u32 = 1024 * 1024;

/// A UDF filesystem parsed from disc.
#[derive(Debug)]
pub struct UdfFs {
    /// Root directory with full tree
    pub root: DirEntry,
    /// UDF Volume Identifier from Primary Volume Descriptor
    pub volume_id: String,
    /// Physical partition start (absolute sector)
    partition_start: u32,
    /// Metadata partition start (absolute sector)
    /// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
    metadata_start: u32,
    /// Metadata partition size in sectors
    metadata_sectors: u32,
}

/// A directory or file entry.
#[derive(Debug, Clone)]
pub struct DirEntry {
    pub name: String,
    pub is_dir: bool,
    /// LBA within the metadata partition (add metadata_start for absolute)
    pub meta_lba: u32,
    /// File size in bytes (from ICB info_length)
    pub size: u64,
    /// Child entries (if directory)
    pub entries: Vec<DirEntry>,
}

impl UdfFs {
    /// Physical partition start sector.
    pub fn partition_start(&self) -> u32 {
        self.partition_start
    }

    /// Metadata partition start sector.
    pub fn metadata_start(&self) -> u32 {
        self.metadata_start
    }

    /// Metadata partition size in sectors.
    pub(crate) fn metadata_sectors(&self) -> u32 {
        self.metadata_sectors
    }

    /// Find a directory by path (e.g. "/BDMV/PLAYLIST").
    /// Path matching is case-insensitive.
    pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
        let mut current = &self.root;
        for part in &parts {
            current = current
                .entries
                .iter()
                .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part))?;
        }
        Some(current)
    }

    /// Get the absolute starting LBA of a file's first data extent on disc.
    /// Used by the rip pipeline to locate m2ts content sectors.
    pub fn file_start_lba(&self, reader: &mut dyn SectorSource, path: &str) -> Result<u32> {
        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
        let mut current = &self.root;
        for part in &parts[..parts.len() - 1] {
            current = current
                .entries
                .iter()
                .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part))
                .ok_or_else(|| Error::UdfNotFound {
                    path: part.to_string(),
                })?;
        }
        let filename = match parts.last() {
            Some(f) => f,
            None => {
                return Err(Error::UdfNotFound {
                    path: path.to_string(),
                });
            }
        };
        let entry = current
            .entries
            .iter()
            .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case(filename))
            .ok_or_else(|| Error::UdfNotFound {
                path: path.to_string(),
            })?;
        let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?;
        self.partition_start
            .checked_add(data_lba)
            .ok_or(Error::DiscRead {
                sector: self.partition_start as u64,
                status: None,
                sense: None,
            })
    }

    /// Read a file by path, returning its raw bytes.
    /// Reads all data extents sector by sector from disc โ€” no buffering.
    pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
        let mut current = &self.root;

        // Navigate to parent directory
        for part in &parts[..parts.len() - 1] {
            current = current
                .entries
                .iter()
                .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part))
                .ok_or_else(|| Error::UdfNotFound {
                    path: part.to_string(),
                })?;
        }

        // Find the file
        let filename = match parts.last() {
            Some(f) => f,
            None => {
                return Err(Error::UdfNotFound {
                    path: path.to_string(),
                });
            }
        };
        let entry = current
            .entries
            .iter()
            .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case(filename))
            .ok_or_else(|| Error::UdfNotFound {
                path: path.to_string(),
            })?;

        // Read ALL the file's data extents. Multi-extent files (fragmented or
        // split across dual layers) would otherwise be silently truncated to
        // the first extent, since the buffer is sized to entry.size and
        // truncate() can't grow it.
        let extents = self.read_icb_extents(reader, entry.meta_lba)?;

        // Reject an oversized declared total before allocating: entry.size is
        // a raw u64 off the ICB, so a crafted file could otherwise force a
        // multi-hundred-MiB / GiB allocation across its extents.
        if entry.size > MAX_FILE_BYTES {
            return Err(Error::DiscRead {
                sector: self.partition_start as u64,
                status: None,
                sense: None,
            });
        }

        // Read ALL the file's data extents. File DATA is in the physical
        // partition (partition_start + lba), NOT the metadata partition: ICBs
        // are in metadata, data is in physical.
        let mut data = Vec::with_capacity(entry.size as usize);
        let mut sector = [0u8; 2048];
        for (data_lba, data_len) in extents {
            // Cumulative guard: entry.size and each per-extent data_len are
            // capped individually above, but a crafted ICB can chain many
            // small extents (read_icb_extents follows type-3 chains up to
            // MAX_AD_BLOCKS) whose running total grows `data` into GiB. Reject
            // once the accumulated bytes would exceed MAX_FILE_BYTES.
            if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES {
                return Err(Error::DiscRead {
                    sector: self.partition_start as u64,
                    status: None,
                    sense: None,
                });
            }
            // data_len is the disc-controlled 30-bit extent length; reject an
            // oversized extent before reading so a crafted ICB can't grow the
            // buffer past MAX_FILE_BYTES.
            if data_len as u64 > MAX_FILE_BYTES {
                return Err(Error::DiscRead {
                    sector: self.partition_start as u64,
                    status: None,
                    sense: None,
                });
            }
            let abs_start = self
                .partition_start
                .checked_add(data_lba)
                .ok_or(Error::DiscRead {
                    sector: self.partition_start as u64,
                    status: None,
                    sense: None,
                })?;
            let sector_count = (data_len as u64).div_ceil(2048) as u32;
            for i in 0..sector_count {
                let abs = abs_start.checked_add(i).ok_or(Error::DiscRead {
                    sector: abs_start as u64,
                    status: None,
                    sense: None,
                })?;
                read_sector(reader, abs, &mut sector)?;
                data.extend_from_slice(&sector);
            }
        }

        // Trim to the real file size; if extents under-covered the file (e.g.
        // sparse), leave what we have rather than over-reporting.
        if data.len() > entry.size as usize {
            data.truncate(entry.size as usize);
        }
        Ok(data)
    }

    /// Collect all sector ranges needed for disc-info and AACS.
    ///
    /// Returns a list of (start_lba, sector_count) ranges covering:
    ///   - UDF structure (AVDP, VDS, metadata partition, directories)
    ///   - every non-STREAM file the tree walk reaches that is <= 50 MB
    ///
    /// Skip policy (actual): directories named `STREAM` (case-insensitive)
    /// are not descended, and individual files larger than 50 MB are
    /// omitted. Nothing else is filtered by name โ€” `BACKUP`/`DUPLICATE`
    /// are traversed, and `MKB_RO.inf` is excluded only because it exceeds
    /// the 50 MB cap.
    pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
        let mut ranges = Vec::new();

        // UDF structure: sector 0 through end of metadata partition
        // Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories
        let meta_end = self.metadata_start.saturating_add(self.metadata_sectors);
        ranges.push((0, meta_end));

        // Walk tree, collect ranges for each metadata file
        self.collect_file_ranges(reader, &self.root, &mut ranges)?;

        // Merge overlapping/adjacent ranges and sort
        ranges.sort_by_key(|r| r.0);
        let merged = merge_ranges(&ranges);
        Ok(merged)
    }

    /// All sector ranges that contain data (metadata + all files including STREAM).
    /// For full disc-to-ISO dumps โ€” reads only allocated sectors, skips gaps.
    pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
        let mut ranges = Vec::new();

        // UDF structure sectors
        let meta_end = self.metadata_start.saturating_add(self.metadata_sectors);
        ranges.push((0, meta_end));

        // Walk entire tree including STREAM directories
        self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;

        // Merge overlapping/adjacent ranges and sort
        ranges.sort_by_key(|r| r.0);
        let merged = merge_ranges(&ranges);
        Ok(merged)
    }

    fn collect_all_file_ranges(
        &self,
        reader: &mut dyn SectorSource,
        entry: &DirEntry,
        ranges: &mut Vec<(u32, u32)>,
    ) -> Result<()> {
        for child in &entry.entries {
            if child.is_dir {
                self.collect_all_file_ranges(reader, child, ranges)?;
            } else {
                // Include the ICB sector
                ranges.push((self.meta_to_abs(child.meta_lba)?, 1));

                // Include ALL file data extents (large m2ts files have many)
                if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
                    for (data_lba, data_len) in extents {
                        let abs_start = match self.partition_start.checked_add(data_lba) {
                            Some(v) => v,
                            None => continue,
                        };
                        let sector_count = (data_len as u64).div_ceil(2048) as u32;
                        ranges.push((abs_start, sector_count));
                    }
                }
            }
        }
        Ok(())
    }

    fn collect_file_ranges(
        &self,
        reader: &mut dyn SectorSource,
        entry: &DirEntry,
        ranges: &mut Vec<(u32, u32)>,
    ) -> Result<()> {
        for child in &entry.entries {
            if child.is_dir {
                // Only skip STREAM โ€” those are the multi-GB video files
                if child.name.eq_ignore_ascii_case("STREAM") {
                    continue;
                }
                self.collect_file_ranges(reader, child, ranges)?;
            } else {
                // Include the ICB sector itself (in metadata partition)
                ranges.push((self.meta_to_abs(child.meta_lba)?, 1));

                // Include file data โ€” skip only truly huge files (MKB_RO.inf = 134MB)
                if child.size > 50_000_000 {
                    continue;
                }

                // Push every extent: a fragmented AACS cert / MPLS / CLPI can
                // span multiple extents, and key readers downstream need all
                // of them (mirror collect_all_file_ranges).
                if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
                    for (data_lba, data_len) in extents {
                        let abs_start = match self.partition_start.checked_add(data_lba) {
                            Some(v) => v,
                            None => continue,
                        };
                        let sector_count = (data_len as u64).div_ceil(2048) as u32;
                        ranges.push((abs_start, sector_count));
                    }
                }
            }
        }
        Ok(())
    }

    /// Convert a metadata-partition-relative LBA to an absolute sector number.
    /// `meta_lba` is disc-controlled, so the sum is checked to avoid a
    /// wrap-to-wrong-sector on a crafted ICB.
    fn meta_to_abs(&self, meta_lba: u32) -> Result<u32> {
        self.metadata_start
            .checked_add(meta_lba)
            .ok_or(Error::DiscRead {
                sector: self.metadata_start as u64,
                status: None,
                sense: None,
            })
    }

    /// Read an Extended File Entry (tag 266) or File Entry (tag 261)
    /// and return its first allocation extent: (data_lba, data_length).
    /// The data_lba is partition-relative.
    fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<(u32, u32)> {
        let extents = self.read_icb_extents(reader, meta_lba)?;
        extents.first().copied().ok_or(Error::DiscRead {
            // Diagnostic sector only; meta_to_abs can overflow on a crafted
            // meta_lba, in which case 0 is a harmless placeholder for the
            // error-context field.
            sector: self.meta_to_abs(meta_lba).unwrap_or(0) as u64,
            status: None,
            sense: None,
        })
    }

    /// Read ALL allocation extents for a file from its ICB.
    /// Returns Vec of (partition_relative_lba, byte_length) pairs.
    /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)
    /// including files whose allocation descriptors span multiple blocks via
    /// continuation (extent_type 3) descriptors.
    fn read_icb_extents(
        &self,
        reader: &mut dyn SectorSource,
        meta_lba: u32,
    ) -> Result<Vec<(u32, u32)>> {
        let icb_abs = self.meta_to_abs(meta_lba)?;
        let mut icb = [0u8; 2048];
        read_sector(reader, icb_abs, &mut icb)?;

        let tag = u16::from_le_bytes([icb[0], icb[1]]);

        // Get allocation descriptor offset and total length based on ICB type
        let (ad_offset, l_ad) = match tag {
            // Extended File Entry (UDF 2.50, used by BD-ROM)
            266 => {
                let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
                let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
                let ad_offset = 216 + l_ea;
                if ad_offset + l_ad > icb.len() {
                    return Err(Error::DiscRead {
                        sector: icb_abs as u64,
                        status: None,
                        sense: None,
                    });
                }
                (ad_offset, l_ad)
            }
            // Standard File Entry
            261 => {
                let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
                let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
                let ad_offset = 176 + l_ea;
                if ad_offset + l_ad > icb.len() {
                    return Err(Error::DiscRead {
                        sector: icb_abs as u64,
                        status: None,
                        sense: None,
                    });
                }
                (ad_offset, l_ad)
            }
            _ => {
                return Err(Error::DiscRead {
                    sector: icb_abs as u64,
                    status: None,
                    sense: None,
                });
            }
        };

        // Allocation-descriptor type lives in the ICB Tag flags (low 3
        // bits). The ICB Tag immediately follows the 16-byte descriptor
        // tag, and its `flags` u16 is the last field at ICB-tag offset 18
        // โ†’ absolute offset 34, for both File Entry (261) and Extended
        // File Entry (266). 0 = Short AD (8 bytes), 1 = Long AD (16 bytes),
        // 2 = Extended AD (20 bytes), 3 = data embedded inline in the ICB.
        //
        // This MUST be honoured: a Short AD and a Long AD both carry
        // length+lba in their first 8 bytes, so hardcoding an 8-byte
        // stride reads descriptor #0 of a Long-AD file correctly but lands
        // descriptor #1 in the middle of the first Long AD (its impl_use
        // bytes) โ€” garbage that trips the terminator/unknown-type break.
        // Large BD-ROM .m2ts streams use Long ADs, so that bug truncated
        // every multi-extent title at its first extent (~973 MB-1 GiB).
        let icb_flags = u16::from_le_bytes([icb[34], icb[35]]);
        let ad_type = (icb_flags & 0x07) as usize;
        let ad_size: usize = match ad_type {
            0 => 8,  // Short AD
            1 => 16, // Long AD
            2 => 20, // Extended AD
            // 3 = inline/embedded data (no out-of-line extents) โ€” never
            // used for large stream files. Anything else is unexpected;
            // fall back to the historical 8-byte stride rather than fail
            // the whole title.
            _ => 8,
        };

        let mut extents = Vec::new();

        // Parse the first allocation-descriptor list from the ICB. A type-3
        // descriptor ("next extent of allocation descriptors") points at a
        // continuation block in the metadata partition holding more ADs; we
        // follow the chain. The hop count is bounded to avoid looping on a
        // crafted/corrupt disc.
        let mut block = icb;
        let mut ad_start = ad_offset;
        let mut ad_bytes = l_ad;
        const MAX_AD_BLOCKS: usize = 256;

        for _ in 0..MAX_AD_BLOCKS {
            let num_descriptors = ad_bytes / ad_size;
            let mut next_block: Option<u32> = None;

            for i in 0..num_descriptors {
                let off = ad_start + i * ad_size;
                if off + ad_size > block.len() {
                    break;
                }

                let raw_len = u32::from_le_bytes([
                    block[off],
                    block[off + 1],
                    block[off + 2],
                    block[off + 3],
                ]);
                let extent_type = raw_len >> 30;
                let data_len = raw_len & 0x3FFF_FFFF;
                // Short and Long ADs carry the extent LBA at off+4. Extended
                // ADs (20 bytes) place their extent_location lb_addr after
                // three length fields, at off+12.
                let lba_off = if ad_size == 20 { off + 12 } else { off + 4 };
                let data_lba = u32::from_le_bytes([
                    block[lba_off],
                    block[lba_off + 1],
                    block[lba_off + 2],
                    block[lba_off + 3],
                ]);

                match extent_type {
                    // Recorded and allocated. A zero-length type-0
                    // descriptor is the AD-list terminator (continuation
                    // blocks are scanned to the end of the sector, so the
                    // trailing zero padding must not be read as extents).
                    0 if data_len == 0 => break,
                    0 => extents.push((data_lba, data_len)),
                    1 => {} // allocated but not recorded (sparse)
                    3 => {
                        // Continuation: the rest of the ADs live in the block
                        // at data_lba (metadata-partition-relative). Stop
                        // scanning this block and follow the pointer.
                        if data_len > 0 {
                            next_block = Some(data_lba);
                        }
                        break;
                    }
                    _ => break,
                }
            }

            match next_block {
                Some(cont_lba) => {
                    read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
                    // A continuation block is a list of Short ADs from byte 0,
                    // spanning the whole sector.
                    ad_start = 0;
                    ad_bytes = block.len();
                }
                None => break,
            }
        }

        Ok(extents)
    }

    /// Get all absolute disc sector extents for a file.
    /// Returns Vec of (absolute_lba, sector_count) covering the entire file.
    pub fn file_extents(
        &self,
        reader: &mut dyn SectorSource,
        path: &str,
    ) -> Result<Vec<(u32, u32)>> {
        let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
        let mut current = &self.root;
        for part in &parts[..parts.len() - 1] {
            current = current
                .entries
                .iter()
                .find(|e| e.is_dir && e.name.eq_ignore_ascii_case(part))
                .ok_or_else(|| Error::UdfNotFound {
                    path: part.to_string(),
                })?;
        }
        let filename = match parts.last() {
            Some(f) => f,
            None => {
                return Err(Error::UdfNotFound {
                    path: path.to_string(),
                });
            }
        };
        let entry = current
            .entries
            .iter()
            .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case(filename))
            .ok_or_else(|| Error::UdfNotFound {
                path: path.to_string(),
            })?;

        let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?;
        let mut disc_extents = Vec::with_capacity(alloc_extents.len());
        for (lba, byte_len) in alloc_extents {
            let abs_lba = self
                .partition_start
                .checked_add(lba)
                .ok_or(Error::DiscRead {
                    sector: self.partition_start as u64,
                    status: None,
                    sense: None,
                })?;
            let sectors = (byte_len as u64).div_ceil(2048) as u32;
            disc_extents.push((abs_lba, sectors));
        }
        Ok(disc_extents)
    }
}

/// Read the UDF filesystem from a Blu-ray disc.
///
/// Follows the UDF pointer chain:
/// 1. AVDP (sector 256) โ†’ VDS location
/// 2. VDS โ†’ Partition Descriptor (physical partition start)
///    โ†’ Logical Volume Descriptor (FSD location + partition maps)
/// 3. Metadata partition file โ†’ metadata content location
/// 4. FSD โ†’ root directory ICB
/// 5. Root directory โ†’ file tree
pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
    // Step 1: Anchor Volume Descriptor Pointer at sector 256
    // ECMA-167 ยง10.2 โ€” always at sector 256
    let mut avdp = [0u8; 2048];
    read_sector(reader, 256, &mut avdp)?;

    let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
    if tag_id != 2 {
        return Err(Error::DiscRead {
            sector: 256,
            status: None,
            sense: None,
        });
    }

    // Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
    // (We use the VDS at sectors 32+, not the reserve copy at sector 32768+)

    // Step 2: Read Volume Descriptor Sequence (sectors 32-37 typically)
    // Find Partition Descriptor (tag 5) and Logical Volume Descriptor (tag 6)
    let mut partition_start: u32 = 0;
    let mut num_partition_maps: u32 = 0;
    let mut lvd_sector: Option<u32> = None;
    let mut volume_id = String::new();
    let mut metadata_size_bytes: u32 = 0;

    for i in 32..64 {
        let mut desc = [0u8; 2048];
        read_sector(reader, i, &mut desc)?;

        let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
        match desc_tag {
            // Primary Volume Descriptor โ€” volume identifier at offset 24, 32-byte d-string
            1 => {
                volume_id = parse_dstring(&desc[24..56]);
            }
            // Partition Descriptor โ€” tells us where the physical partition starts
            5 => {
                partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
            }
            // Logical Volume Descriptor โ€” contains FSD location and partition maps
            6 => {
                num_partition_maps =
                    u32::from_le_bytes([desc[268], desc[269], desc[270], desc[271]]);
                lvd_sector = Some(i);
            }
            // Terminating Descriptor โ€” end of VDS
            8 => break,
            _ => continue,
        }
    }

    if partition_start == 0 {
        return Err(Error::DiscRead {
            sector: 0,
            status: None,
            sense: None,
        });
    }

    // Step 3: Parse partition maps from LVD to find metadata partition
    // BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
    // The metadata file is stored at lba=0 of the physical partition
    let metadata_start = if num_partition_maps >= 2 {
        let lvd_sec = lvd_sector.ok_or(Error::DiscRead {
            sector: 0,
            status: None,
            sense: None,
        })?;

        // Read LVD to check partition map type
        let mut lvd = [0u8; 2048];
        read_sector(reader, lvd_sec, &mut lvd)?;

        // Parse partition maps starting at offset 440
        // Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
        let _pm1_type = lvd[440]; // First map type
        let pm1_len = lvd[441] as usize;

        if pm1_len > 0 && 440 + pm1_len < 2048 {
            let pm2_type = lvd[440 + pm1_len]; // Second map type

            if pm2_type == 2 {
                // Type 2 = metadata partition
                // The metadata file ICB is at physical partition lba 0
                // Read it to find where the metadata content starts
                let meta_file_lba = partition_start; // lba 0 of partition
                let mut meta_icb = [0u8; 2048];
                read_sector(reader, meta_file_lba, &mut meta_icb)?;

                let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
                if meta_tag == 266 {
                    // Extended File Entry โ€” get allocation extent
                    let l_ea = u32::from_le_bytes([
                        meta_icb[208],
                        meta_icb[209],
                        meta_icb[210],
                        meta_icb[211],
                    ]) as usize;
                    let ad_off = 216 + l_ea;
                    if ad_off + 8 > meta_icb.len() {
                        return Err(Error::DiscRead {
                            sector: meta_file_lba as u64,
                            status: None,
                            sense: None,
                        });
                    }
                    let ad_len = u32::from_le_bytes([
                        meta_icb[ad_off],
                        meta_icb[ad_off + 1],
                        meta_icb[ad_off + 2],
                        meta_icb[ad_off + 3],
                    ]) & 0x3FFF_FFFF;
                    metadata_size_bytes = ad_len;
                    let ad_pos = u32::from_le_bytes([
                        meta_icb[ad_off + 4],
                        meta_icb[ad_off + 5],
                        meta_icb[ad_off + 6],
                        meta_icb[ad_off + 7],
                    ]);
                    // Metadata content starts at partition_start + ad_pos
                    partition_start.checked_add(ad_pos).ok_or(Error::DiscRead {
                        sector: partition_start as u64,
                        status: None,
                        sense: None,
                    })?
                } else {
                    // Fallback: no metadata partition, use physical partition directly
                    partition_start
                }
            } else {
                partition_start
            }
        } else {
            partition_start
        }
    } else {
        // Single partition map โ€” no metadata partition (older UDF)
        partition_start
    };

    // Step 4: Read File Set Descriptor from metadata partition
    // FSD is at metadata-relative lba 0 (first sector of metadata content)
    let mut fsd = [0u8; 2048];
    read_sector(reader, metadata_start, &mut fsd)?;

    let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
    if fsd_tag != 256 {
        return Err(Error::DiscRead {
            sector: metadata_start as u64,
            status: None,
            sense: None,
        });
    }

    // Root Directory ICB: long_ad at FSD offset 400
    // long_ad = extent_length(4) + extent_location: lba(4) + part_ref(2) + impl_use(6)
    let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]);

    // Step 5: Read root directory and build file tree
    let root = read_directory(reader, partition_start, metadata_start, root_lba, "", 0)?;

    let metadata_sectors = (metadata_size_bytes as u64).div_ceil(2048) as u32;

    Ok(UdfFs {
        root,
        volume_id,
        partition_start,
        metadata_start,
        metadata_sectors,
    })
}

/// Maximum directory nesting depth followed when building the tree.
/// Bounds recursion on a corrupt/looping disc; real BD-ROM and DVD trees
/// are far shallower (BDMV/BACKUP/BDJO is the deepest standard path at 3).
const MAX_DIR_DEPTH: u32 = 8;

/// Read a UDF directory and its children (up to [`MAX_DIR_DEPTH`] levels).
///
/// Each directory is an ICB (Extended File Entry) pointing to directory data
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
/// and points to its ICB. Directories deeper than [`MAX_DIR_DEPTH`] are
/// recorded as entries but not descended into.
#[allow(clippy::only_used_in_recursion)]
fn read_directory(
    reader: &mut dyn SectorSource,
    part_start: u32,
    meta_start: u32,
    meta_lba: u32,
    name: &str,
    depth: u32,
) -> Result<DirEntry> {
    // Read ICB for this directory
    let icb_abs = meta_start.checked_add(meta_lba).ok_or(Error::DiscRead {
        sector: meta_start as u64,
        status: None,
        sense: None,
    })?;
    let mut icb = [0u8; 2048];
    read_sector(reader, icb_abs, &mut icb)?;

    let tag = u16::from_le_bytes([icb[0], icb[1]]);

    // Get allocation extent: where the directory data lives
    let (ad_len, ad_pos) = match tag {
        266 => {
            let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
            let ad_off = 216 + l_ea;
            if ad_off + 8 > icb.len() {
                return Err(Error::DiscRead {
                    sector: icb_abs as u64,
                    status: None,
                    sense: None,
                });
            }
            let len = u32::from_le_bytes([
                icb[ad_off],
                icb[ad_off + 1],
                icb[ad_off + 2],
                icb[ad_off + 3],
            ]) & 0x3FFF_FFFF;
            let pos = u32::from_le_bytes([
                icb[ad_off + 4],
                icb[ad_off + 5],
                icb[ad_off + 6],
                icb[ad_off + 7],
            ]);
            (len, pos)
        }
        261 => {
            let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
            let ad_off = 176 + l_ea;
            if ad_off + 8 > icb.len() {
                return Err(Error::DiscRead {
                    sector: icb_abs as u64,
                    status: None,
                    sense: None,
                });
            }
            let len = u32::from_le_bytes([
                icb[ad_off],
                icb[ad_off + 1],
                icb[ad_off + 2],
                icb[ad_off + 3],
            ]) & 0x3FFF_FFFF;
            let pos = u32::from_le_bytes([
                icb[ad_off + 4],
                icb[ad_off + 5],
                icb[ad_off + 6],
                icb[ad_off + 7],
            ]);
            (len, pos)
        }
        _ => {
            return Ok(DirEntry {
                name: name.to_string(),
                is_dir: true,
                meta_lba,
                size: 0,
                entries: Vec::new(),
            });
        }
    };

    // Reject an oversized directory before allocating: ad_len is the
    // disc-controlled 30-bit ICB allocation length, so a corrupt value
    // could otherwise force a ~1 GiB zeroed allocation (amplified by
    // recursion). Real directories are a few KiB; the 1 MiB cap still
    // covers a large STREAM/ dir with thousands of .m2ts FIDs.
    if ad_len > MAX_DIR_BYTES {
        return Err(Error::DiscRead {
            sector: meta_start as u64,
            status: None,
            sense: None,
        });
    }

    // Read directory data
    let dir_abs = meta_start.checked_add(ad_pos).ok_or(Error::DiscRead {
        sector: meta_start as u64,
        status: None,
        sense: None,
    })?;
    let sector_count = ad_len.div_ceil(2048);
    let mut dir_data = vec![0u8; sector_count as usize * 2048];
    for i in 0..sector_count {
        let abs = dir_abs.checked_add(i).ok_or(Error::DiscRead {
            sector: dir_abs as u64,
            status: None,
            sense: None,
        })?;
        read_sector(
            reader,
            abs,
            &mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048],
        )?;
    }

    // Parse File Identifier Descriptors
    let mut entries = Vec::new();
    let mut pos = 0;

    while pos + 38 < dir_data.len().min(ad_len as usize) {
        let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]);
        if fid_tag != 257 {
            break;
        }

        let file_chars = dir_data[pos + 18];
        let l_fi = dir_data[pos + 19] as usize;

        // FID ICB is a long_ad starting at offset 20:
        //   [20:24] = extent_length
        //   [24:28] = extent_location (LBA within metadata partition)
        //   [28:30] = partition_reference_number
        //   [30:36] = implementation_use
        let icb_lba = u32::from_le_bytes([
            dir_data[pos + 24],
            dir_data[pos + 25],
            dir_data[pos + 26],
            dir_data[pos + 27],
        ]);
        let l_iu = u16::from_le_bytes([dir_data[pos + 36], dir_data[pos + 37]]) as usize;

        let is_dir = (file_chars & 0x02) != 0;
        let is_parent = (file_chars & 0x08) != 0;

        if !is_parent && l_fi > 0 {
            let name_start = pos + 38 + l_iu;
            let name_end = name_start + l_fi;
            if name_end > dir_data.len() {
                break;
            }
            let entry_name = parse_udf_name(&dir_data[name_start..name_end]);

            if !entry_name.is_empty() {
                // Read the ICB to get file size
                let file_size = read_file_size(reader, meta_start, icb_lba).unwrap_or(0);

                if is_dir && depth < MAX_DIR_DEPTH {
                    // Recurse into subdirectory. The cap guards against
                    // pathological/looping directory trees on a corrupt disc
                    // while comfortably covering real BD-ROM nesting
                    // (e.g. BDMV/BACKUP/BDJO/*.bdjo is 3 levels deep).
                    let subdir = read_directory(
                        reader,
                        part_start,
                        meta_start,
                        icb_lba,
                        &entry_name,
                        depth + 1,
                    )?;
                    entries.push(subdir);
                } else {
                    entries.push(DirEntry {
                        name: entry_name,
                        is_dir,
                        meta_lba: icb_lba,
                        size: file_size,
                        entries: Vec::new(),
                    });
                }
            }
        }

        // Advance to next FID (4-byte aligned)
        let fid_len = (38 + l_iu + l_fi + 3) & !3;
        pos += fid_len;
    }

    Ok(DirEntry {
        name: name.to_string(),
        is_dir: true,
        meta_lba,
        size: ad_len as u64,
        entries,
    })
}

/// Read file size (info_length) from an Extended File Entry ICB.
fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) -> Result<u64> {
    let abs = meta_start.checked_add(meta_lba).ok_or(Error::DiscRead {
        sector: meta_start as u64,
        status: None,
        sense: None,
    })?;
    let mut icb = [0u8; 2048];
    read_sector(reader, abs, &mut icb)?;

    let tag = u16::from_le_bytes([icb[0], icb[1]]);
    match tag {
        // Both File Entry (261) and Extended File Entry (266) have
        // info_length as a u64 at offset 56
        261 | 266 => Ok(u64::from_le_bytes([
            icb[56], icb[57], icb[58], icb[59], icb[60], icb[61], icb[62], icb[63],
        ])),
        _ => Ok(0),
    }
}

/// Parse a UDF filename from raw bytes.
///
/// UDF uses a compression ID as the first byte:
///   8  = 8-bit characters (ASCII)
///   16 = 16-bit big-endian Unicode (UTF-16BE)
fn parse_udf_name(data: &[u8]) -> String {
    if data.is_empty() {
        return String::new();
    }

    match data[0] {
        8 => {
            // 8-bit ASCII
            String::from_utf8_lossy(&data[1..]).trim().to_string()
        }
        16 => {
            // 16-bit big-endian Unicode
            let mut s = String::new();
            let chars = &data[1..];
            for i in (0..chars.len()).step_by(2) {
                if i + 1 < chars.len() {
                    let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
                    if let Some(ch) = char::from_u32(c as u32) {
                        s.push(ch);
                    }
                }
            }
            s.trim().to_string()
        }
        _ => String::from_utf8_lossy(&data[1..]).trim().to_string(),
    }
}

/// Merge overlapping or adjacent (start, count) ranges.
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
    if ranges.is_empty() {
        return Vec::new();
    }
    let mut result = vec![ranges[0]];
    for &(start, count) in &ranges[1..] {
        let last = result.last_mut().unwrap();
        // Saturating arithmetic: ranges derive from disc-controlled ICB
        // LBAs/lengths, so a corrupt disc could otherwise overflow u32
        // (panic in debug, wrap in release).
        let last_end = last.0.saturating_add(last.1);
        if start <= last_end.saturating_add(1) {
            // Overlapping or adjacent โ€” extend
            let new_end = start.saturating_add(count).max(last_end);
            last.1 = new_end - last.0;
        } else {
            result.push((start, count));
        }
    }
    result
}

/// Parse a UDF d-string (fixed-length field with length byte at the end).
/// Used for Volume Identifier and other UDF descriptor strings.
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
fn parse_dstring(data: &[u8]) -> String {
    if data.is_empty() {
        return String::new();
    }
    let len = *data.last().unwrap() as usize;
    if len == 0 || len > data.len() {
        return String::new();
    }
    let content = &data[..len];
    if content.is_empty() {
        return String::new();
    }
    match content[0] {
        8 => String::from_utf8_lossy(&content[1..])
            .trim_end_matches('\0')
            .trim()
            .to_string(),
        16 => {
            let mut s = String::new();
            let chars = &content[1..];
            for i in (0..chars.len()).step_by(2) {
                if i + 1 < chars.len() {
                    let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
                    if c != 0 {
                        if let Some(ch) = char::from_u32(c as u32) {
                            s.push(ch);
                        }
                    }
                }
            }
            s.trim().to_string()
        }
        _ => String::from_utf8_lossy(&content[1..])
            .trim_end_matches('\0')
            .trim()
            .to_string(),
    }
}

/// Buffered sector reader โ€” reduces SCSI round-trips by coalescing
/// single-sector reads into `batch`-sized SCSI commands. Per-command
/// latency dominates on USB drives, so serving many adjacent single-sector
/// reads from one bulk read is substantially faster than issuing each
/// individually. `batch` is a runtime field, not a fixed count.
pub(crate) struct BufferedSectorReader<'a> {
    inner: &'a mut dyn SectorSource,
    cache_start: u32,
    cache: Vec<u8>,
    cache_sectors: u32,
    batch: u16,
    /// Pre-fetched sector data from bulk reads (sector ranges for AACS, MPLS, CLPI, etc.)
    prefetched: std::collections::HashMap<u32, Vec<u8>>,
}

impl<'a> BufferedSectorReader<'a> {
    pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self {
        Self {
            inner,
            cache_start: u32::MAX,
            cache: Vec::new(),
            cache_sectors: 0,
            batch,
            prefetched: std::collections::HashMap::new(),
        }
    }
}

impl BufferedSectorReader<'_> {
    /// Pre-read a contiguous range of sectors into the sliding cache.
    /// Used to bulk-load the UDF metadata partition so subsequent reads are instant.
    pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) {
        let total = count as usize * 2048;
        self.cache.resize(total, 0);
        let mut offset = 0u32;
        while offset < count {
            let batch = (count - offset).min(self.batch as u32) as u16;
            let buf_off = offset as usize * 2048;
            if self
                .inner
                .read_sectors(
                    start_lba + offset,
                    batch,
                    &mut self.cache[buf_off..buf_off + batch as usize * 2048],
                    true,
                )
                .is_err()
            {
                break;
            }
            offset += batch as u32;
        }
        self.cache_start = start_lba;
        self.cache_sectors = offset;
    }

    /// Pre-read multiple sector ranges into the permanent cache.
    /// Each range is read in batch-sized chunks and stored per-sector in a HashMap.
    /// Used to bulk-load all small files (AACS, MPLS, CLPI, META) before scanning.
    pub(crate) fn prefetch_ranges(&mut self, ranges: &[(u32, u32)]) {
        let mut tmp = vec![0u8; self.batch as usize * 2048];
        for &(start, count) in ranges {
            let mut offset = 0u32;
            while offset < count {
                let batch = (count - offset).min(self.batch as u32) as u16;
                let bytes = batch as usize * 2048;
                if self
                    .inner
                    .read_sectors(start + offset, batch, &mut tmp[..bytes], true)
                    .is_err()
                {
                    break;
                }
                for i in 0..batch as u32 {
                    let s = i as usize * 2048;
                    self.prefetched
                        .insert(start + offset + i, tmp[s..s + 2048].to_vec());
                }
                offset += batch as u32;
            }
        }
    }
}

impl SectorSource for BufferedSectorReader<'_> {
    fn read_sectors(
        &mut self,
        lba: u32,
        count: u16,
        buf: &mut [u8],
        _recovery: bool,
    ) -> std::result::Result<usize, crate::error::Error> {
        if count == 1 {
            // Contract: a single-sector read needs at least one sector of
            // destination. Return an error rather than panicking on the slice.
            if buf.len() < 2048 {
                return Err(crate::error::Error::UdfBufferTooSmall);
            }
            // Check permanent prefetch cache first (HashMap)
            if let Some(data) = self.prefetched.get(&lba) {
                buf[..2048].copy_from_slice(data);
                return Ok(2048);
            }
            // Check sliding cache
            if lba >= self.cache_start && lba < self.cache_start + self.cache_sectors {
                let offset = (lba - self.cache_start) as usize * 2048;
                buf[..2048].copy_from_slice(&self.cache[offset..offset + 2048]);
                return Ok(2048);
            }
            let block = self.batch;
            self.cache.resize(block as usize * 2048, 0);
            match self.inner.read_sectors(lba, block, &mut self.cache, true) {
                Ok(_) => {
                    self.cache_start = lba;
                    self.cache_sectors = block as u32;
                }
                Err(_) => {
                    // By design: a `block`-sector batch read that starts
                    // valid but runs past the last recorded sector fails as
                    // a unit. Retry the one sector actually requested so a
                    // batch overrunning the disc tail still serves the live
                    // LBA instead of erroring; a genuinely bad single sector
                    // then propagates via `?`.
                    self.cache.resize(2048, 0);
                    self.inner.read_sectors(lba, 1, &mut self.cache, true)?;
                    self.cache_start = lba;
                    self.cache_sectors = 1;
                }
            }
            buf[..2048].copy_from_slice(&self.cache[..2048]);
            Ok(2048)
        } else {
            // Multi-sector read โ€” pass through
            self.inner.read_sectors(lba, count, buf, true)
        }
    }
}

/// Read a single 2048-byte sector from the drive.
/// Uses standard READ(10) โ€” no unlock required.
fn read_sector(reader: &mut dyn SectorSource, lba: u32, buf: &mut [u8]) -> Result<()> {
    reader.read_sectors(lba, 1, buf, true)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    /// In-memory SectorSource backed by an explicit absolute-LBA โ†’ sector map.
    /// Unmapped sectors read as zeroes.
    struct MapReader {
        sectors: HashMap<u32, [u8; 2048]>,
    }

    impl MapReader {
        fn new() -> Self {
            Self {
                sectors: HashMap::new(),
            }
        }
        fn put(&mut self, lba: u32, data: [u8; 2048]) {
            self.sectors.insert(lba, data);
        }
    }

    impl SectorSource for MapReader {
        fn read_sectors(
            &mut self,
            lba: u32,
            count: u16,
            buf: &mut [u8],
            _recovery: bool,
        ) -> Result<usize> {
            let need = count as usize * 2048;
            if buf.len() < need {
                return Err(Error::UdfBufferTooSmall);
            }
            for i in 0..count as u32 {
                let off = i as usize * 2048;
                let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
                buf[off..off + 2048].copy_from_slice(&s);
            }
            Ok(need)
        }
    }

    /// Build an Extended File Entry (tag 266) ICB sector with the given
    /// info_length and a list of (extent_type, data_len, data_lba) short ADs.
    fn build_efe(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] {
        let mut s = [0u8; 2048];
        s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag
        s[56..64].copy_from_slice(&info_length.to_le_bytes()); // info_length
        let l_ea: u32 = 0;
        let l_ad: u32 = (ads.len() * 8) as u32;
        s[208..212].copy_from_slice(&l_ea.to_le_bytes());
        s[212..216].copy_from_slice(&l_ad.to_le_bytes());
        let mut off = 216 + l_ea as usize;
        for &(etype, dlen, dlba) in ads {
            let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
            s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
            s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
            off += 8;
        }
        s
    }

    /// Build an Extended File Entry (tag 266) ICB whose allocation
    /// descriptors are LONG ADs (16 bytes: len(4) | lba(4) | part_ref(2) |
    /// impl_use(6)). Sets the ICB Tag flags (abs offset 34) low bits to 1
    /// so the parser must select the 16-byte stride. This is the layout
    /// large BD-ROM .m2ts streams actually use.
    fn build_efe_long(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] {
        let mut s = [0u8; 2048];
        s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag
        // ICB Tag flags at abs offset 34: AD type 1 = Long AD.
        s[34..36].copy_from_slice(&1u16.to_le_bytes());
        s[56..64].copy_from_slice(&info_length.to_le_bytes());
        let l_ea: u32 = 0;
        let l_ad: u32 = (ads.len() * 16) as u32;
        s[208..212].copy_from_slice(&l_ea.to_le_bytes());
        s[212..216].copy_from_slice(&l_ad.to_le_bytes());
        let mut off = 216 + l_ea as usize;
        for &(etype, dlen, dlba) in ads {
            let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
            s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
            s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
            // off+8..off+10 = partition reference (0), off+10..off+16 =
            // impl_use (0). Leaving these zero is what trips the old
            // 8-byte-stride parser into reading a bogus zero-length
            // terminator as descriptor #1.
            off += 16;
        }
        s
    }

    /// A continuation block: a bare list of short ADs from byte 0.
    fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
        let mut s = [0u8; 2048];
        let mut off = 0usize;
        for &(etype, dlen, dlba) in ads {
            let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
            s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
            s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes());
            off += 8;
        }
        s
    }

    fn fs_with(part_start: u32, meta_start: u32, root: DirEntry) -> UdfFs {
        UdfFs {
            root,
            volume_id: String::new(),
            partition_start: part_start,
            metadata_start: meta_start,
            metadata_sectors: 0,
        }
    }

    fn file_entry(name: &str, meta_lba: u32, size: u64) -> DirEntry {
        DirEntry {
            name: name.to_string(),
            is_dir: false,
            meta_lba,
            size,
            entries: Vec::new(),
        }
    }

    #[test]
    fn icb_extents_follow_type3_continuation() {
        let part_start = 1000;
        let meta_start = 100;
        // ICB at meta_lba 5: one real extent + a type-3 continuation pointer.
        let icb = build_efe(
            6144,
            &[
                (0, 4096, 10), // recorded extent at part-rel lba 10
                (3, 2048, 50), // continuation block at meta-rel lba 50
            ],
        );
        // Continuation block holds the tail extent.
        let cont = build_cont_block(&[(0, 2048, 20)]);

        let mut reader = MapReader::new();
        reader.put(meta_start + 5, icb);
        reader.put(meta_start + 50, cont);

        let fs = fs_with(part_start, meta_start, file_entry("X", 5, 6144));
        let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
        assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
    }

    #[test]
    fn icb_extents_long_ad_returns_all_extents_not_just_first() {
        // Regression: BD-ROM large .m2ts files use Long ADs (16-byte
        // descriptors). The pre-fix parser hardcoded an 8-byte stride, so
        // it read descriptor #0 (length+lba align in both layouts) then
        // misread descriptor #1 from the middle of the first Long AD โ€”
        // a zero terminator โ€” and returned ONLY the first extent. That
        // truncated every multi-extent title at ~973 MB-1 GiB.
        //
        // Four Long ADs, each a near-max Short-AD-sized extent. The fix
        // must return all four; the old code returned exactly one.
        let icb = build_efe_long(
            4 * 1_000_000_000,
            &[
                (0, 0x3FFF_F800, 100),     // ~1 GiB extent
                (0, 0x3FFF_F800, 600_000), // next extent
                (0, 0x3FFF_F800, 1_100_000),
                (0, 0x1000_0000, 1_600_000), // shorter tail extent
            ],
        );
        let mut reader = MapReader::new();
        reader.put(5, icb);
        let fs = fs_with(0, 0, file_entry("BIG", 5, 4 * 1_000_000_000));
        let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
        assert_eq!(
            extents,
            vec![
                (100, 0x3FFF_F800),
                (600_000, 0x3FFF_F800),
                (1_100_000, 0x3FFF_F800),
                (1_600_000, 0x1000_0000),
            ],
            "Long-AD file must return ALL extents, not just the first"
        );
    }

    #[test]
    fn read_file_spans_multiple_extents() {
        let part_start = 0;
        let meta_start = 0;
        // Two extents of one sector each; distinct fill bytes per data sector.
        let icb = build_efe(4096, &[(0, 2048, 10), (0, 2048, 30)]);
        let mut reader = MapReader::new();
        reader.put(5, icb);
        reader.put(10, [0xAA; 2048]);
        reader.put(30, [0xBB; 2048]);

        let root = DirEntry {
            name: String::new(),
            is_dir: true,
            meta_lba: 0,
            size: 0,
            entries: vec![file_entry("F", 5, 4096)],
        };
        let fs = fs_with(part_start, meta_start, root);
        let data = fs.read_file(&mut reader, "/F").expect("read");
        assert_eq!(data.len(), 4096);
        assert!(data[..2048].iter().all(|&b| b == 0xAA));
        assert!(data[2048..].iter().all(|&b| b == 0xBB));
    }

    #[test]
    fn read_file_long_ad_returns_full_content_not_truncated() {
        // Regression for BOTH 0.31.0 bugs through `read_file`: this is the
        // exact path `Disc::read_aacs_inputs_from_reader` uses to read
        // `/AACS/MKB_RO.inf` + `Unit_Key_RO.inf`. A Long-AD, multi-extent
        // file (large UHD/Blu-ray layout) must return ALL its bytes. With the
        // pre-fix Short-AD-only parser this read stopped after the first
        // extent, which (a) truncated the mux and (b) made autorip's
        // `key_files()` see a short/garbage AACS file โ†’ `MissingInputs` โ†’
        // the online key request was never sent.
        let icb = build_efe_long(6144, &[(0, 2048, 10), (0, 2048, 30), (0, 2048, 50)]);
        let mut reader = MapReader::new();
        reader.put(5, icb);
        reader.put(10, [0xAA; 2048]);
        reader.put(30, [0xBB; 2048]);
        reader.put(50, [0xCC; 2048]);
        let root = DirEntry {
            name: String::new(),
            is_dir: true,
            meta_lba: 0,
            size: 0,
            entries: vec![file_entry("MKB", 5, 6144)],
        };
        let fs = fs_with(0, 0, root);
        let data = fs.read_file(&mut reader, "/MKB").expect("read");
        assert_eq!(
            data.len(),
            6144,
            "Long-AD file must not truncate at extent #0"
        );
        assert!(data[..2048].iter().all(|&b| b == 0xAA));
        assert!(data[2048..4096].iter().all(|&b| b == 0xBB));
        assert!(data[4096..].iter().all(|&b| b == 0xCC));
    }

    #[test]
    fn read_aacs_inputs_reads_long_ad_files_in_full() {
        // PRECOMMIT proof for the autorip online-keyserver path (no disc / no
        // deploy). autorip's key request is gated on Disc::read_aacs_inputs
        // (keysource.rs key_files()): it reads /AACS/Unit_Key_RO.inf and
        // /AACS/MKB_RO.inf. On a Long-AD disc (UHD / large Blu-ray) the
        // pre-0.31.1 Short-AD-only reader truncated those files at their first
        // extent, breaking key derivation. This
        // fixture lays a Long-AD, multi-extent Unit_Key_RO.inf under /AACS and
        // asserts read_aacs_inputs returns its FULL content โ€” i.e. the keyserver
        // inputs are complete, so the request is built correctly.
        let aacs = DirEntry {
            name: "AACS".to_string(),
            is_dir: true,
            meta_lba: 0,
            size: 0,
            entries: vec![
                file_entry("Unit_Key_RO.inf", 5, 4096), // Long-AD, 2 extents
                file_entry("MKB_RO.inf", 7, 2048),
            ],
        };
        let root = DirEntry {
            name: String::new(),
            is_dir: true,
            meta_lba: 0,
            size: 0,
            entries: vec![aacs],
        };
        let mut reader = MapReader::new();
        // Unit_Key_RO.inf: Long-AD ICB with two recorded extents.
        reader.put(5, build_efe_long(4096, &[(0, 2048, 10), (0, 2048, 30)]));
        reader.put(10, [0xAA; 2048]);
        reader.put(30, [0xBB; 2048]);
        // MKB_RO.inf: single Long-AD extent (content is opaque to this test).
        reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
        reader.put(50, [0xCC; 2048]);

        let fs = fs_with(0, 0, root);
        let (inf, _mkb) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
            .expect("read_aacs_inputs must succeed for a Long-AD disc");
        assert_eq!(
            inf.len(),
            4096,
            "Unit_Key_RO.inf (Long-AD, multi-extent) must read in full โ€” the \
             pre-0.31.1 Short-AD parser truncated it to the first 2048-byte extent"
        );
        assert!(inf[..2048].iter().all(|&b| b == 0xAA));
        assert!(inf[2048..].iter().all(|&b| b == 0xBB));
    }

    #[test]
    fn merge_ranges_saturates_near_u32_max() {
        // Adjacent ranges near u32::MAX must not panic (debug) or wrap.
        let ranges = [(u32::MAX - 1, 2), (u32::MAX, 5)];
        let merged = merge_ranges(&ranges);
        // No panic; result is a single merged range starting at the first.
        assert_eq!(merged.len(), 1);
        assert_eq!(merged[0].0, u32::MAX - 1);
    }

    #[test]
    fn buffered_reader_short_buf_errors_not_panics() {
        let mut inner = MapReader::new();
        inner.put(0, [0u8; 2048]);
        let mut br = BufferedSectorReader::new(&mut inner, 8);
        let mut tiny = [0u8; 100];
        let err = br.read_sectors(0, 1, &mut tiny, true);
        assert!(matches!(err, Err(Error::UdfBufferTooSmall)));
    }

    /// Minimal in-memory SectorSource that serves pre-loaded 2048-byte
    /// sectors by LBA. Unmapped LBAs read as zeros.
    struct MemReader {
        sectors: HashMap<u32, [u8; 2048]>,
    }

    impl MemReader {
        fn new() -> Self {
            Self {
                sectors: HashMap::new(),
            }
        }
        fn put(&mut self, lba: u32, sector: [u8; 2048]) {
            self.sectors.insert(lba, sector);
        }
    }

    impl SectorSource for MemReader {
        fn read_sectors(
            &mut self,
            lba: u32,
            count: u16,
            buf: &mut [u8],
            _recovery: bool,
        ) -> Result<usize> {
            for i in 0..count as u32 {
                let off = i as usize * 2048;
                let dst = &mut buf[off..off + 2048];
                match self.sectors.get(&(lba + i)) {
                    Some(s) => dst.copy_from_slice(s),
                    None => dst.fill(0),
                }
            }
            Ok(count as usize * 2048)
        }
    }

    /// Build an Extended File Entry (tag 266) ICB sector with a single
    /// short allocation descriptor declaring `data_len` bytes at `data_lba`.
    /// `info_length` (offset 56) is set to `info_len`.
    fn build_efe_icb(info_len: u64, data_len: u32, data_lba: u32) -> [u8; 2048] {
        let mut icb = [0u8; 2048];
        // tag identifier 266 (Extended File Entry)
        icb[0..2].copy_from_slice(&266u16.to_le_bytes());
        // info_length at offset 56
        icb[56..64].copy_from_slice(&info_len.to_le_bytes());
        // l_ea = 0 at offset 208, l_ad = 8 (one short AD) at offset 212
        icb[208..212].copy_from_slice(&0u32.to_le_bytes());
        icb[212..216].copy_from_slice(&8u32.to_le_bytes());
        // ad_offset = 216 + l_ea = 216. Short AD: len(4) | lba(4).
        // extent_type 0 (recorded) is the top 2 bits = 0, so raw == len.
        icb[216..220].copy_from_slice(&(data_len & 0x3FFF_FFFF).to_le_bytes());
        icb[220..224].copy_from_slice(&data_lba.to_le_bytes());
        icb
    }

    /// Build a UdfFs with a single file entry under root, for read_file tests.
    fn fs_with_file(meta_lba: u32, size: u64) -> UdfFs {
        UdfFs {
            root: DirEntry {
                name: String::new(),
                is_dir: true,
                meta_lba: 0,
                size: 0,
                entries: vec![DirEntry {
                    name: "F".to_string(),
                    is_dir: false,
                    meta_lba,
                    size,
                    entries: Vec::new(),
                }],
            },
            volume_id: String::new(),
            partition_start: 0,
            metadata_start: 0,
            metadata_sectors: 0,
        }
    }

    #[test]
    fn read_file_rejects_oversized_extent_before_allocating() {
        // data_len just over the 64 MiB cap must error, not allocate.
        let oversized = MAX_FILE_BYTES as u32 + 2048;
        let icb = build_efe_icb(oversized as u64, oversized, 100);
        let mut reader = MemReader::new();
        reader.put(10, icb); // ICB at meta_lba 10 (metadata_start 0)

        let fs = fs_with_file(10, oversized as u64);
        let err = fs.read_file(&mut reader, "/F").unwrap_err();
        assert!(matches!(err, Error::DiscRead { .. }));
    }

    /// Build an Extended File Entry ICB with multiple inline short ADs, each
    /// `(data_len, data_lba)`. Lets a test chain extents whose individual
    /// lengths are all under the per-extent cap but whose running total
    /// exceeds MAX_FILE_BYTES.
    fn build_efe_icb_multi(info_len: u64, ads: &[(u32, u32)]) -> [u8; 2048] {
        let mut icb = [0u8; 2048];
        icb[0..2].copy_from_slice(&266u16.to_le_bytes());
        icb[56..64].copy_from_slice(&info_len.to_le_bytes());
        let l_ad = (ads.len() * 8) as u32;
        icb[208..212].copy_from_slice(&0u32.to_le_bytes());
        icb[212..216].copy_from_slice(&l_ad.to_le_bytes());
        for (i, (data_len, data_lba)) in ads.iter().enumerate() {
            let off = 216 + i * 8;
            icb[off..off + 4].copy_from_slice(&(data_len & 0x3FFF_FFFF).to_le_bytes());
            icb[off + 4..off + 8].copy_from_slice(&data_lba.to_le_bytes());
        }
        icb
    }

    #[test]
    fn read_file_rejects_cumulative_extents_over_cap() {
        // Two extents, each individually within MAX_FILE_BYTES, that together
        // exceed it. The cumulative guard must fire on the second extent
        // (before reading it) rather than growing `data` past the cap.
        // First extent: a single sector (read, data.len() = 2048). Second
        // extent: exactly MAX_FILE_BYTES (passes the per-extent cap) โ€” the
        // 2048 already buffered pushes the running total over the cap.
        let big = MAX_FILE_BYTES as u32;
        let icb = build_efe_icb_multi(MAX_FILE_BYTES * 2, &[(2048, 100), (big, 200_000)]);
        let mut reader = MemReader::new();
        reader.put(10, icb);
        let mut data_sector = [0u8; 2048];
        data_sector[0] = 0xCD;
        reader.put(100, data_sector);

        // entry.size declared small so the entry.size cap passes; the
        // cumulative extent total is what must trip the guard.
        let fs = fs_with_file(10, 2048);
        let err = fs.read_file(&mut reader, "/F").unwrap_err();
        assert!(matches!(err, Error::DiscRead { .. }));
    }

    #[test]
    fn read_file_rejects_oversized_info_length() {
        // Small extent but a crafted huge info_length (entry.size) must also
        // be rejected before truncate could be reached.
        let icb = build_efe_icb(0, 2048, 100);
        let mut reader = MemReader::new();
        reader.put(10, icb);

        let fs = fs_with_file(10, MAX_FILE_BYTES + 1);
        let err = fs.read_file(&mut reader, "/F").unwrap_err();
        assert!(matches!(err, Error::DiscRead { .. }));
    }

    #[test]
    fn read_file_accepts_small_file() {
        // A 1-sector file within the cap reads back its declared size.
        let icb = build_efe_icb(2048, 2048, 100);
        let mut reader = MemReader::new();
        reader.put(10, icb);
        // file data sector at partition_start + data_lba = 0 + 100
        let mut data_sector = [0u8; 2048];
        data_sector[0] = 0xAB;
        reader.put(100, data_sector);

        let fs = fs_with_file(10, 2048);
        let data = fs
            .read_file(&mut reader, "/F")
            .expect("small file should read");
        assert_eq!(data.len(), 2048);
        assert_eq!(data[0], 0xAB);
    }

    #[test]
    fn read_directory_rejects_oversized_dir_before_allocating() {
        // A directory ICB declaring an allocation length above the 1 MiB
        // ceiling must error rather than allocate a huge buffer.
        let oversized = MAX_DIR_BYTES + 2048;
        let icb = build_efe_icb(oversized as u64, oversized, 50);
        let mut reader = MemReader::new();
        reader.put(5, icb); // directory ICB at meta_start(0) + meta_lba(5)

        let err = read_directory(&mut reader, 0, 0, 5, "DIR", 0).unwrap_err();
        assert!(matches!(err, Error::DiscRead { .. }));
    }

    #[test]
    fn read_directory_accepts_small_empty_dir() {
        // ad_len within the cap, pointing at zeroed directory data โ†’ an empty
        // (no valid FID) directory parses without error.
        let icb = build_efe_icb(2048, 2048, 50);
        let mut reader = MemReader::new();
        reader.put(5, icb);
        // directory data at meta_start(0) + ad_pos(50) = 50 reads as zeros.
        let dir = read_directory(&mut reader, 0, 0, 5, "DIR", 0).expect("small dir parses");
        assert!(dir.entries.is_empty());
        assert!(dir.is_dir);
    }

    // ---- added: spec-boundary coverage for AD strides, flags, FIDs ----

    /// Build an Extended File Entry (tag 266) ICB whose allocation
    /// descriptors are EXTENDED ADs (ECMA-167 ยง14.14.3, 20 bytes each):
    ///   ExtentLength(4) | RecordedLength(4) | InformationLength(4) |
    ///   ExtentLocation lb_addr { logicalBlockNumber(4) | partitionRef(2) } |
    ///   impl_use(2)
    /// The 30-bit length + 2-bit type live in ExtentLength (offset +0); the
    /// logical block number lives in ExtentLocation at offset +12. Sets ICB
    /// Tag flags (abs offset 34) low bits to 2 = Extended AD so the parser
    /// must select the 20-byte stride AND read the LBA from off+12, not off+4.
    fn build_efe_ext(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] {
        let mut s = [0u8; 2048];
        s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag
        // ICB Tag flags at abs offset 34: AD type 2 = Extended AD.
        s[34..36].copy_from_slice(&2u16.to_le_bytes());
        s[56..64].copy_from_slice(&info_length.to_le_bytes());
        let l_ea: u32 = 0;
        let l_ad: u32 = (ads.len() * 20) as u32;
        s[208..212].copy_from_slice(&l_ea.to_le_bytes());
        s[212..216].copy_from_slice(&l_ad.to_le_bytes());
        let mut off = 216 + l_ea as usize;
        for &(etype, dlen, dlba) in ads {
            let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
            // ExtentLength at +0 (carries type + 30-bit length).
            s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
            // RecordedLength (+4) and InformationLength (+8) set to distinct
            // non-zero junk so a parser misreading the LBA at off+4 would
            // pick THESE up instead of the real LBA at off+12.
            s[off + 4..off + 8].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
            s[off + 8..off + 12].copy_from_slice(&0xCAFE_BABEu32.to_le_bytes());
            // ExtentLocation logicalBlockNumber at +12.
            s[off + 12..off + 16].copy_from_slice(&dlba.to_le_bytes());
            off += 20;
        }
        s
    }

    #[test]
    fn icb_extents_extended_ad_uses_20byte_stride_and_lba_at_off12() {
        // ECMA-167 ยง14.14.3: an Extended AD is 20 bytes and its extent LBA
        // is at byte offset +12, NOT +4 (that's RecordedLength). The parser
        // branches on ICB-tag flags==2 to a 20-byte stride and lba_off=off+12.
        // Three extents must come back with the CORRECT LBAs and lengths.
        let icb = build_efe_ext(3 * 2048, &[(0, 2048, 700), (0, 2048, 800), (0, 4096, 900)]);
        let mut reader = MapReader::new();
        reader.put(5, icb);
        let fs = fs_with(0, 0, file_entry("EXT", 5, 3 * 2048));
        let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
        // If the stride were wrong (8 or 16) or lba_off were off+4, the LBAs
        // would be the 0xDEADBEEF junk or misaligned garbage, not these.
        assert_eq!(extents, vec![(700, 2048), (800, 2048), (900, 4096)]);
    }

    #[test]
    fn icb_extents_short_ad_type1_sparse_extent_is_skipped_not_emitted() {
        // ECMA-167 ยง14.14.1.1: extent type 1 = "allocated but not recorded"
        // (a sparse hole). It carries no on-disc data, so it must NOT be
        // returned as a readable extent. A type-0 extent after it must still
        // be reached (the loop must continue past a type-1, not break).
        let icb = build_efe(
            6144,
            &[
                (0, 2048, 10), // recorded
                (1, 2048, 20), // sparse โ€” allocated, not recorded
                (0, 2048, 30), // recorded, after the hole
            ],
        );
        let mut reader = MapReader::new();
        reader.put(5, icb);
        let fs = fs_with(0, 0, file_entry("SP", 5, 6144));
        let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
        // The sparse (type-1) middle descriptor must be absent; the two
        // recorded extents must both be present and in order.
        assert_eq!(extents, vec![(10, 2048), (30, 2048)]);
    }

    #[test]
    fn icb_extents_zero_length_type0_terminates_list() {
        // ECMA-167: a zero-length type-0 AD terminates the descriptor list.
        // Trailing zero padding (all-zero ADs) MUST stop parsing โ€” otherwise
        // a stray non-zero AD after the terminator becomes a bogus extent.
        // One real extent, then a zero AD, then an AD that must NEVER be read.
        let icb = build_efe(
            2048,
            &[
                (0, 2048, 10),  // recorded extent
                (0, 0, 0),      // zero-length type-0 = terminator
                (0, 4096, 999), // must NOT be parsed
            ],
        );
        let mut reader = MapReader::new();
        reader.put(5, icb);
        let fs = fs_with(0, 0, file_entry("T", 5, 2048));
        let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
        assert_eq!(
            extents,
            vec![(10, 2048)],
            "parsing must stop at the zero-length terminator"
        );
    }

    #[test]
    fn icb_extents_continuation_loop_terminates_without_hang_or_panic() {
        // Hostile input: a type-3 continuation descriptor whose continuation
        // block points back at itself (a cycle). The MAX_AD_BLOCKS bound must
        // make this terminate rather than loop forever. We assert it returns
        // a finite Vec and does not panic. The continuation block at meta-rel
        // lba 50 contains a recorded extent + a type-3 AD pointing to lba 50.
        let icb = build_efe(2048, &[(0, 2048, 10), (3, 2048, 50)]);
        let cont = build_cont_block(&[(0, 2048, 20), (3, 2048, 50)]);
        let mut reader = MapReader::new();
        reader.put(5, icb);
        reader.put(50, cont);
        let fs = fs_with(0, 0, file_entry("LOOP", 5, 2048));
        // Must return Ok (bounded), not hang or panic.
        let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
        // First block contributes extent (10,2048); each revisit of the
        // self-referential cont block adds (20,2048). The hop bound caps the
        // total, so the Vec is finite. (256 blocks max โ†’ < 600 extents.)
        assert!(extents.len() < 1024, "continuation chain must be bounded");
        assert_eq!(extents[0], (10, 2048));
        assert_eq!(extents[1], (20, 2048));
    }

    #[test]
    fn parse_udf_name_decodes_utf16be_compression_id_16() {
        // UDF dchar: compression ID 16 = 16-bit big-endian Unicode. A FID
        // whose filename uses ID 16 must decode correctly, not as mojibake.
        // Bytes: [16][00 'A'][00 'Z'].
        let raw = [16u8, 0x00, b'A', 0x00, b'Z'];
        assert_eq!(parse_udf_name(&raw), "AZ");
    }

    #[test]
    fn parse_udf_name_8bit_compression_id_8() {
        // Compression ID 8 = 8-bit (OSTA CS0 / ASCII). "BDMV" must round-trip.
        let mut raw = vec![8u8];
        raw.extend_from_slice(b"BDMV");
        assert_eq!(parse_udf_name(&raw), "BDMV");
    }

    #[test]
    fn read_directory_honors_l_iu_offset_for_fid_name() {
        // ECMA-167 ยง14.4 File Identifier Descriptor: the File Identifier
        // begins at offset 38 + L_IU. A non-zero L_IU must shift the name
        // read; ignoring it would read impl_use bytes as the name.
        //   0..2  tag = 257   18 file chars   19 L_FI
        //   24..28 ICB LBA    36..38 L_IU      38.. impl_use[L_IU] then FI[L_FI]
        let mut dir = [0u8; 2048];
        let l_iu: u16 = 4;
        let mut name_bytes = vec![8u8]; // compression id 8
        name_bytes.extend_from_slice(b"CLPI");
        let l_fi = name_bytes.len() as u8;
        dir[0..2].copy_from_slice(&257u16.to_le_bytes());
        dir[18] = 0x00; // not parent, not dir โ†’ a file
        dir[19] = l_fi;
        dir[24..28].copy_from_slice(&7u32.to_le_bytes()); // child ICB LBA
        dir[36..38].copy_from_slice(&l_iu.to_le_bytes());
        dir[38..42].copy_from_slice(&[0xFF, 0xFE, 0xFD, 0xFC]); // impl_use junk
        let name_start = 38 + l_iu as usize;
        dir[name_start..name_start + name_bytes.len()].copy_from_slice(&name_bytes);

        let dir_icb = build_efe_icb(2048, 2048, 60); // dir data at ad_pos 60
        let mut reader = MemReader::new();
        reader.put(5, dir_icb);
        reader.put(60, dir);
        reader.put(7, build_efe_icb(123, 2048, 0)); // child size ICB

        let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0).expect("dir parses");
        assert_eq!(parsed.entries.len(), 1, "exactly one FID entry");
        assert_eq!(
            parsed.entries[0].name, "CLPI",
            "name must be read at 38+L_IU, not from impl_use bytes"
        );
        assert!(!parsed.entries[0].is_dir);
    }

    #[test]
    fn read_directory_skips_parent_fid_entry() {
        // ECMA-167 ยง14.4.3: file characteristics bit 3 (0x08) = "parent" (the
        // ".." back-link). It must NOT appear as a named child entry. To
        // isolate the parent-flag gate (rather than the L_FI==0 gate that
        // real parent FIDs also have), this fixture gives the parent FID a
        // VALID non-zero L_FI and a real name: the ONLY reason it must be
        // skipped is the parent characteristic bit.
        let mut dir = [0u8; 2048];
        let mut name_bytes = vec![8u8];
        name_bytes.extend_from_slice(b"PARENT");
        let l_fi = name_bytes.len() as u8;
        dir[0..2].copy_from_slice(&257u16.to_le_bytes());
        dir[18] = 0x08 | 0x02; // parent + directory bits
        dir[19] = l_fi; // non-zero L_FI: name present but must be ignored
        dir[24..28].copy_from_slice(&9u32.to_le_bytes());
        dir[36..38].copy_from_slice(&0u16.to_le_bytes()); // L_IU = 0
        dir[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);

        let dir_icb = build_efe_icb(2048, 2048, 60);
        let mut reader = MemReader::new();
        reader.put(5, dir_icb);
        reader.put(60, dir);
        reader.put(9, build_efe_icb(0, 2048, 0)); // child size ICB

        let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0).expect("dir parses");
        assert!(
            parsed.entries.is_empty(),
            "the parent (..) FID must not be emitted even with a valid name"
        );
    }

    #[test]
    fn parse_dstring_length_byte_caps_content() {
        // UDF d-string: the final byte of the fixed field is the length of
        // valid content (compression id + chars). Bytes past that length must
        // be ignored. Field: [8]['V']['O']['L'] ... last byte = 4.
        let mut field = [0u8; 32];
        field[0] = 8; // compression id 8
        field[1] = b'V';
        field[2] = b'O';
        field[3] = b'L';
        field[10] = b'X'; // garbage beyond declared length โ€” must be ignored
        *field.last_mut().unwrap() = 4; // 4 valid bytes (id + 3 chars)
        assert_eq!(parse_dstring(&field), "VOL");
    }

    #[test]
    fn parse_dstring_oversized_length_byte_returns_empty_not_panic() {
        // Hostile/corrupt input: a length byte larger than the field must not
        // index out of bounds. parse_dstring guards len > data.len() โ†’ "".
        let mut field = [0u8; 8];
        field[0] = 8;
        field[1] = b'A';
        *field.last_mut().unwrap() = 200; // way past the 8-byte field
        assert_eq!(parse_dstring(&field), "");
    }
}