#!/usr/bin/env bash set -Eeuo pipefail getVersions() { local xml="$1" local versions_name="$2" local bases_name="$3" local groups_name="$4" local indexes_name="$5" local -n bases_ref="$bases_name" local -n groups_ref="$groups_name" local -n indexes_ref="$indexes_name" local -n versions_ref="$versions_name" local platform image_count records record_count=0 local image_index display product image edition_id local install_type flags candidate candidate_id local candidate_base evaluation key name structured local separator=$'\x1f' bases_ref=() groups_ref=() indexes_ref=() versions_ref=() platform=$(getPlatform "$xml") || return 1 image_count=$(xmlstarlet sel -T -t -v 'count(/WIM/IMAGE)' - 2>/dev/null <<< "$xml") || return 1 if [[ ! "$image_count" =~ ^[0-9]+$ ]]; then error "Invalid image count in WIM metadata: '$image_count'" return 1 fi (( image_count > 0 )) || return 0 # Keep one compact record per image. XML 1.0 cannot contain U+001F, so it # can safely separate fields while all edition logic remains in Bash. if ! records=$(xmlstarlet sel \ -T -t \ -m '/WIM/IMAGE' \ -v 'normalize-space(@INDEX)' -o "$separator" \ -v 'normalize-space(DISPLAYNAME)' -o "$separator" \ -v 'normalize-space(WINDOWS/PRODUCTNAME)' -o "$separator" \ -v 'normalize-space(NAME)' -o "$separator" \ -v 'normalize-space(WINDOWS/EDITIONID)' -o "$separator" \ -v 'normalize-space(WINDOWS/INSTALLATIONTYPE)' -o "$separator" \ -v 'normalize-space(FLAGS)' -n \ - 2>/dev/null <<< "$xml"); then error "Failed to read image records from WIM metadata!" return 1 fi while IFS="$separator" read -r image_index display product image edition_id install_type flags; do ((record_count += 1)) [ -n "$image_index" ] || continue if [[ ! "$image_index" =~ ^[1-9][0-9]*$ ]]; then warn "Invalid image index in WIM metadata: '$image_index'" continue fi candidate_id="" candidate_base="" # NAME normally contains the most precise edition identifier (including # Server Core), while DISPLAYNAME is the best fallback for other images. for candidate in "$image" "$display" "$product"; do [[ "$candidate" == *"Operating System"* ]] && continue [ -n "$candidate" ] || continue candidate_base=$(fromName "$candidate" "$platform") candidate_id=$(getVersion "$candidate" "$platform") [ -n "$candidate_base" ] && [ -n "$candidate_id" ] && break done # Preserve NAME precedence, but allow a recognized DISPLAYNAME edition to # refine an otherwise generic family name. if [ -n "$candidate_base" ] && [ -n "$candidate_id" ] && [ -n "$display" ]; then name=$(normalizeEdition "$(printVersion "$candidate_base" "")") || return 1 candidate=$(normalizeEdition "$candidate") || return 1 if [ "$candidate" = "$name" ]; then structured=$(getVersion "$display" "$platform") if [ "$(fromName "$display" "$platform")" = "$candidate_base" ] && [ -n "$structured" ] && [[ "${structured%-eval}" != "$candidate_base" ]] && [[ "$(getVersionPriority "$structured" "$candidate_base")" != "other" ]]; then candidate_id="$structured" fi fi fi if [ -z "$candidate_base" ] || [ -z "$candidate_id" ]; then name="${display:-${image:-$product}}" [ -n "$name" ] && warn "Unknown image name: '$name'" continue fi evaluation="" if [[ "${image,,}" == *"evaluation"* || "${display,,}" == *"evaluation"* || "${product,,}" == *"evaluation"* || "${edition_id,,}" == *"eval"* || "${flags,,}" == *"eval"* ]]; then evaluation="-eval" fi if [ -n "$evaluation" ] && [[ "${candidate_id,,}" != *"-eval" ]]; then candidate_id+="$evaluation" fi key="${candidate_id,,}" # Some client media use the same friendly name-derived ID for distinct # editions. Preserve the established unsuffixed Pro ID, and use the # structured edition metadata only to disambiguate a collision. if [[ -v "indexes_ref[$key]" ]]; then structured="" case "${candidate_base,,}" in "winvista"* | "win7"* | "win8"* | "win10"* | "win11"* ) structured=$(normalizeEditionID "${edition_id:-${flags:-}}" "$candidate_base") || return 1 ;; "win20"* ) structured=$(normalizeServerEditionID "${flags:-$edition_id}") || return 1 # Some media use the same EDITIONID for Core and Desktop images. # INSTALLATIONTYPE provides the structural distinction without # requiring a hardcoded marketing name. if [[ "${install_type,,}" == *"core"* && "$structured" != *"-core" ]]; then structured+="-core" fi ;; esac if [ -n "$structured" ]; then candidate_id="$candidate_base-$structured$evaluation" key="${candidate_id,,}" fi fi if [[ -v "indexes_ref[$key]" ]]; then warn "Duplicate image identity '$candidate_id' at indexes ${indexes_ref[$key]} and $image_index" continue fi indexes_ref["$key"]="$image_index" versions_ref+=( "$candidate_id" ) bases_ref+=( "$candidate_base" ) groups_ref+=( "$(getVersionPriority "$candidate_id" "$candidate_base")" ) done <<< "$records" if (( record_count != image_count )); then error "Expected $image_count image records in WIM metadata, found $record_count!" return 1 fi return 0 } getCompatibleVersions() { local wanted="$1" printf '%s\n' "$wanted" # Treat normal and Evaluation variants of the same edition as compatible. # The exact requested variant is always checked first. if [[ "${wanted,,}" == *"-eval" ]]; then printf '%s\n' "${wanted%-eval}" else printf '%s\n' "$wanted-eval" fi } selectVersion() { local versions_name="$1" local indexes_name="$2" local preferred_name="$3" local result_name="$4" local index_name="$5" local -a candidates=() local -n index_map="$indexes_name" local -n version_list="$versions_name" local -n selected_version="$result_name" local -n preference_list="$preferred_name" local -n selected_image_index="$index_name" local wanted candidate match # A detected edition is only selectable when a matching answer file can # actually be staged for it. for wanted in "${preference_list[@]}"; do [ -n "$wanted" ] || continue mapfile -t candidates < <(getCompatibleVersions "$wanted") for candidate in "${candidates[@]}"; do match=$(hasVersion "$candidate" "${version_list[@]}") || continue hasAnswerFile "$match" || continue local key="${match,,}" selected_version="$match" selected_image_index="${index_map[$key]}" return 0 done done return 1 } selectEdition() { local versions_name="$1" local bases_name="$2" local groups_name="$3" local indexes_name="$4" local suggested="$5" local result_name="$6" local index_name="$7" local normalize_name="$8" local order_name="$9" local -A seen=() local -a preferred=() local -n edition_bases="$bases_name" local -n edition_order="$order_name" local -n edition_groups="$groups_name" local -n edition_versions="$versions_name" local base edition entry suffix priority i # Selection precedence is explicit EDITION, source suggestion, canonical # edition order, then noncanonical editions from the same priority groups. if [ -n "$EDITION" ]; then for base in "${edition_bases[@]}"; do edition=$("$normalize_name" "$EDITION" "$base") || return 1 preferred+=("$base${edition:+-$edition}") done if selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name"; then return 0 fi warn "edition '$EDITION' is not supported by this image, using automatic selection instead." fi if [ -n "$suggested" ]; then preferred=("$suggested") if selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name"; then return 0 fi fi # First try each canonical edition in its configured order. preferred=() for entry in "${edition_order[@]}"; do IFS='|' read -r suffix _ _ <<< "$entry" for base in "${edition_bases[@]}"; do preferred+=("$base$suffix") done done if selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name"; then return 0 fi # Then try noncanonical editions from the same preference groups. seen=() preferred=() for entry in "${edition_order[@]}"; do IFS='|' read -r _ priority _ <<< "$entry" [[ -v "seen[$priority]" ]] && continue seen["$priority"]="Y" for ((i=0;i<${#edition_versions[@]};i++)); do [[ "${edition_groups[$i]}" == "$priority" ]] || continue preferred+=("${edition_versions[$i]}") done done selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name" } detectVersion() { local xml="$1" local suggested="${2:-}" local -a bases=() local -a groups=() local -a versions=() local -A image_indexes=() local -a selection_order=() local result="" index="" getVersions "$xml" versions bases groups image_indexes || return 1 if [ "${#versions[@]}" -eq 0 ]; then printf '%s\n%s\n' "$result" "$index" return 0 fi local normalize="normalizeEditionID" case "${bases[0],,}" in "win20"* ) normalize="normalizeServerEditionID" ;; esac mapfile -t selection_order < <(getEditionOrder "${bases[0]}") if selectEdition versions bases groups image_indexes "$suggested" result index "$normalize" selection_order; then printf '%s\n%s\n' "$result" "$index" return 0 fi # Keep the first detected image identity when no edition with a usable answer # file was found, so manual and generic fallback handling can still continue. result="${versions[0]}" local key="${result,,}" index="${image_indexes[$key]}" printf '%s\n%s\n' "$result" "$index" return 0 } getVersionPriority() { local id="${1,,}" local base="${2,,}" local entry priority patterns pattern local result="other" score best_score=-1 local -a order=() id="${id%-eval}" mapfile -t order < <(getEditionOrder "$base") local edition="${id#"$base"}" edition="${edition#-}" # Use the most specific matching pattern. This prevents broad patterns # such as enterprise-* from taking precedence over enterprise-iot-*. for entry in "${order[@]}"; do IFS='|' read -r _ priority patterns <<< "$entry" for pattern in $patterns; do if [ "$pattern" = "@default" ]; then [ -z "$edition" ] || continue score=1 elif [[ "$pattern" == *"*" ]]; then local prefix="${pattern%\*}" [[ "$edition" == "$prefix"* ]] || continue score="${#pattern}" elif [ "$edition" = "$pattern" ]; then score="${#pattern}" else continue fi if (( score > best_score )); then result="$priority" best_score="$score" fi done done echo "$result" return 0 } detectLanguage() { local xml="$1" local index="${2:-}" local -a paths=() local lang culture path local image='/WIM/IMAGE' if [[ "$index" =~ ^[1-9][0-9]*$ ]]; then image="/WIM/IMAGE[@INDEX='$index']" fi # Prefer the selected image's default language, then its fallback default, # and finally the first listed language. paths=( "$image/WINDOWS/LANGUAGES/DEFAULT[1]" "$image/WINDOWS/LANGUAGES/FALLBACK/DEFAULT[1]" "$image/WINDOWS/LANGUAGES/LANGUAGE[1]" ) lang="" for path in "${paths[@]}"; do lang=$(xmlstarlet sel -T -t -v "normalize-space(string(($path)[1]))" - 2>/dev/null <<< "$xml") || lang="" [ -n "$lang" ] && break done if [ -z "$lang" ]; then warn "Language could not be detected from ISO!" return 0 fi culture=$(getLanguage "$lang" "culture") || return 1 if [ -n "$culture" ]; then LANGUAGE="$lang" return 0 fi warn "Invalid language detected: \"$lang\"" return 0 } getImageSize() { local stage="$1" local folder="${2:-}" local size bytes path output local required large_file local mib=$((1024 * 1024)) local minimum=$((64 * mib)) local payload=0 paths=("$stage") if [ ! -d "$stage" ]; then error "Failed to find setup directory: $stage" return 1 fi [ -n "$folder" ] && paths+=("$folder") # The setup image uses FAT32, so reject files that cannot be represented even # when the image itself has enough free space. large_file=$(find -L "${paths[@]}" -type f -size +4294967295c -print -quit) || return 1 if [ -n "$large_file" ]; then error "Setup file exceeds the FAT32 limit: $large_file" return 1 fi for path in "${paths[@]}"; do if ! output=$(du -Llsb --apparent-size -- "$path"); then error "Failed to calculate setup size!" return 1 fi if ! read -r bytes _ <<< "$output" || [[ ! "$bytes" =~ ^[0-9]+$ ]]; then error "Failed to calculate setup size!" return 1 fi payload=$((payload + bytes)) done # Reserve generous filesystem and directory overhead, then round up to a # power-of-two image size with a 64 MiB minimum. size="$minimum" required=$((payload + ((payload + 3) / 4) + (32 * mib))) while ((size < required)); do size=$((size * 2)) done printf '%s\n' "$size" } hasVersion() { local wanted="$1" shift local actual for actual in "$@"; do [[ "${actual,,}" == "${wanted,,}" ]] || continue echo "$actual" return 0 done return 1 } checkPlatform() { local xml="$1" local platform platform=$(getPlatform "$xml") || return 1 case "${platform,,}" in "mixed" ) error "Windows images with mixed architectures are not supported!" return 1 ;; "x86" | "x64" | "arm64" ) ;; * ) error "Unsupported Windows image architecture: ${platform:-unknown}" return 1 ;; esac case "${PLATFORM,,}" in "x64" ) if [[ "${platform,,}" == "x64" || "${platform,,}" == "x86" ]]; then return 0 fi ;; "arm64" ) if [[ "${platform,,}" == "arm64" ]]; then return 0 fi ;; * ) error "Unsupported container platform: $PLATFORM" return 1 ;; esac error "You cannot boot ${platform^^} images on a $PLATFORM CPU!" return 1 } getPlatform() { local xml="$1" local image_count invalid_count local x86 x64 arm64 unknown value local platform="" count=0 output local -a counts=() if ! output=$(xmlstarlet sel \ -T -t \ -v 'count(/WIM/IMAGE)' -n \ -v 'count(/WIM/IMAGE[count(WINDOWS/ARCH) != 1])' -n \ -v 'count(/WIM/IMAGE/WINDOWS/ARCH[normalize-space(.)="0"])' -n \ -v 'count(/WIM/IMAGE/WINDOWS/ARCH[normalize-space(.)="9"])' -n \ -v 'count(/WIM/IMAGE/WINDOWS/ARCH[normalize-space(.)="12"])' -n \ -v 'count(/WIM/IMAGE/WINDOWS/ARCH[normalize-space(.)!="0" and normalize-space(.)!="9" and normalize-space(.)!="12"])' -n \ - 2>/dev/null <<< "$xml"); then error "Failed to read architecture metadata from WIM image!" return 1 fi mapfile -t counts <<< "$output" if (( ${#counts[@]} != 6 )); then error "Failed to read architecture counts from WIM metadata!" return 1 fi for value in "${counts[@]}"; do if [[ ! "$value" =~ ^[0-9]+$ ]]; then error "Invalid architecture count in WIM metadata: '$value'" return 1 fi done image_count="${counts[0]}" invalid_count="${counts[1]}" x86="${counts[2]}" x64="${counts[3]}" arm64="${counts[4]}" unknown="${counts[5]}" if (( image_count == 0 )); then error "No images were found in WIM metadata!" return 1 fi if (( invalid_count > 0 )); then error "Missing or duplicate architecture metadata in WIM image!" return 1 fi if (( unknown > 0 )); then error "Unsupported architecture value in WIM metadata!" return 1 fi (( x86 > 0 )) && ((count += 1)) (( x64 > 0 )) && ((count += 1)) (( arm64 > 0 )) && ((count += 1)) if (( count > 1 )); then platform="mixed" elif (( x86 > 0 )); then platform="x86" elif (( x64 > 0 )); then platform="x64" elif (( arm64 > 0 )); then platform="arm64" else error "Failed to determine architecture from WIM metadata!" return 1 fi echo "$platform" return 0 } canUseSetupImage() { local id="$1" local iso="$2" supportsXML "$id" || return 1 [[ "${iso,,}" == *".esd" ]] && return 1 return 0 } createImageDirectory() { local image="$1" local directory="$2" # Treat an existing directory as success; create it only when mdir cannot # already resolve it. if mdir -i "$image" "$directory" >/dev/null 2>&1; then return 0 fi mmd -i "$image" "$directory" >/dev/null } createSetupImage() { local stage="$1" local image="$2" local tmp="${image}.tmp" local target="::/\$OEM\$/\$1/OEM" local install="$stage/.overlay-install.bat" local folder size sectors entry find_pid local entries=() folder=$(getOemFolder) || return 1 if ! size=$(getImageSize "$stage" "$folder"); then return 1 fi sectors=$((size / 512)) local msg="Writing overlay image..." info "$msg" && html "$msg" # Build and verify a temporary FAT32 image before replacing the active setup # image, so a partial write never becomes boot media. rm -f -- "$tmp" || return 1 if ! mformat -i "$tmp" -C -F -T "$sectors" -v "SETUP" ::; then rm -f -- "$tmp" error "Failed to format setup image!" return 1 fi mapfile -d '' entries < <( find "$stage" -mindepth 1 -maxdepth 1 ! -name '.overlay-install.bat' -print0 ) # Process substitution hides the find status, so wait for it explicitly. find_pid=$! if ! wait "$find_pid"; then rm -f -- "$tmp" error "Failed to enumerate image files!" return 1 fi for entry in "${entries[@]}"; do if ! mcopy -Q -s -i "$tmp" "$entry" ::; then rm -f -- "$tmp" error "Failed to copy image file: $entry" return 1 fi done if [ -n "$folder" ] || [ -f "$install" ]; then if ! createImageDirectory "$tmp" "::/\$OEM\$" || ! createImageDirectory "$tmp" "::/\$OEM\$/\$1" || ! createImageDirectory "$tmp" "$target"; then rm -f -- "$tmp" error "Failed to create OEM directory in setup image!" return 1 fi fi if [ -n "$folder" ]; then mapfile -d '' entries < <( find "$folder" -mindepth 1 -maxdepth 1 -print0 ) # Preserve errors from the second process-substitution find as well. find_pid=$! if ! wait "$find_pid"; then rm -f -- "$tmp" error "Failed to enumerate OEM files!" return 1 fi for entry in "${entries[@]}"; do if ! mcopy -Q -s -o -i "$tmp" "$entry" "$target"; then rm -f -- "$tmp" error "Failed to copy OEM file: $entry" return 1 fi done fi # Copy the generated overlay script last so it replaces an install.bat from # the mounted OEM folder when both are present. if [ -f "$install" ]; then if ! mcopy -Q -o -i "$tmp" "$install" "$target/install.bat"; then rm -f -- "$tmp" error "Failed to replace install.bat in setup image!" return 1 fi fi # Verify that mtools can read the completed filesystem before publishing it. if ! mdir -i "$tmp" :: >/dev/null; then rm -f -- "$tmp" error "Failed to verify image!" return 1 fi if ! enabled "$MANUAL"; then local answer="$stage/Autounattend.xml" if [ ! -f "$answer" ] || [ ! -s "$answer" ]; then rm -f -- "$tmp" error "Failed to find staged answer file: $answer" return 1 fi # Ensure the answer file survived the FAT32 copy byte-for-byte. if ! mtype -i "$tmp" ::/Autounattend.xml | cmp -s - "$answer"; then rm -f -- "$tmp" error "Failed to verify staged answer file!" return 1 fi fi if ! mv -f -- "$tmp" "$image"; then rm -f -- "$tmp" error "Failed to save setup image: $image" return 1 fi if ! setOwner "$image"; then warn "Failed to set the owner for \"$image\" !" fi return 0 } detectLegacy() { local dir="$1" local marker [[ "${PLATFORM,,}" == "x64" ]] || return 1 # Legacy media is identified from setup marker files rather than WIM # metadata. The order is intentional because several releases share markers. marker=$(find "$dir" -maxdepth 1 -type d -iname 'ia64' -print -quit) || return 1 if [ -n "$marker" ]; then error "Windows IA-64 (Itanium) images are not supported by this container!" return 1 fi marker=$(find "$dir" -maxdepth 1 -type d -iname WIN95 -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="win95" return 0 fi marker=$(find "$dir" -maxdepth 1 -type d -iname WIN98 -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="win98" return 0 fi marker=$(find "$dir" -maxdepth 1 -type d -iname WIN9X -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="win9x" return 0 fi marker=$(find "$dir" -maxdepth 1 -type f \ \( \ -iname CDROM_W.40 -o \ -iname CDROM_S.40 -o \ -iname CDROM_TS.40 \ \) \ -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="winnt4" return 0 fi marker=$(find "$dir" -maxdepth 1 -type f -iname CDROM_NT.5 -print -quit) || return 1 if [ -n "$marker" ]; then marker=$(find "$dir" -maxdepth 1 -type f \ \( \ -iname CDROM_IA.5 -o \ -iname CDROM_ID.5 -o \ -iname CDROM_IP.5 -o \ -iname CDROM_IS.5 \ \) \ -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="win2k" return 0 fi fi # WIN51 identifies the NT 5.1/5.2 media family; the companion marker then # distinguishes XP x86, XP x64, and Server 2003. marker=$(find "$dir" -maxdepth 1 -iname WIN51 -print -quit) || return 1 [ -n "$marker" ] || return 1 marker=$(find "$dir" -maxdepth 1 -type f -iname WIN51AP -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="winxpx64" return 0 fi marker=$(find "$dir" -maxdepth 1 -type f \ \( \ -iname WIN51IC -o \ -iname WIN51IP -o \ -iname setupxp.htm \ \) \ -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="winxpx86" return 0 fi marker=$(find "$dir" -maxdepth 1 -type f \ \( \ -iname WIN51IS -o \ -iname WIN51IA -o \ -iname WIN51IB -o \ -iname WIN51ID -o \ -iname WIN51IL -o \ -iname WIN51AA -o \ -iname WIN51AD -o \ -iname WIN51AS -o \ -iname WIN51MA -o \ -iname WIN51MD -o \ -iname WIN51MP \ \) \ -print -quit) || return 1 if [ -n "$marker" ]; then DETECTED="win2003r2" return 0 fi return 1 } detectReactOS() { local dir="$1" local marker marker=$(find "$dir" -maxdepth 2 -type f \ \( -ipath '*/reactos/reactos.inf' -o -ipath '*/reactos/unattend.inf' \) -print -quit) || return 1 [ -n "$marker" ] || return 1 DETECTED="reactos" return 0 } findIsoImage() { local iso="$1" local path # Prefer install.wim when both payload forms are present. for path in /sources/install.wim /sources/install.esd; do if udfread stat --ignore-case "$iso" "$path" >/dev/null 2>&1; then printf '%s' "$path" return 0 fi done return 1 } readWimHeader() { local iso="$1" local image="$2" local size signature local header="$TMP/wim-header.bin" rm -f -- "$header" || return 1 # Read only the fixed WIM header so metadata can be located without # extracting install.wim or install.esd from the ISO. if ! udfread range --ignore-case -o "$header" "$iso" "$image" 0 208 >/dev/null 2>&1 || ! size=$(stat -c%s -- "$header") || (( size != 208 )) || ! signature=$(od -An -N8 -tx1 "$header" | tr -d ' \n') || [[ "$signature" != "4d5357494d000000" ]]; then rm -f -- "$header" return 1 fi echo "$header" return 0 } readIsoImageInfo() { local iso="$1" local image="$2" local header="$3" local raw result root xml_count local header_size version local part_number total_parts image_count local xml_offset xml_size xml_original xml_flags local -a bytes=() values=() [ -f "$header" ] || return 1 raw=$(od -An -v -N208 -tu1 -- "$header") || return 1 read -r -a bytes <<< "${raw//$'\n'/ }" (( ${#bytes[@]} == 208 )) || return 1 # Validate the MSWIM\0\0\0 signature. (( bytes[0] == 77 && bytes[1] == 83 && bytes[2] == 87 && bytes[3] == 73 && bytes[4] == 77 && bytes[5] == 0 && bytes[6] == 0 && bytes[7] == 0 )) || return 1 # Header size at offset 0x08. header_size=$(( \ bytes[8] | bytes[9] << 8 | bytes[10] << 16 | bytes[11] << 24 )) (( header_size == 208 )) || return 1 # WIM version at offset 0x0c. version=$(( \ bytes[12] | bytes[13] << 8 | bytes[14] << 16 | bytes[15] << 24 )) (( version == 0x10d00 || version == 0x0e00 )) || return 1 # Split-WIM information at offsets 0x28 and 0x2a. part_number=$((bytes[40] | bytes[41] << 8)) total_parts=$((bytes[42] | bytes[43] << 8)) (( part_number > 0 && total_parts > 0 && part_number <= total_parts )) || return 1 # Image count at offset 0x2c. image_count=$(( \ bytes[44] | bytes[45] << 8 | bytes[46] << 16 | bytes[47] << 24 )) (( image_count > 0 && image_count <= 65535 )) || return 1 result=$(parseWimHeader "$iso" "$image" "$header") || return 1 mapfile -t values <<< "$result" (( ${#values[@]} == 4 )) || return 1 xml_offset="${values[0]}" xml_size="${values[1]}" xml_original="${values[2]}" xml_flags="${values[3]}" [[ "$xml_offset" =~ ^[0-9]+$ && "$xml_size" =~ ^[0-9]+$ && "$xml_original" =~ ^[0-9]+$ && "$xml_flags" =~ ^[0-9]+$ ]] || return 1 (( xml_size > 0 && xml_original > 0 && xml_size == xml_original && xml_size % 2 == 0 )) || return 1 # These resource forms cannot be decoded as a direct UTF-16LE byte range: # # 0x04: compressed # 0x08: spanned # 0x10: solid # # The metadata flag 0x02 is expected and deliberately allowed. (( !(xml_flags & 0x1c) )) || return 1 result=$(udfread range --ignore-case "$iso" "$image" "$xml_offset" "$xml_size" \ 2>/dev/null | iconv -f UTF-16LE -t UTF-8 2>/dev/null ) || { local rc=$? if (( rc >= 129 )); then exit "$rc" fi return 1 } [ -n "$result" ] || return 1 local metadata separator=$'\x1f' metadata=$(xmlstarlet sel \ -T -t \ -v 'local-name(/*)' -o "$separator" \ -v 'count(/*[local-name()="WIM"]/*[local-name()="IMAGE"])' \ - 2>/dev/null <<< "$result") || return 1 IFS="$separator" read -r root xml_count <<< "$metadata" [ "$root" = "WIM" ] || return 1 [[ "$xml_count" =~ ^[0-9]+$ ]] || return 1 (( xml_count == image_count )) || return 1 printf '%s' "$result" return 0 } parseWimHeader() { local iso="$1" local image="$2" local header="$3" local -a bytes=() local header_size=0 local parsed_size=0 local parsed_flags=0 local parsed_offset=0 local parsed_original=0 local details image_size raw if [ ! -f "$header" ] || [ ! -s "$header" ]; then return 1 fi if ! raw=$(od -An -v -j8 -N88 -tu1 -- "$header"); then return 1 fi read -r -a bytes <<< "${raw//$'\n'/ }" if (( ${#bytes[@]} != 88 )); then return 1 fi local i # The WIM header size is a 32-bit little-endian value at offset 0x08. for ((i=3; i>=0; i--)); do header_size=$((header_size * 256 + bytes[i])) done if (( header_size != 208 )); then return 1 fi # The XML resource descriptor starts at offset 0x48. Its first seven bytes # contain the stored size and its eighth byte contains the resource flags. for ((i=70; i>=64; i--)); do parsed_size=$((parsed_size * 256 + bytes[i])) done parsed_flags="${bytes[71]}" # The XML resource offset is an unsigned 64-bit little-endian value at 0x50. if (( bytes[79] >= 128 )); then return 1 fi for ((i=79; i>=72; i--)); do parsed_offset=$((parsed_offset * 256 + bytes[i])) done # The uncompressed XML size is an unsigned 64-bit value at offset 0x58. if (( bytes[87] >= 128 )); then return 1 fi for ((i=87; i>=80; i--)); do parsed_original=$((parsed_original * 256 + bytes[i])) done if (( parsed_size <= 0 || parsed_offset < header_size || parsed_original <= 0 )); then return 1 fi if ! details=$(udfread stat --ignore-case "$iso" "$image" 2>/dev/null); then return 1 fi image_size=$(sed -n 's/^Size: \([0-9][0-9]*\) bytes$/\1/p' <<< "$details") if [[ ! "$image_size" =~ ^[0-9]+$ ]]; then return 1 fi if (( parsed_offset > image_size || parsed_size > image_size - parsed_offset )); then return 1 fi printf '%s\n' "$parsed_offset" "$parsed_size" "$parsed_original" "$parsed_flags" return 0 } findImage() { local dir="$1" local sources result sources=$(find "$dir" -maxdepth 1 -type d -iname sources -print -quit) || return 1 if [ ! -d "$sources" ]; then warn "failed to locate 'sources' folder in ISO image, $FB" return 1 fi result=$(find "$sources" -maxdepth 1 -type f \( -iname install.wim -or -iname install.esd \) -print -quit) || return 1 if [ ! -f "$result" ]; then warn "failed to locate 'install.wim' or 'install.esd' in ISO image, $FB" return 1 fi echo "$result" return 0 } readImageInfo() { local wim="$1" local result="" result=$(wimlib-imagex info -xml "$wim" | iconv -f UTF-16LE -t UTF-8) || { local rc=$? if (( rc >= 129 )); then exit "$rc" fi result="" } if [ -z "$result" ]; then warn "failed to read Windows image information, $FB" return 1 fi printf '%s' "$result" return 0 } getSuggestion() { [ -z "$CUSTOM" ] || return 0 [ -n "${REUSED_ISO:-}" ] || return 0 # A reused ISO may still correspond to the originally requested catalog # version, but the suggestion remains only a preference during detection. echo "${SUGGEST:-}" } validateEdition() { [ -n "$EDITION" ] || return 0 [[ "${DETECTED,,}" == "win20"* ]] || return 0 local edition edition=$(normalizeServerEditionID "$EDITION") || return 1 [ -n "$edition" ] || return 0 if [[ "${DETECTED,,}" == *"-${edition,,}" || "${DETECTED,,}" == *"-${edition,,}-eval" ]]; then return 0 fi # Discard a stale server-edition override when it conflicts with the image # that was actually detected. EDITION="" return 0 } unknownImage() { local msg="Failed to determine Windows version from image" # Unknown media can continue when a custom answer file or manual mode already # provides the required installation path; otherwise force manual fallback. if setXML "" || enabled "$MANUAL"; then info "${msg}!" else MANUAL="Y" warn "${msg}, $FB." fi return 0 } describeImage() { local info_xml="$1" local index="$2" local result result=$(printEdition "$DETECTED" "$DETECTED" "Y") || return 1 detectLanguage "$info_xml" "$index" || return 1 if [[ "${LANGUAGE,,}" != "en" && "${LANGUAGE,,}" != "en-"* ]]; then local language language=$(getLanguage "$LANGUAGE" "desc") || return 1 result+=" ($language)" fi printf '%s' "$result" return 0 } configureImage() { local index="$1" local desc="$2" # Prefer the exact answer file, then a family-level fallback. Manual mode is # the final supported path when neither can be generated. setXML "" "$index" && return 0 if [[ "$DETECTED" == "win81x86"* || "$DETECTED" == "win10x86"* ]]; then error "The 32-bit version of $desc is not supported!" exit 67 fi local msg="the answer file for $desc was not found ($DETECTED.xml)" local fallback="/run/assets/${DETECTED%%-*}.xml" if setXML "$fallback" "$index"; then if ! enabled "$MANUAL"; then warn "${msg}." fi return 0 fi enabled "$MANUAL" && return 0 MANUAL="Y" warn "${msg}, $FB." return 0 } detectImageInfo() { local image_info="$1" local desc suggested index checkPlatform "$image_info" || exit 67 suggested=$(getSuggestion) || return 1 local output output=$(detectVersion "$image_info" "$suggested") || return 1 local -a detected=() mapfile -t detected <<< "$output" DETECTED="${detected[0]:-}" index="${detected[1]:-}" validateEdition || return 1 if [ -z "$DETECTED" ]; then unknownImage || return 1 return 0 fi desc=$(describeImage "$image_info" "$index") || return 1 info "Detected: $desc" configureImage "$index" "$desc" || return 1 return 0 } detectIsoImage() { local iso="$1" local image header image_info # Return 1 when direct ISO inspection is unavailable so the caller may fall # back to extraction; return 2 when metadata was read but configuration failed. image=$(findIsoImage "$iso") || return 1 header=$(readWimHeader "$iso" "$image") || return 1 image_info=$(readIsoImageInfo "$iso" "$image" "$header") || return 1 info "Detecting version from ISO image..." detectImageInfo "$image_info" || return 2 return 0 } checkFreeSpace() { local dir="$1" local size="$2" local space size_gb space_gb space=$(df --output=avail -B 1 "$dir" | tail -n 1) || return 1 [[ "$space" =~ ^[[:space:]]*[0-9]+[[:space:]]*$ ]] || { error "Failed to determine available disk space for $dir!" return 1 } space="${space//[[:space:]]/}" if (( size > space )); then size_gb=$(formatBytes "$size") space_gb=$(formatBytes "$space") error "Not enough free space in $STORAGE, have $space_gb available but need at least $size_gb." return 1 fi return 0 } extractESD() { local iso="$1" local dir="$2" local version="$3" local desc="$4" local bootTotal bootLinks wimTotal wimLinks local installSize size edition imgEdition local bootWim installWim bootSize wimSize local image index line ret metadata count local result resultCount resultEdition xml local -a fields local minSize=100000000 local bootPad=60000000 local installPad=3000000 local spacePad=1073741824 local msg="Extracting bootdisk from ESD file" info "$msg..." && html "$msg..." if ! size=$(stat -c%s -- "$iso"); then error "Failed to determine size of ISO file \"$iso\" !" return 1 fi if (( size < minSize )); then error "The downloaded ISO file is too small!" return 1 fi if ! rm -rf -- "$dir"; then error "Failed to remove directory \"$dir\" !" return 1 fi if ! makeDir "$dir"; then error "Failed to create directory \"$dir\" !" return 1 fi if ! xml=$(wimlib-imagex info "$iso" --xml 2>/dev/null | iconv -f UTF-16LE -t UTF-8 2>/dev/null); then error "Cannot read ESD file information!" return 1 fi # Microsoft download ESDs use images 1-3 for setup media, WinPE, and Windows # Setup; images 4 and higher contain installable editions. Read all metadata # once because repeatedly inspecting a solid-compressed ESD is expensive. if ! metadata=$(xmlstarlet sel -t \ -v 'count(/WIM/IMAGE)' -n \ -v 'normalize-space(/WIM/IMAGE[@INDEX="1"]/TOTALBYTES)' -n \ -v 'normalize-space(/WIM/IMAGE[@INDEX="1"]/HARDLINKBYTES)' -n \ -v 'normalize-space(/WIM/IMAGE[@INDEX="3"]/TOTALBYTES)' -n \ -v 'normalize-space(/WIM/IMAGE[@INDEX="3"]/HARDLINKBYTES)' -n \ -m '/WIM/IMAGE[number(@INDEX) >= 4]' -v '@INDEX' -o $'\t' -v 'DESCRIPTION' -n \ <<< "$xml" 2>/dev/null); then error "Cannot read ESD file information!" return 1 fi mapfile -t fields <<< "$metadata" count="${fields[0]:-}" if [[ ! "$count" =~ ^[0-9]+$ ]]; then error "Cannot read the image count in ESD file!" return 1 fi if (( count < 3 )); then error "Invalid ESD file: expected at least 3 images, found $count." return 1 fi bootTotal="${fields[1]:-}" bootLinks="${fields[2]:-}" if [[ ! "$bootTotal" =~ ^[0-9]+$ ]] || [[ ! "$bootLinks" =~ ^[0-9]+$ ]]; then error "Cannot read bootdisk size from ESD file!" return 1 fi bootSize=$(( bootTotal - bootLinks )) wimTotal="${fields[3]:-}" wimLinks="${fields[4]:-}" if [[ ! "$wimTotal" =~ ^[0-9]+$ ]] || [[ ! "$wimLinks" =~ ^[0-9]+$ ]]; then error "Cannot read boot.wim size from ESD file!" return 1 fi wimSize=$(( wimTotal - wimLinks + bootPad )) # The downloaded ESD already occupies disk space and is moved into the # installation media. Peak additional usage consists of the extracted setup # files and boot.wim, plus the final ISO containing those files and the ESD. local freeSpace=$(( size + 2 * (bootSize + wimSize) + spacePad )) checkFreeSpace "$dir" "$freeSpace" || return 1 /run/progress.sh "$dir" "$bootSize" "$msg ([P])..." & index="1" wimlib-imagex apply "$iso" "$index" "$dir" --quiet 2>/dev/null || { ret=$? fKill "progress.sh" error "Extracting $desc bootdisk failed ($ret)" return 1 } fKill "progress.sh" bootWim="$dir/sources/boot.wim" installWim="$dir/sources/install.esd" msg="Extracting environment from ESD file" info "$msg..." && html "$msg..." index="2" /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." & wimlib-imagex export "$iso" "$index" "$bootWim" \ --compress=none --quiet || { ret=$? fKill "progress.sh" error "Adding WinPE failed ($ret)" return 1 } fKill "progress.sh" msg="Extracting setup from ESD file" info "$msg..." index="3" /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." & wimlib-imagex export "$iso" "$index" "$bootWim" \ --compress=none --boot --quiet || { ret=$? fKill "progress.sh" error "Adding Windows Setup failed ($ret)" return 1 } fKill "progress.sh" if [[ "${PLATFORM,,}" == "x64" ]]; then LABEL="CCCOMA_X64FRE_EN-US_DV9" else LABEL="CPBA_A64FRE_EN-US_DV9" fi msg="Extracting image from ESD file" info "$msg..." && html "$msg..." edition=$(getCatalog "$version" "name") if [ -z "$edition" ]; then error "Invalid VERSION specified, value \"$version\" is not recognized!" return 1 fi index="" for line in "${fields[@]:5}"; do IFS=$'\t' read -r image imgEdition <<< "$line" [[ ! "$image" =~ ^[0-9]+$ ]] && continue [[ "${imgEdition,,}" != "${edition,,}" ]] && continue index="$image" break done if [ -z "$index" ]; then error "Failed to find product '$edition' in install.esd!" return 1 fi installSize=$(( size + installPad )) /run/progress.sh "$installWim" "$installSize" "$msg ([P])..." & if ! rm -f -- "$dir/sources/install.wim" "$installWim"; then fKill "progress.sh" error "Failed to remove previous Windows installation image!" return 1 fi # Reuse the downloaded solid ESD instead of exporting the selected image. # Both paths are below $TMP, so this is a same-filesystem rename. if ! mv -f -- "$iso" "$installWim"; then fKill "progress.sh" error "Failed to move downloaded ESD file into the installation media!" return 1 fi # Remove all other images without rebuilding the solid-compressed resources. # Deleting in descending order leaves the selected edition at index 1. for (( image=count; image >= 1; image-- )); do (( image == index )) && continue if ! wimlib-imagex delete "$installWim" "$image" --soft --quiet; then fKill "progress.sh" error "Failed to remove image $image from install.esd!" return 1 fi done if ! result=$(wimlib-imagex info "$installWim" --xml 2>/dev/null | iconv -f UTF-16LE -t UTF-8 2>/dev/null); then fKill "progress.sh" error "Cannot verify the prepared install.esd file!" return 1 fi if ! metadata=$(xmlstarlet sel -t \ -v 'count(/WIM/IMAGE)' -n \ -v 'normalize-space(/WIM/IMAGE[@INDEX="1"]/DESCRIPTION)' -n \ <<< "$result" 2>/dev/null); then fKill "progress.sh" error "Cannot verify the prepared install.esd file!" return 1 fi mapfile -t fields <<< "$metadata" resultCount="${fields[0]:-}" resultEdition="${fields[1]:-}" if [[ "$resultCount" != "1" ]] || [[ "${resultEdition,,}" != "${edition,,}" ]]; then fKill "progress.sh" error "Prepared install.esd does not contain only '$edition' at index 1!" return 1 fi fKill "progress.sh" return 0 } normalizeBatch() { local file="$1" local bom tmp encoding [ ! -s "$file" ] && return 0 bom=$(od -An -N2 -tx1 "$file" | tr -d ' \n') || return 1 # Convert only BOM-marked UTF-16 files; unmarked ANSI and UTF-8 batch files # are deliberately left unchanged. case "$bom" in "fffe" ) encoding="UTF-16LE" ;; "feff" ) encoding="UTF-16BE" ;; * ) return 0 ;; esac if ! tmp=$(mktemp "${file}.XXXXXX"); then error "Failed to create temporary batch file!" return 1 fi if ! tail -c +3 "$file" | iconv -f "$encoding" -t UTF-8 > "$tmp"; then rm -f "$tmp" error "Failed to convert $file from $encoding to UTF-8!" return 1 fi if ! chmod --reference="$file" "$tmp" || ! mv -f "$tmp" "$file"; then rm -f "$tmp" error "Failed to replace batch file: $file" return 1 fi return 0 } reportBatchMatches() { local file="$1" local source="$2" local pattern="$3" local message="$4" local suggestion="$5" local matches line matches=$(grep -Pin "$pattern" "$file" || true) [ -n "$matches" ] || return 0 warn "$message in $source:" while IFS= read -r line; do printf ' %s\n' "$line" >&2 done <<< "$matches" printf ' %s\n\n' "$suggestion" >&2 return 0 } checkBatch() { local file="$1" local tmp output local matches line local enabled_rules [ -s "$file" ] || return 0 if ! tmp=$(mktemp -d /tmp/blinter.XXXXXX); then warn "failed to create temporary Blinter directory." return 0 fi local source="your install.bat file" [ -n "${COMMAND:-}" ] && source="your COMMAND variable" # Only check rules that indicate likely execution or behavioural failures. enabled_rules="E001,E002,E003,E004,E005,E006,E007,E008" enabled_rules+=",E009,E010,E011,E012,E013,E014,E015,E016" enabled_rules+=",E017,E018,E019,E020,E021,E022,E023,E024" enabled_rules+=",E025,E027,E028,E029,E030,E031,E032,E033,E034" enabled_rules+=",W004,W005,W013,W017,W021,W022,W034,W038,W040" if enabled "$DEBUG"; then if LC_ALL=C grep -Pq '[^\x09\x0D\x20-\x7E]' "$file"; then warn "non-ASCII characters were detected in $source and may not execute correctly in Windows Command Prompt." fi fi cat > "$tmp/blinter.ini" <&1 || true ) # Remove header. output=$( awk ' /^DETAILED ISSUES:/ { found = 1 next } found && !started { if (/^-+$/) { started = 1 } next } started { print } ' <<< "$output" ) output="${output#"${output%%[!$'\r\n ']*}"}" output="${output%"${output##*[!$'\r\n ']}"}" if grep -Eq '^(ERROR|WARNING|SECURITY) LEVEL ISSUES:$' <<< "$output"; then warn "possible issues were detected in $source:" printf '\n%s\n\n' "$output" >&2 fi rm -rf "$tmp" || true reportBatchMatches \ "$file" \ "$source" \ '(? 65535 )); then error "Invalid boot image load size found in $desc ISO!" return 1 fi return 0 } extractBootImage() { local iso="$1" local dir="$2" local desc="$3" local offset info ETFS="boot.img" [ -s "$dir/$ETFS" ] && return 0 rm -f "$dir/$ETFS" || return 1 if ! info=$(isoinfo -d -i "$iso"); then error "Failed to read boot image information from $desc ISO!" return 1 fi offset=$(awk '/Bootoff / { print $NF; exit }' <<< "$info") if [ -z "$offset" ]; then error "Failed to determine boot image offset from $desc ISO!" return 1 fi if [[ ! "$offset" =~ ^[0-9]+$ ]]; then error "Invalid boot image location found in $desc ISO!" return 1 fi # isoinfo reports the boot offset in 2048-byte sectors, while dd below uses # 512-byte blocks, hence the factor of four. if ! dd "if=$iso" "of=$dir/$ETFS" bs=512 "count=$BOOT_LOAD_SIZE" "skip=$((offset * 4))" status=none; then rm -f "$dir/$ETFS" || true error "Failed to extract boot image from $desc ISO!" return 1 fi if [ ! -s "$dir/$ETFS" ]; then rm -f "$dir/$ETFS" || true error "Failed to locate file \"$ETFS\" in $desc ISO image!" return 1 fi return 0 } buildImage() { local dir="$1" local cat="BOOT.CAT" local log="/run/shm/iso.log" local base size desc failed="" if [ -f "$BOOT" ]; then error "File $BOOT does already exist?!" && return 1 fi base=$(basename "$BOOT") local out="$TMP/${base%.*}.tmp" rm -f "$out" desc=$(printVariant "$DETECTED" "ISO") local msg="Building $desc image" [[ "${ISO,,}" == *.esd ]] && msg+=" from ESD file" info "$msg..." && html "$msg..." [ -z "$LABEL" ] && LABEL="Windows" if [ ! -f "$dir/$ETFS" ] || [ ! -s "$dir/$ETFS" ]; then error "Failed to locate file \"$ETFS\" in ISO image!" && return 1 fi if ! size=$(du -b --max-depth=0 "$dir" | cut -f1); then error "Failed to calculate the size of directory \"$dir\"!" return 1 fi checkFreeSpace "$TMP" "$size" || return 1 if [[ "${BOOT_MODE,,}" == "windows_legacy" ]] && [ -z "${BOOT_LOAD_SIZE:-}" ]; then if [[ "${DETECTED,,}" != "win9"* && "${DETECTED,,}" != "winnt4" ]]; then error "Failed to determine the boot image load size!" return 1 fi fi /run/progress.sh "$out" "$size" "$msg ([P])..." & local -a args=( -o "$out" -b "$ETFS" ) local -a name_args=( -J -l -D -N -joliet-long -relaxed-filenames -V "${LABEL::30}" ) # Use separate layouts for modern hybrid media, NT 5.x legacy media, Win9x, # and other legacy releases because their El Torito requirements differ. if [[ "${BOOT_MODE,,}" != "windows_legacy" ]]; then args+=( -no-emul-boot -c "$cat" -iso-level 4 "${name_args[@]}" -udf -boot-info-table -eltorito-alt-boot -eltorito-boot "$EFISYS" -no-emul-boot -allow-limited-size ) else case "${DETECTED,,}" in "win2k"* | "winxp"* | "win2003"* ) args+=( -no-emul-boot -boot-load-seg 1984 -boot-load-size "$BOOT_LOAD_SIZE" -c "$cat" -iso-level 2 "${name_args[@]}" ) ;; "win9"* | "winnt4" ) args+=( -J -r -V "${LABEL::30}" ) ;; * ) args+=( -no-emul-boot -boot-load-size "$BOOT_LOAD_SIZE" -c "$cat" -iso-level 2 "${name_args[@]}" -udf -allow-limited-size ) ;; esac fi if ! genisoimage "${args[@]}" -quiet "$dir" 2> "$log"; then failed="y" fi fKill "progress.sh" if [ -n "$failed" ]; then [ -s "$log" ] && echo "$(<"$log")" error "Failed to build image!" && return 1 fi local err="" local hide="Warning: creating filesystem that does not conform to ISO-9660." [ -s "$log" ] && err="$(<"$log")" # UDF hybrid media intentionally triggers this genisoimage warning. if [ -n "$err" ] && [[ "$err" != "$hide" ]]; then echo "$err" fi mv -f "$out" "$BOOT" || return 1 if ! setOwner "$BOOT"; then warn "Failed to set the owner for \"$BOOT\" !" fi return 0 } return 0