226 lines
7.4 KiB
Python
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]}"
|