226 lines
7.8 KiB
Python
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)
|