525 lines
19 KiB
Python
525 lines
19 KiB
Python
"""Utilities for modification of disk image files.
|
|
|
|
Image file modification includes extracting ISO archives, adding files to
|
|
initrd-archives contained within the ISO, recalculating md5sum-files
|
|
inside the ISO and rebuilding bootable ISOs from directories on the
|
|
local filesystem.
|
|
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
import gzip
|
|
import hashlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
|
|
from cli.clibella import Printer
|
|
from core.utils import find_all_files_under
|
|
|
|
|
|
def extract_iso(path_to_output_dir, path_to_input_file):
|
|
"""Extracts the contents of the ISO-file into the specified directory.
|
|
|
|
Source: https://wiki.debian.org/DebianInstaller/Preseed/EditIso#Extracting_the_Initrd_from_an_ISO_Image
|
|
|
|
Parameters
|
|
----------
|
|
path_to_output_dir : str or pathlike object
|
|
Path to the directory into which the contents of the ISO archive will
|
|
be extracted.
|
|
path_to_input_file : str or pathlike object
|
|
Path to the file/archive which should be extracted.
|
|
|
|
Raises
|
|
------
|
|
FileNotFoundError
|
|
Raised if the input file does not exist or is not a file.
|
|
NotADirectoryError
|
|
Raised if the output directory does not exist or is not a directory.
|
|
|
|
Example
|
|
-------
|
|
extract_iso("/tmp/isocontents", "/home/myuser/downloads/debian-11.iso")
|
|
|
|
"""
|
|
|
|
if "~" in str(path_to_output_dir):
|
|
path_to_output_dir = Path(path_to_output_dir).expanduser()
|
|
path_to_output_dir = Path(path_to_output_dir).resolve()
|
|
|
|
if "~" in str(path_to_input_file):
|
|
path_to_input_file = Path(path_to_input_file).expanduser()
|
|
path_to_input_file = Path(path_to_input_file).resolve()
|
|
|
|
# check if paths are valid
|
|
if not path_to_output_dir.is_dir():
|
|
raise NotADirectoryError(f"No such directory: '{path_to_output_dir}'.")
|
|
if not path_to_input_file.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_input_file}'.")
|
|
|
|
# extract file to destination
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"xorriso",
|
|
"-osirrox", "on",
|
|
"-indev", path_to_input_file,
|
|
"-extract", "/",
|
|
path_to_output_dir
|
|
],
|
|
capture_output=True,
|
|
check=True
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
raise RuntimeError(
|
|
f"An error occurred while extracting '{path_to_input_file}'."
|
|
)
|
|
|
|
|
|
def append_file_contents_to_initrd_archive(
|
|
path_to_initrd_archive,
|
|
path_to_input_file
|
|
):
|
|
"""Appends the input file to the specified initrd archive.
|
|
|
|
The initrd archive is extracted, the input file is appended, and
|
|
the initrd is repacked again.
|
|
Source: https://wiki.debian.org/DebianInstaller/Preseed/EditIso#Adding_a_Preseed_File_to_the_Initrd
|
|
|
|
Parameters
|
|
----------
|
|
path_to_initrd_archive : str or pathlike object
|
|
Path the the initrd archive to which the input file shall be
|
|
appended. The initrd file must be called 'initrd.gz'.
|
|
path_to_input_file : str or pathlike object
|
|
Path to the input file which shall be added to the initrd
|
|
archive.
|
|
|
|
Raises
|
|
------
|
|
AssertionError
|
|
Thrown if the initrd archive is not named 'initrd.gz'.
|
|
FileNotFoundError
|
|
Thrown if the initrd archive file or input file does not
|
|
exist.
|
|
|
|
Examples
|
|
--------
|
|
append_file_contents_to_initrd_archive(
|
|
"/tmp/isofiles/install.386/initrd.gz",
|
|
"/tmp/preseed.cfg")
|
|
|
|
"""
|
|
|
|
if "~" in str(path_to_initrd_archive):
|
|
path_to_initrd_archive = Path(path_to_initrd_archive).expanduser()
|
|
path_to_initrd_archive = Path(path_to_initrd_archive).resolve()
|
|
|
|
if "~" in str(path_to_input_file):
|
|
path_to_input_file = Path(path_to_input_file).expanduser()
|
|
path_to_input_file = Path(path_to_input_file).resolve()
|
|
|
|
# check if initrd file exists and has the correct name
|
|
if not path_to_initrd_archive.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_initrd_archive}'.")
|
|
if not path_to_initrd_archive.name == "initrd.gz":
|
|
raise AssertionError(f"Does not seem to be an initrd.gz archive: "
|
|
f"'{path_to_initrd_archive.name}'.")
|
|
|
|
# check if input file exists
|
|
if not path_to_input_file.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_input_file}'.")
|
|
|
|
# make archive and its parent directory temporarily writable
|
|
path_to_initrd_archive.chmod(0o644)
|
|
path_to_initrd_archive.parent.chmod(0o755)
|
|
|
|
path_to_initrd_extracted = path_to_initrd_archive.with_suffix("")
|
|
|
|
# extract archive in-place
|
|
with gzip.open(path_to_initrd_archive, "rb") as file_gz:
|
|
with open(path_to_initrd_extracted, "wb") as file_raw:
|
|
shutil.copyfileobj(file_gz, file_raw)
|
|
path_to_initrd_archive.unlink()
|
|
|
|
try:
|
|
# append contents of input_file to extracted archive using cpio
|
|
# NOTE cpio must be called from within the input file's parent
|
|
# directory, and the input file's name is piped into it
|
|
completed_process = subprocess.Popen(
|
|
["cpio", "-H", "newc", "-o", "-A",
|
|
"-F", str(path_to_initrd_extracted.resolve())],
|
|
cwd=path_to_input_file.resolve().parent,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
completed_process.communicate(
|
|
input=str(path_to_input_file.name).encode())
|
|
|
|
except subprocess.CalledProcessError:
|
|
raise RuntimeError(f"Failed while appending contents of "
|
|
f"'{path_to_input_file}' to "
|
|
f"'{path_to_initrd_archive}'.")
|
|
|
|
# repack archive
|
|
with gzip.open(path_to_initrd_archive, "wb") as file_gz:
|
|
with open(path_to_initrd_extracted, "rb") as file_raw:
|
|
shutil.copyfileobj(file_raw, file_gz)
|
|
path_to_initrd_extracted.unlink()
|
|
|
|
# revert write permissions from repacked archive and its parent dir
|
|
path_to_initrd_archive.chmod(0o444)
|
|
path_to_initrd_archive.parent.chmod(0o555)
|
|
|
|
|
|
def regenerate_iso_md5sums_file(path_to_extracted_iso_root):
|
|
"""Recalculates and rewrites the md5sum.txt file for the extracted ISO.
|
|
|
|
Source: https://wiki.debian.org/DebianInstaller/Preseed/EditIso#Regenerating_md5sum.txt
|
|
|
|
Parameters
|
|
----------
|
|
path_to_extracted_iso_root : str or pathlike object
|
|
Path to the root folder containing an extracted ISO's
|
|
contents.
|
|
|
|
Raises
|
|
------
|
|
RuntimeError
|
|
Raised if the recalculation/rewrite operation fails.
|
|
NotADirectoryError
|
|
Raised if the specified directory is not a directory or does
|
|
not exist.
|
|
|
|
Examples
|
|
--------
|
|
regenerate_iso_md5sums_file("/tmp/extracted_iso")
|
|
|
|
"""
|
|
|
|
if "~" in str(path_to_extracted_iso_root):
|
|
path_to_extracted_iso_root = Path(path_to_extracted_iso_root).expanduser()
|
|
path_to_extracted_iso_root = Path(path_to_extracted_iso_root).resolve()
|
|
|
|
# check if input path exists
|
|
if not path_to_extracted_iso_root.is_dir():
|
|
raise NotADirectoryError(
|
|
f"No such directory: '{path_to_extracted_iso_root}'."
|
|
)
|
|
|
|
path_to_md5sum_file = path_to_extracted_iso_root/"md5sum.txt"
|
|
|
|
# make md5sum file and its parent dir temporarily writable
|
|
path_to_md5sum_file.chmod(0o644)
|
|
path_to_md5sum_file.parent.chmod(0o755)
|
|
|
|
# remove original md5sum.txt
|
|
path_to_md5sum_file.unlink()
|
|
|
|
# create a new md5sum file:
|
|
# structure: '<md5_hash> path/to/file/relative/to/iso_root'
|
|
# with one line per file, for each file anywhere under the ISO root folder.
|
|
# Note the two spaces between hash and filepath!
|
|
|
|
# find all files
|
|
subpaths = find_all_files_under(path_to_extracted_iso_root)
|
|
|
|
with open(path_to_md5sum_file, "w") as md5sum_file:
|
|
for subpath in subpaths:
|
|
md5hash = hashlib.md5()
|
|
with open(subpath, "rb") as file:
|
|
# calculate md5 hash
|
|
md5hash.update(file.read())
|
|
md5sum_file.write(
|
|
md5hash.hexdigest()
|
|
+ " "
|
|
+ str(subpath.relative_to(path_to_extracted_iso_root))
|
|
+ "\n"
|
|
)
|
|
|
|
# revert write permissions from md5sum.txt and its parent dir
|
|
path_to_md5sum_file.chmod(0o444)
|
|
path_to_md5sum_file.parent.chmod(0o555)
|
|
|
|
|
|
def extract_mbr_from_iso(path_to_output_file, path_to_source_iso):
|
|
"""Extracts the MBR-data from the ISO and writes it into the outputfile.
|
|
|
|
The source ISO file must be a BIOS-bootable '.iso'- or
|
|
'.img'-file. You should use a "vanilla" debian installation ISO as
|
|
the source file.
|
|
|
|
Source: https://wiki.debian.org/RepackBootableISO#Determine_those_options_which_need_to_be_adapted_on_amd64_or_i386
|
|
|
|
Parameters
|
|
----------
|
|
path_to_output_file : str or pathlike object
|
|
Path to the file which will be created and contain the MBR
|
|
data.
|
|
path_to_source_iso : str or pathlike object
|
|
Path to the source ISO whose MBR data will get extracted.
|
|
|
|
Raises
|
|
------
|
|
RuntimeError
|
|
Raised if the input ISO has the wrong file extension.
|
|
FileNotFoundError
|
|
Raised if the input ISO does not exist or is not a file.
|
|
FileExistsError
|
|
Raised if the output file already exists.
|
|
|
|
Examples
|
|
--------
|
|
extract_mbr_from_iso("/tmp/mbr-data.bin", "/tmp/debian-11.0.4-netinst.iso")
|
|
|
|
"""
|
|
|
|
if "~" in str(path_to_output_file):
|
|
path_to_output_file = Path(path_to_output_file).expanduser()
|
|
path_to_output_file = Path(path_to_output_file).resolve()
|
|
|
|
if "~" in str(path_to_source_iso):
|
|
path_to_source_iso = Path(path_to_source_iso).expanduser()
|
|
path_to_source_iso = Path(path_to_source_iso)
|
|
|
|
# make sure output file does not exist already
|
|
if path_to_output_file.exists():
|
|
raise FileExistsError(
|
|
f"Outputfile exists and would get overwritten: "
|
|
f"'{path_to_output_file}'.")
|
|
|
|
# make sure input file exists and has the right extension
|
|
if not path_to_source_iso.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_source_iso}'.")
|
|
if path_to_source_iso.suffix not in [".iso", ".img"]:
|
|
raise RuntimeError(
|
|
f"Input file is not an image file: '{path_to_source_iso}'.")
|
|
|
|
# extract the MBR (first 432 Bytes) of the source ISO file
|
|
with open(path_to_source_iso, mode="r+b") as iso_file:
|
|
with open(path_to_output_file, mode="w+b") as mbr_file:
|
|
mbr_file.write(iso_file.read(432))
|
|
|
|
|
|
def repack_iso(path_to_output_iso,
|
|
path_to_mbr_data_file,
|
|
path_to_input_files_root_dir,
|
|
created_iso_filesystem_name):
|
|
"""Rebuilds a bootable ISO image using the input files.
|
|
|
|
The input files root directory contains the contents of a previously
|
|
extracted ISO file, with its contents possibly modified.
|
|
The MBR data file used should contain MBR data extracted from the
|
|
originially extracted ISO.
|
|
The given filesystem name written into the modified ISO appears when
|
|
the ISO gets mounted. It may only contain alphanumeric characters,
|
|
hyphens, underscores or periods.
|
|
|
|
Source: https://wiki.debian.org/RepackBootableISO#Determine_those_options_which_need_to_be_adapted_on_amd64_or_i386
|
|
|
|
Parameters
|
|
----------
|
|
path_to_output_iso : str or pathlike object
|
|
Path to the file as which the created ISO file will be saved.
|
|
path_to_mbr_data_file : str or pathlike object
|
|
Path to an existing file containing MBR data.
|
|
path_to_input_files_root_dir : str or pathlike object
|
|
Path to the root directory of those files which will be repacked into
|
|
the new ISO file.
|
|
created_iso_filesystem_name : str
|
|
Name of the filesystem which the created ISO will have upon
|
|
mounting it.
|
|
|
|
Raises
|
|
------
|
|
RuntimeError
|
|
Raised if the ISO packing process fails.
|
|
NotADirectoryError
|
|
Raised if the specified input files root directory does not
|
|
exist or is not a directory.
|
|
FileNotFoundError
|
|
Raised if the MBR data file does not exist or is not a file.
|
|
FileExistsError
|
|
Raised if the output file already exists.
|
|
|
|
Examples
|
|
--------
|
|
repack_iso("/tmp/debian-11.0.4-modified.iso",
|
|
"/tmp/mbr-data.bin",
|
|
"/tmp/extracted-iso",
|
|
"Debian 11.0.4 installation image")
|
|
|
|
"""
|
|
|
|
if "~" in str(path_to_output_iso):
|
|
path_to_output_iso = Path(path_to_output_iso).expanduser()
|
|
path_to_output_iso = Path(path_to_output_iso).resolve()
|
|
|
|
if "~" in str(path_to_mbr_data_file):
|
|
path_to_mbr_data_file = Path(path_to_mbr_data_file).expanduser()
|
|
path_to_mbr_data_file = Path(path_to_mbr_data_file).resolve()
|
|
|
|
if "~" in str(path_to_input_files_root_dir):
|
|
path_to_input_files_root_dir = Path(path_to_input_files_root_dir).expanduser()
|
|
path_to_input_files_root_dir = Path(path_to_input_files_root_dir).resolve()
|
|
|
|
# make sure output file does not exist yet
|
|
if path_to_output_iso.exists():
|
|
raise FileExistsError(f"Existing file would get overwritten: "
|
|
f"'{path_to_output_iso}'.")
|
|
|
|
# make sure input files exist
|
|
if not path_to_mbr_data_file.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_mbr_data_file}'.")
|
|
if not path_to_input_files_root_dir.is_dir():
|
|
raise NotADirectoryError(f"No such directory: "
|
|
f"'{path_to_input_files_root_dir}'.")
|
|
|
|
# make sure specified filesystem name contains no illegal characters:
|
|
# only alphanumeric, ' ', '.', '_' and '-' are allowed.
|
|
filesystem_name_invalid_char_regex = re.compile(r"[^\w .-]")
|
|
invalid_char_match = filesystem_name_invalid_char_regex.search(
|
|
created_iso_filesystem_name)
|
|
if invalid_char_match is not None:
|
|
raise RuntimeError(f"Invalid character in filesystem name: "
|
|
f"'{invalid_char_match.group()[0]}'.")
|
|
|
|
# repack the ISO using xorriso
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"xorriso", "-as", "mkisofs",
|
|
"-r", "-V", created_iso_filesystem_name,
|
|
"-o", path_to_output_iso,
|
|
"-J", "-J", "-joliet-long", "-cache-inodes",
|
|
"-isohybrid-mbr", path_to_mbr_data_file,
|
|
"-b", "isolinux/isolinux.bin",
|
|
"-c", "isolinux/boot.cat",
|
|
"-boot-load-size", "4", "-boot-info-table", "-no-emul-boot",
|
|
"-eltorito-alt-boot",
|
|
"-e", "boot/grub/efi.img", "-no-emul-boot",
|
|
"-isohybrid-gpt-basdat", "-isohybrid-apm-hfsplus",
|
|
path_to_input_files_root_dir,
|
|
],
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
|
|
except subprocess.CalledProcessError:
|
|
raise RuntimeError(f"Failed while repacking ISO from source files: "
|
|
f"'{path_to_input_files_root_dir}'.")
|
|
|
|
|
|
def inject_preseed_file_into_iso(
|
|
path_to_output_iso_file,
|
|
path_to_input_iso_file,
|
|
path_to_input_preseed_file,
|
|
iso_filesystem_name="Debian",
|
|
printer=None,
|
|
):
|
|
"""Injects the specified preseed file into the specified ISO file.
|
|
|
|
Extracts the input ISO into a temporary directory, then extracts the input
|
|
ISO's MBR into a temporary file, then appends the preseed file to the
|
|
extracted ISO's initrd, then regenerates the extracted ISO's internal MD5
|
|
hash list and finally repacks the extracted ISO into the output ISO.
|
|
|
|
The input ISO file itself is left unchanged.
|
|
The output ISO file is newly created.
|
|
|
|
Parameters
|
|
----------
|
|
path_to_output_iso_file : str or pathlike object
|
|
Path to which the resulting ISO file will be saved.
|
|
path_to_input_iso_file : str or pathlike object
|
|
Path to the origin ISO file.
|
|
path_to_input_preseed_file : str or pathlike object
|
|
Path to the input preseed file.
|
|
printer : clibella.Printer
|
|
A printer for CLI output.
|
|
"""
|
|
|
|
# verify and resolve paths
|
|
if "~" in str(path_to_input_iso_file):
|
|
path_to_input_iso_file = Path(path_to_input_iso_file).expanduser()
|
|
path_to_input_iso_file = Path(path_to_input_iso_file).resolve()
|
|
if not path_to_input_iso_file.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_input_iso_file}'.")
|
|
|
|
if "~" in str(path_to_output_iso_file):
|
|
path_to_output_iso_file = Path(path_to_output_iso_file).expanduser()
|
|
path_to_output_iso_file = Path(path_to_output_iso_file).resolve()
|
|
if path_to_output_iso_file.is_file():
|
|
raise FileExistsError(f"Output file exists: '{path_to_input_iso_file}'.")
|
|
if not path_to_output_iso_file.parent.is_dir():
|
|
raise NotADirectoryError(f"No such directory: '{path_to_output_iso_file.parent}'.")
|
|
|
|
if "~" in str(path_to_input_preseed_file):
|
|
path_to_input_preseed_file = Path(path_to_input_preseed_file).expanduser()
|
|
path_to_input_preseed_file = Path(path_to_input_preseed_file).resolve()
|
|
if not path_to_input_preseed_file.is_file():
|
|
raise FileNotFoundError(f"No such file: '{path_to_input_preseed_file}'.")
|
|
|
|
if printer is None:
|
|
p = Printer()
|
|
else:
|
|
if not isinstance(printer, Printer):
|
|
raise TypeError(f"Expected a {type(Printer)} object.")
|
|
p = printer
|
|
|
|
# extract image file to a temporary directory
|
|
temp_extracted_iso_dir = TemporaryDirectory()
|
|
path_to_extracted_iso_dir = Path(temp_extracted_iso_dir.name)
|
|
p.info(f"Extracting contents of {path_to_input_iso_file.name}...")
|
|
extract_iso(
|
|
path_to_extracted_iso_dir,
|
|
path_to_input_iso_file
|
|
)
|
|
p.ok("ISO extraction complete.")
|
|
|
|
# extract ISO MBR into a temporary directory
|
|
p.info(f"Extracting MBR from {path_to_input_iso_file.name}...")
|
|
temp_mbr_dir = TemporaryDirectory()
|
|
path_to_mbr_dir = Path(temp_mbr_dir.name)
|
|
path_to_mbr_file = path_to_mbr_dir/"mbr.bin"
|
|
extract_mbr_from_iso(
|
|
path_to_mbr_file,
|
|
path_to_input_iso_file,
|
|
)
|
|
p.ok("MBR extraction complete.")
|
|
|
|
# create a correctly named copy of the input preseed file and append it
|
|
# to the extracted ISO's initrd
|
|
p.info(f"Appending {path_to_input_preseed_file.name} to initrd...")
|
|
temp_preseed_dir = TemporaryDirectory()
|
|
path_to_preseed_dir = Path(temp_preseed_dir.name)
|
|
path_to_preseed_file = path_to_preseed_dir/"preseed.cfg"
|
|
shutil.copy(path_to_input_preseed_file, path_to_preseed_file)
|
|
append_file_contents_to_initrd_archive(
|
|
path_to_extracted_iso_dir/"install.amd"/"initrd.gz",
|
|
path_to_preseed_file
|
|
)
|
|
p.ok("Preseed file appended successfully.")
|
|
|
|
# regenerate extracted ISO's md5sum.txt file
|
|
p.info("Regenerating MD5 checksums...")
|
|
regenerate_iso_md5sums_file(path_to_extracted_iso_dir)
|
|
p.ok("MD5 calculations complete.")
|
|
|
|
# repack exctracted ISO into a single file
|
|
p.info("Repacking ISO...")
|
|
repack_iso(
|
|
path_to_output_iso_file,
|
|
path_to_mbr_file,
|
|
path_to_extracted_iso_dir,
|
|
iso_filesystem_name
|
|
)
|
|
p.success(f"ISO file was created successfully at '{path_to_output_iso_file}'.")
|
|
|
|
# clear out temporary directories
|
|
temp_preseed_dir.cleanup()
|
|
temp_mbr_dir.cleanup()
|
|
temp_extracted_iso_dir.cleanup()
|