1use std::io::{Read, Seek, SeekFrom};
14
15use crate::findings::{Anomaly, AnomalyKind, Severity};
16use crate::pvd::IsoDateTime;
17use crate::{IsoError, IsoReader};
18
19#[derive(Debug, Clone, Copy, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23pub struct AnalyseOptions {}
24
25#[derive(Debug, Clone)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize))]
28pub struct BootRecord {
29 pub platform: String,
31 pub bootable: bool,
33 pub load_lba: u32,
35 pub sectors: u16,
37 pub sha256: Option<String>,
40}
41
42#[derive(Debug, Clone)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize))]
46pub struct IsoVolumeInfo {
47 pub volume_label: String,
48 pub system_id: String,
49 pub volume_set_id: String,
50 pub publisher_id: String,
51 pub data_preparer_id: String,
53 pub application_id: String,
54 pub creation_time: Option<String>,
56 pub modification_time: Option<String>,
58 pub sector_mode: String,
60 pub session_count: usize,
62 pub has_rock_ridge: bool,
63 pub has_joliet: bool,
64 pub has_enhanced_volume_descriptor: bool,
65 pub boot_entries: Vec<BootRecord>,
67 pub rock_ridge_uids: Vec<u32>,
70 pub rock_ridge_gids: Vec<u32>,
72 pub rock_ridge_inodes: Vec<u64>,
75 pub earliest_file_time: Option<String>,
78 pub latest_file_time: Option<String>,
80}
81
82#[derive(Debug)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize))]
85pub struct IsoAnalysis {
86 pub volume: IsoVolumeInfo,
88 pub anomalies: Vec<Anomaly>,
90}
91
92impl IsoAnalysis {
93 #[must_use]
95 pub fn max_severity(&self) -> Option<Severity> {
96 self.anomalies.iter().map(|a| a.severity).max()
97 }
98}
99
100pub fn analyse<R: Read + Seek>(reader: &mut R) -> Result<IsoAnalysis, IsoError> {
105 analyse_with_options(reader, AnalyseOptions::default())
106}
107
108pub fn analyse_with_options<R: Read + Seek>(
113 reader: &mut R,
114 _opts: AnalyseOptions,
115) -> Result<IsoAnalysis, IsoError> {
116 let image_bytes = reader.seek(SeekFrom::End(0))?;
118 reader.seek(SeekFrom::Start(0))?;
119
120 let (
124 volume,
125 declared_sectors,
126 phys,
127 be_mismatches,
128 slack_hits,
129 presys_hits,
130 symlink_issues,
131 lost_files,
132 pt_divergence,
133 pt_endian,
134 time_anomalies,
135 ) = {
136 let mut iso = IsoReader::open(&mut *reader)?;
137 let raw_boots = iso.boot_entries()?;
140 let mut boot_entries: Vec<BootRecord> = Vec::with_capacity(raw_boots.len());
141 for b in &raw_boots {
142 let want = usize::from(b.sector_count) * 512;
144 let nsec = want.div_ceil(2048);
145 let mut data = Vec::with_capacity(nsec * 2048);
146 let mut readable = want > 0;
147 for i in 0..nsec {
148 match iso.read_sector_raw(u64::from(b.lba) + i as u64) {
149 Ok(s) => data.extend_from_slice(&s),
150 Err(_) => {
151 readable = false;
152 break;
153 }
154 }
155 }
156 let sha256 = if readable {
157 use sha2::{Digest, Sha256};
158 data.truncate(want);
159 Some(Sha256::digest(&data).iter().map(|x| format!("{x:02x}")).collect())
160 } else {
161 None
162 };
163 boot_entries.push(BootRecord {
164 platform: format!("{:?}", b.platform),
165 bootable: b.bootable,
166 load_lba: b.lba,
167 sectors: b.sector_count,
168 sha256,
169 });
170 }
171 let mut volume = IsoVolumeInfo {
172 volume_label: iso.volume_label().to_string(),
173 system_id: iso.system_id().to_string(),
174 volume_set_id: iso.volume_set_id().to_string(),
175 publisher_id: iso.publisher_id().to_string(),
176 data_preparer_id: iso.data_preparer_id().to_string(),
177 application_id: iso.application_id().to_string(),
178 creation_time: iso.volume_creation_time().map(fmt_dt),
179 modification_time: iso.volume_modification_time().map(fmt_dt),
180 sector_mode: format!("{:?}", iso.sector_mode()),
181 session_count: iso.session_count(),
182 has_rock_ridge: iso.has_rock_ridge(),
183 has_joliet: iso.has_joliet(),
184 has_enhanced_volume_descriptor: iso.has_enhanced_volume_descriptor(),
185 boot_entries,
186 rock_ridge_uids: Vec::new(),
187 rock_ridge_gids: Vec::new(),
188 rock_ridge_inodes: Vec::new(),
189 earliest_file_time: None,
190 latest_file_time: None,
191 };
192
193 {
196 let mut uids = std::collections::BTreeSet::new();
197 let mut gids = std::collections::BTreeSet::new();
198 let mut inodes = std::collections::BTreeSet::new();
199 let mut earliest: Option<IsoDateTime> = None;
200 let mut latest: Option<IsoDateTime> = None;
201 for e in iso.walk()? {
202 if let Some(px) = crate::rock_ridge::posix_attrs(&e.record.system_use) {
203 uids.insert(px.uid);
204 gids.insert(px.gid);
205 if let Some(ino) = px.ino {
206 inodes.insert(ino);
207 }
208 }
209 if !e.record.is_dir() {
210 if let Some(dt) = &e.record.recorded {
211 if earliest.as_ref().is_none_or(|m| utc_key(dt) < utc_key(m)) {
212 earliest = Some(dt.clone());
213 }
214 if latest.as_ref().is_none_or(|m| utc_key(dt) > utc_key(m)) {
215 latest = Some(dt.clone());
216 }
217 }
218 }
219 }
220 volume.rock_ridge_uids = uids.into_iter().collect();
221 volume.rock_ridge_gids = gids.into_iter().collect();
222 volume.rock_ridge_inodes = inodes.into_iter().collect();
223 volume.earliest_file_time = earliest.as_ref().map(fmt_dt);
224 volume.latest_file_time = latest.as_ref().map(fmt_dt);
225 }
226 let be = iso.audit_both_endian()?;
227 let slack: Vec<_> = iso.audit_file_slack()?.into_iter().filter(|s| s.nonzero).collect();
228 let presys = iso.audit_pre_system()?;
229 let symlinks = iso.audit_symlinks()?;
230 let lost = iso.recover_lost_files()?;
231
232 let pt = iso.audit_path_table()?;
237 let pt_div: Vec<(String, u32)> = pt
238 .phantom_lbas
239 .iter()
240 .map(|&lba| ("phantom".to_string(), lba))
241 .chain(pt.ghost_lbas.iter().map(|&lba| ("ghost".to_string(), lba)))
242 .collect();
243
244 let pt_endian = iso.audit_path_table_endian()?;
247
248 let mut pvd_reserved: Vec<Anomaly> = Vec::new();
251 {
252 let pvd_lba = *iso.session_pvd_lbas.last().unwrap_or(&16);
253 let raw = iso.read_sector_raw(pvd_lba)?;
254 for (region, start, end) in [
255 ("byte 7 (unused)", 7usize, 8usize),
256 ("byte 882 (unused)", 882, 883),
257 ("reserved tail", 1395, 2048),
258 ] {
259 let nz = raw[start..end].iter().filter(|&&b| b != 0).count();
260 if nz > 0 {
261 pvd_reserved.push(Anomaly::new(AnomalyKind::ReservedFieldData {
262 region: region.to_string(),
263 pvd_offset: start as u32,
264 nonzero_bytes: nz as u32,
265 }));
266 }
267 }
268 }
269
270 let mut name_div: Vec<Anomaly> = Vec::new();
274 if iso.has_joliet() {
275 let norm = |s: &str| -> String {
276 let s = s.trim();
277 s.rsplit_once(';').map_or(s, |(a, _)| a).to_ascii_lowercase()
278 };
279 let mut prim: std::collections::HashMap<u32, (String, String)> =
280 std::collections::HashMap::new();
281 for e in iso.walk()? {
282 if e.record.is_dir() {
283 continue;
284 }
285 if let Some(rr) = crate::rock_ridge::alternate_name(&e.record.system_use) {
286 prim.entry(e.record.lba).or_insert((e.record.iso_name(), rr));
287 }
288 }
289 for e in iso.walk_joliet()? {
290 if e.record.is_dir() {
291 continue;
292 }
293 let jol = e.record.joliet_name();
294 if let Some((iso_n, rr)) = prim.get(&e.record.lba) {
295 if norm(rr) != norm(&jol) {
296 name_div.push(Anomaly::new(AnomalyKind::NameDivergence {
297 lba: e.record.lba,
298 iso_name: iso_n.clone(),
299 joliet_name: jol,
300 rock_ridge_name: rr.clone(),
301 }));
302 }
303 }
304 }
305 }
306
307 let mut versioned: Vec<Anomaly> = Vec::new();
310 for e in iso.walk()? {
311 if e.record.is_dir() {
312 continue;
313 }
314 if let Some(pos) = e.record.name_bytes.iter().position(|&b| b == b';') {
315 let digits = &e.record.name_bytes[pos + 1..];
316 let ver: u16 =
317 digits.iter().take_while(|b| b.is_ascii_digit()).fold(0u16, |acc, &b| {
318 acc.saturating_mul(10).saturating_add(u16::from(b - b'0'))
319 });
320 if !digits.is_empty() && ver != 1 {
321 versioned.push(Anomaly::new(AnomalyKind::VersionedFile {
322 entry_path: e.path,
323 version: ver,
324 }));
325 }
326 }
327 }
328
329 let mut time_mismatch: Vec<Anomaly> = Vec::new();
333 for e in iso.walk()? {
334 if e.record.is_dir() {
335 continue;
336 }
337 let (Some(it), Some(rr)) =
338 (e.record.recorded.as_ref(), crate::rock_ridge::timestamps(&e.record.system_use))
339 else {
340 continue;
341 };
342 if let Some(m) = rr.modify {
343 let iso_key = (it.year, it.month, it.day, it.hour, it.minute);
344 let rr_key = (u16::from(m[0]) + 1900, m[1], m[2], m[3], m[4]);
345 if iso_key != rr_key {
346 time_mismatch.push(Anomaly::new(AnomalyKind::IsoRrTimeMismatch {
347 entry_path: e.path,
348 iso_time: fmt_dt(it),
349 rock_ridge_time: fmt_short(&m),
350 }));
351 }
352 }
353 }
354
355 let mut disguised: Vec<Anomaly> = Vec::new();
359 {
360 const DOC_EXTS: &[&str] = &[
361 "txt", "doc", "docx", "pdf", "jpg", "jpeg", "png", "gif", "csv", "xml", "html",
362 "htm", "md", "rtf", "log", "json", "bmp", "tif", "tiff",
363 ];
364 for e in iso.walk()? {
365 if e.record.is_dir() || e.record.size < 4 {
366 continue;
367 }
368 let lower = e.path.to_ascii_lowercase();
369 let Some(ext) = lower.rsplit('.').next().filter(|_| lower.contains('.')) else {
370 continue;
371 };
372 if !DOC_EXTS.contains(&ext) {
373 continue;
374 }
375 let Ok(hdr) = iso.read_sector_raw(u64::from(e.record.lba)) else {
376 continue;
377 };
378 if let Some(format) = exe_magic(&hdr) {
379 disguised.push(Anomaly::new(AnomalyKind::DisguisedExecutable {
380 entry_path: e.path,
381 format: format.to_string(),
382 claimed_ext: ext.to_string(),
383 }));
384 }
385 }
386 }
387
388 let mut dir_cycles: Vec<Anomaly> = Vec::new();
392 {
393 let entries = iso.walk()?;
394 let mut dir_lba: std::collections::HashMap<String, u32> =
395 std::collections::HashMap::new();
396 dir_lba.insert(String::new(), iso.pvd.root_dir_lba);
397 for e in &entries {
398 if e.record.is_dir() {
399 dir_lba.insert(e.path.clone(), e.record.lba);
400 }
401 }
402 for e in &entries {
403 if !e.record.is_dir() {
404 continue;
405 }
406 let parts: Vec<&str> = e.path.split('/').collect();
407 let cycles_back = (0..parts.len()).any(|i| {
409 let anc = parts[..i].join("/");
410 dir_lba.get(&anc) == Some(&e.record.lba)
411 });
412 if cycles_back {
413 dir_cycles.push(Anomaly::new(AnomalyKind::DirectoryCycle {
414 entry_path: e.path.clone(),
415 lba: e.record.lba,
416 }));
417 }
418 }
419 }
420
421 let mut overlaps: Vec<Anomaly> = Vec::new();
426 {
427 let mut exts: Vec<(u32, u32, String)> = iso
428 .walk()?
429 .into_iter()
430 .filter(|e| !e.record.is_dir() && e.record.size > 0)
431 .map(|e| {
432 let start = e.record.lba;
433 let sectors =
434 u32::try_from(u64::from(e.record.size).div_ceil(2048)).unwrap_or(u32::MAX);
435 (start, start.saturating_add(sectors), e.path)
436 })
437 .collect();
438 exts.sort();
439 let mut cover: Option<(u32, u32, String)> = None;
440 for (start, end, path) in exts {
441 if let Some((cs, ce, cpath)) = cover.as_ref() {
442 if start < *ce && !(start == *cs && end == *ce) {
443 overlaps.push(Anomaly::new(AnomalyKind::OverlappingExtents {
444 path: path.clone(),
445 lba: start,
446 overlaps_path: cpath.clone(),
447 overlaps_lba: *cs,
448 }));
449 }
450 }
451 if cover.as_ref().is_none_or(|(_, ce, _)| end > *ce) {
452 cover = Some((start, end, path));
453 }
454 }
455 }
456
457 let mut superseded: Vec<Anomaly> = Vec::new();
461 let session_n = iso.session_count();
462 if session_n > 1 {
463 let active: std::collections::HashMap<String, u32> = iso
464 .walk()?
465 .into_iter()
466 .filter(|e| !e.record.is_dir())
467 .map(|e| (e.path, e.record.lba))
468 .collect();
469 for idx in 0..session_n - 1 {
470 for e in iso.walk_session(idx)? {
471 if e.record.is_dir() {
472 continue;
473 }
474 let status = match active.get(&e.path) {
475 None => "deleted",
476 Some(&l) if l != e.record.lba => "replaced",
477 Some(_) => continue, };
479 superseded.push(Anomaly::new(AnomalyKind::SupersededFile {
480 entry_path: e.path,
481 session: idx,
482 lba: e.record.lba,
483 status: status.to_string(),
484 }));
485 }
486 }
487 }
488
489 let image_sectors = image_bytes / iso.sector_mode().physical_sector_size();
492 let mut oob_anoms: Vec<Anomaly> = Vec::new();
493 for e in iso.walk()? {
494 if e.record.size == 0 {
495 continue;
496 }
497 let end = u64::from(e.record.lba) + u64::from(e.record.size).div_ceil(2048);
498 if u64::from(e.record.lba) >= image_sectors || end > image_sectors {
499 oob_anoms.push(Anomaly::new(AnomalyKind::OutOfBoundsExtent {
500 entry_path: e.path,
501 lba: e.record.lba,
502 size: e.record.size,
503 image_sectors: u32::try_from(image_sectors).unwrap_or(u32::MAX),
504 }));
505 }
506 }
507
508 const OPTICAL_ERA_FLOOR: u16 = 1985;
515 const OPTICAL_ERA_CEILING: u16 = 2100;
516 let mut time_anoms: Vec<Anomaly> = Vec::new();
517 for (which, t) in [
518 ("creation", iso.volume_creation_time().cloned()),
519 ("modification", iso.volume_modification_time().cloned()),
520 ] {
521 if let Some(dt) = t {
522 if (1..OPTICAL_ERA_FLOOR).contains(&dt.year) || dt.year > OPTICAL_ERA_CEILING {
523 time_anoms.push(Anomaly::new(AnomalyKind::ImplausibleVolumeDate {
524 which: which.to_string(),
525 year: dt.year,
526 }));
527 }
528 }
529 }
530 if let Some(vt) = iso.volume_creation_time().cloned() {
531 let vkey = utc_key(&vt);
532 let mut tz_offsets = std::collections::BTreeSet::new();
533 tz_offsets.insert(vt.tz_offset_15min);
534 for e in iso.walk()? {
535 if e.record.is_dir() {
536 continue;
537 }
538 if let Some(ft) = &e.record.recorded {
539 tz_offsets.insert(ft.tz_offset_15min);
540 if utc_key(ft) > vkey {
541 time_anoms.push(Anomaly::new(AnomalyKind::FileAfterVolume {
542 entry_path: e.path,
543 file_time: fmt_dt(ft),
544 volume_time: fmt_dt(&vt),
545 }));
546 }
547 }
548 }
549 if tz_offsets.len() > 1 {
550 time_anoms.push(Anomaly::new(AnomalyKind::MixedTimezones {
551 offsets: tz_offsets.into_iter().collect(),
552 }));
553 }
554 }
555
556 if iso.has_joliet() {
560 let extents =
561 |entries: Vec<crate::WalkEntry>| -> std::collections::BTreeMap<u32, String> {
562 entries
563 .into_iter()
564 .filter(|e| !e.record.is_dir() && e.record.size > 0)
565 .map(|e| (e.record.lba, e.path))
566 .collect()
567 };
568 let primary = extents(iso.walk()?);
569 let joliet = extents(iso.walk_joliet()?);
570 for (lba, path) in &primary {
571 if !joliet.contains_key(lba) {
572 time_anoms.push(Anomaly::new(AnomalyKind::TreeDivergence {
573 tree: "primary-only".to_string(),
574 lba: *lba,
575 path: path.clone(),
576 }));
577 }
578 }
579 for (lba, path) in &joliet {
580 if !primary.contains_key(lba) {
581 time_anoms.push(Anomaly::new(AnomalyKind::TreeDivergence {
582 tree: "joliet-only".to_string(),
583 lba: *lba,
584 path: path.clone(),
585 }));
586 }
587 }
588 }
589
590 (
591 volume,
592 u64::from(iso.volume_space_size()),
593 iso.sector_mode().physical_sector_size(),
594 be,
595 slack,
596 presys,
597 symlinks,
598 lost,
599 pt_div,
600 pt_endian,
601 {
602 time_anoms.extend(oob_anoms);
603 time_anoms.extend(superseded);
604 time_anoms.extend(pvd_reserved);
605 time_anoms.extend(overlaps);
606 time_anoms.extend(dir_cycles);
607 time_anoms.extend(name_div);
608 time_anoms.extend(disguised);
609 time_anoms.extend(time_mismatch);
610 time_anoms.extend(versioned);
611 time_anoms
612 },
613 )
614 };
615
616 let mut anomalies = Vec::new();
617
618 for m in be_mismatches {
622 anomalies.push(Anomaly::new(AnomalyKind::BothEndianMismatch {
623 context: m.context,
624 field: m.field,
625 byte_offset: m.byte_offset,
626 le: m.le_val,
627 be: m.be_val,
628 }));
629 }
630
631 for s in slack_hits {
633 anomalies.push(Anomaly::new(AnomalyKind::SlackData {
634 entry_path: s.entry_path,
635 lba: s.lba,
636 slack_bytes: s.slack_bytes,
637 }));
638 }
639
640 for p in presys_hits {
642 anomalies.push(Anomaly::new(AnomalyKind::PreSystemData {
643 sector: p.sector,
644 kind: p.kind.to_string(),
645 }));
646 }
647
648 for s in symlink_issues {
650 anomalies.push(Anomaly::new(AnomalyKind::SymlinkAnomaly {
651 entry_path: s.entry_path,
652 target: s.target,
653 issue: s.issue.to_string(),
654 }));
655 }
656
657 for (direction, lba) in pt_divergence {
660 anomalies.push(Anomaly::new(AnomalyKind::PathTableDivergence { direction, lba }));
661 }
662
663 for m in pt_endian {
666 anomalies.push(Anomaly::new(AnomalyKind::PathTableEndianDivergence {
667 index: m.index,
668 description: m.description,
669 }));
670 }
671
672 for l in lost_files {
674 anomalies.push(Anomaly::new(AnomalyKind::OrphanedFile {
675 name: l.name,
676 lba: l.lba,
677 size: l.size,
678 parent_lba: l.parent_lba,
679 }));
680 }
681
682 anomalies.extend(time_anomalies);
684
685 let declared_bytes = declared_sectors.saturating_mul(phys);
688 if image_bytes > declared_bytes && trailing_has_nonzero(reader, declared_bytes, image_bytes)? {
689 anomalies.push(Anomaly::new(AnomalyKind::TrailingData {
690 declared_bytes,
691 image_bytes,
692 trailing_bytes: image_bytes - declared_bytes,
693 }));
694 }
695
696 if phys >= 2352 {
700 let total = image_bytes / phys;
701 let mut sec = vec![0u8; 2352];
702 let mut checked = 0u32;
703 let mut edc_invalid = 0u32;
704 let mut edc_first = 0u32;
705 let mut ecc_invalid = 0u32;
706 let mut ecc_first = 0u32;
707 for lba in 0..total {
708 reader.seek(SeekFrom::Start(lba * phys))?;
709 if reader.read_exact(&mut sec).is_err() {
710 break;
711 }
712 let sync_ok = sec[0] == 0 && sec[1..11].iter().all(|&b| b == 0xFF) && sec[11] == 0;
713 if !sync_ok || sec[15] != 1 {
714 continue; }
716 checked += 1;
717 let stored = u32::from_le_bytes([sec[2064], sec[2065], sec[2066], sec[2067]]);
718 if crate::sector::cd_edc(&sec[0..2064]) != stored {
719 if edc_invalid == 0 {
720 edc_first = u32::try_from(lba).unwrap_or(u32::MAX);
721 }
722 edc_invalid += 1;
723 }
724 if !crate::sector::mode1_ecc_valid(&sec) {
725 if ecc_invalid == 0 {
726 ecc_first = u32::try_from(lba).unwrap_or(u32::MAX);
727 }
728 ecc_invalid += 1;
729 }
730 }
731 if edc_invalid > 0 {
732 anomalies.push(Anomaly::new(AnomalyKind::EdcInvalid {
733 sectors_checked: checked,
734 sectors_invalid: edc_invalid,
735 first_invalid_lba: edc_first,
736 }));
737 }
738 if ecc_invalid > 0 {
739 anomalies.push(Anomaly::new(AnomalyKind::EccInvalid {
740 sectors_checked: checked,
741 sectors_invalid: ecc_invalid,
742 first_invalid_lba: ecc_first,
743 }));
744 }
745 }
746
747 Ok(IsoAnalysis { volume, anomalies })
748}
749
750fn exe_magic(header: &[u8]) -> Option<&'static str> {
753 if header.len() < 4 {
754 return None;
755 }
756 if header[0] == 0x4D && header[1] == 0x5A {
757 return Some("PE"); }
759 if header[0] == 0x7F && &header[1..4] == b"ELF" {
760 return Some("ELF");
761 }
762 let be = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);
763 if matches!(be, 0xFEED_FACE | 0xFEED_FACF | 0xCEFA_EDFE | 0xCFFA_EDFE | 0xCAFE_BABE) {
765 return Some("Mach-O");
766 }
767 None
768}
769
770fn trailing_has_nonzero<R: Read + Seek>(
771 reader: &mut R,
772 start: u64,
773 end: u64,
774) -> Result<bool, IsoError> {
775 reader.seek(SeekFrom::Start(start))?;
776 let mut remaining = end - start;
777 let mut buf = [0u8; 65536];
778 while remaining > 0 {
779 let want = remaining.min(buf.len() as u64) as usize;
780 reader.read_exact(&mut buf[..want])?;
781 if buf[..want].iter().any(|&b| b != 0) {
782 return Ok(true);
783 }
784 remaining -= want as u64;
785 }
786 Ok(false)
787}
788
789fn utc_key(dt: &IsoDateTime) -> i64 {
793 let y = i64::from(dt.year);
794 let m = i64::from(dt.month.max(1));
795 let d = i64::from(dt.day.max(1));
796 let y = if m <= 2 { y - 1 } else { y };
797 let era = if y >= 0 { y } else { y - 399 } / 400;
798 let yoe = y - era * 400;
799 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
800 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
801 let days = era * 146_097 + doe - 719_468; let local = days * 86_400
803 + i64::from(dt.hour) * 3600
804 + i64::from(dt.minute) * 60
805 + i64::from(dt.second);
806 local - i64::from(dt.tz_offset_15min) * 15 * 60
808}
809
810fn fmt_dt(dt: &IsoDateTime) -> String {
811 format!(
812 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
813 dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
814 )
815}
816
817fn fmt_short(t: &[u8; 7]) -> String {
819 format!(
820 "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
821 u16::from(t[0]) + 1900,
822 t[1],
823 t[2],
824 t[3],
825 t[4],
826 t[5]
827 )
828}