From cd8af5af511558e5014da283a4bf0fe648a86367 Mon Sep 17 00:00:00 2001 From: Kroese Date: Mon, 3 Aug 2026 05:33:01 +0200 Subject: [PATCH] feat: Read WIM metadata directly from ISO (#2036) --- Dockerfile | 9 +- src/answer.sh | 118 +++++++++---- src/image.sh | 464 +++++++++++++++++++++++++++++++++++++++++++++---- src/install.sh | 30 +++- 4 files changed, 550 insertions(+), 71 deletions(-) diff --git a/Dockerfile b/Dockerfile index f5e0cab3..1fe4bcd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ FROM scratch AS build-amd64 COPY --from=qemux/qemu:7.43 / / ARG TARGETARCH + +ARG VERSION_UDF="1.2.0" ARG VERSION_WSDD="1.26" ARG VERSION_VIRTIO="1.9.58" ARG VERSION_BLINTER="1.0.112" @@ -24,6 +26,7 @@ RUN <1#0#g' "$asset"; then + if ! sed -i -E \ + "s#[[:space:]]*${current}[[:space:]]*#$target#g" \ "$asset"; then error "Failed to update DiskID in answer file: $asset" return 1 fi @@ -1418,42 +1422,90 @@ getXMLArchitecture() { setConfigurationXML() { local asset="$1" - local section + local setup='/*[local-name()="unattend"]/*[local-name()="settings" and @pass="windowsPE"]/*[local-name()="component" and @name="Microsoft-Windows-Setup"]' + local config="$setup/*[local-name()=\"UseConfigurationSet\"]" + local userdata="$setup/*[local-name()=\"UserData\"]" + local setup_count config_count userdata_count tmp [ -s "$asset" ] || return 1 - section=$(sed -n -E ' - /]*pass="windowsPE"[^>]*>/,/<\/settings>/ { - /]*name="Microsoft-Windows-Setup"[^>]*>/,/<\/component>/p - } - ' "$asset") || return 1 + setup_count=$(xmlstarlet sel -t -v "count($setup)" "$asset") || return 1 - [ -n "$section" ] || return 1 - - if grep -Fq '' <<< "$section"; then - - sed -i -E ' - /]*pass="windowsPE"[^>]*>/,/<\/settings>/ { - /]*name="Microsoft-Windows-Setup"[^>]*>/,/<\/component>/ { - s#[^<]*#true#g - } - } - ' "$asset" || return 1 - - return 0 - fi - - if ! grep -Fq '' <<< "$section"; then + if [ "$setup_count" != "1" ]; then + error "Failed to find a unique Microsoft-Windows-Setup component: $asset" return 1 fi - sed -i -E ' - /]*pass="windowsPE"[^>]*>/,/<\/settings>/ { - /]*name="Microsoft-Windows-Setup"[^>]*>/,/<\/component>/ { - s#^([[:space:]]*)#\1true\n\1# - } + config_count=$(xmlstarlet sel -t -v "count($config)" "$asset") || return 1 + + if [ "$config_count" -gt 1 ]; then + error "Multiple UseConfigurationSet entries found in answer file: $asset" + return 1 + fi + + if ! tmp=$(mktemp "${asset}.XXXXXX"); then + error "Failed to create a temporary answer file!" + return 1 + fi + + if [ "$config_count" -eq 1 ]; then + + if ! xmlstarlet ed \ + -u "$config" \ + -v "true" \ + "$asset" > "$tmp"; then + + rm -f "$tmp" + error "Failed to enable the Windows configuration set!" + return 1 + fi + + else + + userdata_count=$(xmlstarlet sel -t -v "count($userdata)" "$asset") || { + rm -f "$tmp" + return 1 } - ' "$asset" || return 1 + + if [ "$userdata_count" -gt 0 ]; then + + if ! xmlstarlet ed \ + -i "${userdata}[1]" \ + -t elem \ + -n "UseConfigurationSet" \ + -v "true" \ + "$asset" > "$tmp"; then + + rm -f "$tmp" + error "Failed to insert UseConfigurationSet into answer file!" + return 1 + fi + + else + + if ! xmlstarlet ed \ + -s "$setup" \ + -t elem \ + -n "UseConfigurationSet" \ + -v "true" \ + "$asset" > "$tmp"; then + + rm -f "$tmp" + error "Failed to append UseConfigurationSet to answer file!" + return 1 + fi + + fi + + fi + + if ! chmod --reference="$asset" "$tmp" || + ! mv -f "$tmp" "$asset"; then + + rm -f "$tmp" + error "Failed to replace the updated answer file!" + return 1 + fi return 0 } diff --git a/src/image.sh b/src/image.sh index e5161792..352bb56f 100644 --- a/src/image.sh +++ b/src/image.sh @@ -88,26 +88,20 @@ getVersionPriority() { local id="${1,,}" local base="${2,,}" - local order_name="EDITION_ORDER" local entry priority patterns pattern local result="other" score best_score=-1 + local -a order=() id="${id%-eval}" - case "$base" in - "win20"* ) - order_name="SERVER_EDITION_ORDER" - ;; - esac - - local -n order_ref="$order_name" + getEditionOrder "$base" order || return 1 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_ref[@]}"; do + for entry in "${order[@]}"; do IFS='|' read -r _ priority patterns <<< "$entry" @@ -891,6 +885,386 @@ setImage() { return 0 } +findIsoImage() { + + local iso="$1" + local result_name="$2" + local path + + printf -v "$result_name" '%s' "" + + for path in \ + /sources/install.wim \ + /sources/install.esd; do + + if udfread stat \ + --ignore-case \ + "$iso" \ + "$path" \ + >/dev/null 2>&1; then + + printf -v "$result_name" '%s' "$path" + return 0 + fi + + done + + return 1 +} + +readWimHeader() { + + local iso="$1" + local image="$2" + local result_name="$3" + + local header="$TMP/wim-header.bin" + local size signature + + printf -v "$result_name" '%s' "" + + if ! rm -f -- "$header"; then + return 1 + fi + + if ! udfread range \ + --ignore-case \ + -o "$header" \ + "$iso" \ + "$image" \ + 0 \ + 208 \ + >/dev/null 2>&1; then + + rm -f -- "$header" + return 1 + fi + + if ! size=$(stat -c%s -- "$header"); then + rm -f -- "$header" + return 1 + fi + + if (( size != 208 )); then + rm -f -- "$header" + return 1 + fi + + if ! signature=$(od -An -N8 -tx1 "$header" | tr -d ' \n'); then + rm -f -- "$header" + return 1 + fi + + if [[ "$signature" != "4d5357494d000000" ]]; then + rm -f -- "$header" + return 1 + fi + + printf -v "$result_name" '%s' "$header" + return 0 +} + +readIsoImageInfo() { + + local iso="$1" + local image="$2" + local header="$3" + local result_name="$4" + + 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=() + + printf -v "$result_name" '%s' "" + + if [ ! -f "$header" ]; then + return 1 + fi + + if ! raw=$(od -An -v -N208 -tu1 -- "$header"); then + return 1 + fi + + read -r -a bytes <<< "${raw//$'\n'/ }" + + if (( ${#bytes[@]} != 208 )); then + return 1 + fi + + # Validate the MSWIM\0\0\0 signature. + if (( bytes[0] != 77 || + bytes[1] != 83 || + bytes[2] != 87 || + bytes[3] != 73 || + bytes[4] != 77 || + bytes[5] != 0 || + bytes[6] != 0 || + bytes[7] != 0 )); then + return 1 + fi + + # Header size at offset 0x08. + header_size=$(( \ + bytes[8] | + bytes[9] << 8 | + bytes[10] << 16 | + bytes[11] << 24 + )) + + if (( header_size != 208 )); then + return 1 + fi + + # WIM version at offset 0x0c. + version=$(( \ + bytes[12] | + bytes[13] << 8 | + bytes[14] << 16 | + bytes[15] << 24 + )) + + if (( version != 0x10d00 && + version != 0x0e00 )); then + return 1 + fi + + # Split-WIM information at offsets 0x28 and 0x2a. + part_number=$(( \ + bytes[40] | + bytes[41] << 8 + )) + + total_parts=$(( \ + bytes[42] | + bytes[43] << 8 + )) + + if (( part_number == 0 || + total_parts == 0 || + part_number > total_parts )); then + return 1 + fi + + # Image count at offset 0x2c. + image_count=$(( \ + bytes[44] | + bytes[45] << 8 | + bytes[46] << 16 | + bytes[47] << 24 + )) + + if (( image_count == 0 || + image_count > 65535 )); then + return 1 + fi + + if ! parseWimHeader \ + "$iso" \ + "$image" \ + "$header" \ + xml_offset \ + xml_size \ + xml_original \ + xml_flags; then + return 1 + fi + + if [[ ! "$xml_offset" =~ ^[0-9]+$ || + ! "$xml_size" =~ ^[0-9]+$ || + ! "$xml_original" =~ ^[0-9]+$ || + ! "$xml_flags" =~ ^[0-9]+$ ]]; then + return 1 + fi + + if (( xml_size == 0 || + xml_original == 0 || + xml_size != xml_original || + xml_size % 2 != 0 )); then + return 1 + fi + + # 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. + if (( xml_flags & 0x1c )); then + return 1 + fi + + 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 + + root=$( + xmllint \ + --nonet \ + --xpath 'name(/*)' \ + - \ + 2>/dev/null <<< "$result" + ) || return 1 + + if [ "$root" != "WIM" ]; then + return 1 + fi + + xml_count=$( + xmllint \ + --nonet \ + --xpath 'count(/WIM/IMAGE)' \ + - \ + 2>/dev/null <<< "$result" + ) || return 1 + + if [[ ! "$xml_count" =~ ^[0-9]+$ ]]; then + return 1 + fi + + if (( xml_count != image_count )); then + return 1 + fi + + printf -v "$result_name" '%s' "$result" + return 0 +} + +parseWimHeader() { + + local iso="$1" + local image="$2" + local header="$3" + local offset_name="$4" + local size_name="$5" + local original_name="$6" + local flags_name="$7" + + local -n offset_ref="$offset_name" + local -n size_ref="$size_name" + local -n original_ref="$original_name" + local -n flags_ref="$flags_name" + + local details image_size raw + local header_size=0 + local parsed_size=0 + local parsed_offset=0 + local parsed_original=0 + local parsed_flags=0 + local -a bytes=() + + offset_ref="" + size_ref="" + original_ref="" + flags_ref="" + + 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 + + offset_ref="$parsed_offset" + size_ref="$parsed_size" + original_ref="$parsed_original" + flags_ref="$parsed_flags" + + return 0 +} + findImage() { local dir="$1" @@ -1030,6 +1404,57 @@ configureImage() { return 0 } +detectImageInfo() { + + local image_info="$1" + local desc suggested index + + checkPlatform "$image_info" || exit 67 + + suggested=$(getSuggestion) || return 1 + + detectVersion \ + "$image_info" \ + "$suggested" \ + DETECTED \ + index || return 1 + + validateEdition || return 1 + + if [ -z "$DETECTED" ]; then + unknownImage || return 1 + return 0 + fi + + describeImage "$image_info" "$index" desc || return 1 + info "Detected: $desc" + + configureImage "$index" "$desc" || return 1 + + return 0 +} + +detectIsoImage() { + + local iso="$1" + local image header image_info + + findIsoImage "$iso" image || return 1 + readWimHeader "$iso" "$image" header || return 1 + + readIsoImageInfo \ + "$iso" \ + "$image" \ + "$header" \ + image_info || return 1 + + info "Detecting version from ISO image..." + + detectImageInfo "$image_info" || return 2 + + return 0 +} + detectImage() { local dir="$1" @@ -1049,26 +1474,7 @@ detectImage() { local image_info readImageInfo "$wim" image_info || return 1 - checkPlatform "$image_info" || exit 67 - - local suggested - suggested=$(getSuggestion) || return 1 - - local index - detectVersion "$image_info" "$suggested" DETECTED index || return 1 - validateEdition || return 1 - - if [ -z "$DETECTED" ]; then - unknownImage || return 1 - return 0 - fi - - describeImage "$image_info" "$index" desc || return 1 - info "Detected: $desc" - - configureImage "$index" "$desc" || return 1 - - return 0 + detectImageInfo "$image_info" } normalizeBatch() { diff --git a/src/install.sh b/src/install.sh index 23cef87e..329829ce 100644 --- a/src/install.sh +++ b/src/install.sh @@ -39,16 +39,30 @@ startWindows() { else - if ! extractImage "$ISO" "$dir" "$VERSION"; then - removeIso "$ISO" && return 74 - fi + local detect_rc=0 + detectIsoImage "$ISO" || detect_rc=$? - extracted=1 + case "$detect_rc" in - if ! detectImage "$dir"; then - abortInstall "$dir" "$ISO" "$boot" || return 76 - return 0 - fi + 0 ) ;; + 1 ) + + if ! extractImage "$ISO" "$dir" "$VERSION"; then + removeIso "$ISO" && return 74 + fi + + extracted=1 + + if ! detectImage "$dir"; then + abortInstall "$dir" "$ISO" "$boot" || return 76 + return 0 + fi ;; + + * ) + abortInstall "$dir" "$ISO" "$boot" || return 76 + return 0 ;; + + esac fi