"""Filesystem diff engine for ISO images. Compares two ISO images at the *filesystem* level — which entries exist in one but not the other, which have changed size or modification time — without comparing file *contents*. Like :mod:`iso_handler`, this module is GUI-agnostic: it depends only on pycdlib (via IsoHandler) and the standard library, so it can be used from the CLI or a test harness with no display attached. The diff is computed in the *default* naming convention of image A (or an explicit :class:`~iso_scalpel.iso_record.NameType`). Entries that exist in both images are matched by path within that convention. """ # 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 from dataclasses import dataclass, field from .iso_handler import IsoHandler from .iso_record import IsoRecord, NameType class DiffStatus(enum.Enum): """How an entry differs between the two images.""" SAME = "same" # present in both, same size MODIFIED = "modified" # present in both, different size or date ADDED = "added" # only in B (the second image) REMOVED = "removed" # only in A (the first image) @dataclass class DiffEntry: """A single row in the diff output.""" status: DiffStatus path: str # path relative to root (always "/"-prefixed) is_dir: bool a: IsoRecord | None # record from image A (None if ADDED) b: IsoRecord | None # record from image B (None if REMOVED) @property def status_label(self) -> str: return { DiffStatus.SAME: "=", DiffStatus.MODIFIED: "M", DiffStatus.ADDED: "+", DiffStatus.REMOVED: "-", }[self.status] @property def a_size(self) -> int: return self.a.size if self.a else 0 @property def b_size(self) -> int: return self.b.size if self.b else 0 @property def a_date(self) -> str: return self.a.date_label if self.a else "" @property def b_date(self) -> str: return self.b.date_label if self.b else "" @dataclass class DiffResult: """The full result of comparing two images.""" a_file: str | None # on-disk filename of A b_file: str | None # on-disk filename of B entries: list[DiffEntry] = field(default_factory=list) @property def same_count(self) -> int: return sum(1 for e in self.entries if e.status == DiffStatus.SAME) @property def modified_count(self) -> int: return sum(1 for e in self.entries if e.status == DiffStatus.MODIFIED) @property def added_count(self) -> int: return sum(1 for e in self.entries if e.status == DiffStatus.ADDED) @property def removed_count(self) -> int: return sum(1 for e in self.entries if e.status == DiffStatus.REMOVED) @property def is_identical(self) -> bool: return not self.modified_count and not self.added_count and not self.removed_count def diff_images(a: IsoHandler, b: IsoHandler, name_type: NameType | None = None) -> DiffResult: """Compare two open images and return a :class:`DiffResult`. ``name_type`` selects which naming convention to compare in. If ``None``, the default convention of image A is used (falling back to ISO9660 if A has no UDF/RR/Joliet). """ if not a.is_open or not b.is_open: raise ValueError("Both images must be open") if name_type is None: # Use the richest convention common to both images. a_types = set(a.available_name_types()) b_types = set(b.available_name_types()) common = a_types & b_types for preferred in (NameType.UDF, NameType.ROCK_RIDGE, NameType.JOLIET, NameType.ISO9660): if preferred in common: name_type = preferred break if name_type is None: name_type = NameType.ISO9660 tree_a = _walk(a, "/", name_type) tree_b = _walk(b, "/", name_type) entries: list[DiffEntry] = [] all_paths = sorted(set(tree_a) | set(tree_b)) for path in all_paths: ra = tree_a.get(path) rb = tree_b.get(path) if ra and rb: if (ra.is_dir and rb.is_dir) or (ra.size == rb.size and ra.modified == rb.modified): status = DiffStatus.SAME else: status = DiffStatus.MODIFIED elif rb and not ra: status = DiffStatus.ADDED else: status = DiffStatus.REMOVED entries.append(DiffEntry(status=status, path=path, is_dir=(ra or rb).is_dir, a=ra, b=rb)) return DiffResult(a_file=a.filename, b_file=b.filename, entries=entries) def _walk(handler: IsoHandler, root: str, name_type: NameType) -> dict[str, IsoRecord]: """Recursively walk an image, returning ``{path: IsoRecord}``.""" out: dict[str, IsoRecord] = {} _walk_into(handler, root, name_type, out) return out def _walk_into(handler: IsoHandler, path: str, name_type: NameType, out: dict[str, IsoRecord]) -> None: try: records = handler.list_dir(path, name_type) except (OSError, ValueError, KeyError): # A directory that cannot be listed is skipped; its descendants are # simply absent from the diff rather than aborting the whole walk. return for rec in records: out[rec.path] = rec if rec.is_dir: _walk_into(handler, rec.path, name_type, out) def format_diff_text(result: DiffResult) -> str: """Render a :class:`DiffResult` as plain text (for the CLI).""" lines: list[str] = [] a_name = result.a_file or "A" b_name = result.b_file or "B" lines.append(f"--- {a_name}") lines.append(f"+++ {b_name}") lines.append("") if result.is_identical: lines.append("Filesystems are identical.") return "\n".join(lines) lines.append(f"{result.added_count} added, {result.removed_count} removed, " f"{result.modified_count} modified, {result.same_count} unchanged") lines.append("") # Each row formats one diff entry. ``SAME`` entries are skipped here and # summarised by count at the end, so the formatter is a flat lookup over # the three "interesting" statuses. def _line_for(e: DiffEntry) -> str | None: mark = e.status_label tag = "dir " if e.is_dir else "" if e.status == DiffStatus.MODIFIED: return f" {mark} {e.path} ({e.a_size} -> {e.b_size} bytes)" if e.status == DiffStatus.ADDED: return f" {mark} {tag}{e.path} ({e.b_size} bytes)" if e.status == DiffStatus.REMOVED: return f" {mark} {tag}{e.path} ({e.a_size} bytes)" return None changed = [e for e in result.entries if e.status != DiffStatus.SAME] for e in changed: line = _line_for(e) if line is not None: lines.append(line) if result.same_count: lines.append("") lines.append(f"({result.same_count} entries unchanged)") return "\n".join(lines)