"""Unified record abstraction over pycdlib's various record types. pycdlib exposes different record objects depending on the naming convention in use (ISO9660 ``DirectoryRecord`` vs UDF ``UDFFileEntry``). The UI wants a single, consistent shape, so ``IsoRecord`` normalises them. """ # This file is part of ISO Scalpel. # Copyright (C) 2025 Jeremy Anderson # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, see # or write to the Free Software Foundation, Inc., 51 Franklin Street, # Fifth Floor, Boston, MA 02110-1301 USA. from __future__ import annotations import enum import time from dataclasses import dataclass class NameType(enum.Enum): """Which naming convention a record's path belongs to.""" ISO9660 = "iso9660" # plain ISO9660 (upper case, 8.3) ROCK_RIDGE = "rock" # Rock Ridge (Unix names) JOLIET = "joliet" # Joliet (Unicode) UDF = "udf" # UDF # Friendly labels for the UI / dialogs. NAME_TYPE_LABELS = { NameType.ISO9660: "ISO9660", NameType.ROCK_RIDGE: "Rock Ridge", NameType.JOLIET: "Joliet", NameType.UDF: "UDF", } def _decode(ident) -> str: """Decode a pycdlib file identifier (bytes) into a python str. Joliet identifiers carry a leading NUL byte and are UTF-16-BE; everything else is treated as UTF-8. A bytes payload that decodes as neither falls back to latin-1 so the UI never crashes on a malformed image. """ if ident is None: return "" if isinstance(ident, bytes): try: return (ident.decode("utf-16-be") if ident.startswith(b"\x00") else ident.decode("utf-8", "replace")) except (UnicodeDecodeError, ValueError): return ident.decode("latin-1", "replace") return str(ident) def _format_iso9660_name(raw: str) -> str: """Humanise an ISO9660 identifier for display. pycdlib returns names like ``b'TEST.TXT;1'``. We strip the version suffix (``;1``) for readability in the list view. """ name = _decode(raw) if ";" in name: name = name.split(";", 1)[0] return name @dataclass class IsoRecord: """A normalised view of a single entry inside an ISO image.""" name: str # display name (already de-mangled) raw_name: str # raw identifier as stored is_dir: bool is_file: bool size: int # bytes (0 for directories) path: str # full path in the active NameType convention name_type: NameType modified: float | None = None # epoch seconds record: object = None # the underlying pycdlib record (advanced use) # -- convenience ------------------------------------------------------- @property def is_dot(self) -> bool: return self.name in (".", "..") @property def size_label(self) -> str: if self.is_dir: return "" return _human_size(self.size) @property def date_label(self) -> str: if not self.modified: return "" try: return time.strftime("%Y-%m-%d %H:%M", time.localtime(self.modified)) except (OSError, ValueError, OverflowError): # Out-of-range epoch or malformed struct cannot be formatted. return "" @classmethod def from_pycdlib(cls, rec, path: str, name_type: NameType) -> IsoRecord: """Build an :class:`IsoRecord` from a raw pycdlib record.""" if rec is None: return cls("", "", False, False, 0, path, name_type) raw_ident = rec.file_identifier() is_dir = bool(rec.is_dir()) if hasattr(rec, "is_dir") else False is_file = bool(rec.is_file()) if hasattr(rec, "is_file") else False # data length differs between ISO9660 records and UDF entries. size = 0 if hasattr(rec, "get_data_length"): try: size = int(rec.get_data_length()) except (TypeError, ValueError): size = 0 elif hasattr(rec, "data_length"): try: size = int(rec.data_length) except (TypeError, ValueError): size = 0 # Display name if name_type == NameType.ISO9660: name = _format_iso9660_name(raw_ident) else: name = _decode(raw_ident) # Strip a leading version like ';1' on ISO9660 names for display if name_type == NameType.ISO9660 and ";" in name: name = name.split(";", 1)[0] # Modified time: take the first attribute that yields a usable epoch. _TIME_ATTRS = ("mod_time", "date", "access_time", "attr_time") modified = next( (ts for attr in _TIME_ATTRS for val in (getattr(rec, attr, None),) if val is not None for ts in (_to_epoch(val),) if ts is not None), None, ) return cls( name=name, raw_name=_decode(raw_ident), is_dir=is_dir, is_file=is_file, size=size, path=path, name_type=name_type, modified=modified, record=rec, ) def _to_epoch(val) -> float | None: """Best-effort conversion of a pycdlib date-ish object to epoch seconds.""" if val is None: return None if isinstance(val, (int, float)): return float(val) # pycdlib uses its own date structs; try common attributes. for attr in ("year", "month", "day", "hour", "minute", "second"): if not hasattr(val, attr): return None try: y = int(getattr(val, "year", 1970)) mo = int(getattr(val, "month", 1)) d = int(getattr(val, "day", 1)) h = int(getattr(val, "hour", 0)) mi = int(getattr(val, "minute", 0)) s = int(getattr(val, "second", 0)) except (TypeError, ValueError): return None if y <= 0: return None try: return time.mktime((y, mo, d, h, mi, s, 0, 0, -1)) except (OSError, ValueError, OverflowError): return None _SIZE_UNITS: tuple[str, ...] = ("B", "KiB", "MiB", "GiB", "TiB") def _human_size(n: float | None) -> str: """Format a byte count with binary units (B, KiB, MiB, GiB, TiB). A None or non-numeric input reads as ``"0 B"``. Values >= 1 PiB are reported in TiB (the largest unit ISO media realistically reaches). """ if n is None: return "0 B" try: value = float(n) except (TypeError, ValueError): return "0 B" if value < 0: value = 0.0 # Walk the unit table; stop at the first unit whose threshold the # value no longer crosses, or at the last unit (TiB) as a ceiling. for unit in _SIZE_UNITS: if value < 1024.0 or unit == _SIZE_UNITS[-1]: if unit == "B": return f"{int(value)} {unit}" return f"{value:.1f} {unit}" value /= 1024.0 return f"{value:.1f} {_SIZE_UNITS[-1]}"