468 lines
19 KiB
Python
Executable File
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())
|