iso-scalpel/iso_scalpel/iso_record.py

226 lines
7.4 KiB
Python

"""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 <info@dcos.net>
#
# 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 <https://www.gnu.org/licenses/>
# 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 "<DIR>"
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]}"