diff --git a/local/recipes/tests/redox-drm-prime-test/recipe.toml b/local/recipes/tests/redox-drm-prime-test/recipe.toml new file mode 100644 index 00000000..124ffcae --- /dev/null +++ b/local/recipes/tests/redox-drm-prime-test/recipe.toml @@ -0,0 +1,15 @@ +#TODO: Runtime validation requires redox-drm scheme daemon running on bare metal or QEMU + +[source] +# no external source — inline test program +path = "source" + +[build] +template = "custom" +dependencies = [ + "redox-drm", +] +script = """ +DYNAMIC_INIT +cookbook_cargo +""" diff --git a/local/recipes/tests/redox-drm-prime-test/source/Cargo.toml b/local/recipes/tests/redox-drm-prime-test/source/Cargo.toml new file mode 100644 index 00000000..ae5f53dd --- /dev/null +++ b/local/recipes/tests/redox-drm-prime-test/source/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "redox-drm-prime-test" +version = "0.1.0" +edition = "2021" +description = "PRIME DMA-BUF test for redox-drm scheme" + +[[bin]] +name = "redox-drm-prime-test" +path = "main.rs" + +[dependencies] diff --git a/local/recipes/tests/redox-drm-prime-test/source/main.rs b/local/recipes/tests/redox-drm-prime-test/source/main.rs new file mode 100644 index 00000000..1123b8c4 --- /dev/null +++ b/local/recipes/tests/redox-drm-prime-test/source/main.rs @@ -0,0 +1,413 @@ +use std::ffi::c_void; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::mem::{size_of, MaybeUninit}; +use std::os::fd::AsRawFd; +use std::process::ExitCode; +use std::ptr::{self, NonNull}; +use std::slice; + +const DRM_CARD_PATH: &str = "/scheme/drm/card0"; +const GEM_SIZE: usize = 4096; +const MAGIC_PATTERN: [u8; 16] = *b"RBOS-PRIME-TEST!"; + +const DRM_IOCTL_BASE: usize = 0x00A0; +const DRM_IOCTL_GEM_CREATE: usize = DRM_IOCTL_BASE + 26; +const DRM_IOCTL_GEM_CLOSE: usize = DRM_IOCTL_BASE + 27; +const DRM_IOCTL_GEM_MMAP: usize = DRM_IOCTL_BASE + 28; +const DRM_IOCTL_PRIME_HANDLE_TO_FD: usize = DRM_IOCTL_BASE + 29; +const DRM_IOCTL_PRIME_FD_TO_HANDLE: usize = DRM_IOCTL_BASE + 30; + +const MAP_SHARED: i32 = 0x0001; +const PROT_WRITE: i32 = 0x0002; +const PROT_READ: i32 = 0x0004; +const MAP_FAILED: isize = -1; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct GemCreateWire { + size: u64, + handle: u32, + _pad: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct GemCloseWire { + handle: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct GemMmapWire { + handle: u32, + _pad: u32, + offset: u64, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct PrimeHandleToFdWire { + handle: u32, + flags: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct PrimeHandleToFdResponseWire { + fd: i32, + _pad: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct PrimeFdToHandleWire { + fd: i32, + _pad: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct PrimeFdToHandleResponseWire { + handle: u32, + _pad: u32, +} + +unsafe extern "C" { + fn mmap( + addr: *mut c_void, + len: usize, + prot: i32, + flags: i32, + fd: i32, + offset: isize, + ) -> *mut c_void; + fn munmap(addr: *mut c_void, len: usize) -> i32; +} + +struct MappedRegion { + ptr: NonNull, + len: usize, +} + +impl MappedRegion { + fn map(file: &File, len: usize, offset: u64) -> io::Result { + let offset = isize::try_from(offset).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("mmap offset {offset} does not fit in isize"), + ) + })?; + + let ptr = unsafe { + mmap( + ptr::null_mut(), + len, + PROT_READ | PROT_WRITE, + MAP_SHARED, + file.as_raw_fd(), + offset, + ) + }; + + if ptr as isize == MAP_FAILED { + return Err(io::Error::last_os_error()); + } + + let ptr = NonNull::new(ptr.cast::()) + .ok_or_else(|| io::Error::other("mmap returned a null pointer"))?; + + Ok(Self { ptr, len }) + } + + fn as_slice(&self) -> &[u8] { + unsafe { slice::from_raw_parts(self.ptr.as_ptr(), self.len) } + } + + fn as_mut_slice(&mut self) -> &mut [u8] { + unsafe { slice::from_raw_parts_mut(self.ptr.as_ptr(), self.len) } + } +} + +impl Drop for MappedRegion { + fn drop(&mut self) { + let _ = unsafe { munmap(self.ptr.as_ptr().cast::(), self.len) }; + } +} + +fn main() -> ExitCode { + match run() { + Ok(()) => { + println!("PASS: PRIME DMA-BUF test completed"); + ExitCode::SUCCESS + } + Err(err) => { + println!("FAIL: PRIME DMA-BUF test aborted: {err}"); + ExitCode::FAILURE + } + } +} + +fn run() -> io::Result<()> { + let mut card = step("open /scheme/drm/card0", || open_card())?; + + let gem = step("allocate GEM buffer", || { + ioctl::<_, GemCreateWire>( + &mut card, + DRM_IOCTL_GEM_CREATE, + &GemCreateWire { + size: GEM_SIZE as u64, + ..GemCreateWire::default() + }, + ) + })?; + println!("info: created GEM handle {}", gem.handle); + + let gem_map = step("request GEM mmap offset", || { + ioctl::<_, GemMmapWire>( + &mut card, + DRM_IOCTL_GEM_MMAP, + &GemMmapWire { + handle: gem.handle, + ..GemMmapWire::default() + }, + ) + })?; + println!("info: GEM mmap offset/address {:#x}", gem_map.offset); + + let mut gem_region = step("mmap GEM buffer", || { + MappedRegion::map(&card, GEM_SIZE, gem_map.offset) + })?; + + step("write magic pattern into GEM buffer", || { + let bytes = gem_region.as_mut_slice(); + bytes.fill(0); + bytes[..MAGIC_PATTERN.len()].copy_from_slice(&MAGIC_PATTERN); + Ok(()) + })?; + + let export = step("export GEM handle via PRIME_HANDLE_TO_FD", || { + ioctl::<_, PrimeHandleToFdResponseWire>( + &mut card, + DRM_IOCTL_PRIME_HANDLE_TO_FD, + &PrimeHandleToFdWire { + handle: gem.handle, + flags: 0, + }, + ) + })?; + if export.fd < 0 { + return Err(io::Error::other(format!( + "scheme returned a negative PRIME fd token: {}", + export.fd + ))); + } + println!("info: exported PRIME token {}", export.fd); + + let dmabuf_path = format!("{DRM_CARD_PATH}/dmabuf/{}", export.fd); + let dmabuf = step("open exported dmabuf node", || { + OpenOptions::new() + .read(true) + .write(true) + .open(&dmabuf_path) + .map_err(|err| { + io::Error::new(err.kind(), format!("failed to open {dmabuf_path}: {err}")) + }) + })?; + println!("info: dmabuf fd {}", dmabuf.as_raw_fd()); + + let dmabuf_region = step("mmap exported dmabuf fd", || { + MappedRegion::map(&dmabuf, GEM_SIZE, 0) + })?; + + step("verify dmabuf mapping sees magic pattern", || { + let observed = &dmabuf_region.as_slice()[..MAGIC_PATTERN.len()]; + if observed != MAGIC_PATTERN { + return Err(io::Error::other(format!( + "expected {:?}, observed {:?}", + MAGIC_PATTERN, observed + ))); + } + Ok(()) + })?; + + // The scheme's PRIME_FD_TO_HANDLE expects the opaque export token + // (returned by PRIME_HANDLE_TO_FD), not the raw GEM handle. + // In production, libdrm extracts the token via redox_fpath() on the dmabuf fd. + let imported = step("import via PRIME_FD_TO_HANDLE using export token", || { + ioctl::<_, PrimeFdToHandleResponseWire>( + &mut card, + DRM_IOCTL_PRIME_FD_TO_HANDLE, + &PrimeFdToHandleWire { + fd: export.fd, + _pad: 0, + }, + ) + })?; + + step("verify imported handle matches original GEM handle", || { + if imported.handle != gem.handle { + return Err(io::Error::other(format!( + "imported handle {} did not match original {}", + imported.handle, gem.handle + ))); + } + Ok(()) + })?; + + drop(dmabuf_region); + drop(dmabuf); + drop(gem_region); + + step("close GEM handle", || { + ioctl_no_response( + &mut card, + DRM_IOCTL_GEM_CLOSE, + &GemCloseWire { handle: gem.handle }, + ) + })?; + + test_stale_token_after_gem_close(&mut card)?; + + Ok(()) +} + +fn test_stale_token_after_gem_close(card: &mut File) -> io::Result<()> { + let gem2 = step("stale-token: allocate second GEM", || { + ioctl::<_, GemCreateWire>( + card, + DRM_IOCTL_GEM_CREATE, + &GemCreateWire { + size: GEM_SIZE as u64, + ..GemCreateWire::default() + }, + ) + })?; + + let export2 = step("stale-token: export via PRIME_HANDLE_TO_FD", || { + ioctl::<_, PrimeHandleToFdResponseWire>( + card, + DRM_IOCTL_PRIME_HANDLE_TO_FD, + &PrimeHandleToFdWire { + handle: gem2.handle, + flags: 0, + }, + ) + })?; + assert!(export2.fd >= 0); + + step("stale-token: close GEM before opening dmabuf", || { + ioctl_no_response( + card, + DRM_IOCTL_GEM_CLOSE, + &GemCloseWire { + handle: gem2.handle, + }, + ) + })?; + + step( + "stale-token: open dmabuf with stale token must fail", + || { + let stale_path = format!("{DRM_CARD_PATH}/dmabuf/{}", export2.fd); + match OpenOptions::new().read(true).write(true).open(&stale_path) { + Ok(_) => Err(io::Error::other( + "expected ENOENT for stale token, but open succeeded", + )), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(io::Error::other(format!("wrong error kind: {e}"))), + } + }, + )?; + + step( + "stale-token: PRIME_FD_TO_HANDLE with stale token must fail", + || match ioctl::<_, PrimeFdToHandleResponseWire>( + card, + DRM_IOCTL_PRIME_FD_TO_HANDLE, + &PrimeFdToHandleWire { + fd: export2.fd, + _pad: 0, + }, + ) { + Ok(_) => Err(io::Error::other( + "expected ENOENT for stale token, but import succeeded", + )), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(io::Error::other(format!("wrong error kind: {e}"))), + }, + ) +} + +fn open_card() -> io::Result { + OpenOptions::new() + .read(true) + .write(true) + .open(DRM_CARD_PATH) + .map_err(|err| io::Error::new(err.kind(), format!("failed to open {DRM_CARD_PATH}: {err}"))) +} + +fn step(name: &str, action: F) -> io::Result +where + F: FnOnce() -> io::Result, +{ + match action() { + Ok(value) => { + println!("PASS: {name}"); + Ok(value) + } + Err(err) => { + println!("FAIL: {name}: {err}"); + Err(err) + } + } +} + +fn ioctl(file: &mut File, request: usize, payload: &TReq) -> io::Result +where + TReq: Copy, + TResp: Copy, +{ + write_request(file, request, payload)?; + read_plain(file) +} + +fn ioctl_no_response(file: &mut File, request: usize, payload: &TReq) -> io::Result<()> +where + TReq: Copy, +{ + write_request(file, request, payload)?; + let mut ack = [0_u8; 1]; + file.read_exact(&mut ack)?; + Ok(()) +} + +fn write_request(file: &mut File, request: usize, payload: &TReq) -> io::Result<()> +where + TReq: Copy, +{ + let request = u64::try_from(request).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("request code {request} does not fit in u64"), + ) + })?; + + file.write_all(&request.to_le_bytes())?; + file.write_all(as_bytes(payload))?; + Ok(()) +} + +fn read_plain(file: &mut File) -> io::Result +where + T: Copy, +{ + let mut value = MaybeUninit::::uninit(); + let buf = unsafe { slice::from_raw_parts_mut(value.as_mut_ptr().cast::(), size_of::()) }; + file.read_exact(buf)?; + Ok(unsafe { value.assume_init() }) +} + +fn as_bytes(value: &T) -> &[u8] { + unsafe { slice::from_raw_parts((value as *const T).cast::(), size_of::()) } +} diff --git a/local/scripts/extract-linux-quirks.py b/local/scripts/extract-linux-quirks.py new file mode 100644 index 00000000..1f73272e --- /dev/null +++ b/local/scripts/extract-linux-quirks.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Extract quirk entries from Linux kernel source and generate Red Bear OS TOML quirk files. + +Usage: + python3 extract-linux-quirks.py /path/to/linux/drivers/pci/quirks.c + python3 extract-linux-quirks.py /path/to/linux/drivers/usb/core/quirks.c + +Outputs TOML quirk entries to stdout that can be appended to files in +/etc/quirks.d/ or local/recipes/system/redbear-quirks/source/quirks.d/. + +PCI mode: handler-name → flag mapping is heuristic (substring match). Output +requires manual review — the script may misinfer flags. USB table extraction is +direct and does not require review. +""" + +import re +import sys + + +PCI_FLAG_MAP = { + "PCI_DEV_FLAGS_NO_D3": "no_d3cold", + "PCI_DEV_FLAGS_NO_ASPM": "no_aspm", + "PCI_DEV_FLAGS_NO_MSI": "no_msi", + "PCI_DEV_FLAGS_NO_MSIX": "no_msix", + "PCI_DEV_FLAGS_ASSIGN_BARS": "disable_bar_sizing", + "PCI_DEV_FLAGS_BROKEN_PM": "no_pm", +} + +USB_FLAG_MAP = { + "USB_QUIRK_STRING_FETCH": "no_string_fetch", + "USB_QUIRK_NO_RESET_RESUME": "need_reset", + "USB_QUIRK_NO_SET_INTF": "no_set_config", + "USB_QUIRK_NO_LPM": "no_lpm", + "USB_QUIRK_NO_U1_U2": "no_u1u2", + "USB_QUIRK_DELAY_INIT": "reset_delay", + "USB_QUIRK_LINEAR_UFRAME_INTR_BINTERVAL": "bad_descriptor", + "USB_QUIRK_DISCONNECT_SUSPEND": "no_suspend", +} + +PCI_FIXUP_RE = re.compile( + r'DECLARE_PCI_FIXUP_(?:FINAL|HEADER|EARLY|ENABLE|RESUME|LATE)\s*\(\s*' + r'(?:0x([0-9a-fA-F]+)|PCI_ANY_ID)\s*,\s*' + r'(?:0x([0-9a-fA-F]+)|PCI_ANY_ID)\s*,\s*' + r'(\w+)\s*\)' +) + +DMI_MATCH_RE = re.compile( + r'DMI_MATCH\s*\(\s*DMI_([A-Z_]+)\s*,\s*"([^"]+)"\s*\)' +) + +USB_QUIRK_TABLE_RE = re.compile( + r'\{\s*USB_DEVICE\s*\(\s*(?:0x([0-9a-fA-F]+)|USB_ANY_ID)\s*,\s*' + r'(?:0x([0-9a-fA-F]+)|USB_ANY_ID)\s*\)\s*,' + r'([^}]+)\}' +) + + +def extract_pci_fixups(source): + entries = [] + for m in PCI_FIXUP_RE.finditer(source): + vendor = int(m.group(1), 16) if m.group(1) else 0xFFFF + device = int(m.group(2), 16) if m.group(2) else 0xFFFF + handler = m.group(3) + entries.append((vendor, device, handler)) + return entries + + +def extract_usb_quirks(source): + entries = [] + for m in USB_QUIRK_TABLE_RE.finditer(source): + vendor = int(m.group(1), 16) if m.group(1) else 0xFFFF + product = int(m.group(2), 16) if m.group(2) else 0xFFFF + flags_raw = m.group(3) + flags = [] + for flag_name, toml_name in USB_FLAG_MAP.items(): + if flag_name in flags_raw: + flags.append(toml_name) + entries.append((vendor, product, flags)) + return entries + + +def format_pci_toml(entries): + lines = [] + for vendor, device, flags in entries: + if not flags: + continue + lines.append("[[pci_quirk]]") + if vendor != 0xFFFF: + lines.append(f"vendor = 0x{vendor:04X}") + if device != 0xFFFF: + lines.append(f"device = 0x{device:04X}") + lines.append(f'flags = [{", ".join(f"\"{f}\"" for f in flags)}]') + lines.append("") + return "\n".join(lines) + + +def format_usb_toml(entries): + lines = [] + for vendor, product, flags in entries: + if not flags: + continue + lines.append("[[usb_quirk]]") + if vendor != 0xFFFF: + lines.append(f"vendor = 0x{vendor:04X}") + if product != 0xFFFF: + lines.append(f"product = 0x{product:04X}") + lines.append(f'flags = [{", ".join(f"\"{f}\"" for f in flags)}]') + lines.append("") + return "\n".join(lines) + + +def main(): + if len(sys.argv) < 2: + print(__doc__, file=sys.stderr) + sys.exit(1) + + path = sys.argv[1] + with open(path) as f: + source = f.read() + + if "usb_quirk" in source.lower() or "USB_QUIRK" in source: + entries = extract_usb_quirks(source) + print(format_usb_toml(entries)) + else: + entries = extract_pci_fixups(source) + flags_map = PCI_FLAG_MAP + mapped = [] + for vendor, device, handler in entries: + flags = [] + for flag_name, toml_name in flags_map.items(): + if flag_name.lower() in handler.lower(): + flags.append(toml_name) + mapped.append((vendor, device, flags)) + print("# WARNING: PCI handler-name → flag mapping is heuristic.") + print("# WARNING: Output requires manual review before committing.") + print("# USB table extraction is direct and does not need review.") + print(format_pci_toml(mapped)) + + +if __name__ == "__main__": + main() diff --git a/local/scripts/test-phase1-desktop-substrate.sh b/local/scripts/test-phase1-desktop-substrate.sh new file mode 100755 index 00000000..03884965 --- /dev/null +++ b/local/scripts/test-phase1-desktop-substrate.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +# Validate the Red Bear OS Phase 1 desktop substrate (CONSOLE-TO-KDE-DESKTOP-PLAN v2.0). +# +# Modes: +# --guest Run inside a Red Bear OS guest +# --qemu [CONFIG] Boot CONFIG in QEMU and run the same checks automatically + +set -euo pipefail + +find_uefi_firmware() { + local candidates=( + "/usr/share/ovmf/x64/OVMF.4m.fd" + "/usr/share/OVMF/x64/OVMF.4m.fd" + "/usr/share/ovmf/x64/OVMF_CODE.4m.fd" + "/usr/share/OVMF/x64/OVMF_CODE.4m.fd" + "/usr/share/ovmf/OVMF.fd" + "/usr/share/OVMF/OVMF_CODE.fd" + "/usr/share/qemu/edk2-x86_64-code.fd" + ) + local path + for path in "${candidates[@]}"; do + if [[ -f "$path" ]]; then + printf '%s\n' "$path" + return 0 + fi + done + return 1 +} + +run_guest_checks() { + echo "=== Red Bear OS Phase 1 Desktop Substrate Test ===" + echo + + local failures=0 + + require_path() { + local path="$1" + local message="$2" + if [ -e "$path" ]; then + echo " PASS $message" + else + echo " FAIL $message" + failures=$((failures + 1)) + fi + } + + require_command() { + local cmd="$1" + local message="$2" + if command -v "$cmd" >/dev/null 2>&1; then + echo " PASS $message" + else + echo " FAIL $message" + failures=$((failures + 1)) + fi + } + + echo "--- relibc POSIX API surface ---" + require_path /usr/include/sys/signalfd.h "sys/signalfd.h header present" + require_path /usr/include/sys/timerfd.h "sys/timerfd.h header present" + require_path /usr/include/sys/eventfd.h "sys/eventfd.h header present" + require_path /usr/lib/libwayland-client.so "libwayland-client.so present (relibc consumer)" + require_command wayland-scanner "wayland-scanner is installed" + echo + + echo "--- evdevd input path ---" + require_command evdevd "evdevd command is installed" + require_path /scheme/evdev "/scheme/evdev exists" + if command -v redbear-phase3-input-check >/dev/null 2>&1; then + echo " NOTE redbear-phase3-input-check available (run manually for full input validation)" + fi + echo + + echo "--- udev-shim device enumeration ---" + require_command udev-shim "udev-shim command is installed" + require_path /scheme/udev "/scheme/udev exists" + local libinput_found=false + for lib in /usr/lib/libinput.so /usr/lib/libinput.so.10 /usr/lib/libinput.so.*; do + if [ -e "$lib" ]; then + libinput_found=true + break + fi + done + if $libinput_found; then + echo " PASS libinput shared library present" + else + echo " FAIL libinput shared library not found" + failures=$((failures + 1)) + fi + echo + + echo "--- firmware-loader ---" + require_path /scheme/firmware "/scheme/firmware exists" + require_path /lib/firmware "/lib/firmware directory exists" + echo + + echo "--- DRM/KMS ---" + local drm_found=false + if [ -e /usr/bin/redox-drm ] || command -v redox-drm >/dev/null 2>&1; then + drm_found=true + fi + if $drm_found; then + echo " PASS redox-drm is installed" + else + echo " FAIL redox-drm not found" + failures=$((failures + 1)) + fi + if [ -e /scheme/drm ]; then + echo " PASS /scheme/drm exists" + else + echo " FAIL /scheme/drm does not exist" + failures=$((failures + 1)) + fi + echo + + echo "--- health check summary ---" + if command -v redbear-info >/dev/null 2>&1; then + local report + report="$(redbear-info --json 2>/dev/null || true)" + if [ -n "$report" ]; then + local net_ok=false + case "$report" in + *'"networking"'*|*'"virtio_net_present"'*|*'"ip"'*) net_ok=true ;; + esac + if $net_ok; then + echo " PASS networking state reported in redbear-info" + else + echo " FAIL networking state not reported in redbear-info" + failures=$((failures + 1)) + fi + local drm_reported=false + case "$report" in + *'scheme drm'*|*'/scheme/drm'*|*'"drm"'*) drm_reported=true ;; + esac + if $drm_reported; then + echo " PASS DRM scheme reported in redbear-info" + else + echo " FAIL DRM scheme not reported in redbear-info" + failures=$((failures + 1)) + fi + local fw_reported=false + case "$report" in + *'scheme firmware'*|*'/scheme/firmware'*|*'"firmware"'*) fw_reported=true ;; + esac + if $fw_reported; then + echo " PASS firmware scheme reported in redbear-info" + else + echo " FAIL firmware scheme not reported in redbear-info" + failures=$((failures + 1)) + fi + else + echo " FAIL redbear-info --json returned empty" + failures=$((failures + 1)) + fi + else + echo " FAIL redbear-info is not installed" + failures=$((failures + 1)) + fi + echo + + echo "=== Phase 1 Desktop Substrate Test Complete ===" + if [ "$failures" -gt 0 ]; then + echo " $failures check(s) FAILED" + return 1 + fi + echo " All checks PASSED" + return 0 +} + +run_qemu_checks() { + local config="$1" + local firmware + firmware="$(find_uefi_firmware)" || { + echo "ERROR: no usable x86_64 UEFI firmware found" >&2 + exit 1 + } + + local arch image extra + arch="${ARCH:-$(uname -m)}" + image="build/$arch/$config/harddrive.img" + extra="build/$arch/$config/extra.img" + + if [[ ! -f "$image" ]]; then + echo "ERROR: missing image $image" >&2 + echo "Build it first with: ./local/scripts/build-redbear.sh $config" >&2 + exit 1 + fi + + if [[ ! -f "$extra" ]]; then + truncate -s 1g "$extra" + fi + + expect <