iso-scalpel/iso_scalpel/diff.py

226 lines
7.8 KiB
Python

"""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 <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
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)