iso-scalpel/cli.py

468 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""ISO Scalpel — command-line interface.
Provides scriptable access to the ISO engine without the GUI. Every
feature of the graphical application is reachable from here, plus the
filesystem diff mode.
Usage:
python cli.py <command> [options]
Commands:
new Create a new ISO image
list List the contents of an ISO image
info Show volume metadata for an ISO image
add Add a file or directory to an image
extract Extract a file or directory from an image
rm Remove an entry from an image
boot Configure or show El Torito boot information
diff Compare the filesystems of two images
gui Launch the graphical interface (default if no command)
"""
# 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 argparse
import os
import sys
# Make the package importable when run directly.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from iso_scalpel import __app_name__, __version__
from iso_scalpel.diff import DiffEntry, DiffStatus, diff_images, format_diff_text
from iso_scalpel.iso_handler import BootInfo, IsoHandler, NameType, NewIsoOptions
from iso_scalpel.iso_record import NAME_TYPE_LABELS, _human_size
# --------------------------------------------------------------------------
# Argument parsing
# --------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="iso-scalpel",
description=f"{__app_name__} {__version__} — disc image editor (CLI)",
)
p.add_argument("--version", action="version", version=f"{__app_name__} {__version__}")
sub = p.add_subparsers(dest="command", metavar="<command>")
# --- new -----------------------------------------------------------
sp_new = sub.add_parser("new", help="Create a new ISO image")
sp_new.add_argument("output", help="Output .iso filename")
sp_new.add_argument("-l", "--label", default="CDROM", help="Volume label")
sp_new.add_argument("--level", type=int, choices=[1, 2, 3], default=1,
help="ISO9660 interchange level (default: 1)")
sp_new.add_argument("--joliet", type=int, choices=[1, 2, 3], default=None,
help="Enable Joliet at the given level")
sp_new.add_argument("--rock-ridge", choices=["1.09", "1.12"], default=None,
help="Enable Rock Ridge")
sp_new.add_argument("--udf", choices=["2.50", "2.60"], default=None,
help="Enable UDF")
sp_new.add_argument("--publisher", default="")
sp_new.add_argument("--preparer", default="")
sp_new.add_argument("--application", default="ISO Scalpel")
sp_new.add_argument("--add", action="append", default=[],
help="Add a file or directory to the new image (repeatable). "
"Format: LOCAL_PATH[:ISO_PATH]")
sp_new.set_defaults(func=cmd_new)
# --- list ----------------------------------------------------------
sp_list = sub.add_parser("list", help="List the contents of an ISO image")
sp_list.add_argument("file", help="ISO image file")
sp_list.add_argument("path", nargs="?", default="/", help="Directory to list (default: /)")
sp_list.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"],
default=None, help="Naming convention to display")
sp_list.add_argument("-r", "--recursive", action="store_true",
help="Recursively list all entries")
sp_list.set_defaults(func=cmd_list)
# --- info ----------------------------------------------------------
sp_info = sub.add_parser("info", help="Show volume metadata")
sp_info.add_argument("file", help="ISO image file")
sp_info.set_defaults(func=cmd_info)
# --- add -----------------------------------------------------------
sp_add = sub.add_parser("add", help="Add a file or directory to an image")
sp_add.add_argument("file", help="ISO image file to modify")
sp_add.add_argument("local", help="Local file or directory to add")
sp_add.add_argument("iso_path", nargs="?", default="/",
help="Destination directory in the image (default: /)")
sp_add.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"],
default=None, help="Naming convention for the destination")
sp_add.set_defaults(func=cmd_add)
# --- extract -------------------------------------------------------
sp_ex = sub.add_parser("extract", help="Extract a file or directory")
sp_ex.add_argument("file", help="ISO image file")
sp_ex.add_argument("iso_path", help="Path inside the image to extract")
sp_ex.add_argument("local", help="Local destination path")
sp_ex.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"],
default=None)
sp_ex.set_defaults(func=cmd_extract)
# --- rm ------------------------------------------------------------
sp_rm = sub.add_parser("rm", help="Remove an entry from an image")
sp_rm.add_argument("file", help="ISO image file to modify")
sp_rm.add_argument("iso_path", help="Path inside the image to remove")
sp_rm.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"],
default=None)
sp_rm.set_defaults(func=cmd_rm)
# --- boot ----------------------------------------------------------
sp_boot = sub.add_parser("boot", help="Show or configure El Torito boot")
sp_boot.add_argument("file", help="ISO image file")
sp_boot.add_argument("--set", metavar="BOOT_FILE",
help="Set a host file as the boot image")
sp_boot.add_argument("--platform", type=lambda x: int(x, 0), default=0,
help="Platform ID (0=x86, 1=PPC, 2=Mac, 0xEF=EFI)")
sp_boot.add_argument("--media", default="noemul",
choices=["noemul", "floppy", "hdemul"],
help="Media type")
sp_boot.add_argument("--no-bootable", action="store_true",
help="Mark as non-bootable")
sp_boot.add_argument("--info-table", action="store_true",
help="Patch boot-info-table (for ISOLINUX)")
sp_boot.add_argument("--clear", action="store_true",
help="Remove the El Torito boot record")
sp_boot.set_defaults(func=cmd_boot)
# --- diff ----------------------------------------------------------
sp_diff = sub.add_parser("diff", help="Compare the filesystems of two images")
sp_diff.add_argument("file_a", help="First ISO image (the 'from')")
sp_diff.add_argument("file_b", help="Second ISO image (the 'to')")
sp_diff.add_argument("-v", "--view", choices=["iso9660", "rr", "joliet", "udf"],
default=None, help="Naming convention to compare in")
sp_diff.add_argument("--all", action="store_true",
help="Show unchanged entries too")
sp_diff.set_defaults(func=cmd_diff)
# --- gui -----------------------------------------------------------
sp_gui = sub.add_parser("gui", help="Launch the graphical interface")
sp_gui.add_argument("file", nargs="?", help="Optional ISO file to open")
sp_gui.set_defaults(func=cmd_gui)
return p
# --------------------------------------------------------------------------
# Name-type parsing helper
# --------------------------------------------------------------------------
_VIEW_MAP = {
"iso9660": NameType.ISO9660,
"rr": NameType.ROCK_RIDGE,
"joliet": NameType.JOLIET,
"udf": NameType.UDF,
}
def _resolve_view(handler: IsoHandler, view: str | None) -> NameType:
if view is None:
return handler.default_name_type()
nt = _VIEW_MAP.get(view)
if nt is None:
raise SystemExit(f"Unknown view: {view}")
return nt
# --------------------------------------------------------------------------
# Command implementations
# --------------------------------------------------------------------------
def cmd_new(args: argparse.Namespace) -> int:
opts = NewIsoOptions(
volume_label=args.label,
interchange_level=args.level,
joliet=args.joliet,
rock_ridge=args.rock_ridge,
udf=args.udf,
publisher=args.publisher,
preparer=args.preparer,
application=args.application,
)
handler = IsoHandler()
try:
handler.new(opts)
nt = handler.default_name_type()
for spec in args.add:
if ":" in spec and os.path.exists(spec.split(":", 1)[0]):
local, iso_path = spec.split(":", 1)
else:
local, iso_path = spec, "/"
if os.path.isdir(local):
name = os.path.basename(local)
new_dir = handler.add_directory(iso_path, nt, name)
_import_tree(handler, local,
iso_path.rstrip("/") + "/" + new_dir, nt)
else:
handler.add_file(local, iso_path, nt,
nice_name=os.path.basename(local))
handler.save(args.output)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
handler.close()
print(f"Created {args.output} ({_human_size(os.path.getsize(args.output))})")
return 0
def _import_tree(handler: IsoHandler, local_dir: str, iso_dir: str,
nt: NameType) -> None:
for entry in sorted(os.listdir(local_dir)):
full = os.path.join(local_dir, entry)
if os.path.isdir(full):
new_dir = handler.add_directory(iso_dir, nt, entry)
_import_tree(handler, full, iso_dir.rstrip("/") + "/" + new_dir, nt)
else:
handler.add_file(full, iso_dir, nt, nice_name=entry)
def cmd_list(args: argparse.Namespace) -> int:
handler = IsoHandler()
try:
handler.open(args.file)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
nt = _resolve_view(handler, args.view)
if args.recursive:
entries = []
_collect_recursive(handler, args.path, nt, entries)
else:
entries = handler.list_dir(args.path, nt)
print(f"{args.file} ({NAME_TYPE_LABELS[nt]} view)")
print(f"{'Name':<40} {'Size':>12} {'Type':<6} {'Date'}")
for rec in entries:
name = rec.name + ("/" if rec.is_dir else "")
size = "<DIR>" if rec.is_dir else _human_size(rec.size)
typ = "dir" if rec.is_dir else "file"
print(f"{name:<40} {size:>12} {typ:<6} {rec.date_label}")
handler.close()
return 0
def _collect_recursive(handler: IsoHandler, path: str, nt: NameType,
out: list) -> None:
for rec in handler.list_dir(path, nt):
out.append(rec)
if rec.is_dir:
_collect_recursive(handler, rec.path, nt, out)
def cmd_info(args: argparse.Namespace) -> int:
handler = IsoHandler()
try:
handler.open(args.file)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
p = handler.get_properties()
print(f"File: {handler.filename}")
print(f"Volume label: {p.volume_label}")
print(f"Image size: {_human_size(p.total_size)} ({p.total_size} bytes)")
print(f"Block size: {p.block_size}")
print(f"ISO9660 level: {p.interchange_level}")
print(f"Extensions: {', '.join(p.extensions) or '(none)'}")
# Optional descriptor fields are printed only when present.
optional_fields = (
("System ID:", p.system_id),
("Publisher:", p.publisher),
("Preparer:", p.preparer),
("Application:", p.application),
("Volume set ID:", p.volume_set_id if p.volume_set_id and p.volume_set_id.strip() else ""),
)
for label, value in optional_fields:
if value:
print(f"{label:<17}{value}")
boot = handler.get_boot_info()
print(f"El Torito boot: {'enabled' if boot.enabled else 'disabled'}")
handler.close()
return 0
def cmd_add(args: argparse.Namespace) -> int:
handler = IsoHandler()
try:
handler.open(args.file)
nt = _resolve_view(handler, args.view)
if os.path.isdir(args.local):
name = os.path.basename(args.local)
new_dir = handler.add_directory(args.iso_path, nt, name)
_import_tree(handler, args.local,
args.iso_path.rstrip("/") + "/" + new_dir, nt)
else:
handler.add_file(args.local, args.iso_path, nt,
nice_name=os.path.basename(args.local))
handler.save(args.file)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
handler.close()
print(f"Added {args.local} to {args.file}")
return 0
def cmd_extract(args: argparse.Namespace) -> int:
handler = IsoHandler()
try:
handler.open(args.file)
nt = _resolve_view(handler, args.view)
# determine if it's a dir or file by looking it up
recs = {r.path: r for r in handler.list_dir(
args.iso_path.rpartition("/")[0] or "/", nt)}
rec = recs.get(args.iso_path)
if rec and rec.is_dir:
handler.extract_dir(args.iso_path, nt, args.local)
else:
handler.extract_file(args.iso_path, nt, args.local)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
handler.close()
print(f"Extracted {args.iso_path} to {args.local}")
return 0
def cmd_rm(args: argparse.Namespace) -> int:
handler = IsoHandler()
try:
handler.open(args.file)
nt = _resolve_view(handler, args.view)
# look up whether it's a dir
parent = args.iso_path.rpartition("/")[0] or "/"
recs = {r.path: r for r in handler.list_dir(parent, nt)}
rec = recs.get(args.iso_path)
is_dir = rec.is_dir if rec else False
handler.remove(args.iso_path, nt, is_dir)
handler.save(args.file)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
handler.close()
print(f"Removed {args.iso_path} from {args.file}")
return 0
def cmd_boot(args: argparse.Namespace) -> int:
handler = IsoHandler()
try:
handler.open(args.file)
if args.clear:
handler.clear_boot()
handler.save(args.file)
print("Boot record removed.")
return 0
if args.set:
info = BootInfo(
bootable=not args.no_bootable,
platform_id=args.platform,
media_name=args.media,
boot_info_table=args.info_table,
load_segment=0x07C0,
)
if args.platform == 0xEF:
info.efi = True
handler.set_boot(info, boot_file_local=args.set)
handler.save(args.file)
print(f"Boot image set to {args.set}")
return 0
# just show info
info = handler.get_boot_info()
print(f"Boot enabled: {info.enabled}")
print(f"Bootable: {info.bootable}")
print(f"Boot image: {info.boot_image_path or '(none)'}")
print(f"Platform ID: 0x{info.platform_id:02X}")
print(f"Media: {info.media_name}")
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
handler.close()
return 0
def cmd_diff(args: argparse.Namespace) -> int:
ha = IsoHandler()
hb = IsoHandler()
try:
ha.open(args.file_a)
hb.open(args.file_b)
nt = _resolve_view(ha, args.view) if args.view else None
result = diff_images(ha, hb, name_type=nt)
except Exception as exc: # noqa: BLE001 -- top-level CLI error boundary
print(f"error: {exc}", file=sys.stderr)
return 1
finally:
ha.close()
hb.close()
if args.all:
# Show every entry, including unchanged. The size annotation is
# selected by status via a flat table so the print loop stays linear.
print(f"--- {result.a_file}")
print(f"+++ {result.b_file}")
print(f"{result.added_count} added, {result.removed_count} removed, "
f"{result.modified_count} modified, {result.same_count} unchanged")
print()
def _size_note(e: DiffEntry) -> str:
if e.status == DiffStatus.MODIFIED:
return f"{e.a_size} -> {e.b_size} bytes"
if e.status == DiffStatus.ADDED:
return f"{e.b_size} bytes"
return f"{e.a_size} bytes"
for e in result.entries:
mark = e.status_label
name = e.path + ("/" if e.is_dir else "")
print(f" {mark} {name} ({_size_note(e)})")
else:
print(format_diff_text(result))
# POSIX diff(1) convention: 0 = identical, 1 = different, 2 = error.
return 0 if result.is_identical else 1
def cmd_gui(args: argparse.Namespace) -> int:
from iso_scalpel.app import run
argv = ["iso-scalpel"]
if args.file:
argv.append(args.file)
return run(argv)
# --------------------------------------------------------------------------
# Entry point
# --------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not getattr(args, "command", None):
# No subcommand: launch the GUI.
from iso_scalpel.app import run
return run(["iso-scalpel"] + (argv or []))
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())