diff --git a/src/answer.sh b/src/answer.sh index 342eef1c..d508ed01 100644 --- a/src/answer.sh +++ b/src/answer.sh @@ -4,6 +4,7 @@ set -Eeuo pipefail hasAnswerFile() { local id="$1" + local file="/run/assets/$id.xml" [ -s "$file" ] && return 0 @@ -29,6 +30,7 @@ stageAnswer() { local asset="$1" local language="$2" local stage="$3" + local answer="$stage/Autounattend.xml" local script="" name @@ -52,6 +54,8 @@ stageAnswer() { removeGeneratedXML "$asset" || return 1 + # Custom answer files keep their user-defined settings, but still receive + # the media-specific disk and configuration-set adjustments below. if [ -z "${CUSTOM_XML:-}" ]; then if ! updateXML "$answer" "$language"; then error "Failed to update answer file: $answer" @@ -72,7 +76,7 @@ stageAnswer() { validateGeneratedXML "$answer" || return 1 if [ -z "${CUSTOM_XML:-}" ]; then - prepareSetupScript "$asset" "$stage" script || exit 84 + script=$(prepareSetupScript "$asset" "$stage") || exit 84 fi return 0 @@ -81,6 +85,7 @@ stageAnswer() { markGeneratedXML() { local file="$1" + local marker='' [ -s "$file" ] || return 1 @@ -120,64 +125,89 @@ generateAnswerFile() { local index="$4" local type="$5" local remove_selector="$6" - local tmp + + local ns="urn:schemas-microsoft-com:unattend" + local wcm="http://schemas.microsoft.com/WMIConfig/2002/State" + local setup='/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]' + local os_image="$setup/u:ImageInstall/u:OSImage" + local install_from="$os_image/u:InstallFrom" + local install_to="$os_image/u:InstallTo" + local directory install_count install_to_count tmp if [ -n "$index" ] && [[ ! "$index" =~ ^[1-9][0-9]*$ ]]; then error "Invalid $type image index: $index" return 1 fi - if ! tmp=$(mktemp -p /run/assets ".${id}.XXXXXX"); then + directory=$(dirname "$target") || return 1 + + if ! tmp=$(mktemp -p "$directory" ".${id}.XXXXXX"); then error "Failed to create a temporary $type answer file!" return 1 fi - local expressions - - if [ "$type" = "evaluation" ]; then - expressions=( - -e '/.*<\/ProductKey>/d' - -e '//,/<\/ProductKey>/d' - ) - else - expressions=( - -e '/.*<\/InstallFrom>/d' - -e '/.*<\/ProductKey>/d' - -e '//,/<\/InstallFrom>/d' - -e '//,/<\/ProductKey>/d' - ) - fi - - if ! sed "${expressions[@]}" "$source" > "$tmp"; then + if ! cp -L -- "$source" "$tmp"; then rm -f "$tmp" error "Failed to generate $type answer file from $source!" return 1 fi - if [ "$type" = "evaluation" ] && [ "$remove_selector" = "Y" ]; then - if ! sed -i \ - -e '/.*<\/InstallFrom>/d' \ - -e '//,/<\/InstallFrom>/d' \ - "$tmp"; then + # Keep empty ProductKey structures because some Windows installers require + # the node to exist, but remove a concrete key that could select another edition. + if ! removeEmbeddedProductKeys "$tmp"; then + rm -f "$tmp" + error "Failed to remove the embedded $type product key!" + return 1 + fi + + if [ "$type" != "evaluation" ] || [ "$remove_selector" = "Y" ]; then + if ! xmlstarlet ed -L -N "u=$ns" -d "$install_from" "$tmp"; then rm -f "$tmp" - error "Failed to replace evaluation image selector!" + error "Failed to generate $type answer file from $source!" return 1 fi fi - if [ -n "$index" ] && ! grep -q '' "$tmp"; then - if ! sed -i \ - '0,//{ //i\ - \ - \ - /IMAGE/INDEX\ - '"$index"'\ - \ - - }' "$tmp"; then + if [ -n "$index" ]; then + install_count=$(getXMLNodeCount "$tmp" "$install_from") || { rm -f "$tmp" - error "Failed to select $type image index $index!" return 1 + } + + if (( install_count > 1 )); then + rm -f "$tmp" + error "Multiple $type image selectors were found!" + return 1 + fi + + if [ "$install_count" = "0" ]; then + # InstallFrom must be inserted before InstallTo to preserve the ordering + # expected by the Windows Setup schema. + install_to_count=$(getXMLNodeCount "$tmp" "$install_to") || { + rm -f "$tmp" + return 1 + } + + if [ "$install_to_count" != "1" ]; then + rm -f "$tmp" + error "Failed to find a unique $type installation target!" + return 1 + fi + + if ! xmlstarlet ed -L \ + -N "u=$ns" \ + -N "wcm=$wcm" \ + -i "($install_to)[1]" -t elem -n 'InstallFrom' \ + -s "$os_image/*[local-name()='InstallFrom']" -t elem -n 'MetaData' \ + -i "$os_image/*[local-name()='InstallFrom']/*[local-name()='MetaData']" -t attr -n 'wcm:action' -v 'add' \ + -s "$os_image/*[local-name()='InstallFrom']/*[local-name()='MetaData']" -t elem -n 'Key' -v '/IMAGE/INDEX' \ + -s "$os_image/*[local-name()='InstallFrom']/*[local-name()='MetaData']" -t elem -n 'Value' -v "$index" \ + "$tmp"; then + + rm -f "$tmp" + error "Failed to select $type image index $index!" + return 1 + fi fi fi @@ -236,8 +266,7 @@ generateEvalXML() { esac fi - generateAnswerFile \ - "$id" "$source" "$target" "$index" "evaluation" "$remove_selector" || return 1 + generateAnswerFile "$id" "$source" "$target" "$index" "evaluation" "$remove_selector" || return 1 return 0 } @@ -249,6 +278,7 @@ generateFallbackXML() { local id="$1" local index="${2:-}" + local source="/run/assets/${id%%-*}.xml" local target="/run/assets/$id.xml" @@ -257,8 +287,7 @@ generateFallbackXML() { removeGeneratedXML "$source" || return 1 [ -s "$source" ] || return 1 - generateAnswerFile \ - "$id" "$source" "$target" "$index" "fallback" "Y" || return 1 + generateAnswerFile "$id" "$source" "$target" "$index" "fallback" "Y" || return 1 return 0 } @@ -267,13 +296,10 @@ setXML() { local file="$1" local index="${2:-}" + local target="/run/assets/$DETECTED.xml" - local custom_files=( - "/custom.xml" - "$STORAGE/custom.xml" - "/run/assets/custom.xml" - ) + local custom_files=("/custom.xml" "$STORAGE/custom.xml" "/run/assets/custom.xml") CUSTOM_XML="" @@ -284,6 +310,8 @@ setXML() { exit 67 fi + # A custom answer file always takes precedence over bundled or generated + # templates, in root, storage, then asset-directory order. for file in "${custom_files[@]}"; do if [ -f "$file" ] && [ -s "$file" ]; then CUSTOM_XML="Y" @@ -294,6 +322,8 @@ setXML() { file="$1" + # Generate evaluation or edition-specific templates only when the selected + # source is unavailable or differs from the detected image identity. if [[ "${DETECTED,,}" == *"-eval" ]] && { [ ! -f "$file" ] || [ ! -s "$file" ]; }; then @@ -321,10 +351,13 @@ updateXML() { local asset="$1" local language="$2" + local domain="${DOMAIN:-}" local workgroup="${WORKGROUP:-}" local account="" local auth="" + local result + local -a values=() [ -z "${WIDTH:-}" ] && WIDTH="1280" [ -z "${HEIGHT:-}" ] && HEIGHT="720" @@ -334,21 +367,17 @@ updateXML() { updateLocaleXML "$asset" "$language" || return 1 if [ -n "$domain" ]; then - prepareDomainAccount "$domain" account auth || return 1 + result=$(prepareDomainAccount "$domain") || return 1 + mapfile -t values <<< "$result" + (( ${#values[@]} == 2 )) || return 1 + account="${values[0]}" + auth="${values[1]}" else updateLocalAccount "$asset" || return 1 fi - sed -i -E \ - "s|[^<]*</PlainText>|<PlainText>false</PlainText>|g" \ - "$asset" || return 1 - updateMembership \ - "$asset" \ - "$domain" \ - "$workgroup" \ - "$account" \ - "$auth" || return 1 + updateMembership "$asset" "$domain" "$workgroup" "$account" "$auth" || return 1 updateAutologinXML "$asset" || return 1 updateEditionXML "$asset" || return 1 @@ -361,18 +390,16 @@ prepareSetupScript() { local asset="$1" local stage="$2" - local result_name="$3" + local staged="" - printf -v "$result_name" '%s' "" - - stageSetupScript "$asset" "$stage" staged || return 1 + staged=$(stageSetupScript "$asset" "$stage") || return 1 [ -n "$staged" ] || return 0 updateSetupScript "$staged" "$asset" || return 1 finalizeSetupScript "$staged" || return 1 - printf -v "$result_name" '%s' "$staged" + printf '%s' "$staged" return 0 } @@ -380,6 +407,7 @@ updateSetupScript() { local script="$1" local asset="$2" + local domain="${DOMAIN:-}" local user="${USERNAME:-}" local content id @@ -397,9 +425,11 @@ updateSetupScript() { id=$(basename "$asset") || return 1 id="${id%.*}" + # Set-LocalUser is unavailable on older releases, which still require + # the equivalent WMIC command. + case "${id,,}" in - "win10"* | "win11"* | \ - "win2016"* | "win2019"* | "win2022"* | "win2025"* ) + "win10"* | "win11"* | "win2016"* | "win2019"* | "win2022"* | "win2025"* ) printf -v content '%s\n%s' \ 'rem Prevent the local user password from expiring.' \ "powershell.exe -ExecutionPolicy Unrestricted -NoLogo -NoProfile -NonInteractive Set-LocalUser -Name \"$user\" -PasswordNeverExpires 1" @@ -424,6 +454,7 @@ updateSetupScript() { findSetupScript() { local asset="$1" + local dir name id normal candidate local candidates=() @@ -465,10 +496,8 @@ stageSetupScript() { local asset="$1" local stage="$2" - local result_name="$3" - local source target - printf -v "$result_name" '%s' "" + local source target source=$(findSetupScript "$asset") || return 1 [ -n "$source" ] || return 0 @@ -494,7 +523,7 @@ stageSetupScript() { validateSetupScript "$target" || return 1 - printf -v "$result_name" '%s' "$target" + printf '%s' "$target" return 0 } @@ -502,6 +531,7 @@ installSetupScript() { local script="$1" local root="$2" + local target [ -n "$script" ] || return 0 @@ -526,17 +556,26 @@ installSetupScript() { return 0 } -replaceSetupBlock() { +rewriteSetupBlock() { local file="$1" local block="$2" - local content="$3" + local action="$3" + local content="${4:-}" + local begin="rem BEGIN $block" local end="rem END $block" local line inside=0 tmp + case "$action" in + "replace" | "remove" ) ;; + * ) return 1 ;; + esac + validateSetupBlock "$file" "$block" || return 1 + # Rewrite through a temporary file so malformed markers or interrupted writes + # cannot leave a partially modified setup script. if ! tmp=$(mktemp "${file}.XXXXXX"); then error "Failed to create temporary setup script!" return 1 @@ -545,21 +584,28 @@ replaceSetupBlock() { while IFS= read -r line || [ -n "$line" ]; do if [ "$line" = "$begin" ]; then - if ! printf '%s\n' "$line" >> "$tmp" || - ! printf '%s\n' "$content" >> "$tmp"; then - rm -f "$tmp" - return 1 + if [ "$action" = "replace" ]; then + if ! printf '%s\n' "$line" >> "$tmp" || + ! printf '%s\n' "$content" >> "$tmp"; then + rm -f "$tmp" + return 1 + fi fi + inside=1 continue fi if [ "$line" = "$end" ]; then inside=0 - if ! printf '%s\n' "$line" >> "$tmp"; then - rm -f "$tmp" - return 1 + + if [ "$action" = "replace" ]; then + if ! printf '%s\n' "$line" >> "$tmp"; then + rm -f "$tmp" + return 1 + fi fi + continue fi @@ -575,57 +621,21 @@ replaceSetupBlock() { if ! chmod --reference="$file" "$tmp" || ! mv -f -- "$tmp" "$file"; then rm -f "$tmp" - error "Failed to replace the $block block in setup script: $file" + error "Failed to $action the $block block in setup script: $file" return 1 fi return 0 } +replaceSetupBlock() { + + rewriteSetupBlock "$1" "$2" "replace" "$3" +} + removeSetupBlock() { - local file="$1" - local block="$2" - local begin="rem BEGIN $block" - local end="rem END $block" - local line inside=0 tmp - - validateSetupBlock "$file" "$block" || return 1 - - if ! tmp=$(mktemp "${file}.XXXXXX"); then - error "Failed to create temporary setup script!" - return 1 - fi - - while IFS= read -r line || [ -n "$line" ]; do - - if [ "$line" = "$begin" ]; then - inside=1 - continue - fi - - if [ "$line" = "$end" ]; then - inside=0 - continue - fi - - if (( ! inside )); then - if ! printf '%s\n' "$line" >> "$tmp"; then - rm -f "$tmp" - return 1 - fi - fi - - done < "$file" - - if ! chmod --reference="$file" "$tmp" || - ! mv -f -- "$tmp" "$file"; then - rm -f "$tmp" - error "Failed to remove the $block block from setup script: $file" - return 1 - fi - - return 0 + rewriteSetupBlock "$1" "$2" "remove" } finalizeSetupScript() { @@ -661,13 +671,9 @@ validateGeneratedXML() { validateSetupScript() { local file="$1" + local block - local blocks=( - LOCAL_ACCOUNT - PRODUCT_KEY - SHARED_FOLDER - OEM_SCRIPT - ) + local blocks=(LOCAL_ACCOUNT PRODUCT_KEY SHARED_FOLDER OEM_SCRIPT) [ -s "$file" ] || return 1 @@ -682,6 +688,7 @@ validateSetupBlock() { local file="$1" local block="$2" + local begin="rem BEGIN $block" local end="rem END $block" local begin_count end_count begin_line end_line @@ -781,6 +788,7 @@ validateComputerName() { validateWorkgroup() { local value="$1" + local safe [ -z "$value" ] && return 0 @@ -825,6 +833,7 @@ validatePassword() { local value="$1" local desc="${2:-}" + local suffix="" [ -n "$desc" ] && suffix=" for $desc" @@ -846,6 +855,7 @@ validateUsername() { local value="$1" local type="$2" + local maximum length_suffix invalid_message case "$type" in @@ -944,174 +954,333 @@ validateDomainName() { return 0 } -updateWorkgroup() { +getXMLNodeCount() { local asset="$1" - local workgroup arch tmp + local xpath="$2" - workgroup=$(escapeXML "$2") || return 1 - arch=$(getXMLArchitecture "$asset") || return 1 + local ns="urn:schemas-microsoft-com:unattend" - grep -q 'Microsoft-Windows-UnattendedJoin' "$asset" && return 1 + xmlstarlet sel -N "u=$ns" -T -t -v "count($xpath)" "$asset" +} - tmp=$(mktemp -d) || return 1 - local result="$tmp/answer.xml" +copyXMLAsset() { - if ! WORKGROUP_XML="$workgroup" ARCH_XML="$arch" awk ' - /<settings[^>]*pass="specialize"[^>]*>/ { section = "specialize" } + local asset="$1" - section == "specialize" && !workgroup_added && - /^[[:space:]]*<\/settings>[[:space:]]*$/ { - print " <component name=\"Microsoft-Windows-UnattendedJoin\" processorArchitecture=\"" ENVIRON["ARCH_XML"] "\" publicKeyToken=\"31bf3856ad364e35\" language=\"neutral\" versionScope=\"nonSxS\">\n" \ - " <Identification>\n" \ - " <JoinWorkgroup>" ENVIRON["WORKGROUP_XML"] "</JoinWorkgroup>\n" \ - " </Identification>\n" \ - " </component>" - workgroup_added = 1 - } + local copy - { print } + if ! copy=$(mktemp "${asset}.XXXXXX") || + ! cp -p -- "$asset" "$copy"; then - /^[[:space:]]*<\/settings>[[:space:]]*$/ { section = "" } - END { exit !workgroup_added } - ' "$asset" > "$result" || - ! mv -f "$result" "$asset"; then - - rm -rf "$tmp" || true + rm -f "${copy:-}" + return 1 + fi + + printf '%s' "$copy" + return 0 +} + +replaceXMLAsset() { + + local asset="$1" + local tmp="$2" + + if ! chmod --reference="$asset" "$tmp" || + ! mv -f "$tmp" "$asset"; then + + rm -f "$tmp" + return 1 + fi + + return 0 +} + +ensureUnattendedJoin() { + + local asset="$1" + local arch="$2" + + local ns="urn:schemas-microsoft-com:unattend" + local specialize='/u:unattend/u:settings[@pass="specialize"]' + local component="$specialize/u:component[@name='Microsoft-Windows-UnattendedJoin']" + local identification="$component/u:Identification" + local counts settings_count component_count identification_count + + counts=$(xmlstarlet sel \ + -N "u=$ns" \ + -T -t \ + -v "count($specialize)" -o '|' \ + -v "count($component)" -o '|' -v "count($identification)" "$asset") || return 1 + + IFS='|' read -r settings_count component_count identification_count <<< "$counts" + + [ "$settings_count" = "1" ] || return 1 + (( component_count <= 1 )) || return 1 + (( identification_count <= 1 )) || return 1 + + # Templates may omit the join component entirely. Create it when absent, or + # normalize its architecture and schema attributes when already present. + if [ "$component_count" = "0" ]; then + local created="($specialize/*[local-name()='component'])[last()]" + + xmlstarlet ed -L \ + -N "u=$ns" \ + -s "$specialize" -t elem -n 'component' \ + -i "$created" -t attr -n 'name' -v 'Microsoft-Windows-UnattendedJoin' \ + -i "$created" -t attr -n 'processorArchitecture' -v "$arch" \ + -i "$created" -t attr -n 'publicKeyToken' -v '31bf3856ad364e35' \ + -i "$created" -t attr -n 'language' -v 'neutral' \ + -i "$created" -t attr -n 'versionScope' -v 'nonSxS' \ + -s "$created" -t elem -n 'Identification' "$asset" || return 1 + + return 0 + fi + + xmlstarlet ed -L \ + -N "u=$ns" \ + -i "${component}[not(@processorArchitecture)]" -t attr -n 'processorArchitecture' -v "$arch" \ + -u "$component/@processorArchitecture" -v "$arch" \ + -i "${component}[not(@publicKeyToken)]" -t attr -n 'publicKeyToken' -v '31bf3856ad364e35' \ + -u "$component/@publicKeyToken" -v '31bf3856ad364e35' \ + -i "${component}[not(@language)]" -t attr -n 'language' -v 'neutral' \ + -u "$component/@language" -v 'neutral' \ + -i "${component}[not(@versionScope)]" -t attr -n 'versionScope' -v 'nonSxS' \ + -u "$component/@versionScope" -v 'nonSxS' \ + -s "${component}[not(u:Identification)]" -t elem -n 'Identification' "$asset" || return 1 + + return 0 +} + +configureDomainAccounts() { + + local asset="$1" + local domain="$2" + local account="$3" + local pass="$4" + + local ns="urn:schemas-microsoft-com:unattend" + local wcm="http://schemas.microsoft.com/WMIConfig/2002/State" + local shell='/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]' + local accounts="$shell/u:UserAccounts" + local administrator="$accounts/u:AdministratorPassword" + local autologon="$shell/u:AutoLogon" + local domain_accounts="$accounts/u:DomainAccounts" + local counts shell_count accounts_count administrator_count autologon_count child_count + + counts=$(xmlstarlet sel \ + -N "u=$ns" \ + -T -t \ + -v "count($shell)" -o '|' \ + -v "count($accounts)" -o '|' \ + -v "count($administrator)" -o '|' -v "count($autologon)" "$asset") || return 1 + + IFS='|' read -r shell_count accounts_count administrator_count autologon_count <<< "$counts" + + [ "$shell_count" = "1" ] || return 1 + (( accounts_count <= 1 )) || return 1 + (( administrator_count <= 1 )) || return 1 + (( autologon_count <= 1 )) || return 1 + + if [ "$accounts_count" = "0" ]; then + child_count=$(getXMLNodeCount "$asset" "$shell/*") || return 1 + + if [ "$child_count" = "0" ]; then + xmlstarlet ed -L -N "u=$ns" -s "$shell" -t elem -n 'UserAccounts' "$asset" || return 1 + else + xmlstarlet ed -L -N "u=$ns" -i "$shell/*[1]" -t elem -n 'UserAccounts' "$asset" || return 1 + fi + fi + + local created_accounts="$accounts/*[local-name()='DomainAccounts']" + local account_list="$created_accounts/*[local-name()='DomainAccountList']" + local domain_account="$account_list/*[local-name()='DomainAccount']" + local created_autologon="$shell/*[local-name()='AutoLogon']" + local auto_password="$created_autologon/*[local-name()='Password']" + + # Rebuild domain-account and autologon nodes from a clean state so stale + # local-template values cannot survive a domain conversion. + local -a args=( + -L + -N "u=$ns" + -N "wcm=$wcm" + -d "$domain_accounts | $autologon" + ) + + # Insert DomainAccounts before AdministratorPassword when it exists to retain + # the child order expected by the unattend schema. + if [ "$administrator_count" = "1" ]; then + args+=(-i "($administrator)[1]" -t elem -n 'DomainAccounts') + else + args+=(-s "$accounts" -t elem -n 'DomainAccounts') + fi + + args+=( + -s "$created_accounts" -t elem -n 'DomainAccountList' + -i "$account_list" -t attr -n 'wcm:action' -v 'add' + -s "$account_list" -t elem -n 'DomainAccount' + -i "$domain_account" -t attr -n 'wcm:action' -v 'add' + -s "$domain_account" -t elem -n 'Name' + -s "$domain_account" -t elem -n 'Group' -v 'Administrators' + -s "$account_list" -t elem -n 'Domain' + -a "$accounts" -t elem -n 'AutoLogon' + -s "$created_autologon" -t elem -n 'Username' + -s "$created_autologon" -t elem -n 'Domain' + -s "$created_autologon" -t elem -n 'Enabled' -v 'true' + -s "$created_autologon" -t elem -n 'LogonCount' -v '65432' + -s "$created_autologon" -t elem -n 'Password' + -s "$auto_password" -t elem -n 'Value' + -s "$auto_password" -t elem -n 'PlainText' -v 'true' + ) + + xmlstarlet ed "${args[@]}" "$asset" || return 1 + + xmlstarlet ed -L \ + -N "u=$ns" \ + -u "$domain_account/*[local-name()='Name']" -v "$account" \ + -u "$account_list/*[local-name()='Domain']" -v "$domain" \ + -u "$created_autologon/*[local-name()='Username']" -v "$account" \ + -u "$created_autologon/*[local-name()='Domain']" -v "$domain" \ + -u "$auto_password/*[local-name()='Value']" -v "$pass" "$asset" || return 1 + + return 0 +} + +configureDomainJoin() { + + local asset="$1" + local domain="$2" + local auth="$3" + local pass="$4" + local ou="$5" + local arch="$6" + + local ns="urn:schemas-microsoft-com:unattend" + local specialize='/u:unattend/u:settings[@pass="specialize"]' + local component="$specialize/u:component[@name='Microsoft-Windows-UnattendedJoin']" + local identification="$component/u:Identification" + local credentials="$identification/*[local-name()='Credentials']" + local cred_domain="$domain" + + local -a args=( + -L + -N "u=$ns" + -d "$identification/u:Credentials | $identification/u:JoinDomain | $identification/u:JoinWorkgroup | $identification/u:MachineObjectOU" + -s "$identification" -t elem -n 'Credentials' + ) + + ensureUnattendedJoin "$asset" "$arch" || return 1 + + # A user@domain UPN already contains its qualifier; adding a separate Domain + # credential node would describe the account twice. + case "$auth" in + *@* ) cred_domain="" ;; + esac + + if [ -n "$cred_domain" ]; then + args+=(-s "$credentials" -t elem -n 'Domain') + fi + + args+=( + -s "$credentials" -t elem -n 'Username' + -s "$credentials" -t elem -n 'Password' + -s "$identification" -t elem -n 'JoinDomain' + ) + + if [ -n "$ou" ]; then + args+=(-s "$identification" -t elem -n 'MachineObjectOU') + fi + + xmlstarlet ed "${args[@]}" "$asset" || return 1 + + local -a values=( + -L + -N "u=$ns" + -u "$credentials/*[local-name()='Username']" -v "$auth" + -u "$credentials/*[local-name()='Password']" -v "$pass" + -u "$identification/*[local-name()='JoinDomain']" -v "$domain" + ) + + if [ -n "$cred_domain" ]; then + values+=(-u "$credentials/*[local-name()='Domain']" -v "$cred_domain") + fi + + if [ -n "$ou" ]; then + values+=(-u "$identification/*[local-name()='MachineObjectOU']" -v "$ou") + fi + + xmlstarlet ed "${values[@]}" "$asset" || return 1 + return 0 +} + +updateWorkgroup() { + + local asset="$1" + local workgroup="$2" + + local ns="urn:schemas-microsoft-com:unattend" + local specialize='/u:unattend/u:settings[@pass="specialize"]' + local component="$specialize/u:component[@name='Microsoft-Windows-UnattendedJoin']" + local identification="$component/u:Identification" + local join="$identification/*[local-name()='JoinWorkgroup']" + local arch tmp + + arch=$(getXMLArchitecture "$asset") || return 1 + # Apply all membership changes to a copy and publish it only after the old + # domain, credential, OU, and workgroup nodes have been replaced successfully. + tmp=$(copyXMLAsset "$asset") || return 1 + + if ! ensureUnattendedJoin "$tmp" "$arch" || + ! xmlstarlet ed -L \ + -N "u=$ns" \ + -d "$identification/u:Credentials | $identification/u:JoinDomain | $identification/u:JoinWorkgroup | $identification/u:MachineObjectOU" \ + -s "$identification" -t elem -n 'JoinWorkgroup' "$tmp" || + ! xmlstarlet ed -L -N "u=$ns" -u "$join" -v "$workgroup" "$tmp" || + ! replaceXMLAsset "$asset" "$tmp"; then + + rm -f "$tmp" return 1 fi - rm -rf "$tmp" || return 1 return 0 } updateDomain() { local asset="$1" - local domain account auth pass - local ou arch tmp + local domain="$2" + local account="$3" + local auth="$4" + local pass="$5" + local ou="$6" + + local arch tmp - domain=$(escapeXML "$2") || return 1 - account=$(escapeXML "$3") || return 1 - auth=$(escapeXML "$4") || return 1 - pass=$(escapeXML "$5") || return 1 - ou=$(escapeXML "$6") || return 1 arch=$(getXMLArchitecture "$asset") || return 1 + # Account and join settings are separate XML transformations, so update a + # copy to keep the original answer file intact if either transformation fails. + tmp=$(copyXMLAsset "$asset") || return 1 - local cred_domain="$domain" + if ! configureDomainAccounts "$tmp" "$domain" "$account" "$pass" || + ! configureDomainJoin "$tmp" "$domain" "$auth" "$pass" "$ou" "$arch" || + ! replaceXMLAsset "$asset" "$tmp"; then - case "$4" in - *@* ) cred_domain="" ;; - esac - - grep -Eq 'Microsoft-Windows-UnattendedJoin|<DomainAccounts([[:space:]/>])' "$asset" && return 1 - - tmp=$(mktemp -d) || return 1 - local result="$tmp/answer.xml" - - if ! DOMAIN_XML="$domain" ACCOUNT_XML="$account" \ - AUTH_XML="$auth" PASS_XML="$pass" \ - CRED_DOMAIN="$cred_domain" OU_XML="$ou" \ - ARCH_XML="$arch" \ - awk ' - /<settings[^>]*pass="specialize"[^>]*>/ { section = "specialize" } - /<settings[^>]*pass="oobeSystem"[^>]*>/ { section = "oobeSystem" } - section == "oobeSystem" && /<UserAccounts([[:space:]>])/ { in_accounts = 1 } - section == "oobeSystem" && /<AutoLogon([[:space:]>])/ { in_autologon = 1 } - - section == "oobeSystem" && in_accounts && !accounts_added && - /<AdministratorPassword([[:space:]>])/ { - print " <DomainAccounts>\n" \ - " <DomainAccountList wcm:action=\"add\">\n" \ - " <DomainAccount wcm:action=\"add\">\n" \ - " <Name>" ENVIRON["ACCOUNT_XML"] "</Name>\n" \ - " <Group>Administrators</Group>\n" \ - " </DomainAccount>\n" \ - " <Domain>" ENVIRON["DOMAIN_XML"] "</Domain>\n" \ - " </DomainAccountList>\n" \ - " </DomainAccounts>" - accounts_added = 1 - } - - section == "oobeSystem" && in_autologon && - /^[[:space:]]*<Username>.*<\/Username>[[:space:]]*$/ { - print " <Username>" ENVIRON["ACCOUNT_XML"] "</Username>\n" \ - " <Domain>" ENVIRON["DOMAIN_XML"] "</Domain>" - autologon_added = 1 - next - } - - section == "oobeSystem" && in_autologon && - /^[[:space:]]*<Domain([[:space:]/>])/ { next } - - section == "oobeSystem" && in_autologon && - /^[[:space:]]*<Value>.*<\/Value>[[:space:]]*$/ { - print " <Value>" ENVIRON["PASS_XML"] "</Value>" - password_added = 1 - next - } - - section == "oobeSystem" && in_autologon && - /^[[:space:]]*<PlainText([[:space:]/>])/ { - print " <PlainText>true</PlainText>" - plaintext_added = 1 - next - } - - section == "specialize" && !join_added && - /^[[:space:]]*<\/settings>[[:space:]]*$/ { - print " <component name=\"Microsoft-Windows-UnattendedJoin\" processorArchitecture=\"" ENVIRON["ARCH_XML"] "\" publicKeyToken=\"31bf3856ad364e35\" language=\"neutral\" versionScope=\"nonSxS\">\n" \ - " <Identification>\n" \ - " <Credentials>" - - if (ENVIRON["CRED_DOMAIN"] != "") { - print " <Domain>" ENVIRON["CRED_DOMAIN"] "</Domain>" - } - - print " <Username>" ENVIRON["AUTH_XML"] "</Username>\n" \ - " <Password>" ENVIRON["PASS_XML"] "</Password>\n" \ - " </Credentials>\n" \ - " <JoinDomain>" ENVIRON["DOMAIN_XML"] "</JoinDomain>" - - if (ENVIRON["OU_XML"] != "") { - print " <MachineObjectOU>" ENVIRON["OU_XML"] "</MachineObjectOU>" - } - - print " </Identification>\n" \ - " </component>" - - join_added = 1 - } - - { print } - - section == "oobeSystem" && /<\/AutoLogon>/ { in_autologon = 0 } - section == "oobeSystem" && /<\/UserAccounts>/ { in_accounts = 0 } - /^[[:space:]]*<\/settings>[[:space:]]*$/ { section = "" } - - END { exit !(join_added && accounts_added && autologon_added && password_added && plaintext_added) } - ' "$asset" > "$result" || - ! mv -f "$result" "$asset"; then - - rm -rf "$tmp" || true + rm -f "$tmp" return 1 fi - rm -rf "$tmp" || return 1 return 0 } prepareDomainAccount() { local domain="$1" - local -n account_ref="$2" - local -n auth_ref="$3" + + local account="" + local auth="${USERNAME:-}" local qualifier="" - auth_ref="${USERNAME:-}" - account_ref="" - - if [ -z "$auth_ref" ]; then + if [ -z "$auth" ]; then error "The USERNAME variable must be specified when joining a domain!" return 1 fi @@ -1123,17 +1292,19 @@ prepareDomainAccount() { validateDomainName "$domain" || return 1 - if [[ "$auth_ref" == *\\* ]]; then + # Accept user or user@domain. DOMAIN\user is rejected because unattended + # setup stores the domain separately from the credential username. + if [[ "$auth" == *\\* ]]; then error "The USERNAME variable must use either \"user\" or \"user@domain\" format!" return 1 fi - case "$auth_ref" in + case "$auth" in *@* ) - account_ref="${auth_ref%%@*}" - qualifier="${auth_ref#*@}" + account="${auth%%@*}" + qualifier="${auth#*@}" - if [ -z "$account_ref" ] || + if [ -z "$account" ] || [ -z "$qualifier" ] || [[ "$qualifier" == *@* ]]; then @@ -1150,13 +1321,13 @@ prepareDomainAccount() { ;; * ) - account_ref="$auth_ref" + account="$auth" ;; esac - validateUsername "$account_ref" "domain" || return 1 + validateUsername "$account" "domain" || return 1 - if [[ "${account_ref,,}" == "docker" ]]; then + if [[ "${account,,}" == "docker" ]]; then error "The USERNAME variable must be changed from its default value when joining a domain!" return 1 fi @@ -1166,24 +1337,32 @@ prepareDomainAccount() { return 1 fi + printf '%s\n' "$account" "$auth" return 0 } updateDisplayXML() { local asset="$1" - local app host - app=$(escapeXMLSed "$APP for $ENGINE") || return 1 + local ns="urn:schemas-microsoft-com:unattend" + local setup='/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]' + local specialize='/u:unattend/u:settings[@pass="specialize"]/u:component[@name="Microsoft-Windows-Shell-Setup"]' + local oobe='/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]' + local app="$APP for $ENGINE" + local -a args=( + -L + -N "u=$ns" + -u "$setup/u:UserData/u:Organization | $specialize/u:OEMInformation/u:Model | $specialize/u:OEMName | $specialize/u:RegisteredOwner | $oobe/u:RegisteredOwner" -v "$app" + -u "$oobe/u:Display/u:VerticalResolution" -v "$HEIGHT" + -u "$oobe/u:Display/u:HorizontalResolution" -v "$WIDTH" + ) - sed -i "s|>Windows for Docker<|>$app<|g" "$asset" || return 1 - sed -i -E "s|<VerticalResolution>[^<]*</VerticalResolution>|<VerticalResolution>$HEIGHT</VerticalResolution>|g" "$asset" || return 1 - sed -i -E "s|<HorizontalResolution>[^<]*</HorizontalResolution>|<HorizontalResolution>$WIDTH</HorizontalResolution>|g" "$asset" || return 1 + if [ -n "${HOST:-}" ]; then + args+=(-u "$specialize/u:ComputerName" -v "$HOST") + fi - [ -n "${HOST:-}" ] || return 0 - - host=$(escapeXMLSed "$HOST") || return 1 - sed -i -E "s|<ComputerName>[^<]*</ComputerName>|<ComputerName>$host</ComputerName>|g" "$asset" || return 1 + xmlstarlet ed "${args[@]}" "$asset" || return 1 return 0 } @@ -1192,65 +1371,266 @@ updateLocaleXML() { local asset="$1" local language="$2" - local culture region keyboard value + + local ns="urn:schemas-microsoft-com:unattend" + local international='/u:unattend/u:settings/u:component[@name="Microsoft-Windows-International-Core" or @name="Microsoft-Windows-International-Core-WinPE"]' + local culture region keyboard + local -a args=(-L -N "u=$ns") culture=$(getLanguage "$language" "culture") || return 1 - if [ -n "$culture" ] && [[ "${culture,,}" != "en-us" ]]; then - value=$(escapeXMLSed "$culture") || return 1 - sed -i "s|<UILanguage>en-US</UILanguage>|<UILanguage>$value</UILanguage>|g" "$asset" || return 1 + if [ -n "$culture" ]; then + args+=(-u "$international//u:UILanguage" -v "$culture") fi region="${REGION:-$culture}" - if [ -n "$region" ] && [[ "${region,,}" != "en-us" ]]; then - value=$(escapeXMLSed "$region") || return 1 - sed -i "s|<UserLocale>en-US</UserLocale>|<UserLocale>$value</UserLocale>|g" "$asset" || return 1 - sed -i "s|<SystemLocale>en-US</SystemLocale>|<SystemLocale>$value</SystemLocale>|g" "$asset" || return 1 + if [ -n "$region" ]; then + args+=(-u "$international/u:UserLocale | $international/u:SystemLocale" -v "$region") fi keyboard="${KEYBOARD:-$culture}" - if [ -n "$keyboard" ] && [[ "${keyboard,,}" != "en-us" ]]; then - value=$(escapeXMLSed "$keyboard") || return 1 - sed -i "s|<InputLocale>en-US</InputLocale>|<InputLocale>$value</InputLocale>|g" "$asset" || return 1 - sed -i "s|<InputLocale>0409:00000409</InputLocale>|<InputLocale>$value</InputLocale>|g" "$asset" || return 1 + if [ -n "$keyboard" ]; then + args+=(-u "$international/u:InputLocale" -v "$keyboard") + fi + + if (( ${#args[@]} > 3 )); then + xmlstarlet ed "${args[@]}" "$asset" || return 1 fi return 0 } +findPrimaryLocalAccount() { + + local asset="$1" + + local ns="urn:schemas-microsoft-com:unattend" + local shell='/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]' + local local_accounts="$shell/u:UserAccounts/u:LocalAccounts/u:LocalAccount" + local administrator="$shell/u:UserAccounts/u:AdministratorPassword" + local autologon="$shell/u:AutoLogon" + local auto_primary=0 auto_matches=0 + local admin_primary=0 admin_matches=0 + local selected=0 separator=$'\x1f' + + local -a groups=() + local counts records auto_user selected_user position name group + local shell_count local_count found_admin found_autologon token + + counts=$(xmlstarlet sel \ + -N "u=$ns" \ + -T -t \ + -v "count($shell)" -o '|' \ + -v "count($local_accounts)" -o '|' \ + -v "count($administrator)" -o '|' -v "count($autologon)" "$asset") || return 1 + + IFS='|' read -r shell_count local_count found_admin found_autologon <<< "$counts" + + [ "$shell_count" = "1" ] || return 1 + (( local_count > 0 )) || return 1 + (( found_admin <= 1 )) || return 1 + (( found_autologon <= 1 )) || return 1 + + auto_user="" + + if [ "$found_autologon" = "1" ]; then + auto_user=$(xmlstarlet sel \ + -N "u=$ns" -T -t -v "normalize-space(string($autologon/u:Username))" "$asset") || return 1 + fi + + records=$(xmlstarlet sel \ + -N "u=$ns" \ + -T -t \ + -m "$local_accounts" \ + -v 'position()' -o "$separator" \ + -v 'normalize-space(string(u:Name))' -o "$separator" \ + -v 'normalize-space(string(u:Group))' -n "$asset") || return 1 + + while IFS="$separator" read -r position name group; do + + if [ -n "$auto_user" ] && + [[ "${name,,}" == "${auto_user,,}" ]]; then + auto_primary="$position" + ((auto_matches += 1)) + fi + + IFS=';,' read -r -a groups <<< "$group" + + for token in "${groups[@]}"; do + token="${token#"${token%%[![:space:]]*}"}" + token="${token%"${token##*[![:space:]]}"}" + [[ "${token,,}" == "administrators" ]] || continue + admin_primary="$position" + ((admin_matches += 1)) + break + done + + done <<< "$records" + + if (( auto_matches > 1 )); then + error "Multiple local accounts match the automatic-logon username!" + return 1 + fi + + # Prefer the account referenced by AutoLogon, then the only account, then + # the only administrator. Ambiguous templates are rejected rather than guessed. + if (( auto_matches == 1 )); then + selected="$auto_primary" + elif (( local_count == 1 )); then + selected=1 + elif (( admin_matches == 1 )); then + selected="$admin_primary" + else + error "Failed to identify the primary local account in the answer file!" + return 1 + fi + + selected_user=$(xmlstarlet sel \ + -N "u=$ns" -T -t -v "normalize-space(string(${local_accounts}[${selected}]/u:Name))" "$asset") || return 1 + + [ -n "$selected_user" ] || return 1 + + printf '%s\n' \ + "$selected" "$selected_user" "$found_admin" "$found_autologon" + + return 0 +} + +validateUniqueXMLNodes() { + + local asset="$1" + shift + + local xpath count + + for xpath in "$@"; do + count=$(getXMLNodeCount "$asset" "$xpath") || return 1 + (( count <= 1 )) || return 1 + done + + return 0 +} + +encodeUnattendPassword() { + + local password="$1" + local suffix="$2" + + # Windows unattend password fields use a field-specific suffix before + # UTF-16LE/Base64 encoding; this is obfuscation rather than encryption. + printf '%s' "${password}${suffix}" | + iconv -f utf-8 -t utf-16le | + base64 -w 0 +} + updateLocalAccount() { local asset="$1" + local user="${USERNAME:-}" local pass="${PASSWORD:-admin}" - local user_xml pw admin + local ns="urn:schemas-microsoft-com:unattend" + local setup='/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]' + local shell='/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]' + local local_accounts="$shell/u:UserAccounts/u:LocalAccounts/u:LocalAccount" + local administrator="$shell/u:UserAccounts/u:AdministratorPassword" + local autologon="$shell/u:AutoLogon" + local primary admin_count autologon_count tmp + local current_user target_user pw admin result + local -a values=() validateUsername "$user" "local" || return 1 - if [ -n "$user" ]; then - user_xml=$(escapeXMLSed "$user") || return 1 - sed -i "s|<Name>Docker</Name>|<Name>$user_xml</Name>|g" "$asset" || return 1 - sed -i "s|<FullName>Docker</FullName>|<FullName>$user_xml</FullName>|g" "$asset" || return 1 - sed -i "s|<Username>Docker</Username>|<Username>$user_xml</Username>|g" "$asset" || return 1 + result=$(findPrimaryLocalAccount "$asset") || return 1 + mapfile -t values <<< "$result" + (( ${#values[@]} == 4 )) || return 1 + primary="${values[0]}" + current_user="${values[1]}" + admin_count="${values[2]}" + autologon_count="${values[3]}" + + local account="${local_accounts}[${primary}]" + local password="$account/*[local-name()='Password']" + local admin_value="$administrator/*[local-name()='Value']" + local admin_plain="$administrator/*[local-name()='PlainText']" + local auto_password="$autologon/*[local-name()='Password']" + local auto_value="$auto_password/*[local-name()='Value']" + local auto_plain="$auto_password/*[local-name()='PlainText']" + + target_user="${user:-$current_user}" + + # Update the selected local account, Administrator password, and AutoLogon + # credentials atomically so they cannot become inconsistent. + tmp=$(copyXMLAsset "$asset") || return 1 + + if ! validateUniqueXMLNodes "$tmp" \ + "$password" \ + "$password/*[local-name()='Value']" \ + "$password/*[local-name()='PlainText']" \ + "$admin_value" \ + "$admin_plain" \ + "$autologon/*[local-name()='Username']" "$auto_password" "$auto_value" "$auto_plain"; then + + rm -f "$tmp" + return 1 fi - pw=$(printf '%s' "${pass}Password" | - iconv -f utf-8 -t utf-16le | - base64 -w 0) || return 1 + pw=$(encodeUnattendPassword "$pass" "Password") || { + rm -f "$tmp" + return 1 + } - admin=$(printf '%s' "${pass}AdministratorPassword" | - iconv -f utf-8 -t utf-16le | - base64 -w 0) || return 1 + admin=$(encodeUnattendPassword "$pass" "AdministratorPassword") || { + rm -f "$tmp" + return 1 + } - sed -i -z -E \ - "s#(<Password>[[:space:]]*<Value)([[:space:]]*/>|>[^<]*</Value>)#\1>$pw</Value>#g" \ - "$asset" || return 1 + local -a args=( + -L + -N "u=$ns" + -s "${account}[not(*[local-name()='Password'])]" -t elem -n 'Password' + -s "${password}[not(*[local-name()='Value'])]" -t elem -n 'Value' + -s "${password}[not(*[local-name()='PlainText'])]" -t elem -n 'PlainText' + -u "$password/*[local-name()='Value']" -v "$pw" + -u "$password/*[local-name()='PlainText']" -v 'false' + ) - sed -i -z -E \ - "s#(<AdministratorPassword>[[:space:]]*<Value)([[:space:]]*/>|>[^<]*</Value>)#\1>$admin</Value>#g" \ - "$asset" || return 1 + if [ -n "$user" ]; then + args+=( + -u "$account/u:Name" -v "$user" + -u "$setup/u:UserData/u:FullName" -v "$user" + ) + fi + + if [ "$admin_count" = "1" ]; then + args+=( + -s "${administrator}[not(*[local-name()='Value'])]" -t elem -n 'Value' + -s "${administrator}[not(*[local-name()='PlainText'])]" -t elem -n 'PlainText' + -u "$admin_value" -v "$admin" + -u "$admin_plain" -v 'false' + ) + fi + + if [ "$autologon_count" = "1" ]; then + args+=( + -s "${autologon}[not(*[local-name()='Username'])]" -t elem -n 'Username' + -s "${autologon}[not(*[local-name()='Password'])]" -t elem -n 'Password' + -s "${auto_password}[not(*[local-name()='Value'])]" -t elem -n 'Value' + -s "${auto_password}[not(*[local-name()='PlainText'])]" -t elem -n 'PlainText' + -u "$autologon/*[local-name()='Username']" -v "$target_user" + -u "$auto_value" -v "$pw" + -u "$auto_plain" -v 'false' + ) + fi + + if ! xmlstarlet ed "${args[@]}" "$tmp" || + ! replaceXMLAsset "$asset" "$tmp"; then + + rm -f "$tmp" + return 1 + fi return 0 } @@ -1265,13 +1645,9 @@ updateMembership() { if [ -n "$domain" ]; then - if ! updateDomain \ - "$asset" \ - "$domain" \ - "$account" \ - "$auth" \ - "$PASSWORD" \ - "${DOMAIN_OU:-}"; then + # Domain customization is optional: if the template cannot be transformed, + # retain its local-account path and allow installation to continue. + if ! updateDomain "$asset" "$domain" "$account" "$auth" "$PASSWORD" "${DOMAIN_OU:-}"; then warn "failed to add domain configuration to answer file!" return 0 @@ -1294,11 +1670,12 @@ updateAutologinXML() { local asset="$1" + local ns="urn:schemas-microsoft-com:unattend" + local shell='/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]' + disabled "${AUTOLOGIN:-}" || return 0 - sed -i -E \ - '/^[[:space:]]*<AutoLogon([[:space:]>])/,/^[[:space:]]*<\/AutoLogon>[[:space:]]*$/d' \ - "$asset" || return 1 + xmlstarlet ed -L -N "u=$ns" -d "$shell/u:AutoLogon" "$asset" || return 1 return 0 } @@ -1306,18 +1683,49 @@ updateAutologinXML() { updateEditionXML() { local asset="$1" - local edition + + local ns="urn:schemas-microsoft-com:unattend" + local upper='ABCDEFGHIJKLMNOPQRSTUVWXYZ' + local lower='abcdefghijklmnopqrstuvwxyz' + local setup='/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]' + local selector="$setup/u:ImageInstall/u:OSImage/u:InstallFrom/u:MetaData[translate(normalize-space(u:Key), '$lower', '$upper')='/IMAGE/NAME']/u:Value" + local edition count records position value prefix replacement + local separator=$'\x1f' [ -n "${EDITION:-}" ] || return 0 + count=$(getXMLNodeCount "$asset" "$selector") || return 1 + + # Client and index-based answer files do not contain an /IMAGE/NAME + # selector. In that case there is nothing to update. + [ "$count" != "0" ] || return 0 + edition=$(normalizeServerEdition "$EDITION") || return 1 edition="${edition//-/}" edition="${edition^^}" - edition=$(escapeXMLSed "$edition") || return 1 - sed -i \ - "s|SERVERSTANDARD</Value>|SERVER$edition</Value>|g" \ - "$asset" || return 1 + records=$(xmlstarlet sel \ + -N "u=$ns" -T -t -m "$selector" -v 'position()' -o "$separator" -v 'string(.)' -n "$asset") || return 1 + + while IFS="$separator" read -r position value; do + [ -n "$position" ] || continue + + # Only Windows Server templates use EDITION as a mutable answer-file + # selector. Products such as Hyper-V Server have fixed SERVER* flags that + # must not be rewritten. + [[ "${value,,}" == *"windows server"* ]] || continue + + if [[ "$value" =~ ^(.*[[:space:]])SERVER[A-Za-z0-9_-]+[[:space:]]*$ ]]; then + prefix="${BASH_REMATCH[1]}" + replacement="${prefix}SERVER$edition" + elif [[ "$value" =~ ^SERVER[A-Za-z0-9_-]+[[:space:]]*$ ]]; then + replacement="SERVER$edition" + else + continue + fi + + xmlstarlet ed -L -N "u=$ns" -u "($selector)[$position]" -v "$replacement" "$asset" || return 1 + done <<< "$records" return 0 } @@ -1325,6 +1733,7 @@ updateEditionXML() { updateProductKey() { local script="$1" + local key="${KEY:-}" local content @@ -1347,11 +1756,18 @@ updateDiskID() { local asset="$1" local disk_type="${2,,}" local mode="${3:-setup}" + local target="0" - local matches ids current count rc + local ns="urn:schemas-microsoft-com:unattend" + local setup='/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]' + local disk_ids="$setup//u:DiskID" + local count values value current + local -a ids=() [ -s "$asset" ] || return 1 + # The setup overlay occupies disk 0, so common VirtIO installation disks move + # to disk 1 in setup-image mode. Rebuilt media keeps the original disk layout. case "$mode" in "setup" ) case "$disk_type" in @@ -1362,29 +1778,33 @@ updateDiskID() { * ) return 1 ;; esac - matches=$(grep -oE '<DiskID>[[:space:]]*[0-9]+[[:space:]]*</DiskID>' "$asset") || { - rc=$? - if [ "$rc" -eq 1 ]; then - matches="" - else - error "Failed to read DiskID values from answer file: $asset" - return 1 - fi + count=$(getXMLNodeCount "$asset" "$disk_ids") || { + error "Failed to read DiskID values from answer file: $asset" + return 1 } - # Some custom answer files do not contain a disk configuration. - [ -n "$matches" ] || return 0 + [ "$count" != "0" ] || return 0 - ids=$(printf '%s\n' "$matches" | - sed -E 's#.*<DiskID>[[:space:]]*([0-9]+)[[:space:]]*</DiskID>.*#\1#' | - sort -u) || return 1 + values=$(xmlstarlet sel -N "u=$ns" -T -t -m "$disk_ids" -v 'normalize-space(.)' -n "$asset") || { + error "Failed to read DiskID values from answer file: $asset" + return 1 + } - count=$(printf '%s\n' "$ids" | wc -l) || return 1 + while IFS= read -r value; do + if [[ ! "$value" =~ ^[0-9]+$ ]]; then + error "Invalid DiskID value in answer file: $asset" + return 1 + fi + + ids+=( "$value" ) + done <<< "$values" + + mapfile -t ids < <(printf '%s\n' "${ids[@]}" | sort -u) # Leave explicit multi-disk configurations untouched. - [ "$count" -eq 1 ] || return 0 + (( ${#ids[@]} == 1 )) || return 0 - current="$ids" + current="${ids[0]}" [ "$current" = "$target" ] && return 0 case "$current" in @@ -1395,8 +1815,8 @@ updateDiskID() { ;; esac - if ! sed -i -E \ - "s#<DiskID>[[:space:]]*${current}[[:space:]]*</DiskID>#<DiskID>$target</DiskID>#g" \ "$asset"; then + if ! xmlstarlet ed -L -N "u=$ns" -u "${disk_ids}[normalize-space(.)='$current']" -v "$target" "$asset"; then + error "Failed to update DiskID in answer file: $asset" return 1 fi @@ -1407,102 +1827,105 @@ updateDiskID() { getXMLArchitecture() { local asset="$1" + + local ns="urn:schemas-microsoft-com:unattend" local arch + # Prefer architecture declarations from Windows PE setup components and skip + # wow64 compatibility components, which do not describe the target image. + local -a paths=( + '/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]/@processorArchitecture' + '/u:unattend/u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-International-Core-WinPE"]/@processorArchitecture' + '/u:unattend/u:settings/u:component[translate(@processorArchitecture, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") != "wow64"]/@processorArchitecture' + ) + local path - arch=$(sed -n -E \ - '0,/processorArchitecture="/s/.*processorArchitecture="([^"]+)".*/\1/p' \ - "$asset") || return 1 + for path in "${paths[@]}"; do + arch=$(xmlstarlet sel -N "u=$ns" -T -t -v "normalize-space(string(($path)[1]))" "$asset") || arch="" - [ -n "$arch" ] || return 1 + [ -n "$arch" ] || continue + [[ "${arch,,}" != "wow64" ]] || continue + printf '%s' "$arch" + return 0 + done - printf '%s' "$arch" - return 0 + return 1 } setConfigurationXML() { local asset="$1" - 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 + + local ns="urn:schemas-microsoft-com:unattend" + local userdata="$setup/*[local-name()='UserData']" + local config="$setup/*[local-name()='UseConfigurationSet']" + local setup='/*[local-name()="unattend"]/*[local-name()="settings" and @pass="windowsPE"]/*[local-name()="component" and @name="Microsoft-Windows-Setup"]' + local setup_count config_count config_value userdata_count result_count tmp [ -s "$asset" ] || return 1 - setup_count=$(xmlstarlet sel -t -v "count($setup)" "$asset") || return 1 + setup_count=$(getXMLNodeCount "$asset" "$setup") || return 1 if [ "$setup_count" != "1" ]; then error "Failed to find a unique Microsoft-Windows-Setup component: $asset" return 1 fi - config_count=$(xmlstarlet sel -t -v "count($config)" "$asset") || return 1 + config_count=$(getXMLNodeCount "$asset" "$config") || 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!" + if [ "$config_count" = "1" ]; then + config_value=$(xmlstarlet sel -T -t -v "translate(normalize-space(string($config)), 'TRUE', 'true')" "$asset") || return 1 + [ "$config_value" != "true" ] || return 0 + fi + + userdata_count=$(getXMLNodeCount "$asset" "$userdata") || return 1 + + if [ "$userdata_count" -gt 1 ]; then + error "Multiple UserData entries found in answer file: $asset" return 1 fi - if [ "$config_count" -eq 1 ]; then - - if ! xmlstarlet ed \ - -u "$config" \ - -v "true" \ - "$asset" > "$tmp"; then + tmp=$(copyXMLAsset "$asset") || { + error "Failed to create a temporary answer file!" + return 1 + } + if [ "$config_count" = "1" ]; then + xmlstarlet ed -L -N "u=$ns" -u "$config" -v "true" "$tmp" || { rm -f "$tmp" error "Failed to enable the Windows configuration set!" return 1 - fi - - else - - userdata_count=$(xmlstarlet sel -t -v "count($userdata)" "$asset") || { + } + elif [ "$userdata_count" = "1" ]; then + xmlstarlet ed -L -N "u=$ns" -i "$userdata" -t elem -n "u:UseConfigurationSet" -v "true" "$tmp" || { rm -f "$tmp" + error "Failed to enable the Windows configuration set!" + return 1 + } + else + xmlstarlet ed -L -N "u=$ns" -s "$setup" -t elem -n "u:UseConfigurationSet" -v "true" "$tmp" || { + rm -f "$tmp" + error "Failed to enable the Windows configuration set!" 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 - + result_count=$(getXMLNodeCount "$tmp" "${config}[normalize-space(.)='true']") || { rm -f "$tmp" + return 1 + } + + if [ "$result_count" != "1" ]; then + rm -f "$tmp" + error "Failed to enable the Windows configuration set!" + return 1 + fi + + if ! replaceXMLAsset "$asset" "$tmp"; then error "Failed to replace the updated answer file!" return 1 fi @@ -1528,10 +1951,11 @@ removeLocalAccount() { local asset="$1" - if ! sed -i -E \ - -e '/^[[:space:]]*<LocalAccounts([[:space:]>])/,/^[[:space:]]*<\/LocalAccounts>[[:space:]]*$/d' \ - -e '/^[[:space:]]*<AdministratorPassword([[:space:]>])/,/^[[:space:]]*<\/AdministratorPassword>[[:space:]]*$/d' \ - "$asset"; then + local ns="urn:schemas-microsoft-com:unattend" + local accounts='/u:unattend/u:settings[@pass="oobeSystem"]/u:component[@name="Microsoft-Windows-Shell-Setup"]/u:UserAccounts' + + if ! xmlstarlet ed -L \ + -N "u=$ns" -d "$accounts/u:LocalAccounts | $accounts/u:AdministratorPassword" "$asset"; then error "Failed to remove local account configuration from answer file!" return 1 @@ -1540,9 +1964,45 @@ removeLocalAccount() { return 0 } +removeEmbeddedProductKeys() { + + local asset="$1" + + local product_keys='//u:ProductKey' + local separator=$'\x1f' delete_xpath="" + local ns="urn:schemas-microsoft-com:unattend" + local records position child_key direct_key + + records=$(xmlstarlet sel \ + -N "u=$ns" -T -t \ + -m "$product_keys" \ + -v 'position()' -o "$separator" \ + -v 'normalize-space(string((u:Key[normalize-space(.)])[1]))' -o "$separator" \ + -v 'normalize-space(string(text()[normalize-space()][1]))' -n \ + "$asset") || return 1 + + while IFS="$separator" read -r position child_key direct_key; do + [ -n "$position" ] || continue + + if [[ ! "$child_key" =~ ^[A-Za-z0-9]{5}(-[A-Za-z0-9]{5}){4}$ ]] && + [[ ! "$direct_key" =~ ^[A-Za-z0-9]{5}(-[A-Za-z0-9]{5}){4}$ ]]; then + continue + fi + + [ -z "$delete_xpath" ] || delete_xpath+=" | " + delete_xpath+="($product_keys)[$position]" + done <<< "$records" + + [ -n "$delete_xpath" ] || return 0 + + xmlstarlet ed -L -N "u=$ns" -d "$delete_xpath" "$asset" || return 1 + return 0 +} + enableLog() { local script="$1" + local content enabled "${LOG:-}" || return 0 @@ -1561,6 +2021,7 @@ validateLegacyText() { local name="$1" local value="$2" local desc="${3:-}" + local suffix="" [ -n "$desc" ] && suffix=" for $desc" @@ -1582,6 +2043,7 @@ validateLegacyUsername() { local value="$1" local desc="${2:-}" + local suffix="" [ -n "$desc" ] && suffix=" for $desc" @@ -1630,19 +2092,6 @@ validateLegacyUsername() { return 0 } -escapeXMLSed() { - - local s - - s=$(escapeXML "$1") || return 1 - s=${s//\\/\\\\} - s=${s//&/\\&} - s=${s//|/\\|} - - printf '%s' "$s" - return 0 -} - escapeSIFValue() { local s="$1" @@ -1681,6 +2130,7 @@ copyStorageDriver() { local driver="$3" local arch="$4" local drivers="$5" + local destination="$dir/\$OEM\$/\$1/Drivers/viostor" if [ ! -f "$drivers/viostor/$driver/$arch/viostor.sys" ]; then @@ -1704,6 +2154,7 @@ addNetworkDriver() { local driver="$2" local arch="$3" local drivers="$4" + local destination="$dir/\$OEM\$/\$1/Drivers/NetKVM" if [ ! -f "$drivers/NetKVM/$driver/$arch/netkvm.sys" ]; then @@ -1724,6 +2175,8 @@ patchStorageDriver() { local file="$1" local arch="$2" + # Text-mode setup reads TXTSETUP.SIF before Plug and Play is available, so the + # VirtIO storage service and hardware IDs must be registered there explicitly. sed -i '/^\[SCSI.Load\]/s/$/\nviostor=viostor.sys,4/' "$file" || return 1 sed -i '/^\[SourceDisksFiles.'"$arch"'\]/s/$/\nviostor.sys=1,,,,,,4_,4,1,,,1,4/' "$file" || return 1 sed -i '/^\[SCSI\]/s/$/\nviostor=\"Red Hat VirtIO SCSI Disk Device\"/' "$file" || return 1 @@ -1741,6 +2194,7 @@ addSataDriver() { local arch="$3" local drivers="$4" local file="$5" + local destination="$dir/\$OEM\$/\$1/Drivers/sata" if [ ! -d "$drivers/sata/xp/$arch" ]; then @@ -1773,6 +2227,7 @@ addLegacyDrivers() { local driver="$3" local arch="$4" local drivers="$5" + local file local msg="Adding drivers to image..." @@ -1803,6 +2258,7 @@ setLegacyKey() { local driver="$2" local arch="$3" local desc="$4" + local setup pid key file setup=$(find "$target" -maxdepth 1 -type f -iname setupp.ini -print -quit) || return 1 @@ -1827,6 +2283,8 @@ setLegacyKey() { if [[ -n "$file" ]]; then + # Prefer a staging or OEM key already shipped on the media before falling + # back to Microsoft's documented generic installation keys. if [[ "$driver" == "2k3" ]]; then key=$(grep -i -A 2 "StagingKey" "$file" | tail -n 2 | head -n 1) || key="" else @@ -1936,17 +2394,13 @@ writeSIF() { ' Hibernation="No"' \ '' \ '[GuiUnattended]' \ - ' OEMSkipRegional=1' \ - ' OemSkipWelcome=1' \ - " AdminPassword=\"$sifPassword\"" \ - ' TimeZone=0' + ' OEMSkipRegional=1' ' OemSkipWelcome=1' " AdminPassword=\"$sifPassword\"" ' TimeZone=0' if disabled "$AUTOLOGIN"; then printf '%s\n' ' AutoLogon=No' else printf '%s\n' \ - ' AutoLogon=Yes' \ - ' AutoLogonCount=65432' + ' AutoLogon=Yes' ' AutoLogonCount=65432' fi printf '%s\n' \ @@ -1973,23 +2427,14 @@ writeSIF() { '' \ '[URL]' \ ' Home_Page = http://www.google.com' \ - ' Search_Page = http://www.google.com' \ - '' \ - '[TerminalServices]' \ - ' AllowConnections=1' \ - '' + ' Search_Page = http://www.google.com' '' '[TerminalServices]' ' AllowConnections=1' '' } | unix2dos > "$target/WINNT.SIF" || return 1 if [[ "$driver" == "2k3" ]]; then { printf '%s\n' \ '[Components]' \ - ' TerminalServer=On' \ - '' \ - '[LicenseFilePrintData]' \ - ' AutoMode=PerServer' \ - ' AutoUsers=5' \ - '' + ' TerminalServer=On' '' '[LicenseFilePrintData]' ' AutoMode=PerServer' ' AutoUsers=5' '' } | unix2dos >> "$target/WINNT.SIF" || return 1 fi @@ -2031,17 +2476,13 @@ writeRegistry() { '' \ '[HKEY_CURRENT_USER\Software\Microsoft\Internet Connection Wizard]' \ '"Completed"="1"' \ - '"Desktopchanged"="1"' \ - '' \ - '[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon]' + '"Desktopchanged"="1"' '' '[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon]' if disabled "$AUTOLOGIN"; then printf '%s\n' '"AutoAdminLogon"="0"' else printf '%s\n' \ - '"AutoAdminLogon"="1"' \ - "\"DefaultUserName\"=\"$regUsername\"" \ - "\"DefaultPassword\"=\"$regPassword\"" + '"AutoAdminLogon"="1"' "\"DefaultUserName\"=\"$regUsername\"" "\"DefaultPassword\"=\"$regPassword\"" fi printf '%s\n' \ @@ -2078,9 +2519,7 @@ appendRegistry() { if [[ "$driver" == "2k" ]]; then { printf '%s\n' \ - '[HKEY_USERS\.DEFAULT\Software\Microsoft\Windows\CurrentVersion\Runonce]' \ - '"^SetupICWDesktop"=-' \ - '' + '[HKEY_USERS\.DEFAULT\Software\Microsoft\Windows\CurrentVersion\Runonce]' '"^SetupICWDesktop"=-' '' } | unix2dos >> "$dir/\$OEM\$/install.reg" || return 1 fi @@ -2091,8 +2530,7 @@ appendRegistry() { '@=dword:00000000' \ '' \ '[HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\ServerOOBE\SecurityOOBE]' \ - '"DontLaunchSecurityOOBE"=dword:00000000' \ - '' + '"DontLaunchSecurityOOBE"=dword:00000000' '' } | unix2dos >> "$dir/\$OEM\$/install.reg" || return 1 fi @@ -2105,6 +2543,9 @@ writeVBS() { local username="$2" local shortcut="$3" + # Locate the built-in Administrator by its RID 500 SID rather than its + # localized display name, then rename that account to the requested username. + { printf '%s\n' \ 'Set WshShell = WScript.CreateObject("WScript.Shell")' \ @@ -2143,31 +2584,18 @@ writeVBS() { ' If Left(sid, 9) = "S-1-5-21-" And Right(sid, 4) = "-500" Then' \ ' LocalAdminADsPath = DomainItem.ADsPath' \ ' Exit For' \ - ' End If' \ - ' End If' \ - 'Next' \ - '' \ - "Call Domain.MoveHere(LocalAdminADsPath, \"$username\")" \ - '' + ' End If' ' End If' 'Next' '' "Call Domain.MoveHere(LocalAdminADsPath, \"$username\")" '' if enabled "$shortcut"; then printf '%s\n' \ 'Set oLink = WshShell.CreateShortcut(WshShell.SpecialFolders("Desktop") & "\Shared.lnk")' \ - 'With oLink' \ - ' .TargetPath = "\\host.lan\Data"' \ - ' .Save' \ - 'End With' \ - 'Set oLink = Nothing' \ - '' + 'With oLink' ' .TargetPath = "\\host.lan\Data"' ' .Save' 'End With' 'Set oLink = Nothing' '' fi } | unix2dos > "$dir/\$OEM\$/install.vbs" || return 1 { printf '%s\n' \ - '[COMMANDS]' \ - '"REGEDIT /s install.reg"' \ - '"Wscript install.vbs"' \ - '' + '[COMMANDS]' '"REGEDIT /s install.reg"' '"Wscript install.vbs"' '' } | unix2dos > "$dir/\$OEM\$/cmdlines.txt" || return 1 return 0 @@ -2176,14 +2604,11 @@ writeVBS() { disableAutoReboot() { local target="$1" + local file file=$(find \ - "$target" \ - -maxdepth 1 \ - -type f \ - -iname HIVESYS.INF \ - -print -quit + "$target" -maxdepth 1 -type f -iname HIVESYS.INF -print -quit ) || return 1 if [ -z "$file" ]; then @@ -2191,9 +2616,9 @@ disableAutoReboot() { return 1 fi - if grep -Fqi \ - 'HKLM,"SYSTEM\CurrentControlSet\Control\CrashControl","AutoReboot"' \ - "$file"; then + # Keep setup crashes visible instead of immediately rebooting into an + # opaque installation loop. + if grep -Fqi 'HKLM,"SYSTEM\CurrentControlSet\Control\CrashControl","AutoReboot"' "$file"; then sed -i -E \ 's|^(HKLM,"SYSTEM\\CurrentControlSet\\Control\\CrashControl","AutoReboot",[^,]*,)[^[:space:]]*|\1 0|I' \ @@ -2215,6 +2640,7 @@ legacyInstall() { local dir="$2" local desc="$3" local driver="$4" + local shortcut="Y" local drivers="/tmp/drivers" @@ -2234,6 +2660,8 @@ legacyInstall() { return 1 fi + # Legacy media uses directory names rather than metadata to identify the + # architecture and the text-mode setup source tree. local arch="amd64" [ ! -d "$dir/AMD64" ] && arch="x86" @@ -2269,11 +2697,7 @@ legacyInstall() { if [ -d "$oem_dir" ]; then install=$(find \ - "$oem_dir" \ - -maxdepth 1 \ - -type f \ - -iname install.bat \ - -print -quit + "$oem_dir" -maxdepth 1 -type f -iname install.bat -print -quit ) || return 1 fi @@ -2302,6 +2726,8 @@ legacyInstall() { validateLegacyUsername "$username" "$desc" || return 1 validatePassword "$password" "$desc" || return 1 + # WINNT.SIF and .reg files use different escaping rules, so prepare their + # values independently before generating either file. sifHost=$(escapeSIFValue "${HOST:-*}") || return 1 sifUsername=$(escapeSIFValue "$username") || return 1 sifPassword=$(escapeSIFValue "$password") || return 1 @@ -2313,19 +2739,9 @@ legacyInstall() { writeSIF \ "$target" \ "$driver" \ - "$product" \ - "$sifHost" \ - "$sifUsername" \ - "$sifPassword" \ - "$sifOrganization" \ - "$sifWorkgroup" || return 1 + "$product" "$sifHost" "$sifUsername" "$sifPassword" "$sifOrganization" "$sifWorkgroup" || return 1 - writeRegistry \ - "$dir" \ - "$shortcut" \ - "$oem" \ - "$regUsername" \ - "$regPassword" || return 1 + writeRegistry "$dir" "$shortcut" "$oem" "$regUsername" "$regPassword" || return 1 appendRegistry "$dir" "$driver" || return 1 writeVBS "$dir" "$username" "$shortcut" || return 1 diff --git a/src/define.sh b/src/define.sh index 94d60e8b..6398a32a 100644 --- a/src/define.sh +++ b/src/define.sh @@ -47,116 +47,79 @@ parseVersion() { case "${VERSION,,}" in "11" | "11p" | "win11" | "pro11" | "win11p" | "windows11" | "windows 11" ) - VERSION="win11x64" - ;; + VERSION="win11x64" ;; "11e" | "win11e" | "windows11e" | "windows 11e" ) - VERSION="win11x64-enterprise-eval" - ;; + VERSION="win11x64-enterprise-eval" ;; "11l" | "11ltsc" | "ltsc11" | "win11l" | "win11-ltsc" | "win11x64-ltsc" ) - VERSION="win11x64-enterprise-ltsc-eval" - ;; + VERSION="win11x64-enterprise-ltsc-eval" ;; "11i" | "11iot" | "iot11" | "win11i" | "win11-iot" | "win11x64-iot" ) - VERSION="win11x64-enterprise-iot-eval" - ;; + VERSION="win11x64-enterprise-iot-eval" ;; "10" | "10p" | "win10" | "pro10" | "win10p" | "windows10" | "windows 10" ) - VERSION="win10x64" - ;; + VERSION="win10x64" ;; "10e" | "win10e" | "windows10e" | "windows 10e" ) - VERSION="win10x64-enterprise-eval" - ;; + VERSION="win10x64-enterprise-eval" ;; "10l" | "10ltsc" | "ltsc10" | "win10l" | "win10-ltsc" | "win10x64-ltsc" ) - VERSION="win10x64-enterprise-ltsc-eval" - ;; + VERSION="win10x64-enterprise-ltsc-eval" ;; "10i" | "10iot" | "iot10" | "win10i" | "win10-iot" | "win10x64-iot" ) - VERSION="win10x64-enterprise-iot-eval" - ;; + VERSION="win10x64-enterprise-iot-eval" ;; "8" | "8p" | "81" | "81p" | "pro8" | "8.1" | "win8" | "win8p" | "win81" | "win81p" | "windows 8" ) - VERSION="win81x64" - ;; + VERSION="win81x64" ;; "8e" | "81e" | "8.1e" | "win8e" | "win81e" | "windows 8e" ) - VERSION="win81x64-enterprise-eval" - ;; + VERSION="win81x64-enterprise-eval" ;; "7" | "win7" | "windows7" | "windows 7" ) - VERSION="win7x64" - ;; + VERSION="win7x64" ;; "7u" | "win7u" | "windows7u" | "windows 7u" ) - VERSION="win7x64-ultimate" - ;; + VERSION="win7x64-ultimate" ;; "7e" | "win7e" | "windows7e" | "windows 7e" ) - VERSION="win7x64-enterprise" - ;; + VERSION="win7x64-enterprise" ;; "7x86" | "win7x86" | "win732" | "windows7x86" ) - VERSION="win7x86" - ;; + VERSION="win7x86" ;; "7ux86" | "7u32" | "win7x86-ultimate" ) - VERSION="win7x86-ultimate" - ;; + VERSION="win7x86-ultimate" ;; "7ex86" | "7e32" | "win7x86-enterprise" ) - VERSION="win7x86-enterprise" - ;; + VERSION="win7x86-enterprise" ;; "vista" | "vs" | "6" | "winvista" | "windowsvista" | "windows vista" ) - VERSION="winvistax64" - ;; + VERSION="winvistax64" ;; "vistu" | "vu" | "6u" | "winvistu" ) - VERSION="winvistax64-ultimate" - ;; + VERSION="winvistax64-ultimate" ;; "viste" | "ve" | "6e" | "winviste" ) - VERSION="winvistax64-enterprise" - ;; + VERSION="winvistax64-enterprise" ;; "vistax86" | "vista32" | "6x86" | "winvistax86" | "windowsvistax86" ) - VERSION="winvistax86" - ;; + VERSION="winvistax86" ;; "vux86" | "vu32" | "winvistax86-ultimate" ) - VERSION="winvistax86-ultimate" - ;; + VERSION="winvistax86-ultimate" ;; "vex86" | "ve32" | "winvistax86-enterprise" ) - VERSION="winvistax86-enterprise" - ;; + VERSION="winvistax86-enterprise" ;; "xp" | "xp32" | "xpx86" | "5" | "5x86" | "winxp" | "winxp86" | "windowsxp" | "windows xp" ) - VERSION="winxpx86" - ;; + VERSION="winxpx86" ;; "xp64" | "xpx64" | "5x64" | "winxp64" | "winxpx64" | "windowsxp64" | "windowsxpx64" ) - VERSION="winxpx64" - ;; + VERSION="winxpx64" ;; "2k" | "2000" | "win2k" | "win2000" | "windows2k" | "windows2000" ) - VERSION="win2kx86" - ;; + VERSION="win2kx86" ;; "25" | "2025" | "win25" | "win2025" | "windows2025" | "windows 2025" ) - VERSION="win2025-eval" - ;; + VERSION="win2025-eval" ;; "22" | "2022" | "win22" | "win2022" | "windows2022" | "windows 2022" ) - VERSION="win2022-eval" - ;; + VERSION="win2022-eval" ;; "19" | "2019" | "win19" | "win2019" | "windows2019" | "windows 2019" ) - VERSION="win2019-eval" - ;; + VERSION="win2019-eval" ;; "16" | "2016" | "win16" | "win2016" | "windows2016" | "windows 2016" ) - VERSION="win2016-eval" - ;; + VERSION="win2016-eval" ;; "hv" | "hyperv" | "hyper v" | "hyper-v" | "19hv" | "2019hv" | "win2019hv" ) - VERSION="win2019-hv" - ;; + VERSION="win2019-hv" ;; "2012" | "2012r2" | "win2012" | "win2012r2" | "windows2012" | "windows 2012" ) - VERSION="win2012r2-eval" - ;; + VERSION="win2012r2-eval" ;; "2008" | "2008r2" | "win2008" | "win2008r2" | "windows2008" | "windows 2008" ) - VERSION="win2008r2" - ;; + VERSION="win2008r2" ;; "2003" | "2003r2" | "win2003" | "win2003r2" | "windows2003" | "windows 2003" ) - VERSION="win2003r2" - ;; + VERSION="win2003r2" ;; "core11" | "core 11" ) - VERSION="core11" - ;; + VERSION="core11" ;; "tiny11" | "tiny 11" ) - VERSION="tiny11" - ;; + VERSION="tiny11" ;; "tiny10" | "tiny 10" ) - VERSION="tiny10" - ;; + VERSION="tiny10" ;; "reactos" | "react os" ) - VERSION="reactos" - ;; + VERSION="reactos" ;; esac SUGGEST=$(getSuggestedVersion "$VERSION") @@ -172,29 +135,21 @@ getSuggestedVersion() { case "$id" in "win10x64" | "win11x64" ) - echo "$id" - ;; + echo "$id" ;; "win7x64" | "win7x86" | "winvistax64" | "winvistax86" ) - echo "$id-ultimate" - ;; + echo "$id-ultimate" ;; "tiny10" ) - echo "win10x64-ltsc" - ;; + echo "win10x64-ltsc" ;; *"-enterprise-ltsc-eval" ) - echo "${id%-enterprise-ltsc-eval}-ltsc" - ;; + echo "${id%-enterprise-ltsc-eval}-ltsc" ;; *"-enterprise-iot-eval" ) - echo "${id%-enterprise-iot-eval}-iot" - ;; + echo "${id%-enterprise-iot-eval}-iot" ;; *"-enterprise-ltsc" ) - echo "${id%-enterprise-ltsc}-ltsc" - ;; + echo "${id%-enterprise-ltsc}-ltsc" ;; *"-enterprise-iot" ) - echo "${id%-enterprise-iot}-iot" - ;; + echo "${id%-enterprise-iot}-iot" ;; *"-eval" ) - echo "${id%-eval}" - ;; + echo "${id%-eval}" ;; esac return 0 @@ -205,11 +160,8 @@ getLanguage() { local source="$1" local input="${1,,}" local ret="$2" - local id="$source" - local lang="" - local desc="" - local short="" - local culture="" + + local id="$source" lang="" desc="" short="" culture="" case "$input" in "ar" | "ar-"* | "arabic" | "arab" ) @@ -496,14 +448,11 @@ printVariant() { case "${id,,}" in *"-iot" | *"-iot-eval" ) - desc+=" IoT" - ;; + desc+=" IoT" ;; *"-ltsc" | *"-ltsc-eval" ) - desc+=" LTSC" - ;; + desc+=" LTSC" ;; *"-enterprise" | *"-enterprise-eval" ) - desc+=" Enterprise" - ;; + desc+=" Enterprise" ;; esac if enabled "$show_eval" && [[ "${id,,}" == *"-eval" ]]; then @@ -540,6 +489,7 @@ printEdition() { local id="$1" local desc="$2" local show_eval="${3:-N}" + local normalized="${id,,}" local result edition="" suffix="" @@ -561,20 +511,15 @@ printEdition() { esac ;; "home" ) - edition="Home" - ;; + edition="Home" ;; "starter" ) - edition="Starter" - ;; + edition="Starter" ;; "ultimate" ) - edition="Ultimate" - ;; + edition="Ultimate" ;; "enterprise" ) - edition="Enterprise" - ;; + edition="Enterprise" ;; "education" ) - edition="Education" - ;; + edition="Education" ;; "n" ) case "$normalized" in "win7"* ) edition="Professional N" ;; @@ -582,22 +527,18 @@ printEdition() { esac ;; "iot" | "enterprise-iot" ) - edition="IoT Enterprise LTSC" - ;; + edition="IoT Enterprise LTSC" ;; "ltsc" | "enterprise-ltsc" ) - edition="Enterprise LTSC" - ;; + edition="Enterprise LTSC" ;; * ) edition=$(formatEdition "$suffix") ;; esac ;; "winxp"* ) - edition="Professional" - ;; + edition="Professional" ;; "win2019-hv"* ) - edition="2019" - ;; + edition="2019" ;; "win20"* ) [[ "$normalized" == *"-"* ]] && suffix="${normalized#*-}" @@ -625,10 +566,10 @@ printEdition() { fromFile() { - local id="" local desc="$1" local file="${1,,}" - local arch="${PLATFORM,,}" + + local id="" arch="${PLATFORM,,}" file="${file//-/_}" file="${file// /_}" @@ -694,10 +635,11 @@ fromFile() { fromName() { - local id="" local name="$1" local arch="$2" + local id="" + local add="" [[ "$arch" != "x64" ]] && add="$arch" @@ -723,9 +665,23 @@ fromName() { return 0 } +isClientEdition() { + + case "${1,,}" in + "pro" | "professional" | "business" | \ + "enterprise" | "ultimate" | "education" | \ + "home" | "homepremium" | "home-premium" | \ + "homebasic" | "home-basic" | "starter" | "core" ) + return 0 ;; + esac + + return 1 +} + normalizeEdition() { local source="${1,,}" + local edition source="${source//evaluation/}" @@ -745,7 +701,7 @@ normalizeEdition() { normalizeEditionID() { - local edition + local edition base local id="$2" edition=$(normalizeEdition "$1") @@ -753,8 +709,21 @@ normalizeEditionID() { case "$edition" in "pro" | "professional" | "business" ) edition="" ;; - "pro-n" | "pron" | "professional-n" | "professionaln" ) + "pro-n" | "pron" | "professional-n" | "professionaln" | "business-n" | "businessn" ) edition="n" ;; + * ) + if ! isClientEdition "$edition"; then + case "$edition" in + *"-n" ) base="${edition%-n}" ;; + *"n" ) base="${edition%n}" ;; + * ) base="" ;; + esac + + if [ -n "$base" ] && isClientEdition "$base"; then + edition="$base-n" + fi + + fi ;; esac case "${id,,}" in @@ -778,6 +747,7 @@ getEditionID() { local name="${1,,}" local id="${2,,}" + local edition case "$id" in @@ -813,26 +783,19 @@ normalizeServerEdition() { case "$edition" in "core" | "core-installation" | "server-core-installation" ) - edition="standard-core" - ;; + edition="standard-core" ;; "desktop-experience" | "server-with-a-gui" | "full-installation" ) - edition="standard" - ;; + edition="standard" ;; *"-server-core-installation" ) - edition="${edition%-server-core-installation}-core" - ;; + edition="${edition%-server-core-installation}-core" ;; *"-core-installation" ) - edition="${edition%-core-installation}-core" - ;; + edition="${edition%-core-installation}-core" ;; *"-desktop-experience" ) - edition="${edition%-desktop-experience}" - ;; + edition="${edition%-desktop-experience}" ;; *"-server-with-a-gui" ) - edition="${edition%-server-with-a-gui}" - ;; + edition="${edition%-server-with-a-gui}" ;; *"-full-installation" ) - edition="${edition%-full-installation}" - ;; + edition="${edition%-full-installation}" ;; esac edition="${edition#server-}" @@ -879,6 +842,7 @@ getServerEditionID() { local name="${1,,}" local id="${2,,}" + local edition case "$id" in @@ -901,42 +865,36 @@ getServerEditionID() { getEditionOrder() { local id="${1,,}" - local result_name="$2" - local -n result="$result_name" - - result=() case "$id" in "win20"* ) - result=( - "|default|@default" - "-datacenter|datacenter|datacenter datacenter-*" - "-datacenter-azure|datacenter|datacenter-azure" - "-enterprise|enterprise|enterprise enterprise-*" - "-web|web|web web-*" - "-foundation|foundation|foundation foundation-*" - "-essentials|essentials|essentials essentials-*" - "-standard-core|standard-core|standard-core standard-core-*" - "-datacenter-core|datacenter-core|datacenter-core datacenter-core-*" - "-datacenter-azure-core|datacenter-core|datacenter-azure-core" - "-enterprise-core|enterprise-core|enterprise-core enterprise-core-*" - "-web-core|web-core|web-core web-core-*" + printf '%s\n' \ + "|default|@default" \ + "-datacenter|datacenter|datacenter datacenter-*" \ + "-datacenter-azure|datacenter|datacenter-azure" \ + "-enterprise|enterprise|enterprise enterprise-*" \ + "-web|web|web web-*" \ + "-foundation|foundation|foundation foundation-*" \ + "-essentials|essentials|essentials essentials-*" \ + "-standard-core|standard-core|standard-core standard-core-*" \ + "-datacenter-core|datacenter-core|datacenter-core datacenter-core-*" \ + "-datacenter-azure-core|datacenter-core|datacenter-azure-core" \ + "-enterprise-core|enterprise-core|enterprise-core enterprise-core-*" \ + "-web-core|web-core|web-core web-core-*" \ "-hv|hv|hv hv-*" - ) ;; * ) - result=( - "-enterprise|enterprise|enterprise enterprise-*" - "-ultimate|ultimate|ultimate ultimate-*" - "|default|@default n pro pro-* professional professional-* business business-*" - "-iot|iot|iot iot-* enterprise-iot enterprise-iot-*" - "-ltsc|ltsc|ltsc ltsc-* enterprise-ltsc enterprise-ltsc-*" - "-education|education|education education-* pro-education pro-education-*" - "-home|home|home home-*" - "-home-premium|home|home-premium home-premium-*" - "-home-basic|home|home-basic home-basic-*" + printf '%s\n' \ + "-enterprise|enterprise|enterprise enterprise-*" \ + "-ultimate|ultimate|ultimate ultimate-*" \ + "|default|@default n pro pro-* professional professional-* business business-*" \ + "-iot|iot|iot iot-* enterprise-iot enterprise-iot-*" \ + "-ltsc|ltsc|ltsc ltsc-* enterprise-ltsc enterprise-ltsc-*" \ + "-education|education|education education-* pro-education pro-education-*" \ + "-home|home|home home-*" \ + "-home-premium|home|home-premium home-premium-*" \ + "-home-basic|home|home-basic home-basic-*" \ "-starter|starter|starter starter-*" - ) ;; esac @@ -945,9 +903,10 @@ getEditionOrder() { getVersion() { - local id edition local name="$1" local arch="$2" + + local id edition local evaluation="" id=$(fromName "$name" "$arch") @@ -1002,16 +961,11 @@ isLegacy() { switchEdition() { - local -n id="$1" + local version="$1" - [[ "${id,,}" == *"-eval" ]] || return 1 - - id="${id::-5}" - - if ! enabled "${DETECTED_ORG:-}"; then - DETECTED="${SUGGEST:-$id}" - fi + [[ "${version,,}" == *"-eval" ]] || return 1 + echo "${version::-5}" return 0 } @@ -1020,9 +974,8 @@ getMido() { local id="$1" local lang="$2" local ret="$3" - local url="" - local sum="" - local size="" + + local url="" sum="" size="" [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0 @@ -1119,9 +1072,8 @@ getLink1() { local id="$1" local lang="$2" local ret="$3" - local url="" - local sum="" - local size="" + + local url="" sum="" size="" local host="https://dl.bobpony.com/windows" [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0 @@ -1258,9 +1210,8 @@ getLink2() { local id="$1" local lang="$2" local ret="$3" - local url="" - local sum="" - local size="" + + local url="" sum="" size="" local host="https://files.dog/MSDN" [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0 @@ -1352,9 +1303,8 @@ getLink3() { local id="$1" local lang="$2" local ret="$3" - local url="" - local sum="" - local size="" + + local url="" sum="" size="" local host="https://iso.reactos.org" [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0 @@ -1381,9 +1331,8 @@ getLink4() { local id="$1" local lang="$2" local ret="$3" - local url="" - local sum="" - local size="" + + local url="" sum="" size="" local host="https://archive.org/download" [[ "${lang,,}" != "en" && "${lang,,}" != "en-us" ]] && return 0 @@ -1552,12 +1501,13 @@ getLink4() { getValue() { - local val="" local id="$2" local lang="$3" local type="$4" local func="getLink$1" + local val="" + if [ "$1" -gt 0 ] && [ "$1" -le "$MIRRORS" ]; then val=$($func "$id" "$lang" "$type") fi @@ -1585,6 +1535,7 @@ isMido() { local id="$1" local lang="$2" + local sum disabled "${MIDO:-}" && return 1 @@ -1607,8 +1558,7 @@ isESD() { "win10${PLATFORM,,}" | \ "win11${PLATFORM,,}-enterprise" | \ "win10${PLATFORM,,}-enterprise" ) - return 0 - ;; + return 0 ;; esac return 1 @@ -1627,10 +1577,8 @@ validVersion() { isESD "$id" "$lang" && return 0 for ((i=1;i<=MIRRORS;i++)); do - url=$(getLink "$i" "$id" "$lang") [ -n "$url" ] && return 0 - done return 1 diff --git a/src/image.sh b/src/image.sh index 352bb56f..70e59de3 100644 --- a/src/image.sh +++ b/src/image.sh @@ -1,35 +1,41 @@ #!/usr/bin/env bash set -Eeuo pipefail -getPlatform() { +hasVersion() { - local xml="$1" - local platform="x64" - local x86 x64 arm64 count=0 + local wanted="$1" + shift - x86=$(xmllint --nonet --xpath 'count(/WIM/IMAGE/WINDOWS/ARCH[text()="0"])' - 2>/dev/null <<< "$xml") || x86=0 - x64=$(xmllint --nonet --xpath 'count(/WIM/IMAGE/WINDOWS/ARCH[text()="9"])' - 2>/dev/null <<< "$xml") || x64=0 - arm64=$(xmllint --nonet --xpath 'count(/WIM/IMAGE/WINDOWS/ARCH[text()="12"])' - 2>/dev/null <<< "$xml") || arm64=0 + local actual - (( x86 > 0 )) && ((count++)) - (( x64 > 0 )) && ((count++)) - (( arm64 > 0 )) && ((count++)) + for actual in "$@"; do + [[ "${actual,,}" == "${wanted,,}" ]] || continue + echo "$actual" + return 0 + done - if (( count > 1 )); then - platform="mixed" - elif (( x86 > 0 )); then - platform="x86" - elif (( arm64 > 0 )); then - platform="arm64" + return 1 +} + +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 - - echo "$platform" - return 0 } checkPlatform() { local xml="$1" + local platform compat platform=$(getPlatform "$xml") @@ -51,50 +57,69 @@ checkPlatform() { return 1 } -hasVersion() { +getPlatform() { - local wanted="$1" - shift + local xml="$1" - local actual + local output platform="x64" + local x86 x64 arm64 count=0 value + local -a counts=() - for actual in "$@"; do - [[ "${actual,,}" == "${wanted,,}" ]] || continue - echo "$actual" - return 0 + if ! output=$(xmlstarlet sel \ + -T -t \ + -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 \ + - 2>/dev/null <<< "$xml"); then + return 1 + fi + + mapfile -t counts <<< "$output" + + if (( ${#counts[@]} != 3 )); 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 - return 1 -} + x86="${counts[0]}" + x64="${counts[1]}" + arm64="${counts[2]}" -getCompatibleVersions() { + (( x86 > 0 )) && ((count += 1)) + (( x64 > 0 )) && ((count += 1)) + (( arm64 > 0 )) && ((count += 1)) - local wanted="$1" - local result_name="$2" - local -n result_ref="$result_name" - - result_ref=("$wanted") - - # Treat normal and Evaluation variants of the same edition as compatible. - # The exact requested variant is always checked first. - if [[ "${wanted,,}" == *"-eval" ]]; then - result_ref+=("${wanted%-eval}") - else - result_ref+=("$wanted-eval") + if (( count > 1 )); then + platform="mixed" + elif (( x86 > 0 )); then + platform="x86" + elif (( arm64 > 0 )); then + platform="arm64" fi + + echo "$platform" + 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}" - getEditionOrder "$base" order || return 1 + mapfile -t order < <(getEditionOrder "$base") local edition="${id#"$base"}" edition="${edition#-}" @@ -140,37 +165,65 @@ getVersions() { local bases_name="$3" local groups_name="$4" local indexes_name="$5" - local -n versions_ref="$versions_name" + 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 count image image_index - local display product platform - local edition_id install_type - local candidate flags i + 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' - versions_ref=() bases_ref=() groups_ref=() indexes_ref=() + versions_ref=() platform=$(getPlatform "$xml") || return 1 - count=$(xmllint --nonet --xpath 'count(/WIM/IMAGE)' - 2>/dev/null <<< "$xml") || return 0 - for ((i=1; i<=count; i++)); do + image_count=$(xmlstarlet sel -T -t -v 'count(/WIM/IMAGE)' - 2>/dev/null <<< "$xml") || return 1 - image_index=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/@INDEX)" - 2>/dev/null <<< "$xml") || continue - display=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/DISPLAYNAME)" - 2>/dev/null <<< "$xml") || display="" - product=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/WINDOWS/PRODUCTNAME)" - 2>/dev/null <<< "$xml") || product="" - image=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/NAME)" - 2>/dev/null <<< "$xml") || image="" - edition_id=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/WINDOWS/EDITIONID)" - 2>/dev/null <<< "$xml") || edition_id="" - install_type=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/WINDOWS/INSTALLATIONTYPE)" - 2>/dev/null <<< "$xml") || install_type="" - flags=$(xmllint --nonet --xpath "string(/WIM/IMAGE[$i]/FLAGS)" - 2>/dev/null <<< "$xml") || flags="" + 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 - local candidate_id="" - local candidate_base="" + + 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. @@ -183,36 +236,36 @@ getVersions() { candidate_id=$(getVersion "$candidate" "$platform") [ -n "$candidate_base" ] && [ -n "$candidate_id" ] && break + done if [ -z "$candidate_base" ] || [ -z "$candidate_id" ]; then - local name="${display:-${image:-$product}}" + + name="${display:-${image:-$product}}" [ -n "$name" ] && warn "Unknown image name: '$name'" + continue + fi - local evaluation="" + evaluation="" - if [[ "${image,,}" == *"evaluation"* || - "${display,,}" == *"evaluation"* || - "${product,,}" == *"evaluation"* || - "${edition_id,,}" == *"eval"* || - "${flags,,}" == *"eval"* ]]; then + if [[ "${image,,}" == *"evaluation"* || "${display,,}" == *"evaluation"* || + "${product,,}" == *"evaluation"* || "${edition_id,,}" == *"eval"* || "${flags,,}" == *"eval"* ]]; then evaluation="-eval" fi - if [ -n "$evaluation" ] && - [[ "${candidate_id,,}" != *"-eval" ]]; then + if [ -n "$evaluation" ] && [[ "${candidate_id,,}" != *"-eval" ]]; then candidate_id+="$evaluation" fi - local key="${candidate_id,,}" + 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 - local structured="" + structured="" case "${candidate_base,,}" in "winvista"* | "win7"* | "win8"* | "win10"* | "win11"* ) @@ -224,8 +277,7 @@ getVersions() { # 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 + if [[ "${install_type,,}" == *"core"* && "$structured" != *"-core" ]]; then structured+="-core" fi ;; @@ -243,11 +295,16 @@ getVersions() { fi indexes_ref["$key"]="$image_index" - versions_ref+=("$candidate_id") - bases_ref+=("$candidate_base") - groups_ref+=("$(getVersionPriority "$candidate_id" "$candidate_base")") + versions_ref+=( "$candidate_id" ) + bases_ref+=( "$candidate_base" ) + groups_ref+=( "$(getVersionPriority "$candidate_id" "$candidate_base")" ) - done + 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 } @@ -259,19 +316,21 @@ selectVersion() { local preferred_name="$3" local result_name="$4" local index_name="$5" + + local -a candidates=() local -n version_list="$versions_name" local -n index_map="$indexes_name" local -n preference_list="$preferred_name" local -n selected_version="$result_name" local -n selected_image_index="$index_name" - local wanted candidate match - local -a candidates=() + # 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 - getCompatibleVersions "$wanted" candidates + mapfile -t candidates < <(getCompatibleVersions "$wanted") for candidate in "${candidates[@]}"; do @@ -301,15 +360,17 @@ selectEdition() { local index_name="$7" local normalize_name="$8" local order_name="$9" - local -n edition_versions="$versions_name" - local -n edition_bases="$bases_name" - local -n edition_groups="$groups_name" - local -n edition_order="$order_name" - local base edition entry suffix priority i - local -a preferred=() 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 @@ -317,12 +378,7 @@ selectEdition() { preferred+=("$base${edition:+-$edition}") done - if selectVersion \ - "$versions_name" \ - "$indexes_name" \ - preferred \ - "$result_name" \ - "$index_name"; then + if selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name"; then return 0 fi @@ -333,12 +389,7 @@ selectEdition() { preferred=("$suggested") - if selectVersion \ - "$versions_name" \ - "$indexes_name" \ - preferred \ - "$result_name" \ - "$index_name"; then + if selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name"; then return 0 fi @@ -357,18 +408,13 @@ selectEdition() { done - if selectVersion \ - "$versions_name" \ - "$indexes_name" \ - preferred \ - "$result_name" \ - "$index_name"; then + if selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name"; then return 0 fi # Then try noncanonical editions from the same preference groups. - preferred=() seen=() + preferred=() for entry in "${edition_order[@]}"; do @@ -384,66 +430,49 @@ selectEdition() { done - selectVersion \ - "$versions_name" \ - "$indexes_name" \ - preferred \ - "$result_name" \ - "$index_name" + selectVersion "$versions_name" "$indexes_name" preferred "$result_name" "$index_name" } detectVersion() { local xml="$1" local suggested="${2:-}" - local result_name="$3" - local index_name="$4" - - local normalize_name="normalizeEditionID" local -a bases=() local -a groups=() local -a versions=() - local -a selection_order=() local -A image_indexes=() + local -a selection_order=() + local result="" index="" - printf -v "$result_name" '%s' "" - printf -v "$index_name" '%s' "" + getVersions "$xml" versions bases groups image_indexes || return 1 - getVersions \ - "$xml" \ - versions \ - bases \ - groups \ - image_indexes || return 1 + if [ "${#versions[@]}" -eq 0 ]; then + printf '%s\n%s\n' "$result" "$index" + return 0 + fi - [ "${#versions[@]}" -eq 0 ] && return 0 + local normalize="normalizeEditionID" case "${bases[0],,}" in "win20"* ) - normalize_name="normalizeServerEditionID" - ;; + normalize="normalizeServerEditionID" ;; esac - getEditionOrder "${bases[0]}" selection_order || return 1 + mapfile -t selection_order < <(getEditionOrder "${bases[0]}") - selectEdition \ - versions \ - bases \ - groups \ - image_indexes \ - "$suggested" \ - "$result_name" \ - "$index_name" \ - "$normalize_name" \ - selection_order && return 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 - local result="${versions[0]}" + # 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 -v "$result_name" '%s' "$result" - printf -v "$index_name" '%s' "${image_indexes[$key]}" - + printf '%s\n%s\n' "$result" "$index" return 0 } @@ -451,15 +480,30 @@ detectLanguage() { local xml="$1" local index="${2:-}" - local xpath lang culture - if [[ "$index" =~ ^[0-9]+$ ]]; then - xpath="string((/WIM/IMAGE[@INDEX='$index']/WINDOWS/LANGUAGES/DEFAULT | /WIM/IMAGE[@INDEX='$index']/WINDOWS/LANGUAGES/FALLBACK/DEFAULT)[1])" - else - xpath='string((/WIM/IMAGE/WINDOWS/LANGUAGES/DEFAULT | /WIM/IMAGE/WINDOWS/LANGUAGES/FALLBACK/DEFAULT)[1])' + local -a paths=() + local lang culture path + local image='/WIM/IMAGE' + + if [[ "$index" =~ ^[1-9][0-9]*$ ]]; then + image="/WIM/IMAGE[@INDEX='$index']" fi - lang=$(xmllint --nonet --xpath "$xpath" - 2>/dev/null <<< "$xml") || lang="" + # 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!" @@ -481,10 +525,11 @@ getImageSize() { local stage="$1" local folder="${2:-}" - local mib=$((1024 * 1024)) - local minimum=$((64 * mib)) + local size bytes path local required large_file + local mib=$((1024 * 1024)) + local minimum=$((64 * mib)) local payload=0 paths=("$stage") if [ ! -d "$stage" ]; then @@ -494,11 +539,9 @@ getImageSize() { [ -n "$folder" ] && paths+=("$folder") - large_file=$(find -L "${paths[@]}" \ - -type f \ - -size +4294967295c \ - -print \ - -quit) || return 1 + # 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" @@ -506,18 +549,20 @@ getImageSize() { fi for path in "${paths[@]}"; do - if ! read -r bytes _ < <( - du -Llsb --apparent-size -- "$path" - ); then + + if ! read -r bytes _ < <(du -Llsb --apparent-size -- "$path"); then error "Failed to calculate setup size!" return 1 fi payload=$((payload + bytes)) + done - required=$((payload + ((payload + 3) / 4) + (32 * mib))) + # 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)) @@ -530,6 +575,8 @@ bootDirect() { local id="$1" + # ReactOS must boot from its original media and does not use the Windows + # setup-overlay or rebuilt-image paths. case "${id,,}" in "reactos" ) return 0 ;; esac @@ -542,13 +589,14 @@ canUseSetupImage() { local id="$1" local iso="$2" + # Legacy installers and ReactOS require modifying or directly booting their + # media. Standalone ESDs and nested archives are not directly bootable ISOs. case "${id,,}" in "win9"* | "winxp"* | "win2k"* | "win2003"* | "reactos" ) return 1 ;; esac - [[ "${iso,,}" != *".esd" ]] && - ! enabled "${UNPACK:-}" + [[ "${iso,,}" != *".esd" ]] && ! enabled "${UNPACK:-}" } createImageDirectory() { @@ -556,6 +604,8 @@ 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 @@ -567,6 +617,7 @@ createSetupImage() { local stage="$1" local image="$2" + local tmp="${image}.tmp" local target="::/\$OEM\$/\$1/OEM" local install="$stage/.overlay-install.bat" @@ -584,28 +635,21 @@ createSetupImage() { 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 + 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 + 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 @@ -623,8 +667,7 @@ createSetupImage() { done if [ -n "$folder" ] || [ -f "$install" ]; then - if ! createImageDirectory "$tmp" "::/\$OEM\$" || - ! createImageDirectory "$tmp" "::/\$OEM\$/\$1" || + if ! createImageDirectory "$tmp" "::/\$OEM\$" || ! createImageDirectory "$tmp" "::/\$OEM\$/\$1" || ! createImageDirectory "$tmp" "$target"; then rm -f -- "$tmp" error "Failed to create OEM directory in setup image!" @@ -638,6 +681,7 @@ createSetupImage() { find "$folder" -mindepth 1 -maxdepth 1 -print0 ) + # Preserve errors from the second process-substitution find as well. find_pid=$! if ! wait "$find_pid"; then @@ -656,6 +700,8 @@ createSetupImage() { 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" @@ -664,6 +710,7 @@ createSetupImage() { 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!" @@ -680,6 +727,7 @@ createSetupImage() { 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!" @@ -704,10 +752,13 @@ createSetupImage() { 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 @@ -769,6 +820,8 @@ detectLegacy() { 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 @@ -819,14 +872,11 @@ detectLegacy() { 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 + \( -ipath '*/reactos/reactos.inf' -o -ipath '*/reactos/unattend.inf' \) -print -quit) || return 1 [ -n "$marker" ] || return 1 @@ -842,9 +892,13 @@ resolveImage() { FB="falling back to manual installation!" [ -z "$DETECTED" ] || return 0 + + # Reused and arbitrary URL media must be inspected because their actual + # contents may no longer match the requested VERSION. [ -z "${REUSED_ISO:-}" ] || return 1 [[ "${version,,}" != "http"* ]] || return 1 + # Only direct-boot custom media can safely bypass content detection. if [ -n "$CUSTOM" ]; then bootDirect "$version" || return 1 DETECTED="$version" @@ -858,6 +912,7 @@ resolveImage() { return 0 fi + # Evaluation media may reuse the normal edition's answer-file template. if [[ "${version,,}" == *"-eval" ]]; then local source="/run/assets/${version%-eval}.xml" @@ -876,6 +931,8 @@ setImage() { setXML "" && return 0 enabled "$MANUAL" && return 0 + # A missing answer file is a supported manual-install path, not a hard media + # failure. MANUAL="Y" local desc @@ -888,22 +945,15 @@ setImage() { findIsoImage() { local iso="$1" - local result_name="$2" + local path - printf -v "$result_name" '%s' "" + # Prefer install.wim when both payload forms are present. + for path in /sources/install.wim /sources/install.esd; do - for path in \ - /sources/install.wim \ - /sources/install.esd; do + if udfread stat --ignore-case "$iso" "$path" >/dev/null 2>&1; then - if udfread stat \ - --ignore-case \ - "$iso" \ - "$path" \ - >/dev/null 2>&1; then - - printf -v "$result_name" '%s' "$path" + printf '%s' "$path" return 0 fi @@ -916,51 +966,23 @@ readWimHeader() { local iso="$1" local image="$2" - local result_name="$3" - local header="$TMP/wim-header.bin" local size signature + local header="$TMP/wim-header.bin" - printf -v "$result_name" '%s' "" + rm -f -- "$header" || return 1 - if ! rm -f -- "$header"; then - return 1 - fi - - if ! udfread range \ - --ignore-case \ - -o "$header" \ - "$iso" \ - "$image" \ - 0 \ - 208 \ - >/dev/null 2>&1; then + # 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 - 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" + echo "$header" return 0 } @@ -969,41 +991,28 @@ 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=() + local -a bytes=() values=() - printf -v "$result_name" '%s' "" - - if [ ! -f "$header" ]; then - return 1 - fi - - if ! raw=$(od -An -v -N208 -tu1 -- "$header"); then - return 1 - fi + [ -f "$header" ] || return 1 + raw=$(od -An -v -N208 -tu1 -- "$header") || return 1 read -r -a bytes <<< "${raw//$'\n'/ }" - - if (( ${#bytes[@]} != 208 )); then - return 1 - fi + (( ${#bytes[@]} == 208 )) || return 1 # 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 + (( 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=$(( \ @@ -1013,9 +1022,7 @@ readIsoImageInfo() { bytes[11] << 24 )) - if (( header_size != 208 )); then - return 1 - fi + (( header_size == 208 )) || return 1 # WIM version at offset 0x0c. version=$(( \ @@ -1025,27 +1032,15 @@ readIsoImageInfo() { bytes[15] << 24 )) - if (( version != 0x10d00 && - version != 0x0e00 )); then - return 1 - fi + (( version == 0x10d00 || version == 0x0e00 )) || return 1 # Split-WIM information at offsets 0x28 and 0x2a. - part_number=$(( \ - bytes[40] | - bytes[41] << 8 - )) + part_number=$((bytes[40] | bytes[41] << 8)) + total_parts=$((bytes[42] | bytes[43] << 8)) - total_parts=$(( \ - bytes[42] | - bytes[43] << 8 - )) - - if (( part_number == 0 || - total_parts == 0 || - part_number > total_parts )); then - return 1 - fi + (( part_number > 0 && + total_parts > 0 && + part_number <= total_parts )) || return 1 # Image count at offset 0x2c. image_count=$(( \ @@ -1055,35 +1050,25 @@ readIsoImageInfo() { bytes[47] << 24 )) - if (( image_count == 0 || - image_count > 65535 )); then - return 1 - fi + (( image_count > 0 && image_count <= 65535 )) || return 1 - if ! parseWimHeader \ - "$iso" \ - "$image" \ - "$header" \ - xml_offset \ - xml_size \ - xml_original \ - xml_flags; then - return 1 - fi + 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]}" - if [[ ! "$xml_offset" =~ ^[0-9]+$ || - ! "$xml_size" =~ ^[0-9]+$ || - ! "$xml_original" =~ ^[0-9]+$ || - ! "$xml_flags" =~ ^[0-9]+$ ]]; then - return 1 - fi + [[ "$xml_offset" =~ ^[0-9]+$ && + "$xml_size" =~ ^[0-9]+$ && + "$xml_original" =~ ^[0-9]+$ && + "$xml_flags" =~ ^[0-9]+$ ]] || return 1 - if (( xml_size == 0 || - xml_original == 0 || - xml_size != xml_original || - xml_size % 2 != 0 )); then - return 1 - fi + (( 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: # @@ -1092,9 +1077,7 @@ readIsoImageInfo() { # 0x10: solid # # The metadata flag 0x02 is expected and deliberately allowed. - if (( xml_flags & 0x1c )); then - return 1 - fi + (( !(xml_flags & 0x1c) )) || return 1 result=$( udfread range \ @@ -1117,35 +1100,21 @@ readIsoImageInfo() { [ -n "$result" ] || return 1 - root=$( - xmllint \ - --nonet \ - --xpath 'name(/*)' \ - - \ - 2>/dev/null <<< "$result" - ) || return 1 + local metadata separator=$'\x1f' - if [ "$root" != "WIM" ]; then - return 1 - fi + metadata=$(xmlstarlet sel \ + -T -t \ + -v 'local-name(/*)' -o "$separator" \ + -v 'count(/*[local-name()="WIM"]/*[local-name()="IMAGE"])' \ + - 2>/dev/null <<< "$result") || return 1 - xml_count=$( - xmllint \ - --nonet \ - --xpath 'count(/WIM/IMAGE)' \ - - \ - 2>/dev/null <<< "$result" - ) || return 1 + IFS="$separator" read -r root xml_count <<< "$metadata" - if [[ ! "$xml_count" =~ ^[0-9]+$ ]]; then - return 1 - fi + [ "$root" = "WIM" ] || return 1 + [[ "$xml_count" =~ ^[0-9]+$ ]] || return 1 + (( xml_count == image_count )) || return 1 - if (( xml_count != image_count )); then - return 1 - fi - - printf -v "$result_name" '%s' "$result" + printf '%s' "$result" return 0 } @@ -1154,28 +1123,14 @@ 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 -a bytes=() local header_size=0 local parsed_size=0 + local parsed_flags=0 local parsed_offset=0 local parsed_original=0 - local parsed_flags=0 - local -a bytes=() - - offset_ref="" - size_ref="" - original_ref="" - flags_ref="" + local details image_size raw if [ ! -f "$header" ] || [ ! -s "$header" ]; then return 1 @@ -1228,39 +1183,25 @@ parseWimHeader() { parsed_original=$((parsed_original * 256 + bytes[i])) done - if (( parsed_size <= 0 || - parsed_offset < header_size || - parsed_original <= 0 )); then + 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 + 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" - ) + 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 + 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" + printf '%s\n' "$parsed_offset" "$parsed_size" "$parsed_original" "$parsed_flags" return 0 } @@ -1268,36 +1209,34 @@ parseWimHeader() { findImage() { local dir="$1" - local result_name="$2" - local src result - src=$(find "$dir" -maxdepth 1 -type d -iname sources -print -quit) + local sources result - if [ ! -d "$src" ]; then + sources=$(find "$dir" -maxdepth 1 -type d -iname sources -print -quit) + + if [ ! -d "$sources" ]; then warn "failed to locate 'sources' folder in ISO image, $FB" return 1 fi - result=$(find "$src" -maxdepth 1 -type f \ - \( -iname install.wim -or -iname install.esd \) -print -quit) + result=$(find "$sources" -maxdepth 1 -type f \( -iname install.wim -or -iname install.esd \) -print -quit) if [ ! -f "$result" ]; then warn "failed to locate 'install.wim' or 'install.esd' in ISO image, $FB" return 1 fi - printf -v "$result_name" '%s' "$result" + echo "$result" return 0 } readImageInfo() { local wim="$1" - local result_name="$2" + local result - result=$(wimlib-imagex info -xml "$wim" | - iconv -f UTF-16LE -t UTF-8) || { + result=$(wimlib-imagex info -xml "$wim" | iconv -f UTF-16LE -t UTF-8) || { local rc=$? if (( rc >= 129 )); then @@ -1308,7 +1247,7 @@ readImageInfo() { return 1 } - printf -v "$result_name" '%s' "$result" + printf '%s' "$result" return 0 } @@ -1317,6 +1256,8 @@ 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:-}" } @@ -1330,11 +1271,12 @@ validateEdition() { [ -n "$edition" ] || return 0 - if [[ "${DETECTED,,}" == *"-${edition,,}" || - "${DETECTED,,}" == *"-${edition,,}-eval" ]]; then + 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 } @@ -1343,6 +1285,8 @@ 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 @@ -1357,7 +1301,7 @@ describeImage() { local info_xml="$1" local index="$2" - local result_name="$3" + local result result=$(printEdition "$DETECTED" "$DETECTED" "Y") || return 1 @@ -1369,7 +1313,7 @@ describeImage() { result+=" ($language)" fi - printf -v "$result_name" '%s' "$result" + printf '%s' "$result" return 0 } @@ -1378,12 +1322,13 @@ 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 + if [[ "$DETECTED" == "win81x86"* || "$DETECTED" == "win10x86"* ]]; then error "The 32-bit version of $desc is not supported!" - return 1 + exit 67 fi local msg="the answer file for $desc was not found ($DETECTED.xml)" @@ -1407,17 +1352,21 @@ configureImage() { 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 + 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 @@ -1426,7 +1375,7 @@ detectImageInfo() { return 0 fi - describeImage "$image_info" "$index" desc || return 1 + desc=$(describeImage "$image_info" "$index") || return 1 info "Detected: $desc" configureImage "$index" "$desc" || return 1 @@ -1437,19 +1386,17 @@ detectImageInfo() { detectIsoImage() { local iso="$1" + local image header image_info - findIsoImage "$iso" image || return 1 - readWimHeader "$iso" "$image" header || return 1 + # 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. - readIsoImageInfo \ - "$iso" \ - "$image" \ - "$header" \ - image_info || return 1 + 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 @@ -1458,10 +1405,12 @@ detectIsoImage() { detectImage() { local dir="$1" + local desc info "Detecting version from ISO image..." + # Marker-based legacy and ReactOS detection must run before looking for a WIM. if detectLegacy "$dir" || detectReactOS "$dir"; then desc=$(printEdition "$DETECTED" "$DETECTED" "Y") || return 1 info "Detected: $desc" @@ -1469,10 +1418,10 @@ detectImage() { fi local wim - findImage "$dir" wim || return 1 + wim=$(findImage "$dir") || return 1 local image_info - readImageInfo "$wim" image_info || return 1 + image_info=$(readImageInfo "$wim") || return 1 detectImageInfo "$image_info" } @@ -1480,12 +1429,15 @@ detectImage() { 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" ;; @@ -1519,6 +1471,7 @@ reportBatchMatches() { local pattern="$3" local message="$4" local suggestion="$5" + local matches line matches=$(grep -Pin "$pattern" "$file" || true) @@ -1539,6 +1492,7 @@ reportBatchMatches() { checkBatch() { local file="$1" + local tmp output local matches line local enabled_rules @@ -1604,9 +1558,7 @@ EOC output="${output#"${output%%[!$'\r\n ']*}"}" output="${output%"${output##*[!$'\r\n ']}"}" - if grep -Eq \ - '^(ERROR|WARNING|SECURITY) LEVEL ISSUES:$' \ - <<< "$output"; then + if grep -Eq '^(ERROR|WARNING|SECURITY) LEVEL ISSUES:$' <<< "$output"; then warn "possible issues were detected in $source:" printf '\n%s\n\n' "$output" >&2 @@ -1650,11 +1602,14 @@ getBootLoadSize() { local iso="$1" local dir="$2" local desc="$3" + local boot_info size value case "${DETECTED,,}" in "win2k"* | "winxp"* | "win2003"* ) + # NT 5.x media may not expose a reliable catalog sector count, so derive + # it directly from the extracted boot image. if [ ! -s "$dir/$ETFS" ]; then error "Failed to locate file \"$ETFS\" in $desc ISO image!" return 1 @@ -1675,6 +1630,7 @@ getBootLoadSize() { * ) + # Other legacy media use the El Torito Nsect value from the ISO catalog. if ! boot_info=$(isoinfo -d -i "$iso"); then error "Failed to read boot image information from $desc ISO!" return 1 @@ -1709,6 +1665,7 @@ extractBootImage() { local iso="$1" local dir="$2" local desc="$3" + local offset info ETFS="boot.img" @@ -1733,13 +1690,9 @@ extractBootImage() { return 1 fi - if ! dd \ - "if=$iso" \ - "of=$dir/$ETFS" \ - bs=512 \ - "count=$BOOT_LOAD_SIZE" \ - "skip=$((offset * 4))" \ - status=none; then + # 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!" @@ -1758,6 +1711,7 @@ extractBootImage() { buildImage() { local dir="$1" + local failed="" local cat="BOOT.CAT" local log="/run/shm/iso.log" @@ -1794,6 +1748,8 @@ buildImage() { /run/progress.sh "$out" "$size" "$msg ([P])..." & + # 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 genisoimage \ @@ -1888,6 +1844,7 @@ buildImage() { [ -s "$log" ] && err="$(<"$log")" + # UDF hybrid media intentionally triggers this genisoimage warning. if [ -n "$err" ] && [[ "$err" != "$hide" ]]; then echo "$err" fi diff --git a/src/install.sh b/src/install.sh index 329829ce..522ec872 100644 --- a/src/install.sh +++ b/src/install.sh @@ -14,83 +14,138 @@ startWindows() { if ! hasImage "$ISO"; then if ! downloadImage "$ISO" "$VERSION" "$LANGUAGE"; then - removeIso "$ISO" && return 68 + removeIso "$ISO" || : + return 68 fi fi - local extracted=0 local boot="$BOOT" local dir="$TMP/unpack" + local handled=0 extracted=0 + selectWindowsImage "$ISO" "$dir" "$boot" || return $? + (( handled )) && return 0 + + configureMachine "$ISO" "$dir" "$boot" || return $? + (( handled )) && return 0 + + prepareWindowsImage "$ISO" "$dir" "$boot" || return $? + (( handled )) && return 0 + + finishInstall "$BOOT" "N" "$boot" || return 100 + + return 0 +} + +selectWindowsImage() { + + local iso="$1" + local dir="$2" + local boot="$3" + + local detect_rc=0 + + # Known versions already provide the required image metadata. if resolveImage "$VERSION"; then if ! setImage; then - abortInstall "$dir" "$ISO" "$boot" || return 70 + abortInstall "$dir" "$iso" "$boot" || return 70 + handled=1 return 0 fi - if needsExtraction "$DETECTED" "$ISO"; then - if ! extractImage "$ISO" "$dir" "$VERSION"; then - removeIso "$ISO" && return 72 - fi - - extracted=1 + if ! needsExtraction "$DETECTED" "$iso"; then + return 0 fi - else + if ! extractImage "$iso" "$dir" "$VERSION"; then + removeIso "$iso" || : + return 72 + fi - local detect_rc=0 - detectIsoImage "$ISO" || detect_rc=$? - - case "$detect_rc" in - - 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 + extracted=1 + return 0 fi - local desc - if ! desc=$(printVariant "$DETECTED" "$DETECTED"); then - abortInstall "$dir" "$ISO" "$boot" || return 78 + # Inspect unknown media directly before falling back to extraction. + detectIsoImage "$iso" || detect_rc=$? + + if (( detect_rc == 0 )); then return 0 fi - if ! setMachine "$DETECTED" "$ISO" "$dir" "$desc"; then - abortInstall "$dir" "$ISO" "$boot" || return 80 + # Only code 1 indicates that extraction may recover detection. + if (( detect_rc != 1 )); then + abortInstall "$dir" "$iso" "$boot" || return 76 + handled=1 + return 0 + fi + + if ! extractImage "$iso" "$dir" "$VERSION"; then + removeIso "$iso" || : + return 74 + fi + + extracted=1 + + if detectImage "$dir"; then + return 0 + fi + + abortInstall "$dir" "$iso" "$boot" || return 76 + handled=1 + return 0 +} + +configureMachine() { + + local iso="$1" + local dir="$2" + local boot="$3" + + local desc + + if ! desc=$(printVariant "$DETECTED" "$DETECTED"); then + abortInstall "$dir" "$iso" "$boot" || return 78 + handled=1 + return 0 + fi + + if ! setMachine "$DETECTED" "$iso" "$dir" "$desc"; then + abortInstall "$dir" "$iso" "$boot" || return 80 + handled=1 return 0 fi if ! restoreMachineState; then - abortInstall "$dir" "$ISO" "$boot" || return 82 + abortInstall "$dir" "$iso" "$boot" || return 82 + handled=1 return 0 fi + # Direct-boot media skips all unattended installation preparation. if bootDirect "$DETECTED"; then - abortInstall "$dir" "$ISO" "$boot" || return 83 + abortInstall "$dir" "$iso" "$boot" || return 83 + handled=1 return 0 fi - if canUseSetupImage "$DETECTED" "$ISO"; then + return 0 +} + +prepareWindowsImage() { + + local iso="$1" + local dir="$2" + local boot="$3" + + # Prefer the original ISO with a small setup image whenever possible. + if canUseSetupImage "$DETECTED" "$iso"; then if ! stageSetup "$XML" "$LANGUAGE" "$TMP/setup"; then - abortInstall "$dir" "$ISO" "$boot" || return 84 + abortInstall "$dir" "$iso" "$boot" || return 84 + handled=1 return 0 fi @@ -98,32 +153,33 @@ startWindows() { exit 86 fi - useOriginalImage "$ISO" || return 88 - - else - - if (( ! extracted )); then - if ! extractImage "$ISO" "$dir" "$VERSION"; then - removeIso "$ISO" && return 90 - fi - fi - - if ! prepareImage "$ISO" "$dir"; then - abortInstall "$dir" "$ISO" "$boot" || return 92 - return 0 - fi - - if ! updateImage "$dir" "$XML" "$LANGUAGE"; then - abortInstall "$dir" "$ISO" "$boot" || return 94 - return 0 - fi - - removeImage "$ISO" || return 96 - buildImage "$dir" || return 98 + useOriginalImage "$iso" || return 88 + return 0 fi - finishInstall "$BOOT" "N" "$boot" || return 100 + # Legacy or modifiable media must be extracted, updated, and rebuilt. + if (( ! extracted )); then + if ! extractImage "$iso" "$dir" "$VERSION"; then + removeIso "$iso" || : + return 90 + fi + fi + + if ! prepareImage "$iso" "$dir"; then + abortInstall "$dir" "$iso" "$boot" || return 92 + handled=1 + return 0 + fi + + if ! updateImage "$dir" "$XML" "$LANGUAGE"; then + abortInstall "$dir" "$iso" "$boot" || return 94 + handled=1 + return 0 + fi + + removeImage "$iso" || return 96 + buildImage "$dir" || return 98 return 0 } @@ -218,6 +274,8 @@ startInstall() { ISO=$(basename "$BOOT") ISO="$TMP/$ISO" + # Work from the temporary directory so the persistent source path can + # later contain either the preserved ISO or the rebuilt installation image. if [ -f "$BOOT" ] && [ -s "$BOOT" ]; then if ! mv -f -- "$BOOT" "$ISO"; then error "Failed to move ISO file from \"$BOOT\" to \"$ISO\" !" @@ -255,11 +313,16 @@ abortInstall() { local dir="$1" local iso="$2" local boot="$3" + local efi efi32 efi64 + # Standalone ESD files and nested archives are not directly bootable media, + # so they cannot use the manual-install fallback. [[ "${iso,,}" == *".esd" ]] && exit 60 enabled "${UNPACK:-}" && exit 60 + # When automatic preparation fails, inspect extracted media to determine + # whether it can still be booted manually using legacy firmware. if [[ "${PLATFORM,,}" == "x64" ]] && [ -d "$dir" ]; then efi=$(find "$dir" -maxdepth 1 -type d -iname efi -print -quit) @@ -278,6 +341,8 @@ abortInstall() { fi + # Preserve custom media in place. Downloaded or reused media must be moved + # back to persistent storage before the manual fallback is started. if [ -n "$CUSTOM" ]; then BOOT="$iso" REMOVE="N" @@ -317,9 +382,12 @@ skipInstall() { local iso="$1" local previousBase="$2" + local boot="$STORAGE/windows.boot" if [ -n "$previousBase" ]; then + # A changed source invalidates an unfinished installation. Back up an + # existing installation, but discard stale media when no disk exists yet. if [[ "${STORAGE,,}/${previousBase,,}" != "${iso,,}" ]]; then if ! hasDisk; then @@ -372,6 +440,7 @@ finishInstall() { local iso="$1" local aborted="$2" local boot="$3" + local base if [ ! -s "$iso" ] || [ ! -f "$iso" ]; then @@ -428,8 +497,9 @@ finishInstall() { findFile() { - local dir file base local fname="$1" + + local dir file base local boot="$STORAGE/windows.boot" dir=$(find / -maxdepth 1 -type d -iname "$fname" -print -quit) @@ -462,6 +532,8 @@ findFile() { ISO="$file" CUSTOM="$file" + # Include the custom ISO size in its persistent name so replacing a + # bind-mounted ISO is detected as a different installation source. BOOT="$STORAGE/windows.$size.iso" return 0 @@ -501,6 +573,8 @@ needsExtraction() { local id="$1" local iso="$2" + # Direct-boot media does not need rebuilding. Legacy/skipped versions, + # standalone ESD downloads, and nested archives require full extraction. bootDirect "$id" && return 1 skipVersion "$id" || @@ -512,6 +586,7 @@ checkFreeSpace() { local dir="$1" local size="$2" + local size_gb space space_gb size_gb=$(formatBytes "$size") @@ -526,16 +601,6 @@ checkFreeSpace() { return 0 } -getEsdField() { - - local list="$1" - local index="$2" - - sed -n "${index}p" <<< "$list" | tr -cd '0-9' - - return 0 -} - extractESD() { local iso="$1" @@ -543,11 +608,11 @@ extractESD() { local version="$3" local desc="$4" - local info count totals links - local bootTotal bootLinks - local wimTotal wimLinks - local installSize size - local edition imgEdition + local bootTotal bootLinks wimTotal wimLinks + local installSize size edition imgEdition + local bootWim installWim bootSize wimSize + local index line ret xml metadata count + local -a fields local minSize=100000000 local freeSpace=9606127360 @@ -579,12 +644,30 @@ extractESD() { checkFreeSpace "$dir" "$freeSpace" || return 1 - info=$(wimlib-imagex info "$iso") || { + 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 - count=$(awk '/Image Count:/ {print $3}' <<< "$info") + # 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 @@ -595,34 +678,33 @@ extractESD() { return 1 fi - totals=$(grep "Total Bytes:" <<< "$info" || true) - links=$(grep "Hard Link Bytes:" <<< "$info" || true) + bootTotal="${fields[1]:-}" + bootLinks="${fields[2]:-}" - bootTotal=$(getEsdField "$totals" 1) - bootLinks=$(getEsdField "$links" 1) - - if [[ ! "$bootTotal" =~ ^[0-9]+$ ]] || [[ ! "$bootLinks" =~ ^[0-9]+$ ]]; then + if [[ ! "$bootTotal" =~ ^[0-9]+$ ]] || + [[ ! "$bootLinks" =~ ^[0-9]+$ ]]; then error "Cannot read bootdisk size from ESD file!" return 1 fi - local bootSize=$(( bootTotal - bootLinks )) + bootSize=$(( bootTotal - bootLinks )) - wimTotal=$(getEsdField "$totals" 3) - wimLinks=$(getEsdField "$links" 3) + wimTotal="${fields[3]:-}" + wimLinks="${fields[4]:-}" - if [[ ! "$wimTotal" =~ ^[0-9]+$ ]] || [[ ! "$wimLinks" =~ ^[0-9]+$ ]]; then + if [[ ! "$wimTotal" =~ ^[0-9]+$ ]] || + [[ ! "$wimLinks" =~ ^[0-9]+$ ]]; then error "Cannot read boot.wim size from ESD file!" return 1 fi - local wimSize=$(( wimTotal - wimLinks + bootPad )) + wimSize=$(( wimTotal - wimLinks + bootPad )) /run/progress.sh "$dir" "$bootSize" "$msg ([P])..." & - local index="1" + index="1" wimlib-imagex apply "$iso" "$index" "$dir" --quiet 2>/dev/null || { - local ret=$? + ret=$? fKill "progress.sh" error "Extracting $desc bootdisk failed ($ret)" return 1 @@ -630,8 +712,8 @@ extractESD() { fKill "progress.sh" - local bootWim="$dir/sources/boot.wim" - local installWim="$dir/sources/install.wim" + bootWim="$dir/sources/boot.wim" + installWim="$dir/sources/install.wim" msg="Extracting $desc environment" info "$msg..." && html "$msg..." @@ -639,8 +721,9 @@ extractESD() { index="2" /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." & - wimlib-imagex export "$iso" "$index" "$bootWim" --compress=none --quiet || { - local ret=$? + wimlib-imagex export "$iso" "$index" "$bootWim" \ + --compress=none --quiet || { + ret=$? fKill "progress.sh" error "Adding WinPE failed ($ret)" return 1 @@ -654,8 +737,9 @@ extractESD() { index="3" /run/progress.sh "$bootWim" "$wimSize" "$msg ([P])..." & - wimlib-imagex export "$iso" "$index" "$bootWim" --compress=none --boot --quiet || { - local ret=$? + wimlib-imagex export "$iso" "$index" "$bootWim" \ + --compress=none --boot --quiet || { + ret=$? fKill "progress.sh" error "Adding Windows Setup failed ($ret)" return 1 @@ -678,18 +762,19 @@ extractESD() { return 1 fi - for (( index=4; index<=count; index++ )); do + for line in "${fields[@]:5}"; do - imgEdition=$(wimlib-imagex info "$iso" "$index" | grep '^Description:' | sed 's/Description:[ \t]*//') + IFS=$'\t' read -r index imgEdition <<< "$line" + + [[ ! "$index" =~ ^[0-9]+$ ]] && continue [[ "${imgEdition,,}" != "${edition,,}" ]] && continue - installSize=$(stat -c%s "$iso") - installSize=$(( installSize + installPad )) + installSize=$(( size + installPad )) /run/progress.sh "$installWim" "$installSize" "$msg ([P])..." & wimlib-imagex export "$iso" "$index" "$installWim" --quiet || { - local ret=$? + ret=$? fKill "progress.sh" error "Addition of $index to the $desc image failed ($ret)" return 1 @@ -710,8 +795,9 @@ extractImage() { local iso="$1" local dir="$2" local version="$3" - local desc="local ISO" + local file size + local desc="local ISO" if [ -z "$CUSTOM" ]; then desc="downloaded ISO" @@ -766,6 +852,8 @@ extractImage() { else + # UNPACK archives contain another ISO. Extract the nested ISO, then + # preserve it as the actual source media for subsequent processing. file=$(find "$dir" -maxdepth 1 -type f -iname "*.iso" -print -quit) if [ -z "$file" ]; then @@ -799,7 +887,11 @@ setMachine() { local dir="$3" local desc="$4" - ETFS="boot/etfsboot.com" + if [[ "${id,,}" != "win9"* ]]; then + ETFS="boot/etfsboot.com" + else + ETFS="[BOOT]/Boot-1.44M.img" + fi local version="" case "${id,,}" in @@ -821,7 +913,7 @@ setMachine() { writeState "mode" "windows_legacy" || return 1 - case "${id,,}" in + case "${id,,}" in "win9"* | "win2k"* | "reactos" ) writeState "vga" "cirrus" || return 1 ;; * ) @@ -903,12 +995,13 @@ prepareImage() { local iso="$1" local dir="$2" + local desc missing desc=$(printVariant "$DETECTED" "$DETECTED") - if [[ "${BOOT_MODE,,}" == "windows_legacy" && - "${DETECTED,,}" != "win9"* ]]; then + # Legacy rebuilt media must retain the source ISO's El Torito boot-load size. + if [[ "${BOOT_MODE,,}" == "windows_legacy" && "${DETECTED,,}" != "win9"* ]]; then getBootLoadSize "$iso" "$dir" "$desc" || return 1 fi @@ -917,13 +1010,14 @@ prepareImage() { if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then extractBootImage "$iso" "$dir" "$desc" && return 0 - error "Failed to extract boot image from ISO image \"${iso}\"!" + return 1 fi EFISYS="efi/microsoft/boot/efisys_noprompt.bin" + # A modern rebuilt ISO requires both its BIOS and no-prompt UEFI boot images. [ -f "$dir/$ETFS" ] && [ -s "$dir/$ETFS" ] && [ -f "$dir/$EFISYS" ] && [ -s "$dir/$EFISYS" ] && return 0 @@ -954,7 +1048,8 @@ addFolder() { local target="${2:-image}" local log="${3:-Y}" local mode="${4:-copy}" - local folder file="" source="" + + local file="" source="" folder local dest="$src/\$OEM\$/\$1/OEM" local install="$src/.overlay-install.bat" @@ -967,6 +1062,8 @@ addFolder() { info "$msg" && html "$msg" fi + # Setup-image mode cannot modify the original ISO, so create a temporary + # writable copy of install.bat for the overlay image. if [ "$mode" = "overlay" ]; then rm -f -- "$install" || return 1 @@ -1037,6 +1134,7 @@ addDriver() { local path="$2" local target="$3" local driver="$4" + local folder="" desc if [ -z "$id" ]; then @@ -1074,8 +1172,7 @@ addDriver() { case "${id,,}" in "winvista"* ) - [[ "${driver,,}" == "viorng" ]] && return 0 - ;; + [[ "${driver,,}" == "viorng" ]] && return 0 ;; esac local dest="$path/$target/$driver" @@ -1093,6 +1190,7 @@ addDrivers() { local file="${4:-}" local index="${5:-}" local log="${6:-Y}" + local drivers="$tmp/drivers" rm -rf "$drivers" @@ -1114,6 +1212,7 @@ addDrivers() { local target="\$WinPEDriver\$" local dest="$drivers/$target" + mkdir -p "$dest" || return 1 if [ -n "$file" ]; then @@ -1221,12 +1320,13 @@ updateImage() { local dir="$1" local asset="$2" local language="$3" + + local script="" local tmp="/tmp/install" local xml="autounattend.xml" local bak="${xml//.xml/.org}" local dat="${xml//.xml/.dat}" - local desc path src wim name info - local script="" + local desc path src wim name info skipVersion "${DETECTED,,}" && return 0 @@ -1255,6 +1355,7 @@ updateImage() { return 1 fi + # Windows Setup normally resides in boot image 2; single-image media uses 1. local idx="1" if ! info=$(wimlib-imagex info -xml "$wim" | iconv -f UTF-16LE -t UTF-8); then @@ -1275,6 +1376,8 @@ updateImage() { error "Failed to add OEM folder to image!" fi + # Preserve an original answer file only once. The .dat marker identifies an + # image where our generated answer file has already been installed. if wimlib-imagex extract "$wim" "$idx" "/$xml" "--dest-dir=$tmp" >/dev/null 2>&1; then if ! wimlib-imagex extract "$wim" "$idx" "/$dat" "--dest-dir=$tmp" >/dev/null 2>&1; then if ! wimlib-imagex extract "$wim" "$idx" "/$bak" "--dest-dir=$tmp" >/dev/null 2>&1; then @@ -1314,7 +1417,7 @@ updateImage() { validateGeneratedXML "$answer" || return 1 if [ -z "${CUSTOM_XML:-}" ]; then - prepareSetupScript "$asset" "$tmp/setup" script || exit 84 + script=$(prepareSetupScript "$asset" "$tmp/setup") || exit 84 fi if ! wimlib-imagex update "$wim" "$idx" --command "add $answer /$xml" > /dev/null; then @@ -1328,6 +1431,8 @@ updateImage() { fi + # Manual mode removes generated automation and restores the original answer + # file when one was backed up earlier. if enabled "$MANUAL"; then removeGeneratedXML "$asset" || return 1 @@ -1342,6 +1447,8 @@ updateImage() { fi + # Prevent a root-level answer file from overriding the selected automatic or + # manual behavior when Windows Setup first boots. name="$xml" enabled "$MANUAL" && name="$bak" path=$(find "$dir" -maxdepth 1 -type f -iname "$name" -print -quit) || return 1 @@ -1392,10 +1499,11 @@ reserveSambaPorts() { backup () { local iso="$1" + local count=1 local name="unknown" local root="$STORAGE/backups" - local file previous failed="" + local failed="" file previous previous=$(readState "base") || return 1 [ -n "$previous" ] && name="${previous%.*}" @@ -1437,6 +1545,8 @@ backup () { -not -iname '*.iso' -print0 ) + # Wait for the process-substitution find command so enumeration failures are + # detected rather than being mistaken for a successful backup. local find_pid=$! if ! wait "$find_pid"; then @@ -1461,6 +1571,8 @@ restoreBootMode() { [ -n "$mode" ] || return 0 + # A saved legacy mode always wins. A saved modern mode only replaces the + # default mode and never an explicit user-selected boot configuration. if [[ "${mode,,}" == "windows_legacy" ]]; then BOOT_MODE="$mode" return 0 @@ -1476,6 +1588,8 @@ restoreBootMode() { restoreMachine() { + # Restore the saved machine only when q35 is still the default; an explicit + # user-selected machine must remain untouched. [[ "${PLATFORM,,}" != "x64" ]] && return 0 [[ "${MACHINE,,}" != "q35" ]] && return 0 diff --git a/src/mido.sh b/src/mido.sh index cd495a34..7a50b10c 100644 --- a/src/mido.sh +++ b/src/mido.sh @@ -6,6 +6,7 @@ handleCurlError() { local code="$1" local server="$2" local reason="${3:-}" + local signal if [ -n "$reason" ] && (( code <= 125 )); then @@ -29,8 +30,7 @@ handleCurlError() { SEGV | ABRT) error "Curl crashed with signal $signal." ;; "") error "Curl terminated with exit status $code." ;; *) error "Curl terminated due to signal $signal." ;; - esac - ;; + esac ;; esac return 1 @@ -38,10 +38,9 @@ handleCurlError() { curlRequest() { - local output="$1" - local server="$2" - local agent="$3" - shift 3 + local server="$1" + local agent="$2" + shift 2 local log reason response @@ -50,6 +49,8 @@ curlRequest() { return 1 fi + # Preserve curl's status under errexit so its stderr can be translated + # into a useful error instead of terminating the script immediately. { response=$(LC_ALL=C curl \ --silent \ @@ -76,10 +77,7 @@ curlRequest() { rm -f "$log" - if [ -n "$output" ]; then - printf -v "$output" '%s' "$response" - fi - + printf '%s' "$response" return 0 } @@ -93,13 +91,13 @@ downloadWindowsLink() { local desc="$6" local type="$7" - local skuId skuJson - local linkJson link - local ovData ovTime session local ovToken="" ovTicks="" local profile="606624d44113" + local skuId skuJson linkJson + local link ovData ovTime session - # uuidgen: For MacOS (installed by default) and other systems (e.g. with no /proc) that don't have a kernel interface for generating random UUIDs + # Prefer the Linux kernel UUID source, with uuidgen as a portable fallback + # for macOS and systems without /proc. if ! session=$(cat /proc/sys/kernel/random/uuid 2> /dev/null || uuidgen --random); then error "Failed to generate session ID!" return 1 @@ -112,32 +110,32 @@ downloadWindowsLink() { return 1 fi - # Microsoft download "protection" requires the sessionId to be whitelisted through vlscppe.microsoft.com/tags + # Register the session with Microsoft's anti-abuse endpoint before + # requesting SKU or download links. local orgId="y6jn8c31" local vlsUrl="https://vlscppe.microsoft.com/tags?org_id=$orgId&session_id=$session" enabled "$DEBUG" && echo "Getting Session ID: $session" - # Permit Session ID - curlRequest "" "Microsoft" "$agent" \ + curlRequest "Microsoft" "$agent" \ --output /dev/null \ --header "Accept:" \ --max-filesize 100K \ -- "$vlsUrl" || return 1 - # Microsoft download "protection" also requires an ov-df.microsoft.com request/reply - # 1) Request mdt.js to get w and rticks. InstanceId is (currently) constant. + # Complete Microsoft's ov-df challenge by retrieving a token and timing + # value, then returning both with the current timestamp. local instance="560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175" local ovUrl="https://ov-df.microsoft.com/mdt.js?instanceId=$instance&PageId=si&session_id=$session" enabled "$DEBUG" && echo -n "Getting OV data: " - curlRequest ovData "Microsoft" "$agent" \ + ovData=$(curlRequest "Microsoft" "$agent" \ --header "Accept:" \ --max-filesize 1M \ - -- "$ovUrl" || return 1 + -- "$ovUrl") || return 1 if [[ $ovData =~ [\?\&]w=([A-Fa-f0-9]+) ]]; then ovToken="${BASH_REMATCH[1]}" @@ -156,14 +154,12 @@ downloadWindowsLink() { sleep 0.2 - # 2) Send a reply with session ID, current epoch and previously retrieved w and rticks - ovTime=$(date +%s%3N) ovUrl="https://ov-df.microsoft.com/?session_id=$session&CustomerId=$instance&PageId=si&w=$ovToken&mdt=$ovTime&rticks=$ovTicks" enabled "$DEBUG" && echo "Sending OV reply: $instance" - curlRequest "" "Microsoft" "$agent" \ + curlRequest "Microsoft" "$agent" \ --output /dev/null \ --header "Accept:" \ --max-filesize 100K \ @@ -173,12 +169,14 @@ downloadWindowsLink() { local skuUrl="https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?profile=$profile&ProductEditionId=$productId&SKU=undefined&friendlyFileName=undefined&Locale=en-US&sessionID=$session" - curlRequest skuJson "Microsoft" "$agent" \ + skuJson=$(curlRequest "Microsoft" "$agent" \ --referer "$url" \ --header "Accept:" \ --max-filesize 100K \ - -- "$skuUrl" || return 1 + -- "$skuUrl") || return 1 + # Guard jq under errexit so malformed API data can be handled as a normal + # missing-result error. The same pattern is reused for the link response. { skuId=$(printf '%s\n' "$skuJson" | jq --arg LANG "$language" -r 'first(.Skus[]? | select(.Language == $LANG) | .Id) // empty') 2>/dev/null; local rc=$?; } || : if [ -z "$skuId" ] || [[ "${skuId,,}" == "null" ]] || (( rc != 0 )); then @@ -190,19 +188,18 @@ downloadWindowsLink() { enabled "$DEBUG" && echo "$skuId" enabled "$DEBUG" && echo "Getting ISO download link..." - # Get ISO download link - # If any request is going to be blocked by Microsoft it's always this last one (the previous requests always seem to succeed) + # Microsoft normally applies request or IP blocking on this final connector + # call rather than during the preceding session setup. local linkUrl="https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?profile=$profile&ProductEditionId=undefined&SKU=$skuId&friendlyFileName=undefined&Locale=en-US&sessionID=$session" - curlRequest linkJson "Microsoft" "$agent" \ + linkJson=$(curlRequest "Microsoft" "$agent" \ --referer "$url" \ --header "Accept:" \ --max-filesize 100K \ - -- "$linkUrl" || return 1 + -- "$linkUrl") || return 1 if ! [ "$linkJson" ]; then - # This should only happen if there's been some change to how this API works error "Microsoft servers gave us an empty response to our request for an automated download." return 1 fi @@ -235,9 +232,8 @@ downloadWindows() { local lang="$2" local desc="$3" - local agent language - local page productId - local type winVer + local agent language page + local productId type winVer agent=$(getAgent) language=$(getLanguage "$lang" "name") @@ -246,22 +242,18 @@ downloadWindows() { "win10x64" ) productId="2618" winVer="10" - type="1" - ;; + type="1" ;; "win11x64" ) productId="3321" winVer="11" - type="1" - ;; + type="1" ;; "win11arm64" ) productId="3324" winVer="11arm64" - type="2" - ;; + type="2" ;; * ) error "Invalid VERSION specified, value \"$id\" is not recognized!" - return 1 - ;; + return 1 ;; esac local url="https://www.microsoft.com/en-us/software-download/windows$winVer" @@ -275,14 +267,16 @@ downloadWindows() { sleep 1 + # Product edition IDs can change. If the configured ID fails, recover the + # current value from Microsoft's public download page and retry once. local msg="retrying using a different method..." info "Microsoft download request failed, $msg" enabled "$DEBUG" && echo "Parsing download page: ${url}" - curlRequest page "Microsoft" "$agent" \ + page=$(curlRequest "Microsoft" "$agent" \ --header "Accept:" \ --max-filesize 1M \ - -- "$url" || return 1 + -- "$url") || return 1 enabled "$DEBUG" && echo -n "Getting Product edition ID: " productId=$(printf '%s' "$page" | @@ -310,6 +304,7 @@ downloadWindowsEval() { local id="$1" local lang="$2" local desc="$3" + local culture compare type local agent language winVer @@ -354,13 +349,12 @@ downloadWindowsEval() { enabled "$DEBUG" && echo "Parsing download page: ${url}" - curlRequest page "Microsoft" "$agent" \ + page=$(curlRequest "Microsoft" "$agent" \ --location \ --max-filesize 1M \ - -- "$url" || return 1 + -- "$url") || return 1 if ! [ "$page" ]; then - # This should only happen if there's been some change to where this download page is located error "Windows server download page gave us an empty response" return 1 fi @@ -375,7 +369,8 @@ downloadWindowsEval() { grep -Eio "https://go\.microsoft\.com/fwlink(/p)?/\?[^\"'<>[:space:]]+" | grep -Ei '(^|[?&])culture='"${culture,,}"'(&|$)' | grep -Ei '(^|[?&])country='"${country,,}"'(&|$)') || { - # This should only happen if there's been some change to the download endpoint web address + # Distinguish a changed or missing English page from an unavailable + # translation for an otherwise supported product. if [[ "${lang,,}" == "en" || "${lang,,}" == "en-"* ]]; then error "Windows server download page gave us no download link!" else @@ -385,6 +380,8 @@ downloadWindowsEval() { return 1 } + # Evaluation pages currently expose several matching fwlinks in a known + # product/platform order, so select the entry for the requested variant. case "$type" in "iot" ) case "${PLATFORM,,}" in @@ -420,19 +417,21 @@ downloadWindowsEval() { [ -z "$link" ] && error "Could not parse download link from page!" && return 1 - # Follow redirect so proceeding log message is useful - # This is a request we make that Fido doesn't + # Resolve the fwlink now so later logging and platform validation use the + # actual ISO URL rather than Microsoft's generic redirect. - curlRequest link "Microsoft" "$agent" \ + link=$(curlRequest "Microsoft" "$agent" \ --location \ --output /dev/null \ --write-out "%{url_effective}" \ --head \ - -- "$link" || return 1 + -- "$link") || return 1 local lower="${link,,}" local separator='(^|[[:space:]_./-])' + # Guard against page-order changes resolving to the wrong architecture + # before downloading a multi-gigabyte image. case "${PLATFORM,,}" in "x64" ) if [[ "$lower" =~ ${separator}(arm64|a64) ]]; then @@ -450,6 +449,8 @@ downloadWindowsEval() { fi ;; esac + # During debug verification, compare the resolved filename with the static + # catalog entry to expose unexpected changes on Microsoft's page. if enabled "$DEBUG" && enabled "$VERIFY" && [[ "${lang,,}" == "en"* ]]; then compare=$(getMido "$id" "$lang" "") @@ -494,14 +495,11 @@ getMidoDetected() { # Derive the normal answer-file identity from the requested download route. case "$default" in *"-enterprise-ltsc-eval" ) - default="${default%-enterprise-ltsc-eval}-ltsc" - ;; + default="${default%-enterprise-ltsc-eval}-ltsc" ;; *"-enterprise-iot-eval" ) - default="${default%-enterprise-iot-eval}-iot" - ;; + default="${default%-enterprise-iot-eval}-iot" ;; *"-eval" ) - default="${default%-eval}" - ;; + default="${default%-eval}" ;; esac # Preserve a genuinely different DETECTED override. @@ -513,17 +511,13 @@ getMidoDetected() { # Select the answer-file identity for the source that actually succeeded. case "$source" in *"-enterprise-ltsc-eval" ) - detected="${source%-enterprise-ltsc-eval}-ltsc-eval" - ;; + detected="${source%-enterprise-ltsc-eval}-ltsc-eval" ;; *"-enterprise-iot-eval" ) - detected="${source%-enterprise-iot-eval}-iot-eval" - ;; + detected="${source%-enterprise-iot-eval}-iot-eval" ;; *"-eval" ) - detected="$source" - ;; + detected="$source" ;; * ) - detected="${current:-$default}" - ;; + detected="${current:-$default}" ;; esac echo "$detected" @@ -535,21 +529,21 @@ downloadWindowsLtsc() { local id="$1" local lang="$2" local desc="$3" + local alternate alternate_desc case "${id,,}" in "win11${PLATFORM,,}-enterprise-iot-eval" ) - alternate="win11${PLATFORM,,}-enterprise-ltsc-eval" - ;; + alternate="win11${PLATFORM,,}-enterprise-ltsc-eval" ;; "win11${PLATFORM,,}-enterprise-ltsc-eval" ) - alternate="win11${PLATFORM,,}-enterprise-iot-eval" - ;; + alternate="win11${PLATFORM,,}-enterprise-iot-eval" ;; * ) error "Invalid VERSION specified, value \"$id\" is not recognized!" - return 1 - ;; + return 1 ;; esac + # IoT and LTSC share related evaluation sources and may become unavailable + # independently, so use the sibling edition as a compatibility fallback. if downloadWindowsEval "$id" "$lang" "$desc" > /dev/null 2>&1; then MIDO_SOURCE="$id" return 0 @@ -574,6 +568,7 @@ getWindows() { local lang="$2" local desc="$3" local web_desc="$4" + local language edition MIDO_SOURCE="" @@ -586,6 +581,8 @@ getWindows() { local web_msg="Requesting $web_desc from the Microsoft servers..." info "$msg" && html "$web_msg" + # These sources are only published in English, so avoid trying download + # routes that cannot satisfy the requested language. case "${version,,}" in "win2008r2"* | \ "win81${PLATFORM,,}"* | \ @@ -598,6 +595,8 @@ getWindows() { fi ;; esac + # ARM64 downloads exist only for the explicitly supported Windows 11 + # routes; all other catalog entries remain x64-only. case "${version,,}" in "win10x64" ) ;; "win11${PLATFORM,,}" ) ;; @@ -610,6 +609,8 @@ getWindows() { fi ;; esac + # Prefer live Microsoft download routes. Unsupported or failed live routes + # fall through to the configured static catalog below. case "${version,,}" in "win10x64" | "win11${PLATFORM,,}" ) @@ -621,8 +622,7 @@ getWindows() { "win11${PLATFORM,,}-enterprise-iot-eval" | \ "win11${PLATFORM,,}-enterprise-ltsc-eval" ) - downloadWindowsLtsc "$version" "$lang" "$edition" && return 0 - ;; + downloadWindowsLtsc "$version" "$lang" "$edition" && return 0 ;; "win11${PLATFORM,,}-enterprise"* ) @@ -643,10 +643,11 @@ getWindows() { * ) error "Invalid VERSION specified, value \"$version\" is not recognized!" - return 1 - ;; + return 1 ;; esac + # Static catalog URLs are the last resort after live Microsoft methods are + # unavailable or have failed. MIDO_URL=$(getMido "$version" "$lang" "") [ -z "$MIDO_URL" ] && return 1 @@ -665,11 +666,10 @@ getBuild() { local id="$1" local ret="$2" - local url="" - local name="" local build="$3" - local edition="" + local file="catalog.xml" + local url="" name="" edition="" case "${id,,}" in "win11${PLATFORM,,}" ) @@ -695,10 +695,9 @@ getCatalog() { local id="$1" local ret="$2" - local url="" - local name="" - local edition="" + local file="catalog.cab" + local url="" name="" edition="" if [[ "${id,,}" == "win11"* ]] && ! isCompatible; then # ARMv8.0 cannot run Windows 11 builds 24H2 and up. @@ -728,19 +727,108 @@ getCatalog() { "url" ) echo "$url" ;; "file" ) echo "$file" ;; "name" ) echo "$name" ;; - "edition" ) echo '[Edition="'"${edition}"'"]' ;; + "edition" ) echo "$edition" ;; *) echo "";; esac return 0 } -getXmlTag() { +parseESD() { - local tag="$1" - local file="$2" + local xml="$1" + local version="$2" + local lang="$3" + local desc="$4" + local edition="$5" + local culture="$6" - xmllint --nonet --xpath "//$tag" "$file" 2>/dev/null | sed -E -e "s/<[\/]?$tag>//g" || true + local xmlFile="${xml##*/}" + local file_path file_sum file_size file_edition + local file_culture file_match=0 language_match=0 + local records architecture language separator=$'\x1f' + + ESD="" + ESD_SUM="" + ESD_SIZE="" + + # Microsoft catalogs have used different XML namespaces. Match elements by + # local name and flatten the catalog once so selection needs no temporary XML. + if ! records=$(xmlstarlet sel \ + -T -t \ + -m "//*[local-name()='File']" \ + -v "normalize-space(*[local-name()='Architecture'])" \ + -o "$separator" \ + -v "normalize-space(*[local-name()='Edition'])" \ + -o "$separator" \ + -v "normalize-space(*[local-name()='LanguageCode'])" \ + -o "$separator" \ + -v "normalize-space(*[local-name()='FilePath'])" \ + -o "$separator" \ + -v "normalize-space(*[local-name()='Sha1'])" \ + -o "$separator" \ + -v "normalize-space(*[local-name()='Size'])" \ + -n \ + "$xml" 2>/dev/null); then + + error "Failed to parse $xmlFile!" + return 1 + fi + + # Track product/platform and language matches separately so failures can + # distinguish an unavailable edition from an unavailable translation. + while IFS="$separator" read -r \ + architecture file_edition file_culture \ + file_path file_sum file_size; do + + [ -n "$architecture$file_path$file_sum$file_size$file_culture$file_edition" ] || continue + + [ "${architecture,,}" = "${PLATFORM,,}" ] || continue + + if [ -n "$edition" ] && + [ "${file_edition,,}" != "${edition,,}" ]; then + continue + fi + + file_match=1 + + [ "${file_culture,,}" = "${culture,,}" ] || continue + + language_match=1 + ESD="$file_path" + ESD_SUM="$file_sum" + ESD_SIZE="$file_size" + break + + done <<< "$records" + + if (( ! file_match )); then + desc=$(printEdition "$version" "$desc" "Y") + error "No download link available for $desc!" + return 1 + fi + + if (( ! language_match )); then + desc=$(printEdition "$version" "$desc" "Y") + language=$(getLanguage "$lang" "desc") + error "No download in the $language language available for $desc!" + return 1 + fi + + if [ -z "$ESD" ]; then + error "Failed to find ESD URL in $xmlFile!" + return 1 + fi + + if [ -z "$ESD_SUM" ]; then + error "Failed to find ESD checksum in $xmlFile!" + return 1 + fi + + if [ -z "$ESD_SIZE" ]; then + error "Failed to find ESD filesize in $xmlFile!" + return 1 + fi return 0 } @@ -751,12 +839,10 @@ getESD() { local version="$2" local lang="$3" local desc="$4" - local file result culture - local language edition catalog + + local file culture log + local edition catalog rc=0 local xmlFile="products.xml" - local esdFile="esd_edition.xml" - local filterFile="products_filter.xml" - local log file=$(getCatalog "$version" "file") catalog=$(getCatalog "$version" "url") @@ -783,10 +869,12 @@ getESD() { return 1 fi + # Preserve wget's status under errexit so its log can provide the actual + # server or filesystem failure reason. { LC_ALL=C wget "$catalog" -O "$dir/$file" --no-verbose --timeout=30 \ --no-http-keep-alive --output-file="$log" - local rc=$? + rc=$? } || : if (( rc != 0 )); then @@ -813,6 +901,8 @@ getESD() { rm -f "$log" + # Normal catalogs arrive as CAB archives, while pinned build catalogs are + # already XML and only need the common filename. if [[ "$file" == *".xml" ]]; then if ! mv -f "$dir/$file" "$dir/$xmlFile"; then @@ -832,61 +922,13 @@ getESD() { fi - if [ ! -f "$dir/$xmlFile" ] || [ ! -s "$dir/$xmlFile" ]; then + if [ ! -s "$dir/$xmlFile" ]; then error "Failed to find $xmlFile in $file!" return 1 fi - local query='//File[Architecture="'${PLATFORM,,}'"]'"${edition}"'' - result=$(xmllint --nonet --xpath "${query}" "$dir/$xmlFile" 2>/dev/null || true) - - if [ -z "$result" ]; then - - query='//File[Architecture="'${PLATFORM^^}'"]'"${edition}"'' - result=$(xmllint --nonet --xpath "${query}" "$dir/$xmlFile" 2>/dev/null || true) - - if [ -z "$result" ]; then - desc=$(printEdition "$version" "$desc" "Y") - language=$(getLanguage "$lang" "desc") - error "No download link available for $desc!" - return 1 - fi - - fi - - echo -e '<Catalog>' > "$dir/$filterFile" - echo "$result" >> "$dir/$filterFile" - echo -e '</Catalog>'>> "$dir/$filterFile" - - result=$(xmllint --nonet --xpath "//File[LanguageCode=\"${culture,,}\"]" "$dir/$filterFile" 2>/dev/null || true) - - if [ -z "$result" ]; then - desc=$(printEdition "$version" "$desc" "Y") - language=$(getLanguage "$lang" "desc") - error "No download in the $language language available for $desc!" - return 1 - fi - - echo "$result" > "$dir/$esdFile" - - ESD=$(getXmlTag "FilePath" "$dir/$esdFile") - - if [ -z "$ESD" ]; then - error "Failed to find ESD URL in $esdFile!" - return 1 - fi - - ESD_SUM=$(getXmlTag "Sha1" "$dir/$esdFile") - - if [ -z "$ESD_SUM" ]; then - error "Failed to find ESD checksum in $esdFile!" - return 1 - fi - - ESD_SIZE=$(getXmlTag "Size" "$dir/$esdFile") - - if [ -z "$ESD_SIZE" ]; then - error "Failed to find ESD filesize in $esdFile!" + if ! parseESD \ + "$dir/$xmlFile" "$version" "$lang" "$desc" "$edition" "$culture"; then return 1 fi @@ -898,11 +940,12 @@ isCompressed() { local url="${1%%\?*}" + # The ReactOS latest-build endpoint returns an archive without a filename + # extension, so recognize its path explicitly. case "${url,,}" in *.7z | *.zip | *.rar | *.tar | *.cab | *.cpio | \ *.lzh | *.lha | *.xar | */latest-x86-gcc-lin-rel ) - return 0 - ;; + return 0 ;; esac return 1 @@ -926,6 +969,9 @@ verifyFile() { [ -z "$check" ] && return 0 enabled "$VERIFY" || return 0 + + # Microsoft ESD catalogs publish SHA1, while current mirror metadata normally + # uses SHA256; the digest length identifies which algorithm is required. [[ "${#check}" == "40" ]] && algo="SHA1" local msg="Verifying downloaded ISO..." @@ -975,10 +1021,13 @@ downloadFile() { local desc="$4" local web_desc="$5" local connections="${6:-1}" + + local domain dots local msg="Downloading $web_desc" local console_msg="Downloading $desc" - local domain dots + # Keep mirror messages concise by reducing subdomains to the final two + # labels, while Microsoft downloads retain the generic description. domain=$(echo "$url" | awk -F/ '{print $3}') dots=$(echo "$domain" | tr -cd '.' | wc -c) (( dots > 1 )) && domain=$(expr "$domain" : '.*\.\(.*\..*\)') @@ -1007,8 +1056,11 @@ tryDownload() { local desc="$6" local seconds="$7" local web_desc="$8" + local total minimum="104857600" + # Compressed archives can legitimately be much smaller than the ISO they + # contain, so use a lower sanity threshold until extraction. if isCompressed "$url"; then minimum="10485760" fi @@ -1059,8 +1111,8 @@ fallbackEnglish() { local lang="$3" local desc="$4" local web_desc="$5" - local culture web_msg + local culture web_msg local msg="No working download method was found for $desc, falling back to English..." info "$msg" @@ -1086,12 +1138,11 @@ downloadImage() { local iso="$1" local version="$2" local lang="$3" + local requested="$version" - local tried="n" - local success="n" - local seconds="5" - local detected="$DETECTED" - local url sum size base desc web_desc language i + local detected="$DETECTED" + local tried="n" success="n" seconds="5" + local i url sum size base language desc web_desc if [[ "${version,,}" == "http"* ]]; then @@ -1127,6 +1178,8 @@ downloadImage() { desc+=" in $language" fi + # Prefer a live Microsoft URL and retry link generation once before moving + # on to ESD catalogs or mirrors. if isMido "$version" "$lang"; then tried="y" @@ -1167,7 +1220,13 @@ downloadImage() { fi fi - if switchEdition version; then + # Some editions share another download route. Update the effective version + # before looking up ESD catalogs and mirrors. + if version=$(switchEdition "$version"); then + + if ! enabled "${DETECTED_ORG:-}"; then + DETECTED="${SUGGEST:-$version}" + fi desc=$(printVariant "$DETECTED" "" "Y") web_desc=$(printVariant "$DETECTED" "") @@ -1196,6 +1255,8 @@ downloadImage() { if [[ "$success" == "y" ]]; then + # Standalone ESD media requires a different extraction path, so expose + # its real extension through the active ISO variable. ISO="${ISO%.*}.esd" if tryDownload "$ISO" "$ESD" "$ESD_SUM" "$ESD_SIZE" "$lang" "$desc" "$seconds" "$web_desc"; then diff --git a/src/power.sh b/src/power.sh index e070706c..f047e60e 100644 --- a/src/power.sh +++ b/src/power.sh @@ -23,6 +23,8 @@ bootStatus() { if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then local line last recent + # Only inspect output produced after the most recent BIOS boot attempt so + # stale failures from an earlier device do not affect the current state. line=$(grep -nE '^Booting from (Hard Disk|DVD/CD)' "$QEMU_PTY" | tail -1) [ -z "$line" ] && return 1 @@ -38,6 +40,9 @@ bootStatus() { return 2 fi + # These BIOS messages only describe the failed device attempt. QEMU may + # immediately continue with another boot target, so clear pending success + # instead of treating them as a terminal failure. if grep -Fq \ -e "Boot failed: not a bootable disk" \ -e "Boot failed: Could not read from CDROM" \ @@ -59,6 +64,8 @@ bootStatus() { local line last recent + # OVMF logs every boot option it tries. Track the newest attempt and only + # evaluate messages emitted from that point onward. line=$(grep -nE \ 'BdsDxe: starting Boot[[:xdigit:]]{4} ' \ "$QEMU_PTY" | tail -1) @@ -105,6 +112,8 @@ waitForBoot() { while isAlive "$pid"; do + # Send the boot key once, either immediately after the prompt appears or + # shortly after firmware starts the DVD when the prompt is not logged. if (( ! keySent )) && needsBootKey; then if keyDelay=$(bootKeyDelay); then @@ -151,6 +160,9 @@ waitForBoot() { marker=$(getBootMarker) + # A firmware boot line alone is not proof that the guest started. Wait + # briefly for a more definitive success or failure message, restarting + # the grace period whenever firmware begins a different boot attempt. if [[ "$marker" != "$pendingLine" ]] || (( status != pendingType )); then pendingLine="$marker" pendingType=$status @@ -173,6 +185,8 @@ waitForBoot() { 5) + # A failed device attempt is transitional because firmware may continue + # with another target. Discard any pending success decision. pendingType=0 pendingLine="" pendingDeadline=0 @@ -213,6 +227,8 @@ legacyBootReady() { last="${line#*:}" recent=$(tail -n +"${line%%:*}" "$QEMU_PTY") + # ACPI shutdown is safe once BIOS has handed control to the hard disk, unless + # the same attempt already produced a known boot failure. [[ "${last,,}" != "${hard,,}"* ]] && return 1 grep -Fq "Loading FreeLoader..." <<< "$recent" && return 0 @@ -226,6 +242,8 @@ legacyBootReady() { ready() { + # The marker means installation completed previously, so shutdown no longer + # needs to infer guest readiness from firmware output. [ -f "$STORAGE/windows.boot" ] && return 0 [ ! -s "$QEMU_PTY" ] && return 1 @@ -239,6 +257,8 @@ ready() { 'BdsDxe: starting Boot[[:xdigit:]]{4} ' \ "$QEMU_PTY" | tail -1) + # Only a Windows Boot Manager entry loaded from a hard disk proves that setup + # has progressed far enough for an ACPI shutdown request to be appropriate. grep -Eq \ 'BdsDxe: starting Boot[[:xdigit:]]{4} "Windows Boot Manager" from .*HD\(' \ <<< "$last" && return 0 @@ -258,6 +278,8 @@ sendKey() { [ ! -S "$ACPI_SOCKET" ] && return 1 [[ "$delay" != "0" ]] && sleep "$delay" + # Send all repeats through one monitor connection so timing remains stable + # and QEMU receives the sequence as one operation. if ! output=$( { for ((i = 1; i <= repeat; i++)); do @@ -272,6 +294,8 @@ sendKey() { return 1 fi + # The human monitor may return success at the transport level while reporting + # a command error in its text response, so inspect that output explicitly. if grep -Eqi \ -e 'unknown command' \ -e 'unknown key' \ @@ -312,11 +336,14 @@ bootKeyDelay() { [ ! -s "$QEMU_PTY" ] && return 1 + # A visible prompt is the safest trigger and should be answered immediately. if grep -Fq "Press any key to" "$QEMU_PTY"; then echo 0 return 0 fi + # Some firmware or Windows versions do not log the prompt. In that case wait + # briefly after the DVD boot attempt and send several short key presses. if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then grep -Fq "Booting from DVD/CD" "$QEMU_PTY" || return 1 else @@ -351,7 +378,8 @@ markWindowsBooted() { return 0 fi - # Remove CD-ROM ISO after install + # Do not remove installation media until firmware output confirms Windows is + # now booting from the installed disk rather than from setup media. ready || return 0 if ! touch "$file"; then @@ -376,6 +404,8 @@ finish() { local reason=$1 failed=0 + # QEMU_END distinguishes an expected shutdown path from an unexpected QEMU + # exit carrying the same process status. if [ ! -f "$QEMU_END" ] && (( reason != 0 )); then failed=1 fi @@ -412,6 +442,8 @@ abortDuringSetup() { local code="$1" + # Before Windows boots from disk, ACPI may be ignored or interpreted by setup + # itself. Terminate QEMU directly instead of waiting for a graceful shutdown. if [[ "${DETECTED,,}" != "reactos" ]] || [ -n "${CUSTOM:-}" ]; then info "Cannot send ACPI signal during $(app) setup, aborting..." else @@ -432,12 +464,16 @@ gracefulShutdown() { local sig="$1" local pid code + # Traps can run in subshells created by pipelines or command substitutions; + # only the original shell may coordinate QEMU shutdown. [[ $BASHPID != "$TRAP_PID" ]] && return code=$(signalCode "$sig") if [ -f "$QEMU_END" ]; then + # A second Ctrl-C during an active shutdown skips the remaining grace period + # and lets the shutdown loop force QEMU down immediately. if (( code == 130 && SHUTDOWN_SIGNAL == code )); then SHUTDOWN_SKIP=1 echo && info "Received SIGINT again, forcing shutdown..." @@ -448,12 +484,16 @@ gracefulShutdown() { return fi + # Signal handlers must complete their own error handling and cleanup without + # errexit terminating the shell partway through the shutdown sequence. set +e SHUTDOWN_SIGNAL=$code touch "$QEMU_END" echo && info "Received $sig signal, sending ACPI shutdown signal..." + # Interactive startup may receive a signal before the PID file appears, so + # briefly wait for it there; non-interactive operation fails immediately. if ! readQemuPid pid; then if ! interactive || ! waitQemuPid pid; then warn "QEMU PID file does not exist?" @@ -479,6 +519,8 @@ gracefulShutdown() { enabled "$SHUTDOWN" || return 0 [ -n "${QEMU_TIMEOUT:-}" ] && TIMEOUT="$QEMU_TIMEOUT" +# Keep Ctrl-C available to interactive users without installing an unnecessary +# SIGINT handler for background/container execution. if interactive; then _trap gracefulShutdown SIGINT fi diff --git a/src/samba.sh b/src/samba.sh index f5f42b99..5a392bbe 100644 --- a/src/samba.sh +++ b/src/samba.sh @@ -22,29 +22,32 @@ configureNetwork() { if enabled "$DHCP"; then - hostname="$UPLINK" - interfaces="$DEV" + SAMBA_HOSTNAME="$UPLINK" + SAMBA_INTERFACES="$DEV" else - hostname="host.lan" + SAMBA_HOSTNAME="host.lan" + # User-mode networking has no host bridge to bind to, so expose Samba only + # through loopback and let QEMU's forwarding provide guest access. if isUserMode; then - interfaces="lo" + SAMBA_INTERFACES="lo" else - interfaces="$BRIDGE" + SAMBA_INTERFACES="$BRIDGE" fi if [ -n "${SAMBA_INTERFACE:-}" ]; then - interfaces+=",$SAMBA_INTERFACE" + SAMBA_INTERFACES+=",$SAMBA_INTERFACE" fi fi - netbios="${hostname%%.*}" - netbios="${netbios:0:15}" + # NetBIOS names are limited to 15 visible characters. + SAMBA_NETBIOS="${SAMBA_HOSTNAME%%.*}" + SAMBA_NETBIOS="${SAMBA_NETBIOS:0:15}" - [ -z "$netbios" ] && netbios="host" + [ -z "$SAMBA_NETBIOS" ] && SAMBA_NETBIOS="host" return 0 } @@ -110,6 +113,8 @@ addShare() { empty="Y" fi + # The generated fallback share contains only instructions and must never be + # writable from the guest. if [[ "$dir" == "$tmp" ]]; then readonly="Y" @@ -118,6 +123,8 @@ addShare() { readonly="Y" + # Test actual write access instead of relying on mount flags or mode bits, + # which may not reflect bind-mount and host filesystem restrictions. elif probe=$(mktemp "$dir/.samba-write-test.XXXXXX" 2>/dev/null); then writable="Y" @@ -127,6 +134,8 @@ addShare() { return 1 fi + # Empty bind mounts are safe to initialize with shared-directory permissions. + # Retry the write probe afterward because the original mode may have blocked it. elif [[ "$empty" == "Y" ]] && chmod 2777 "$dir" 2>/dev/null; then if probe=$(mktemp "$dir/.samba-write-test.XXXXXX" 2>/dev/null); then @@ -146,6 +155,7 @@ addShare() { if [[ "$empty" == "Y" ]]; then + # Keep newly created content in the shared group through the setgid bit. if ! chmod 2777 "$dir"; then error "Failed to set permissions for directory $dir" && return 1 fi @@ -155,6 +165,8 @@ addShare() { return 1 fi + # Docker commonly creates a missing bind source as root. Transfer an empty + # directory to the default non-root owner used for shared content. if [[ "$owner" == "0" ]]; then if ! chown "1000:1000" "$dir"; then error "Failed to set ownership for directory $dir" && return 1 @@ -165,6 +177,7 @@ addShare() { elif [[ "$readonly" != "Y" ]]; then + # Preserve access to non-writable mounts by exporting them read-only. readonly="Y" fi @@ -200,20 +213,27 @@ writeConfig() { if ! { echo "[global]" echo " server string = Dockur" - echo " netbios name = $netbios" + echo " netbios name = $SAMBA_NETBIOS" echo " workgroup = WORKGROUP" - echo " interfaces = $interfaces" + echo " interfaces = $SAMBA_INTERFACES" echo " bind interfaces only = yes" echo " security = user" echo " guest account = nobody" echo " map to guest = Bad User" + + # Retain SMB1 negotiation for legacy Windows guests. echo " server min protocol = NT1" + + # Allow bind-mounted shares to follow symlinks outside their share root. echo " follow symlinks = yes" echo " wide links = yes" echo " unix extensions = no" echo " inherit owner = yes" echo " create mask = 0666" echo " directory mask = 02777" + + # Perform guest filesystem access as root so bind mounts with differing host + # ownership remain usable; share-level read-only checks still apply. echo " force user = root" echo " force group = root" echo " force create mode = 0666" @@ -241,11 +261,13 @@ selectPrimaryShare() { return 1 fi - share="/shared" - [ ! -d "$share" ] && [ -d "$STORAGE/shared" ] && share="$STORAGE/shared" - [ ! -d "$share" ] && [ -d "/data" ] && share="/data" - [ ! -d "$share" ] && [ -d "$STORAGE/data" ] && share="$STORAGE/data" - [ ! -d "$share" ] && share="$tmp" + # Prefer explicit root-level bind mounts, then storage-local compatibility + # paths. When none exist, publish an instructional read-only share. + SAMBA_SHARE="/shared" + [ ! -d "$SAMBA_SHARE" ] && [ -d "$STORAGE/shared" ] && SAMBA_SHARE="$STORAGE/shared" + [ ! -d "$SAMBA_SHARE" ] && [ -d "/data" ] && SAMBA_SHARE="/data" + [ ! -d "$SAMBA_SHARE" ] && [ -d "$STORAGE/data" ] && SAMBA_SHARE="$STORAGE/data" + [ ! -d "$SAMBA_SHARE" ] && SAMBA_SHARE="$tmp" return 0 } @@ -256,6 +278,8 @@ addOptionalShare() { local ref="/shared$index" local name="Data$index" + # Optional shares are best-effort and must not prevent the primary share or + # Samba service from starting. if [ -d "$ref" ]; then addShare "$ref" "$ref" "$name" "Shared" "$SAMBA_CONFIG" || : elif [ -d "/data$index" ]; then @@ -267,13 +291,13 @@ addOptionalShare() { prepareSambaDirs() { - # Create directories if missing mkdir -p \ /var/lib/samba/sysvol \ /var/lib/samba/private \ /var/lib/samba/bind-dns || return 1 - # Try to repair Samba permissions + # Runtime directories may retain restrictive modes from earlier daemon runs + # or package defaults, so repair only the known Samba lock and core paths. [ -d /run/samba/msg.lock ] && chmod -R 0755 /run/samba/msg.lock 2>/dev/null || : [ -d /var/log/samba/cores ] && chmod -R 0700 /var/log/samba/cores 2>/dev/null || : [ -d /var/cache/samba/msg.lock ] && chmod -R 0755 /var/cache/samba/msg.lock 2>/dev/null || : @@ -300,6 +324,8 @@ startDaemon() { rm -f "$log" || : + # Keep initialization alive after a daemon startup failure so its log can be + # streamed and the actual Samba error remains visible to the user. if ! "$@"; then SAMBA_DEBUG="Y" error "Failed to start $name daemon!" @@ -319,7 +345,6 @@ startSamba() { startNetbios() { - # Enable NetBIOS on Windows 7 and lower enabled "$DEBUG" && echo "Starting NetBIOS daemon..." startDaemon "NetBIOS" "/var/log/samba/log.nmbd" \ @@ -330,11 +355,12 @@ startNetbios() { startWsddn() { - # Enable Web Service Discovery on Vista and up enabled "$DEBUG" && echo "Starting wsddn daemon..." + # wsddn accepts one interface, while Samba may bind to an additional + # user-supplied interface as well. startDaemon "wsddn" "/var/log/wsddn.log" \ - wsddn -i "${interfaces%%,*}" -H "$hostname" \ + wsddn -i "${SAMBA_INTERFACES%%,*}" -H "$SAMBA_HOSTNAME" \ --unixd --log-file=/var/log/wsddn.log --pid-file="$DDN_PID" return 0 @@ -346,19 +372,22 @@ html "Initializing shared folder..." enabled "$DEBUG" && echo "Starting Samba daemon..." writeConfig || return 0 - -# Add shared folders selectPrimaryShare || return 0 -addShare "$share" "/shared" "Data" "Shared" "$SAMBA_CONFIG" || return 0 +addShare "$SAMBA_SHARE" "/shared" "Data" "Shared" "$SAMBA_CONFIG" || return 0 addOptionalShare "2" || : addOptionalShare "3" || : prepareSambaDirs || return 0 startSamba || return 0 + +# User-mode networking does not expose a LAN interface where discovery +# broadcasts would be useful. isUserMode && return 0 +# Older Windows versions discover shares through NetBIOS, while modern Windows +# uses Web Services Discovery. if [[ "${BOOT_MODE:-}" == "windows_legacy" ]]; then startNetbios || : else