1use std::collections::HashMap;
2use std::fs::File;
3use std::io::{self, BufRead, BufReader, Read, Seek};
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use strum_macros::{AsRefStr, EnumString};
8
9const DIGITS_PER_BLOCK: usize = 19;
10
11#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, AsRefStr, EnumString)]
12pub enum YcdHeaderInfoElem {
13 FileVersion,
14 Base,
15 FirstDigits,
16 TotalDigits,
17 TotalBlocks,
18 Blocksize,
19 BlockID,
20 EndHeader,
21}
22
23#[derive(Debug, Clone, Eq, PartialEq)]
24pub struct YcdProcessUnit {
25 pub process_no: i64,
26 pub start_digit: i64,
27 pub value: String,
28}
29
30impl YcdProcessUnit {
31 pub fn new(process_no: i64, start_digit: i64, value: String) -> Self {
32 Self {
33 process_no,
34 start_digit,
35 value,
36 }
37 }
38}
39
40struct YcdMetadata {
41 header: HashMap<YcdHeaderInfoElem, String>,
42 data_offset: u64,
43 digit_length: i64,
44 digit_start: i64,
45}
46
47#[derive(Debug)]
48pub struct YcdSeqBlockStream {
49 process_unit_size: usize,
50 file_stream: BufReader<File>,
51 digit_length: i64,
52 digit_start: i64,
53 decoded_digits: i64,
54 next_process_no: i64,
55 next_start_digit: i64,
56 current_process_unit: Option<YcdProcessUnit>,
57 surplus_digit_str: String,
58}
59
60impl YcdSeqBlockStream {
61 pub fn new<P: AsRef<Path>>(file_name: P, unit_size: i32) -> io::Result<Self> {
62 let process_unit_size = validate_unit_size(unit_size)?;
63 let path = file_name.as_ref();
64 let metadata = parse_metadata(path)?;
65 let mut file_stream = BufReader::new(File::open(path)?);
66 file_stream.seek(io::SeekFrom::Start(metadata.data_offset))?;
67
68 Ok(Self {
69 process_unit_size,
70 file_stream,
71 digit_length: metadata.digit_length,
72 digit_start: metadata.digit_start,
73 decoded_digits: 0,
74 next_process_no: 1,
75 next_start_digit: metadata.digit_start,
76 current_process_unit: None,
77 surplus_digit_str: String::new(),
78 })
79 }
80
81 pub fn new_from<P: AsRef<Path>>(
89 file_name: P,
90 unit_size: i32,
91 start_position: i64,
92 ) -> io::Result<Self> {
93 let process_unit_size = validate_unit_size(unit_size)?;
94 let path = file_name.as_ref();
95 let metadata = parse_metadata(path)?;
96
97 let file_end = metadata
98 .digit_start
99 .checked_add(metadata.digit_length)
100 .and_then(|e| e.checked_sub(1))
101 .ok_or_else(|| invalid_data("Digit position overflow"))?;
102 if start_position < metadata.digit_start || start_position > file_end {
103 return Err(io::Error::new(
104 io::ErrorKind::InvalidInput,
105 format!(
106 "Start position {start_position} is outside the file's range \
107 [{}, {file_end}]",
108 metadata.digit_start
109 ),
110 ));
111 }
112
113 let local_start = usize::try_from(
114 start_position
115 .checked_sub(metadata.digit_start)
116 .ok_or_else(|| invalid_data("local_start underflow"))?,
117 )
118 .map_err(|_| invalid_data("local_start overflows usize"))?;
119
120 let block_index = local_start / DIGITS_PER_BLOCK;
121 let offset_in_block = local_start % DIGITS_PER_BLOCK;
122
123 let seek_pos = metadata
124 .data_offset
125 .checked_add(
126 u64::try_from(block_index)
127 .ok()
128 .and_then(|bi| bi.checked_mul(8))
129 .ok_or_else(|| invalid_data("Seek offset overflow"))?,
130 )
131 .ok_or_else(|| invalid_data("Seek offset overflow"))?;
132
133 let mut file_stream = BufReader::new(File::open(path)?);
134 file_stream.seek(io::SeekFrom::Start(seek_pos))?;
135
136 let mut decoded_digits = (block_index * DIGITS_PER_BLOCK) as i64;
137 let mut surplus_digit_str = String::new();
138
139 if offset_in_block > 0 {
140 let mut buffer = [0_u8; 8];
141 file_stream.read_exact(&mut buffer)?;
142 let number = u64::from_le_bytes(buffer);
143 let digits = format!("{number:019}");
144 if digits.len() != DIGITS_PER_BLOCK {
145 return Err(invalid_data(
146 "A compressed block contains more than 19 decimal digits",
147 ));
148 }
149
150 let remaining = usize::try_from(metadata.digit_length - decoded_digits)
151 .map_err(|_| invalid_data("Invalid remaining digit count"))?;
152 let take = remaining.min(DIGITS_PER_BLOCK);
153 decoded_digits += take as i64;
154
155 surplus_digit_str.push_str(&digits[offset_in_block..take]);
156 }
157
158 Ok(Self {
159 process_unit_size,
160 file_stream,
161 digit_length: metadata.digit_length,
162 digit_start: metadata.digit_start,
163 decoded_digits,
164 next_process_no: 1,
165 next_start_digit: start_position,
166 current_process_unit: None,
167 surplus_digit_str,
168 })
169 }
170
171 pub fn has_next(&self) -> bool {
172 self.decoded_digits < self.digit_length || !self.surplus_digit_str.is_empty()
173 }
174
175 #[allow(clippy::should_implement_trait)]
176 pub fn next(&mut self) -> io::Result<&YcdProcessUnit> {
177 if !self.has_next() {
178 return Err(no_more_data_error());
179 }
180
181 let value = self.take_digits(self.process_unit_size)?;
182 let process_no = self.next_process_no;
183 let start_digit = self.next_start_digit;
184
185 self.next_process_no = self
186 .next_process_no
187 .checked_add(1)
188 .ok_or_else(|| invalid_data("Process number overflow"))?;
189 self.next_start_digit = self
190 .next_start_digit
191 .checked_add(value.len() as i64)
192 .ok_or_else(|| invalid_data("Digit position overflow"))?;
193 self.current_process_unit = Some(YcdProcessUnit::new(process_no, start_digit, value));
194
195 Ok(self
196 .current_process_unit
197 .as_ref()
198 .expect("unit was assigned"))
199 }
200
201 fn take_digits(&mut self, maximum: usize) -> io::Result<String> {
202 while self.surplus_digit_str.len() < maximum && self.decoded_digits < self.digit_length {
203 let block = self.read_digit_block()?;
204 self.surplus_digit_str.push_str(&block);
205 }
206
207 let take = maximum.min(self.surplus_digit_str.len());
208 let remainder = self.surplus_digit_str.split_off(take);
209 Ok(std::mem::replace(&mut self.surplus_digit_str, remainder))
210 }
211
212 fn read_digit_block(&mut self) -> io::Result<String> {
213 let mut buffer = [0_u8; 8];
214 self.file_stream.read_exact(&mut buffer)?;
215
216 let number = u64::from_le_bytes(buffer);
217 let digits = format!("{number:019}");
218 if digits.len() != DIGITS_PER_BLOCK {
219 return Err(invalid_data(
220 "A compressed block contains more than 19 decimal digits",
221 ));
222 }
223
224 let remaining = usize::try_from(self.digit_length - self.decoded_digits)
225 .map_err(|_| invalid_data("Invalid remaining digit count"))?;
226 let take = remaining.min(DIGITS_PER_BLOCK);
227 self.decoded_digits += take as i64;
228
229 Ok(digits[..take].to_string())
230 }
231}
232
233#[derive(Debug)]
234pub struct YcdMultiFileStream {
235 process_unit_size: usize,
236 streams: Vec<YcdSeqBlockStream>,
237 current_stream: usize,
238 next_process_no: i64,
239 next_start_digit: i64,
240 current_process_unit: Option<YcdProcessUnit>,
241}
242
243impl YcdMultiFileStream {
244 pub fn new<P: AsRef<Path>>(file_names: &[P], unit_size: i32) -> io::Result<Self> {
245 let process_unit_size = validate_unit_size(unit_size)?;
246 if file_names.is_empty() {
247 return Err(io::Error::new(
248 io::ErrorKind::InvalidInput,
249 "At least one YCD file is required",
250 ));
251 }
252
253 let mut streams = Vec::with_capacity(file_names.len());
254 for file_name in file_names {
255 streams.push(YcdSeqBlockStream::new(file_name, unit_size)?);
256 }
257
258 for pair in streams.windows(2) {
259 let expected_start = pair[0]
260 .digit_start
261 .checked_add(pair[0].digit_length)
262 .ok_or_else(|| invalid_data("Digit position overflow"))?;
263 if pair[1].digit_start != expected_start {
264 return Err(io::Error::new(
265 io::ErrorKind::InvalidInput,
266 format!(
267 "YCD files are not contiguous: expected digit {expected_start}, found {}",
268 pair[1].digit_start
269 ),
270 ));
271 }
272 }
273
274 let next_start_digit = streams[0].digit_start;
275 Ok(Self {
276 process_unit_size,
277 streams,
278 current_stream: 0,
279 next_process_no: 1,
280 next_start_digit,
281 current_process_unit: None,
282 })
283 }
284
285 pub fn new_from<P: AsRef<Path>>(
299 file_names: &[P],
300 unit_size: i32,
301 start_position: i64,
302 ) -> io::Result<Self> {
303 let process_unit_size = validate_unit_size(unit_size)?;
304 if file_names.is_empty() {
305 return Err(io::Error::new(
306 io::ErrorKind::InvalidInput,
307 "At least one YCD file is required",
308 ));
309 }
310 if start_position < 1 {
311 return Err(io::Error::new(
312 io::ErrorKind::InvalidInput,
313 "Start position must be >= 1",
314 ));
315 }
316
317 let file_infos = collect_file_infos(file_names)?;
318
319 let list_start = file_infos[0].file_start as i64;
320 let last = file_infos
321 .last()
322 .expect("non-empty after collect_file_infos");
323 let list_end = last
324 .file_start
325 .checked_add(last.file_length)
326 .and_then(|e| e.checked_sub(1))
327 .ok_or_else(|| invalid_data("Digit range end overflow"))? as i64;
328
329 if start_position < list_start || start_position > list_end {
330 return Err(io::Error::new(
331 io::ErrorKind::InvalidInput,
332 format!(
333 "Start position {start_position} is outside the available range \
334 [{list_start}, {list_end}]"
335 ),
336 ));
337 }
338
339 let start_file_idx = file_infos
340 .partition_point(|fi| fi.file_start + fi.file_length - 1 < start_position as usize);
341
342 let mut streams = Vec::with_capacity(file_names.len() - start_file_idx);
343 for (i, file_name) in file_names[start_file_idx..].iter().enumerate() {
344 if i == 0 {
345 streams.push(YcdSeqBlockStream::new_from(
346 file_name,
347 unit_size,
348 start_position,
349 )?);
350 } else {
351 streams.push(YcdSeqBlockStream::new(file_name, unit_size)?);
352 }
353 }
354
355 Ok(Self {
356 process_unit_size,
357 streams,
358 current_stream: 0,
359 next_process_no: 1,
360 next_start_digit: start_position,
361 current_process_unit: None,
362 })
363 }
364
365 pub fn has_next(&self) -> bool {
366 self.streams[self.current_stream..]
367 .iter()
368 .any(YcdSeqBlockStream::has_next)
369 }
370
371 #[allow(clippy::should_implement_trait)]
372 pub fn next(&mut self) -> io::Result<&YcdProcessUnit> {
373 if !self.has_next() {
374 return Err(no_more_data_error());
375 }
376
377 let mut value = String::with_capacity(self.process_unit_size);
378 while value.len() < self.process_unit_size && self.current_stream < self.streams.len() {
379 let remaining = self.process_unit_size - value.len();
380 let stream = &mut self.streams[self.current_stream];
381 value.push_str(&stream.take_digits(remaining)?);
382
383 if !stream.has_next() {
384 self.current_stream += 1;
385 }
386 }
387
388 let process_no = self.next_process_no;
389 let start_digit = self.next_start_digit;
390 self.next_process_no = self
391 .next_process_no
392 .checked_add(1)
393 .ok_or_else(|| invalid_data("Process number overflow"))?;
394 self.next_start_digit = self
395 .next_start_digit
396 .checked_add(value.len() as i64)
397 .ok_or_else(|| invalid_data("Digit position overflow"))?;
398 self.current_process_unit = Some(YcdProcessUnit::new(process_no, start_digit, value));
399
400 Ok(self
401 .current_process_unit
402 .as_ref()
403 .expect("unit was assigned"))
404 }
405}
406
407#[derive(Debug, Clone)]
414pub struct YcdIndexEntry {
415 pub path: PathBuf,
417 pub data_offset: u64,
419 pub file_start: usize,
421 pub file_length: usize,
423 pub file_size: u64,
425 pub modified: SystemTime,
433}
434
435#[derive(Debug, Clone)]
473pub struct YcdIndex {
474 entries: Vec<YcdIndexEntry>,
475}
476
477impl YcdIndex {
478 pub fn build<P: AsRef<Path>>(files: &[P]) -> io::Result<Self> {
490 if files.is_empty() {
491 return Err(io::Error::new(
492 io::ErrorKind::InvalidInput,
493 "File list must not be empty",
494 ));
495 }
496
497 let raw_infos = collect_file_infos(files)?;
498 let mut entries = Vec::with_capacity(files.len());
499
500 for (path, fi) in files.iter().zip(raw_infos.iter()) {
501 let fs_meta = std::fs::metadata(path.as_ref())?;
502 let file_size = fs_meta.len();
503 let modified = fs_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
504
505 entries.push(YcdIndexEntry {
506 path: std::fs::canonicalize(path.as_ref())?,
507 data_offset: fi.data_offset,
508 file_start: fi.file_start,
509 file_length: fi.file_length,
510 file_size,
511 modified,
512 });
513 }
514
515 Ok(Self { entries })
516 }
517
518 pub fn rebuild<P: AsRef<Path>>(&mut self, files: &[P]) -> io::Result<()> {
523 *self = Self::build(files)?;
524 Ok(())
525 }
526
527 pub fn len(&self) -> usize {
529 self.entries.len()
530 }
531
532 pub fn is_empty(&self) -> bool {
534 self.entries.is_empty()
535 }
536
537 pub fn entries(&self) -> &[YcdIndexEntry] {
539 &self.entries
540 }
541
542 pub fn read_digits(
565 &self,
566 one_based_start_position: usize,
567 length: usize,
568 ) -> io::Result<String> {
569 if self.entries.is_empty() {
570 return Err(io::Error::new(
571 io::ErrorKind::InvalidInput,
572 "Index is empty",
573 ));
574 }
575 if one_based_start_position == 0 {
576 return Err(io::Error::new(
577 io::ErrorKind::InvalidInput,
578 "Start position must be >= 1",
579 ));
580 }
581 if length == 0 {
582 return Err(io::Error::new(
583 io::ErrorKind::InvalidInput,
584 "Length must be >= 1",
585 ));
586 }
587
588 let list_start = self.entries[0].file_start;
589 let last = self.entries.last().expect("non-empty");
590 let list_end = last
591 .file_start
592 .checked_add(last.file_length)
593 .and_then(|e| e.checked_sub(1))
594 .ok_or_else(|| invalid_data("Digit range end overflow"))?;
595
596 if one_based_start_position < list_start || one_based_start_position > list_end {
597 return Err(io::Error::new(
598 io::ErrorKind::InvalidInput,
599 format!(
600 "Start position {one_based_start_position} is outside the available range \
601 [{list_start}, {list_end}]"
602 ),
603 ));
604 }
605
606 let end_position = one_based_start_position
607 .checked_add(length)
608 .ok_or_else(|| invalid_data("End position overflow (start + length)"))?
609 .checked_sub(1)
610 .expect("length >= 1");
611
612 if end_position > list_end {
613 return Err(io::Error::new(
614 io::ErrorKind::InvalidInput,
615 format!(
616 "Requested range ends at {end_position} which exceeds the available \
617 range end {list_end}"
618 ),
619 ));
620 }
621
622 let mut result = String::new();
623 result.try_reserve_exact(length).map_err(io::Error::other)?;
624
625 let start_idx = self
626 .entries
627 .partition_point(|e| e.file_start + e.file_length - 1 < one_based_start_position);
628
629 let mut remaining = length;
630
631 for entry in &self.entries[start_idx..] {
632 if remaining == 0 {
633 break;
634 }
635
636 let fs_meta = std::fs::metadata(&entry.path)?;
637 if fs_meta.len() != entry.file_size {
638 return Err(invalid_data(format!(
639 "Index is stale: file size of '{}' changed \
640 (expected {} bytes, found {} bytes)",
641 entry.path.display(),
642 entry.file_size,
643 fs_meta.len(),
644 )));
645 }
646 let current_modified = fs_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
647 if current_modified != entry.modified {
648 return Err(invalid_data(format!(
649 "Index is stale: last-modified time of '{}' changed",
650 entry.path.display(),
651 )));
652 }
653
654 let digits_read = length - remaining;
655 let current_abs = one_based_start_position
656 .checked_add(digits_read)
657 .expect("validated end_position <= list_end");
658
659 let local_start = current_abs
660 .checked_sub(entry.file_start)
661 .ok_or_else(|| invalid_data("local_start underflow"))?;
662 let available = entry
663 .file_length
664 .checked_sub(local_start)
665 .ok_or_else(|| invalid_data("local_start exceeds file length"))?;
666 let to_take = available.min(remaining);
667
668 let (block_index, offset_in_block, blocks_to_read) =
669 compute_seek_params(local_start, to_take);
670
671 let seek_pos = entry
672 .data_offset
673 .checked_add(
674 u64::try_from(block_index)
675 .ok()
676 .and_then(|bi| bi.checked_mul(8))
677 .ok_or_else(|| invalid_data("Seek offset overflow"))?,
678 )
679 .ok_or_else(|| invalid_data("Seek offset overflow"))?;
680
681 let mut reader = BufReader::new(File::open(&entry.path)?);
682 reader.seek(io::SeekFrom::Start(seek_pos))?;
683
684 let mut skip = offset_in_block;
685 let mut taken = 0usize;
686
687 for _ in 0..blocks_to_read {
688 if taken >= to_take {
689 break;
690 }
691
692 let mut buf = [0_u8; 8];
693 reader.read_exact(&mut buf).map_err(|e| match e.kind() {
694 io::ErrorKind::UnexpectedEof => io::Error::new(
695 io::ErrorKind::UnexpectedEof,
696 "YCD payload is shorter than the logical digit range",
697 ),
698 _ => e,
699 })?;
700
701 let number = u64::from_le_bytes(buf);
702 let block_str = format!("{number:019}");
703 if block_str.len() != DIGITS_PER_BLOCK {
704 return Err(invalid_data(
705 "A compressed block contains more than 19 decimal digits",
706 ));
707 }
708
709 let usable = &block_str[skip..];
710 skip = 0;
711
712 let can_take = usable.len().min(to_take - taken);
713 result.push_str(&usable[..can_take]);
714 taken += can_take;
715 }
716
717 remaining -= to_take;
718 }
719
720 debug_assert_eq!(result.len(), length);
721 Ok(result)
722 }
723}
724
725struct FileInfo {
727 data_offset: u64,
729 file_start: usize,
731 file_length: usize,
733}
734
735pub fn compute_seek_params(local_start: usize, length: usize) -> (usize, usize, usize) {
746 let block_index = local_start / DIGITS_PER_BLOCK;
747 let offset_in_block = local_start % DIGITS_PER_BLOCK;
748 let blocks_to_read = (offset_in_block + length).div_ceil(DIGITS_PER_BLOCK);
749 (block_index, offset_in_block, blocks_to_read)
750}
751
752pub struct YcdFileUtil;
753
754impl YcdFileUtil {
755 pub fn get_header_size<P: AsRef<Path>>(file_name: P) -> io::Result<i32> {
756 i32::try_from(parse_metadata(file_name.as_ref())?.data_offset)
757 .map_err(|_| invalid_data("YCD header is too large"))
758 }
759
760 pub fn get_ycd_header<P: AsRef<Path>>(
761 file_name: P,
762 ) -> io::Result<HashMap<YcdHeaderInfoElem, String>> {
763 Ok(parse_metadata(file_name.as_ref())?.header)
764 }
765
766 pub fn read_digits<P: AsRef<Path>>(
808 files: &[P],
809 one_based_start_position: usize,
810 length: usize,
811 ) -> io::Result<String> {
812 if files.is_empty() {
814 return Err(io::Error::new(
815 io::ErrorKind::InvalidInput,
816 "File list must not be empty",
817 ));
818 }
819 if one_based_start_position == 0 {
820 return Err(io::Error::new(
821 io::ErrorKind::InvalidInput,
822 "Start position must be >= 1",
823 ));
824 }
825 if length == 0 {
826 return Err(io::Error::new(
827 io::ErrorKind::InvalidInput,
828 "Length must be >= 1",
829 ));
830 }
831
832 let file_infos = collect_file_infos(files)?;
834
835 let list_start = file_infos[0].file_start;
837 let last = file_infos
838 .last()
839 .expect("non-empty after collect_file_infos");
840 let list_end = last
841 .file_start
842 .checked_add(last.file_length)
843 .and_then(|e| e.checked_sub(1))
844 .ok_or_else(|| invalid_data("Digit range end overflow"))?;
845
846 if one_based_start_position < list_start || one_based_start_position > list_end {
847 return Err(io::Error::new(
848 io::ErrorKind::InvalidInput,
849 format!(
850 "Start position {one_based_start_position} is outside the available range \
851 [{list_start}, {list_end}]"
852 ),
853 ));
854 }
855
856 let end_position = one_based_start_position
858 .checked_add(length)
859 .ok_or_else(|| invalid_data("End position overflow (start + length)"))?
860 .checked_sub(1)
861 .expect("length >= 1 so this cannot underflow");
862
863 if end_position > list_end {
864 return Err(io::Error::new(
865 io::ErrorKind::InvalidInput,
866 format!(
867 "Requested range ends at {end_position} which exceeds the available \
868 range end {list_end}"
869 ),
870 ));
871 }
872
873 let mut result = String::new();
875 result.try_reserve_exact(length).map_err(io::Error::other)?;
876
877 let start_file_idx = file_infos.partition_point(|fi| {
881 fi.file_start + fi.file_length - 1 < one_based_start_position
883 });
884
885 let mut remaining = length;
887
888 for (idx, fi) in file_infos[start_file_idx..].iter().enumerate() {
889 if remaining == 0 {
890 break;
891 }
892
893 let digits_read = length - remaining;
895 let current_abs = one_based_start_position
896 .checked_add(digits_read)
897 .expect("already validated end_position <= list_end so no overflow here");
898
899 let local_start = current_abs
901 .checked_sub(fi.file_start)
902 .ok_or_else(|| invalid_data("local_start underflow"))?;
903
904 let available = fi
907 .file_length
908 .checked_sub(local_start)
909 .ok_or_else(|| invalid_data("local_start exceeds file length"))?;
910 let to_take = available.min(remaining);
911
912 let (block_index, offset_in_block, blocks_to_read) =
914 compute_seek_params(local_start, to_take);
915
916 let seek_pos = fi
918 .data_offset
919 .checked_add(
920 u64::try_from(block_index)
921 .ok()
922 .and_then(|bi| bi.checked_mul(8))
923 .ok_or_else(|| invalid_data("Seek offset overflow"))?,
924 )
925 .ok_or_else(|| invalid_data("Seek offset overflow"))?;
926
927 let path = files[start_file_idx + idx].as_ref();
930 let mut reader = BufReader::new(File::open(path)?);
931 reader.seek(io::SeekFrom::Start(seek_pos))?;
932
933 let mut skip = offset_in_block;
935 let mut taken = 0usize;
936
937 for _ in 0..blocks_to_read {
938 if taken >= to_take {
939 break;
940 }
941
942 let mut buf = [0_u8; 8];
943 reader.read_exact(&mut buf).map_err(|e| match e.kind() {
944 io::ErrorKind::UnexpectedEof => io::Error::new(
945 io::ErrorKind::UnexpectedEof,
946 "YCD payload is shorter than the logical digit range",
947 ),
948 _ => e,
949 })?;
950
951 let number = u64::from_le_bytes(buf);
952 let block_str = format!("{number:019}");
953 if block_str.len() != DIGITS_PER_BLOCK {
954 return Err(invalid_data(
955 "A compressed block contains more than 19 decimal digits",
956 ));
957 }
958
959 let usable = &block_str[skip..];
961 skip = 0;
962
963 let can_take = usable.len().min(to_take - taken);
964 result.push_str(&usable[..can_take]);
965 taken += can_take;
966 }
967
968 remaining -= to_take;
969 }
970
971 debug_assert_eq!(result.len(), length);
972 Ok(result)
973 }
974}
975
976fn validate_unit_size(unit_size: i32) -> io::Result<usize> {
977 if unit_size < DIGITS_PER_BLOCK as i32 {
978 return Err(io::Error::new(
979 io::ErrorKind::InvalidInput,
980 "Unit size must be at least 19",
981 ));
982 }
983 Ok(unit_size as usize)
984}
985
986fn parse_metadata(path: &Path) -> io::Result<YcdMetadata> {
987 let mut reader = BufReader::new(File::open(path)?);
988 let mut header = HashMap::new();
989 let mut line = Vec::new();
990
991 loop {
992 line.clear();
993 if reader.read_until(b'\n', &mut line)? == 0 {
994 return Err(invalid_data("Missing EndHeader marker"));
995 }
996
997 let text = std::str::from_utf8(&line)
998 .map_err(|_| invalid_data("YCD header is not valid UTF-8"))?;
999 let text = text.trim_end_matches(&['\r', '\n'][..]);
1000 if text == YcdHeaderInfoElem::EndHeader.as_ref() {
1001 break;
1002 }
1003 if text.is_empty() || text.starts_with('#') {
1004 continue;
1005 }
1006
1007 let Some((name, value)) = text.split_once(':') else {
1008 continue;
1009 };
1010 if let Some(key) = header_key(name.trim()) {
1011 header.insert(key, value.trim().to_string());
1012 }
1013 }
1014
1015 consume_data_marker(&mut reader)?;
1016 let data_offset = reader.stream_position()?;
1017 let block_size = parse_positive_i64(&header, YcdHeaderInfoElem::Blocksize)?;
1018 let block_id = parse_nonnegative_i64(&header, YcdHeaderInfoElem::BlockID)?;
1019
1020 let version = required_value(&header, YcdHeaderInfoElem::FileVersion)?;
1021 if version.is_empty() {
1022 return Err(invalid_data("FileVersion must not be empty"));
1023 }
1024 if required_value(&header, YcdHeaderInfoElem::FirstDigits)?.is_empty() {
1025 return Err(invalid_data("FirstDigits must not be empty"));
1026 }
1027
1028 let base = parse_nonnegative_i64(&header, YcdHeaderInfoElem::Base)?;
1029 if base != 10 {
1030 return Err(invalid_data("Only base 10 YCD files are supported"));
1031 }
1032 let total_digits = parse_nonnegative_i64(&header, YcdHeaderInfoElem::TotalDigits)?;
1033 if header.contains_key(&YcdHeaderInfoElem::TotalBlocks) {
1034 parse_nonnegative_i64(&header, YcdHeaderInfoElem::TotalBlocks)?;
1035 }
1036
1037 let digit_offset = block_size
1038 .checked_mul(block_id)
1039 .ok_or_else(|| invalid_data("Digit position overflow"))?;
1040 let digit_length = if total_digits == 0 {
1041 block_size
1042 } else {
1043 let remaining = total_digits
1044 .checked_sub(digit_offset)
1045 .ok_or_else(|| invalid_data("BlockID starts beyond TotalDigits"))?;
1046 if remaining == 0 {
1047 return Err(invalid_data("BlockID starts beyond TotalDigits"));
1048 }
1049 remaining.min(block_size)
1050 };
1051 let digit_start = digit_offset
1052 .checked_add(1)
1053 .ok_or_else(|| invalid_data("Digit position overflow"))?;
1054
1055 Ok(YcdMetadata {
1056 header,
1057 data_offset,
1058 digit_length,
1059 digit_start,
1060 })
1061}
1062
1063fn consume_data_marker(reader: &mut BufReader<File>) -> io::Result<()> {
1064 let first = read_marker_byte(reader)?;
1065 match first {
1066 0 => Ok(()),
1067 b'\n' => require_nul(reader),
1068 b'\r' => {
1069 if read_marker_byte(reader)? != b'\n' {
1070 return Err(invalid_data("Invalid line ending after EndHeader"));
1071 }
1072 require_nul(reader)
1073 }
1074 _ => Err(invalid_data("Missing NUL data marker after EndHeader")),
1075 }
1076}
1077
1078fn require_nul(reader: &mut BufReader<File>) -> io::Result<()> {
1079 if read_marker_byte(reader)? == 0 {
1080 Ok(())
1081 } else {
1082 Err(invalid_data("Missing NUL data marker after EndHeader"))
1083 }
1084}
1085
1086fn read_marker_byte(reader: &mut BufReader<File>) -> io::Result<u8> {
1087 let mut byte = [0_u8; 1];
1088 reader
1089 .read_exact(&mut byte)
1090 .map_err(|error| match error.kind() {
1091 io::ErrorKind::UnexpectedEof => invalid_data("Incomplete YCD header"),
1092 _ => error,
1093 })?;
1094 Ok(byte[0])
1095}
1096
1097fn header_key(name: &str) -> Option<YcdHeaderInfoElem> {
1098 match name {
1099 "FileVersion" => Some(YcdHeaderInfoElem::FileVersion),
1100 "Base" => Some(YcdHeaderInfoElem::Base),
1101 "FirstDigits" => Some(YcdHeaderInfoElem::FirstDigits),
1102 "TotalDigits" => Some(YcdHeaderInfoElem::TotalDigits),
1103 "TotalBlocks" => Some(YcdHeaderInfoElem::TotalBlocks),
1104 "Blocksize" => Some(YcdHeaderInfoElem::Blocksize),
1105 "BlockID" => Some(YcdHeaderInfoElem::BlockID),
1106 _ => None,
1107 }
1108}
1109
1110fn required_value(
1111 header: &HashMap<YcdHeaderInfoElem, String>,
1112 key: YcdHeaderInfoElem,
1113) -> io::Result<&str> {
1114 header
1115 .get(&key)
1116 .map(String::as_str)
1117 .ok_or_else(|| invalid_data(format!("Missing required header field: {}", key.as_ref())))
1118}
1119
1120fn parse_positive_i64(
1121 header: &HashMap<YcdHeaderInfoElem, String>,
1122 key: YcdHeaderInfoElem,
1123) -> io::Result<i64> {
1124 let value = parse_nonnegative_i64(header, key)?;
1125 if value == 0 {
1126 return Err(invalid_data(format!("{} must be positive", key.as_ref())));
1127 }
1128 Ok(value)
1129}
1130
1131fn parse_nonnegative_i64(
1132 header: &HashMap<YcdHeaderInfoElem, String>,
1133 key: YcdHeaderInfoElem,
1134) -> io::Result<i64> {
1135 required_value(header, key)?
1136 .parse::<i64>()
1137 .map_err(|_| invalid_data(format!("{} must be a nonnegative integer", key.as_ref())))
1138 .and_then(|value| {
1139 if value < 0 {
1140 Err(invalid_data(format!(
1141 "{} must be a nonnegative integer",
1142 key.as_ref()
1143 )))
1144 } else {
1145 Ok(value)
1146 }
1147 })
1148}
1149
1150fn collect_file_infos<P: AsRef<Path>>(files: &[P]) -> io::Result<Vec<FileInfo>> {
1155 let mut infos: Vec<FileInfo> = Vec::with_capacity(files.len());
1156
1157 for path in files {
1158 let meta = parse_metadata(path.as_ref())?;
1159
1160 let file_start = usize::try_from(meta.digit_start)
1161 .map_err(|_| invalid_data("Digit start position overflows usize"))?;
1162 let file_length = usize::try_from(meta.digit_length)
1163 .map_err(|_| invalid_data("Digit length overflows usize"))?;
1164
1165 if let Some(prev) = infos.last() {
1166 let expected = prev
1167 .file_start
1168 .checked_add(prev.file_length)
1169 .ok_or_else(|| invalid_data("Digit position overflow in continuity check"))?;
1170 if file_start != expected {
1171 return Err(io::Error::new(
1172 io::ErrorKind::InvalidInput,
1173 format!(
1174 "YCD files are not contiguous: expected start {expected}, found {file_start}"
1175 ),
1176 ));
1177 }
1178 }
1179
1180 infos.push(FileInfo {
1181 data_offset: meta.data_offset,
1182 file_start,
1183 file_length,
1184 });
1185 }
1186
1187 Ok(infos)
1188}
1189
1190fn invalid_data(message: impl Into<String>) -> io::Error {
1191 io::Error::new(io::ErrorKind::InvalidData, message.into())
1192}
1193
1194fn no_more_data_error() -> io::Error {
1195 io::Error::new(io::ErrorKind::UnexpectedEof, "No more data to read")
1196}