diff --git a/changelog.md b/changelog.md index ca8ea39..af80d9f 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +* Stop bundling the Linaro UEFI firmware for ARM64. `linaro_uefi.fd` is no + longer part of the `qemu-system-aarch64` tarball. Its only consumer, OpenBSD + ARM64, now boots on the shared edk2 `uefi.fd` with ACPI disabled + ([cross-platform-actions/action#160](https://github.com/cross-platform-actions/action/pull/160)). + The Linaro release server it was downloaded from has been retired and + redirects to a landing page, so 1.1.0 shipped an HTML page in its place + +### Fixed + +* Fail the build when a download returns an unsuccessful HTTP status or an HTML + document instead of the requested file +* Verify the contents of the bundled firmware files, not only their presence + ## [1.1.0] - 2026-07-26 ### Added diff --git a/ci.rb b/ci.rb index 504d07d..4680e7e 100755 --- a/ci.rb +++ b/ci.rb @@ -120,8 +120,6 @@ def bundle_uefi File.open(uefi_target_path, File::RDWR) do |file| file.truncate(file.read.bytes.rindex { _1 != 0 }) end - - bundle_linaro_uefi end private @@ -141,19 +139,6 @@ def unpack_uefi FileUtils.rm_f uefi_source_path unpack_bzip2 archive end - - def bundle_linaro_uefi - download_file(linaro_uefi_url, linaro_uefi_target_path) - end - - def linaro_uefi_url - "https://releases.linaro.org/components/kernel/uefi-linaro/latest/release/qemu64/QEMU_EFI.fd" - end - - def linaro_uefi_target_path - @linaro_uefi_target_path ||= - File.join(firmware_target_directory, "linaro_uefi.fd") - end end class Riscv64 < Architecture @@ -583,12 +568,34 @@ def execute(*args, env: {}) Kernel.system env, *args, exception: true end +HTML_CONTENT_TYPES = %w[text/html application/xhtml+xml].freeze + +HTML_SIGNATURE = /\A\s*<(?:!doctype\s+html|html[\s>])/i + +# Downloads a file, refusing to write anything but the requested payload. A +# retired host that redirects to a landing page answers with a successful +# status and an HTML body, which would otherwise silently be bundled as if it +# were the expected file. def download_file(url, destination) - URI.open(url) do |uri| - File.open(destination, 'w') { _1.write(uri.read) } + URI.open(url) do |io| + content = io.read + validate_download(url, io, content) + File.binwrite(destination, content) end end +def validate_download(url, io, content) + raise "Failed to download #{url}: HTTP #{Array(io.status).join(' ')}" unless successful_response?(io) + raise "Failed to download #{url}: expected a file, got an HTML document" if html?(io, content) +end + +def successful_response?(io) = Array(io.status).first.to_s.start_with?("2") + +def html?(io, content) + HTML_CONTENT_TYPES.include?(io.content_type) || + HTML_SIGNATURE.match?(content.byteslice(0, 1024).b) +end + def unpack_bzip2(archive) execute "bzip2", "-d", archive end diff --git a/test.rb b/test.rb index 7654564..d3c74e3 100755 --- a/test.rb +++ b/test.rb @@ -22,6 +22,11 @@ def assert_qemu_system(architecture, firmwares:) assert validator.valid?, validator.message end +def assert_usable_firmwares(architecture) + validator = FirmwareValidator.new(architecture) + assert validator.valid?, validator.message +end + def assert_only_system_dependencies(architecture) return unless QemuSystemValidator.host_os == "macos" @@ -62,6 +67,10 @@ def assert_statically_linked(architecture) ] end + it "contains usable firmware files for x86_64" do + assert_usable_firmwares "x86_64" + end + it "is only linked with system dependencies" do assert_only_system_dependencies "x86_64" end @@ -77,10 +86,13 @@ def assert_statically_linked(architecture) efi-e1000.rom efi-virtio.rom uefi.fd - linaro_uefi.fd ] end + it "contains usable firmware files for arm64" do + assert_usable_firmwares "aarch64" + end + it "is only linked with system dependencies" do assert_only_system_dependencies "aarch64" end @@ -99,6 +111,10 @@ def assert_statically_linked(architecture) ] end + it "contains usable firmware files for riscv64" do + assert_usable_firmwares "riscv64" + end + it "is only linked with system dependencies" do assert_only_system_dependencies "riscv64" end @@ -200,6 +216,24 @@ def firmware_paths @firmware_paths ||= paths.filter { _1.start_with?(firmware_directory) } end + # The contents have to be read while the archive is being iterated, an + # entry cannot be read after the reader has moved past it. + def firmware_files + @firmware_files ||= File.open(filename) do |io| + firmware_files = [] + + Gem::Package::TarReader.new(io) do |tar| + tar.each do |entry| + next unless firmware?(entry) + + firmware_files << Firmware.new(basename(entry), entry.read.to_s) + end + end + + firmware_files + end + end + def qemu_binary @qemu_binary ||= paths.filter { _1.start_with?("bin/qemu") } end @@ -211,6 +245,16 @@ def firmwares def firmware_directory "share/qemu/" end + + private + + def firmware?(entry) + entry.file? && full_name(entry).start_with?(firmware_directory) + end + + def basename(entry) = full_name(entry).delete_prefix(firmware_directory) + + def full_name(entry) = entry.full_name.delete_prefix("./") end class MessageFormatter @@ -266,3 +310,70 @@ def to_full_path(array) end end end + +# Verifies that the bundled firmware files actually contain firmware. A failed +# download can produce an HTML error or landing page, which is otherwise +# indistinguishable from a correct bundle since the file is still present. +class FirmwareValidator + def initialize(architecture) + @architecture = architecture + end + + def valid? = unusable_firmwares.empty? + + def message + ["Unusable firmware files in '#{tar_file.filename}':"] + .concat(unusable_firmwares.map(&:message)) + .join("\n") + end + + private + + attr_reader :architecture + + def unusable_firmwares + @unusable_firmwares ||= tar_file.firmware_files.reject(&:usable?) + end + + def tar_file + @tar_file ||= QemuSystemValidator::TarFile.for( + architecture: architecture, + host_os: QemuSystemValidator.host_os + ) + end +end + +# A single firmware file extracted from a bundle. +class Firmware + # The smallest firmware bundled, kvmvapic.bin, is a few kilobytes. + MINIMUM_SIZE = 4096 + + private_constant :MINIMUM_SIZE + + HTML_SIGNATURE = /\A\s*<(?:!doctype\s+html|html[\s>])/i + + private_constant :HTML_SIGNATURE + + attr_reader :name + + def initialize(name, content) + @name = name + @content = content + end + + def usable? = !html? && large_enough? + + def message + return "'#{name}' is an HTML document, not firmware" if html? + + "'#{name}' is #{content.bytesize} bytes, expected at least #{MINIMUM_SIZE}" + end + + private + + attr_reader :content + + def html? = HTML_SIGNATURE.match?(content.byteslice(0, 1024).b) + + def large_enough? = content.bytesize >= MINIMUM_SIZE +end