aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitc235cb62beefd03087e7cfc7f7f55f0c7c6601c9 (patch)
tree2776f86d9226d55c78e3d735764fcd9ecbe5e08e /tests
downloadvmm-c235cb62beefd03087e7cfc7f7f55f0c7c6601c9.tar.gz
Enter vmmHEADmaster
A QEMU/KVM virtual machine manager in one POSIX shell script. Each VM is a directory of plain files under $VMMDIR, configured by a hand-edited KEY=VALUE file that is parsed rather than sourced, so nothing in it can inject a raw QEMU argument. Control is a QMP FIFO pair inside that directory, guarded by its permissions alone; nothing binds a TCP port. The tests are TAP without a framework and boot real guests where the host allows.
Diffstat (limited to 'tests')
-rw-r--r--tests/arch143
-rw-r--r--tests/argv151
-rw-r--r--tests/boot250
-rw-r--r--tests/clone46
-rw-r--r--tests/config187
-rw-r--r--tests/console228
-rw-r--r--tests/create48
-rw-r--r--tests/env32
-rw-r--r--tests/list37
-rw-r--r--tests/lock51
-rw-r--r--tests/net70
-rw-r--r--tests/static17
-rw-r--r--tests/usage27
-rw-r--r--tests/viewer44
14 files changed, 1331 insertions, 0 deletions
diff --git a/tests/arch b/tests/arch
new file mode 100644
index 0000000..2f60451
--- /dev/null
+++ b/tests/arch
@@ -0,0 +1,143 @@
+# ARCH picks the qemu binary, the machine type and the display device.
+# Nothing here needs /dev/kvm: another architecture is emulated.
+
+"$VMM" create ar1 64M >/dev/null 2>&1
+FW=/usr/share/qemu/edk2-aarch64-code.fd
+[ -f "$FW" ] || FW=/usr/share/AAVMF/AAVMF_CODE.fd
+
+cfg ar1 <<EOF
+ARCH=riscv64
+EOF
+refuses 'ARCH must be' "an unsupported ARCH is refused" dryrun ar1
+
+cfg ar1 <<EOF
+ARCH=aarch64
+EOF
+refuses 'requires FIRMWARE' "aarch64 without firmware is refused" dryrun ar1
+
+cfg ar1 <<EOF
+FIRMWARE=firmware.fd
+EOF
+refuses 'FIRMWARE must be an absolute path' "a relative FIRMWARE is refused" dryrun ar1
+
+# The host's own architecture is accelerated. -cpu host means nothing
+# anywhere else, and asking for it under emulation is an error.
+: > "$VMMDIR/ar1/config"
+outputs 'accel=kvm' "the host architecture gets kvm" dryrun ar1
+outputs "-cpu 'host'" "the host architecture gets the host cpu" dryrun ar1
+
+# A host with no /dev/kvm can still run its own architecture, slowly.
+cfg ar1 <<EOF
+HWACCEL=no
+EOF
+outputs 'accel=tcg' "HWACCEL=no emulates the host architecture" dryrun ar1
+outputs "-cpu 'max'" "HWACCEL=no drops -cpu host, which needs kvm" dryrun ar1
+
+cfg ar1 <<EOF
+HWACCEL=maybe
+EOF
+refuses 'HWACCEL must be' "HWACCEL takes yes or no" dryrun ar1
+
+cfg ar1 <<EOF
+ARCH=aarch64
+FIRMWARE="$FW"
+GRAPHICS=vnc
+EOF
+outputs 'qemu-system-aarch64' "ARCH picks the qemu binary" dryrun ar1
+outputs "machine 'virt" "aarch64 gets the virt machine" dryrun ar1
+outputs 'accel=tcg' "a foreign architecture is emulated" dryrun ar1
+outputs "-cpu 'max'" "an emulated guest does not ask for the host cpu" dryrun ar1
+outputs "bios '$FW'" "the firmware reaches the argv" dryrun ar1
+outputs 'virtio-gpu-pci' "aarch64 gets a display device that is not VGA" dryrun ar1
+
+# ARCH names a binary that may not be installed. Saying so is instant;
+# leaving it to setsid costs both monitor timeouts first.
+NOQEMU=$WORK/noqemu-bin
+mkdir -p "$NOQEMU"
+for b in flock uname od awk cat tr; do
+ ln -sf "$(command -v "$b")" "$NOQEMU/$b"
+done
+: > "$VMMDIR/ar1/config"
+out=$(PATH=$NOQEMU "$VMM" start ar1 2>&1) && got=0 || got=$?
+if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'qemu-system-.* is not on PATH'; then
+ ok "a qemu that is not installed is named at once"
+else
+ notok "a qemu that is not installed is named at once" "exit $got: $out"
+fi
+
+# Emulated end to end: the firmware really runs and really talks to the
+# serial console, which is the whole point of passing -bios.
+if [ -f "$FW" ] && command -v qemu-system-aarch64 >/dev/null 2>&1; then
+ cfg ar1 <<EOF
+ARCH=aarch64
+FIRMWARE="$FW"
+MEM=512
+CPUS=1
+EOF
+ exits 0 "an emulated aarch64 guest starts" start ar1
+ outputs 'running' "the emulated guest answers its monitor" \
+ monitor ar1 info status
+ sleep 2
+ holds "the emulated firmware writes to the serial console" \
+ test -s "$VMMDIR/ar1/console.log"
+ "$VMM" kill ar1 >/dev/null 2>&1 || :
+else
+ skip 3 "no qemu-system-aarch64 or no aarch64 firmware"
+fi
+
+# Failure after spawn must reap only that child and remove every runtime
+# object it may have created.
+if command -v "qemu-system-$(uname -m)" >/dev/null 2>&1; then
+ "$VMM" create ar2 64M >/dev/null 2>&1
+ printf 'not a qcow2 image\n' > "$VMMDIR/ar2/disk.qcow2"
+ cfg ar2 <<EOF
+HWACCEL=no
+MEM=256
+CPUS=1
+EOF
+ exits 1 "a qemu startup failure is reported" start ar2
+ holds "a failed start leaves no pidfile" test ! -e "$VMMDIR/ar2/pid"
+ holds "a failed start leaves no input FIFO" test ! -e "$VMMDIR/ar2/qmp.in"
+ holds "a failed start leaves no output FIFO" test ! -e "$VMMDIR/ar2/qmp.out"
+ holds "a failed start releases its lock" \
+ flock -n "$VMMDIR/ar2" true
+else
+ skip 5 "no qemu for the host architecture"
+fi
+
+# A qemu that never opens the monitor does not die of its own accord, and
+# it has written no pidfile to be found by. It is still vmm's own child.
+# The stand-in does not exec, so it keeps the argv this looks for.
+FAKEBIN=$WORK/fake-qemu-bin
+FAKE=$FAKEBIN/qemu-system-$(uname -m)
+mkdir -p "$FAKEBIN"
+cat > "$FAKE" <<'EOF'
+#!/bin/sh
+sleep 5
+EOF
+chmod +x "$FAKE"
+"$VMM" create ar3 64M >/dev/null 2>&1
+out=$(PATH=$FAKEBIN:$PATH MONITOR_TIMEOUT=1 "$VMM" start ar3 2>&1) && got=0 || got=$?
+if [ "$got" = 1 ]; then
+ ok "a start whose monitor never answers fails"
+else
+ notok "a start whose monitor never answers fails" "exit $got: $out"
+fi
+left=
+for cmdline in /proc/[0-9]*/cmdline; do
+ line=
+ IFS= read -r line 2>/dev/null < "$cmdline" || :
+ case $line in
+ *"-pidfile$VMMDIR/ar3/pid")
+ p=${cmdline#/proc/}
+ left="$left ${p%/cmdline}"
+ ;;
+ esac
+done
+if [ -z "$left" ]; then
+ ok "and kills the child it spawned"
+else
+ notok "and kills the child it spawned" "still running:$left"
+ # shellcheck disable=SC2086 # a list of pids this loop collected
+ kill -9 $left 2>/dev/null || :
+fi
diff --git a/tests/argv b/tests/argv
new file mode 100644
index 0000000..628d621
--- /dev/null
+++ b/tests/argv
@@ -0,0 +1,151 @@
+# The QEMU argv, as printed by dryrun.
+
+"$VMM" create av1 64M >/dev/null 2>&1
+IMG=$VMMDIR/av1/disk.qcow2
+
+cfg av1 <<EOF
+HOSTFWD="2222:22"
+EOF
+outputs 'hostfwd=tcp:127.0.0.1:2222-:22' "two field HOSTFWD binds loopback" dryrun av1
+outputs "-vnc 'none'" "GRAPHICS=no still arms -vnc none" dryrun av1
+outputs 'free-page-reporting=on' "balloon is on by default" dryrun av1
+outputs 'strict=on' "the boot list is a restriction, not a hint" dryrun av1
+outputs "sandbox 'on,.*spawn=deny" "the sandbox denies spawning" dryrun av1
+outputs 'panic=pause' "a panicking guest is paused, not killed" dryrun av1
+omits 'aio=io_uring' "the disk uses qemu's default AIO backend" dryrun av1
+
+cfg av1 <<EOF
+HOSTFWD="0.0.0.0:2222:22"
+EOF
+outputs 'hostfwd=tcp:0.0.0.0:2222-:22' "three field HOSTFWD honours the address" dryrun av1
+
+cfg av1 <<EOF
+HOSTFWD="2222:22,0.0.0.0:8080:80"
+EOF
+outputs 'hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:0.0.0.0:8080-:80' \
+ "both HOSTFWD forms in one value" dryrun av1
+
+cfg av1 <<EOF
+BALLOON=no
+EOF
+omits balloon "BALLOON=no omits the device" dryrun av1
+
+cfg av1 <<EOF
+NETWORK=hostonly
+EOF
+outputs 'restrict=on' "NETWORK=hostonly restricts the guest" dryrun av1
+
+cfg av1 <<EOF
+NETWORK=no
+EOF
+outputs "-nic 'none'" "NETWORK=no gives no NIC" dryrun av1
+
+# The CDROM hangs off virtio-scsi, which every machine type has, and not
+# off an IDE bus, which only x86 has.
+cfg av1 <<EOF
+CDROM="$IMG"
+BOOT_ORDER=dc
+EOF
+outputs 'scsi-cd' "the CDROM is a SCSI device" dryrun av1
+outputs 'bus=scsi0.0,bootindex=1' "the CDROM takes its place in the boot order" dryrun av1
+
+# A comma in the fixed disk path must be doubled, not passed through.
+COMMA_DIR=$WORK/vms,comma
+VMMDIR="$COMMA_DIR" "$VMM" create avc 64M >/dev/null 2>&1
+out=$(VMMDIR="$COMMA_DIR" "$VMM" dryrun avc 2>&1) && got=0 || got=$?
+if [ "$got" = 0 ] && printf '%s\n' "$out" | grep -q 'vms,,comma'; then
+ ok "a comma in VMMDIR is escaped"
+else
+ notok "a comma in VMMDIR is escaped" "exit $got: $out"
+fi
+
+: > "$VMMDIR/av1/config"
+
+# Two VMs must not share a MAC.
+"$VMM" create av2 64M >/dev/null 2>&1
+m1=$("$VMM" dryrun av1 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p')
+m2=$("$VMM" dryrun av2 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p')
+if [ -n "$m1" ] && [ "$m1" != "$m2" ]; then
+ ok "each VM gets its own MAC"
+else
+ notok "each VM gets its own MAC" "av1=$m1 av2=$m2"
+fi
+
+# dryrun output must be pasteable, so every argument is single quoted.
+outputs "'node-name=disk0f,driver=file,filename=$IMG" \
+ "dryrun shell-quotes its argv" dryrun av1
+
+# A malformed uuid must never reach QEMU or be silently regenerated.
+cp "$VMMDIR/av1/uuid" "$WORK/uuid.bak"
+: > "$VMMDIR/av1/uuid"
+refuses 'uuid is missing or malformed' "an empty uuid is refused" dryrun av1
+refuses 'uuid is missing or malformed' "start does not repair an empty uuid" start av1
+holds "a refused start leaves the empty uuid untouched" \
+ test ! -s "$VMMDIR/av1/uuid"
+printf 'zzzzzzzz-1111-2222-3333-444444444444\n' > "$VMMDIR/av1/uuid"
+refuses 'uuid is missing or malformed' "a non-hex uuid is refused" dryrun av1
+printf '12345678-1111-2222-3333-44444444444z\n' > "$VMMDIR/av1/uuid"
+refuses 'uuid is missing or malformed' "every uuid group is validated" dryrun av1
+cp "$WORK/uuid.bak" "$VMMDIR/av1/uuid"
+
+# Graphics means a display and an absolute pointer. Guest agents and
+# clipboard sharing are separate policy and do not belong on this argv.
+cfg av1 <<EOF
+GRAPHICS=vnc
+EOF
+outputs 'usb-tablet' "VNC includes an absolute pointer" dryrun av1
+omits 'vdagent' "VNC includes no guest agent" dryrun av1
+omits 'virtio-serial' "VNC includes no unused agent transport" dryrun av1
+
+cfg av1 <<EOF
+GRAPHICS=spice
+EOF
+outputs 'spice.sock' "SPICE names its display socket" dryrun av1
+omits 'clipboard' "SPICE does not enable clipboard sharing" dryrun av1
+omits 'spicevmc' "SPICE includes no guest agent" dryrun av1
+
+: > "$VMMDIR/av1/config"
+
+# dryrun launches nothing, so it must work on a host with no qemu at all.
+# The README promises it as a config checker anywhere.
+BIN=$WORK/dryrun-bin
+mkdir -p "$BIN"
+for b in uname cat sed tr grep od awk; do
+ ln -sf "$(command -v "$b")" "$BIN/$b"
+done
+holds "dryrun works with no qemu installed" \
+ env PATH="$BIN" "$VMM" dryrun av1
+
+# What dryrun prints is what start runs, so the printed argv must boot a
+# guest vmm then recognises as that VM. The CDROM path carries a comma,
+# which reaches QEMU whole only if the doubling is right.
+if command -v "qemu-system-$(uname -m)" >/dev/null 2>&1; then
+ : > "$WORK/comma,name.iso"
+ "$VMM" create av3 64M >/dev/null 2>&1
+ cfg av3 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+CDROM="$WORK/comma,name.iso"
+BOOT_ORDER=cd
+EOF
+ "$VMM" dryrun av3 > "$WORK/av3.sh"
+ sh "$WORK/av3.sh" > "$WORK/av3.out" 2>&1 &
+ pasted=$!
+ i=0
+ while [ ! -s "$VMMDIR/av3/pid" ] && [ "$i" -lt 15 ]; do
+ sleep 1
+ i=$((i + 1))
+ done
+ out=$("$VMM" status av3 2>&1) && got=0 || got=$?
+ if [ "$got" = 0 ]; then
+ ok "the argv dryrun prints boots a guest vmm knows"
+ else
+ notok "the argv dryrun prints boots a guest vmm knows" \
+ "status exit $got: $out" "$(tail -n 5 "$WORK/av3.out")"
+ fi
+ exits 0 "and vmm stops what dryrun started" kill av3
+ wait "$pasted" 2>/dev/null || :
+else
+ skip 2 "no qemu for the host architecture"
+fi
diff --git a/tests/boot b/tests/boot
new file mode 100644
index 0000000..2f778eb
--- /dev/null
+++ b/tests/boot
@@ -0,0 +1,250 @@
+# A real guest, booted for real. Skipped without a usable /dev/kvm, so the
+# suite still exits 0 on a machine with no virtualisation.
+
+if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then
+ skip 1 "no usable /dev/kvm, skipping the boot tests"
+ return 0
+fi
+
+"$VMM" create bt1 64M >/dev/null 2>&1
+cfg bt1 <<EOF
+MEM=256
+CPUS=1
+EOF
+
+exits 0 "start boots a guest" start bt1
+PID=$(cat "$VMMDIR/bt1/pid" 2>/dev/null || echo)
+if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
+ ok "qemu is running"
+else
+ notok "qemu is running" "$(tail -n 5 "$VMMDIR/bt1/stderr" 2>/dev/null)"
+fi
+
+holds "qemu's stdout lands in a file" test -f "$VMMDIR/bt1/stdout"
+holds "qemu's stderr lands in another" test -f "$VMMDIR/bt1/stderr"
+outputs '==> .*stderr <==' "logs shows every stream" logs bt1
+
+exits 0 "status of a running VM exits 0" status bt1
+outputs 'STATE=running' "status reports running" status bt1
+outputs 'running' "list reports running" list
+
+# The lock must be free after start, or every later verb reports busy.
+holds "start releases its lock" flock -n "$VMMDIR/bt1" true
+
+exits 0 "start is idempotent" start bt1
+outputs 'already running' "a second start says so" start bt1
+
+# restart swallows exit 5 from a stop that had to force the guest, and
+# nothing else. A guest with no OS ignores ACPI, so this is that path,
+# and it must come back as a different process.
+old=$(cat "$VMMDIR/bt1/pid" 2>/dev/null || echo)
+exits 0 "restart survives a stop that had to force" restart bt1
+new=$(cat "$VMMDIR/bt1/pid" 2>/dev/null || echo)
+if [ -n "$new" ] && [ "$new" != "$old" ]; then
+ ok "restart comes back as a new process"
+else
+ notok "restart comes back as a new process" "was [$old] now [$new]"
+fi
+outputs 'STATE=running' "the restarted guest is running" status bt1
+
+# A restart that cannot start again must not report success: the config
+# is broken under the guest, so stop works and start refuses.
+cp "$VMMDIR/bt1/config" "$WORK/bt1.config"
+cfg bt1 <<EOF
+NOSUCHKEY=1
+EOF
+refuses 'unknown key' "restart fails loudly when it cannot start again" restart bt1
+exits 3 "and leaves the guest stopped, not half restarted" status bt1
+cp "$WORK/bt1.config" "$VMMDIR/bt1/config"
+exits 0 "start again for the tests below" start bt1
+
+outputs 'running' "the monitor answers" monitor bt1 info status
+
+# Commands read from stdin: the count says every one was sent.
+out=$(printf 'info status\ninfo version\n' | "$VMM" monitor bt1 2>&1) && got=0 || got=$?
+n=$(printf '%s\n' "$out" | grep -c '"return"' || :)
+if [ "$got" = 0 ] && [ "$n" = 2 ]; then
+ ok "monitor answers every command it reads from stdin"
+else
+ notok "monitor answers every command it reads from stdin" \
+ "exit $got, $n replies" "$out"
+fi
+
+# A final line with no newline is a command too: dropped, it would be
+# exit 0 and nothing done.
+out=$(printf 'info status' | "$VMM" monitor bt1 2>&1) && got=0 || got=$?
+if [ "$got" = 0 ] && printf '%s\n' "$out" | grep -q '"return"'; then
+ ok "monitor sends a final line with no newline"
+else
+ notok "monitor sends a final line with no newline" "exit $got: $out"
+fi
+
+# The command is interpolated into a JSON string. This payload closes
+# that string and the object around it, so an unescaped one reaches
+# QEMU's parser as a second complete command and stops the guest. An
+# unescaped backslash instead makes the object unparseable, and QEMU
+# refuses it with an error carrying no id, so no reply arrives at all.
+outputs 'unknown command' "a quote cannot close the monitor's own object" \
+ monitor bt1 'x"}},{"execute":"quit'
+exits 0 "and the quit it carried never ran" status bt1
+outputs '"return"' "a backslash still leaves the monitor a parseable object" \
+ monitor bt1 'info \ block'
+
+exits 5 "stop escalates and exits 5 on a guest with no OS" stop bt1
+holds "stop removes the pidfile once dead" test ! -e "$VMMDIR/bt1/pid"
+exits 0 "stop is idempotent" stop bt1
+
+# SNAPSHOT=yes must build a real overlay, never rely on -snapshot.
+cp "$VMMDIR/bt1/disk.qcow2" "$WORK/bt1-before-snapshot.qcow2"
+cfg bt1 <<EOF
+MEM=256
+CPUS=1
+SNAPSHOT=yes
+EOF
+exits 0 "start with SNAPSHOT=yes" start bt1
+holds "the overlay is unlinked once qemu holds it" \
+ test ! -e "$VMMDIR/bt1/ephemeral.qcow2"
+# Unlinked, but open: qemu still serves the guest from it, and says so.
+outputs 'ephemeral.qcow2' "the guest runs on the overlay, not the disk" \
+ monitor bt1 info block
+outputs '"return": ""' "a snapshot accepts guest disk writes" monitor bt1 \
+ 'qemu-io -d /machine/peripheral/blk0/virtio-backend "write -P 0xa5 1048576 4096"'
+exits 0 "the guest survives losing the overlay's name" status bt1
+
+"$VMM" kill bt1 >/dev/null 2>&1 || :
+holds "snapshot writes do not change the durable disk" qemu-img compare -q \
+ "$WORK/bt1-before-snapshot.qcow2" "$VMMDIR/bt1/disk.qcow2"
+exits 0 "kill is idempotent" kill bt1
+
+# The same write without SNAPSHOT must survive a complete QEMU restart.
+cfg bt1 <<EOF
+MEM=256
+CPUS=1
+EOF
+exits 0 "start without snapshot mode" start bt1
+outputs '"return": ""' "an ordinary guest accepts a disk write" monitor bt1 \
+ 'qemu-io -d /machine/peripheral/blk0/virtio-backend "write -P 0x5a 2097152 4096"'
+"$VMM" kill bt1 >/dev/null 2>&1 || :
+exits 0 "restart after a durable disk write" start bt1
+outputs '"return": ""' "an ordinary disk write survives restart" monitor bt1 \
+ 'qemu-io -d /machine/peripheral/blk0/virtio-backend "read -P 0x5a 2097152 4096"'
+"$VMM" kill bt1 >/dev/null 2>&1 || :
+
+# A running VM must be undeletable and unclonable.
+cfg bt1 <<EOF
+MEM=256
+CPUS=1
+EOF
+"$VMM" start bt1 >/dev/null 2>&1 || :
+exits 4 "delete refuses a running VM" delete -f bt1
+exits 4 "clone refuses a running source" clone bt1 bt9
+"$VMM" kill bt1 >/dev/null 2>&1 || :
+exits 0 "delete -f removes it once stopped" delete -f bt1
+
+# status answers about the process, so an unparseable config must not stop
+# it, and a config edited under a live guest must not make it lie.
+"$VMM" create bt2 64M >/dev/null 2>&1
+cfg bt2 <<EOF
+MEM=256
+CPUS=1
+EOF
+"$VMM" start bt2 >/dev/null 2>&1 || :
+cfg bt2 <<EOF
+THIS IS NOT A CONFIG
+EOF
+outputs 'STATE=running' "status ignores a broken config" status bt2
+omits '^MEM=' "status reports no config values at all" status bt2
+
+# A live guest must be visible under every spelling of VMMDIR, or the
+# verbs that clean up delete the state of a running guest.
+state=$(VMMDIR="$VMMDIR/" "$VMM" status bt2 2>/dev/null | sed -n 's/^STATE=//p')
+if [ "$state" = running ]; then
+ ok "a running guest is visible through a trailing slash"
+else
+ notok "a running guest is visible through a trailing slash" "got [$state]"
+fi
+
+# vm_pid coming back empty is not proof of death. When some other live
+# qemu owns the pidfile, the cleanup paths must leave the directory alone.
+P2=$(cat "$VMMDIR/bt2/pid" 2>/dev/null || echo)
+mkdir -p "$VMMDIR/bt3"
+cp "$VMMDIR/bt2/config" "$VMMDIR/bt3/config"
+printf '%s\n' "$P2" > "$VMMDIR/bt3/pid"
+exits 4 "a different live qemu is reported as unknown state" stop bt3
+holds "state owned by an unrecognised live qemu is left alone" \
+ test -f "$VMMDIR/bt3/pid"
+state=$("$VMM" list | awk '$1 == "bt3" { print $2 }')
+if [ "$state" = unknown ]; then
+ ok "list exposes unrecognised live qemu state"
+else
+ notok "list exposes unrecognised live qemu state" "got [$state]"
+fi
+rm -rf "$VMMDIR/bt3"
+
+# A wedged monitor must be reported, not silently reported as success.
+P=$(cat "$VMMDIR/bt2/pid" 2>/dev/null || echo)
+if [ -n "$P" ]; then
+ kill -STOP "$P" 2>/dev/null || :
+ out=$(MONITOR_TIMEOUT=1 "$VMM" monitor bt2 info status 2>&1) && got=0 || got=$?
+ kill -CONT "$P" 2>/dev/null || :
+ if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'no reply'; then
+ ok "a silent monitor is an error, not exit 0"
+ else
+ notok "a silent monitor is an error, not exit 0" "exit $got: $out"
+ fi
+ # The shell reports the job timeout kills unless it is reaped where
+ # nobody can see it, and that report is not vmm's to print.
+ if printf '%s' "$out" | grep -q 'Terminated'; then
+ notok "a timed out call prints nothing but vmm's own error" "$out"
+ else
+ ok "a timed out call prints nothing but vmm's own error"
+ fi
+else
+ skip 2 "bt2 did not start, cannot test the wedged monitor"
+fi
+
+"$VMM" kill bt2 >/dev/null 2>&1 || :
+exits 3 "status of the killed guest exits 3" status bt2
+
+# GRAPHICS=vnc listens on a unix socket in the VM directory, and status
+# reports the socket that is really there rather than what the config says.
+"$VMM" create bt4 64M >/dev/null 2>&1
+cfg bt4 <<EOF
+MEM=256
+CPUS=1
+GRAPHICS=vnc
+EOF
+if "$VMM" start bt4 >/dev/null 2>&1; then
+ holds "GRAPHICS=vnc creates the socket" test -S "$VMMDIR/bt4/vnc.sock"
+ outputs "VNC=$VMMDIR/bt4/vnc.sock" "status names the VNC socket" status bt4
+ "$VMM" kill bt4 >/dev/null 2>&1 || :
+ holds "the socket is removed once stopped" test ! -e "$VMMDIR/bt4/vnc.sock"
+ out=$("$VMM" status bt4 2>&1) && got=0 || got=$?
+ if [ "$got" = 3 ] && ! printf '%s\n' "$out" | grep -q '^VNC='; then
+ ok "a stopped VM has no VNC line"
+ else
+ notok "a stopped VM has no VNC line" "exit $got: $out"
+ fi
+else
+ skip 4 "this qemu cannot do GRAPHICS=vnc"
+fi
+
+# One directory per VM means two guests are independent of each other:
+# each names its own process, and killing one leaves the other alone.
+"$VMM" create bt5 64M >/dev/null 2>&1
+"$VMM" create bt6 64M >/dev/null 2>&1
+for v in bt5 bt6; do
+ cfg "$v" <<CFG
+MEM=256
+CPUS=1
+CFG
+ "$VMM" start "$v" >/dev/null 2>&1 || :
+done
+p5=$(cat "$VMMDIR/bt5/pid" 2>/dev/null || echo)
+p6=$(cat "$VMMDIR/bt6/pid" 2>/dev/null || echo)
+holds "two guests run at once, as two processes" \
+ test -n "$p5" -a -n "$p6" -a "$p5" != "$p6"
+"$VMM" kill bt5 >/dev/null 2>&1 || :
+exits 3 "killing one stops that one" status bt5
+exits 0 "and leaves the other running" status bt6
+"$VMM" kill bt6 >/dev/null 2>&1 || :
diff --git a/tests/clone b/tests/clone
new file mode 100644
index 0000000..b1ffb92
--- /dev/null
+++ b/tests/clone
@@ -0,0 +1,46 @@
+# clone and delete.
+
+"$VMM" create cl1 64M >/dev/null 2>&1
+cfg cl1 <<EOF
+HOSTFWD="2222:22"
+EOF
+
+exits 0 "clone copies a stopped VM" clone cl1 cl2
+holds "clone copies config byte for byte" \
+ cmp -s "$VMMDIR/cl1/config" "$VMMDIR/cl2/config"
+holds "clone owns a separate fixed disk" \
+ test -f "$VMMDIR/cl2/disk.qcow2"
+holds "clone copies the source disk contents" \
+ cmp -s "$VMMDIR/cl1/disk.qcow2" "$VMMDIR/cl2/disk.qcow2"
+holds "clone gets a new uuid" \
+ test "$(cat "$VMMDIR/cl1/uuid")" != "$(cat "$VMMDIR/cl2/uuid")"
+# The uuid feeds the MAC, so a copy that kept either would collide with
+# its source on the first network it joined.
+m1=$("$VMM" dryrun cl1 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p')
+m2=$("$VMM" dryrun cl2 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p')
+holds "the clone's MAC differs from its source's" \
+ test -n "$m1" -a "$m1" != "$m2"
+
+exits 1 "clone refuses an existing destination" clone cl1 cl2
+
+# Copy failures leave an incomplete destination without committing config.
+mv "$VMMDIR/cl1/disk.qcow2" "$WORK/cl1.qcow2"
+exits 1 "clone reports a disk copy failure" clone cl1 clf
+holds "a failed clone leaves an incomplete directory" test -d "$VMMDIR/clf"
+holds "a failed clone does not commit a config" test ! -e "$VMMDIR/clf/config"
+mv "$WORK/cl1.qcow2" "$VMMDIR/cl1/disk.qcow2"
+
+# A partial destination is preserved for manual inspection.
+mkdir -p "$VMMDIR/clx"
+printf 'partial\n' > "$VMMDIR/clx/junk"
+exits 1 "clone refuses an incomplete destination" clone cl1 clx
+holds "the refused clone leaves the partial contents alone" \
+ test -f "$VMMDIR/clx/junk"
+unlink "$VMMDIR/clx/junk"
+rmdir "$VMMDIR/clx"
+
+exits 0 "delete -f removes a stopped VM" delete -f cl2
+holds "delete really removes the directory" test ! -e "$VMMDIR/cl2"
+holds "deleting a clone leaves the source disk alone" \
+ test -f "$VMMDIR/cl1/disk.qcow2"
+exits 1 "delete without -f and without a tty refuses" delete cl1
diff --git a/tests/config b/tests/config
new file mode 100644
index 0000000..2229bf5
--- /dev/null
+++ b/tests/config
@@ -0,0 +1,187 @@
+# The config parser, against hostile input. dryrun is the parser's only
+# observable behaviour, so every case runs through it.
+
+"$VMM" create cfg1 64M >/dev/null 2>&1
+
+: > "$VMMDIR/cfg1/config"
+exits 0 "minimal config parses" dryrun cfg1
+
+cfg cfg1 <<EOF
+CPUS=2 # two of them
+EOF
+refuses 'config:1' "inline comment is rejected by line" dryrun cfg1
+
+cfg cfg1 <<EOF
+MEMORY=4096
+EOF
+refuses 'unknown key' "a typo'd key is an error, not silence" dryrun cfg1
+
+cfg cfg1 <<EOF
+CPUS=1
+CPUS=2
+EOF
+refuses 'duplicate key' "duplicate keys are an error" dryrun cfg1
+
+cfg cfg1 <<EOF
+IMAGE_FORMAT="qcow2,readonly=on"
+EOF
+refuses 'IMAGE_FORMAT must be' "IMAGE_FORMAT injection is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+GRAPHICS=bogus
+EOF
+refuses 'GRAPHICS must be' "an invalid GRAPHICS value is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+CPUS=abc
+EOF
+refuses 'CPUS must be' "non-numeric CPUS is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD="70000:22"
+EOF
+refuses 'out of range' "out of range host port is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD="0:22"
+EOF
+refuses 'out of range' "host port 0 is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD="99999999999999999999:22"
+EOF
+refuses 'out of range' "a port too big for arithmetic is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD=",,,"
+EOF
+refuses 'no usable entries' "a HOSTFWD of only separators is refused" dryrun cfg1
+
+# Only the dotted quad binds what it looks like: inet_aton reads 127.1
+# as 127.0.0.1 and 010.0.0.1 as octal for 8.0.0.1, and a host name is
+# not an address at all.
+cfg cfg1 <<EOF
+HOSTFWD="localhost:2222:22"
+EOF
+refuses 'must be A.B.C.D' "a host name in HOSTFWD is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD="127.0.0.256:2222:22"
+EOF
+refuses 'must be A.B.C.D' "an octet out of range is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD="127.1:2222:22"
+EOF
+refuses 'must be A.B.C.D' "a short form address is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD="010.0.0.1:2222:22"
+EOF
+refuses 'must be A.B.C.D' "an octal octet is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+HOSTFWD=":2222:22"
+EOF
+refuses 'must be A.B.C.D' "an entry with no address at all is refused" dryrun cfg1
+
+# The address check runs inside the loop over entries and keeps its own
+# scratch names, since the shell has no others to give it.
+cfg cfg1 <<EOF
+HOSTFWD="2222:22,localhost:8080:80"
+EOF
+refuses 'must be A.B.C.D' "a bad address in a later entry is refused" dryrun cfg1
+
+# A forward the guest cannot receive is a config that lies, not a
+# default. A restricted NIC is still a NIC.
+cfg cfg1 <<EOF
+NETWORK=no
+HOSTFWD="2222:22"
+EOF
+refuses 'nothing to forward' "HOSTFWD without a NIC is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+NETWORK=hostonly
+HOSTFWD="2222:22"
+EOF
+exits 0 "HOSTFWD with a restricted NIC is kept" dryrun cfg1
+
+cfg cfg1 <<EOF
+BOOT_ORDER=cc
+EOF
+refuses 'BOOT_ORDER must be' "a repeated BOOT_ORDER letter is refused" dryrun cfg1
+
+# A boot list restricts nothing unless some device it names exists: with
+# no bootindex anywhere, QEMU falls back and boots the disk the list
+# left out.
+cfg cfg1 <<EOF
+BOOT_ORDER=d
+EOF
+refuses 'no device to boot' "a boot list of absent devices is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+BOOT_ORDER=n
+NETWORK=no
+EOF
+refuses 'no device to boot' "netboot without a NIC is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+BOOT_ORDER=dc
+EOF
+exits 0 "a boot list that still names the disk is kept" dryrun cfg1
+
+cfg cfg1 <<EOF
+IMAGE="$WORK/shared.qcow2"
+EOF
+refuses 'unknown key: IMAGE' "the primary disk path cannot be configured" dryrun cfg1
+
+cfg cfg1 <<EOF
+CDROM="/tmp/install.iso
+EOF
+refuses 'unbalanced quote' "unbalanced quote is refused" dryrun cfg1
+
+cfg cfg1 <<EOF
+CPUS=$(printf '2\001')
+EOF
+refuses 'non-printable' "a non-printable byte is refused" dryrun cfg1
+
+# Shell variables cannot carry NUL, so the parser must inspect the file
+# before read(1) can silently remove it.
+printf 'CPUS=2\nMEM=2\000\n' > "$VMMDIR/cfg1/config"
+refuses 'config:2: NUL byte' "a NUL byte is refused on its own line" dryrun cfg1
+
+# The parser is byte oriented whatever the caller's locale says, or a
+# multibyte character passes for one printable character.
+printf 'CDROM="/tmp/\303\251.iso"\n' > "$VMMDIR/cfg1/config"
+out=$(env LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 "$VMM" dryrun cfg1 2>&1) && got=0 || got=$?
+if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'non-printable'; then
+ ok "a UTF-8 byte is refused under a UTF-8 locale"
+else
+ notok "a UTF-8 byte is refused under a UTF-8 locale" "exit $got: $out"
+fi
+
+# A final line with no trailing newline must still be seen.
+printf 'CPUS=3' > "$VMMDIR/cfg1/config"
+outputs "-smp '3'" "final line without a newline is parsed" dryrun cfg1
+
+# CRLF must not leak into a value.
+printf 'CPUS=4\r\n' > "$VMMDIR/cfg1/config"
+outputs "-smp '4'" "CRLF line endings are handled" dryrun cfg1
+
+# An unreadable config must say so, not blame a key it never got to read.
+chmod 000 "$VMMDIR/cfg1/config"
+refuses 'not readable' "an unreadable config names the real problem" dryrun cfg1
+chmod 600 "$VMMDIR/cfg1/config"
+
+# edit exists to re-read the config once the editor has been through it,
+# which is the only reason to run one from here. EDITOR=true is exported
+# by the harness, so what is left of edit is the check.
+: > "$VMMDIR/cfg1/config"
+exits 0 "edit re-validates the config it just wrote" edit cfg1
+outputs 'config ok' "edit says so when the config parses" edit cfg1
+
+cfg cfg1 <<EOF
+MEMORY=4096
+EOF
+refuses 'unknown key' "edit refuses to leave a broken config unreported" edit cfg1
+exits 2 "edit of an unknown VM exits 2" edit nosuchvm
diff --git a/tests/console b/tests/console
new file mode 100644
index 0000000..8ec2242
--- /dev/null
+++ b/tests/console
@@ -0,0 +1,228 @@
+# The serial console needs a controlling terminal, which script(1) makes.
+# The guests are emulated, so no accelerator is needed here.
+
+if [ -t 0 ]; then
+ skip 1 "stdin is a terminal, a console refusal cannot be tested here"
+else
+ "$VMM" create co0 64M >/dev/null 2>&1
+ refuses 'needs a terminal' "console refuses without a terminal" console co0
+fi
+
+if ! command -v script >/dev/null 2>&1; then
+ skip 8 "no script(1) to allocate a pty"
+ return 0
+fi
+
+"$VMM" create co1 64M >/dev/null 2>&1
+cfg co1 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+EOF
+
+if ! "$VMM" start co1 >/dev/null 2>&1; then
+ skip 8 "co1 did not start"
+ return 0
+fi
+
+# Ctrl-] raises SIGINT in the pty's whole foreground process group, this
+# wrapper included. A no-op trap keeps the wrapper alive to report; a
+# child gets default handling either way, so vmm's own trap fires.
+cat > "$WORK/console-run" <<EOF
+#!/bin/sh
+trap ':' INT
+stty -g > "$WORK/tty.before"
+"$VMM" console co1
+echo \$? > "$WORK/console.rc"
+stty -g > "$WORK/tty.after"
+EOF
+chmod +x "$WORK/console-run"
+
+# One Ctrl-] per second until the console takes it and the pipe breaks,
+# so a slow attach costs a retry. timeout is the backstop: a console
+# that never detaches must fail, not hang.
+(
+ i=0
+ while [ "$i" -lt 15 ]; do
+ sleep 1
+ printf '\035'
+ i=$((i + 1))
+ done
+) | timeout 60 script -q -c "$WORK/console-run" /dev/null \
+ > "$WORK/console.out" 2>&1 || :
+
+if grep -q 'Ctrl-] detaches' "$WORK/console.out"; then
+ ok "console attaches and names the pty"
+else
+ notok "console attaches and names the pty" "$(cat "$WORK/console.out")"
+fi
+
+rc=$(cat "$WORK/console.rc" 2>/dev/null || echo missing)
+if [ "$rc" = 130 ]; then
+ ok "Ctrl-] detaches, and says it was interrupted"
+else
+ notok "Ctrl-] detaches, and says it was interrupted" "rc=$rc"
+fi
+
+# A botched console leaves the terminal raw.
+if cmp -s "$WORK/tty.before" "$WORK/tty.after"; then
+ ok "the terminal is handed back exactly as it was"
+else
+ notok "the terminal is handed back exactly as it was" \
+ "before: $(cat "$WORK/tty.before" 2>/dev/null)" \
+ "after: $(cat "$WORK/tty.after" 2>/dev/null)"
+fi
+
+exits 0 "detaching leaves the guest running" status co1
+
+# SIGTERM reaches console_restore with the terminal still open, and the
+# copy processes must all go with it. Ctrl-] cannot show this: it
+# signals the whole foreground group and takes them down anyway.
+rm -f "$WORK/term.wrapper" "$WORK/term.rc"
+cat > "$WORK/console-term" <<EOF
+#!/bin/sh
+echo \$\$ > "$WORK/term.wrapper"
+"$VMM" console co1
+echo \$? > "$WORK/term.rc"
+sleep 20
+EOF
+chmod +x "$WORK/console-term"
+timeout 60 script -q -c "$WORK/console-term" /dev/null \
+ > "$WORK/console-term.out" 2>&1 &
+term_job=$!
+i=0
+while ! grep -q 'Ctrl-] detaches' "$WORK/console-term.out" 2>/dev/null; do
+ [ "$i" -lt 15 ] || break
+ sleep 1
+ i=$((i + 1))
+done
+pts=$(tr -d '\r' < "$WORK/console-term.out" |
+ sed -n 's/^console co1 (\([^)]*\)).*/\1/p')
+# vmm and the subshells it forks carry one argv between them, so the
+# console is the match that is the wrapper's own child. Signalling any
+# other one leaves vmm attached and proves nothing.
+wrapper=$(cat "$WORK/term.wrapper" 2>/dev/null || echo 0)
+termpid=
+for cmdline in /proc/[0-9]*/cmdline; do
+ p=${cmdline#/proc/}
+ p=${p%/cmdline}
+ line=
+ IFS= read -r line 2>/dev/null < "$cmdline" || :
+ case $line in
+ *"$VMM"consoleco1) ;;
+ *) continue ;;
+ esac
+ if [ "$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null)" = "$wrapper" ]; then
+ termpid=$p
+ fi
+done
+kill -TERM "$termpid" 2>/dev/null || :
+i=0
+while [ ! -s "$WORK/term.rc" ] && [ "$i" -lt 10 ]; do
+ sleep 1
+ i=$((i + 1))
+done
+holding=
+if [ -n "$pts" ]; then
+ for fd in /proc/[0-9]*/fd/0 /proc/[0-9]*/fd/1; do
+ if [ "$(readlink "$fd" 2>/dev/null)" = "$pts" ]; then
+ p=${fd#/proc/}
+ holding="$holding ${p%%/*}"
+ fi
+ done
+fi
+if [ -z "$termpid" ] || [ -z "$pts" ] || [ ! -s "$WORK/term.rc" ]; then
+ skip 1 "the console did not attach and exit, cannot check teardown"
+elif [ -z "$holding" ]; then
+ ok "a terminated console leaves nothing holding the guest pty"
+else
+ notok "a terminated console leaves nothing holding the guest pty" \
+ "pty $pts still open by pid:$holding"
+fi
+kill "$term_job" 2>/dev/null || :
+wait "$term_job" 2>/dev/null || :
+
+# Closing QEMU's pty makes both copies report EIO. That is an expected
+# detach, not an error for the user's terminal.
+rm -f "$WORK/console.rc"
+(
+ while :; do
+ printf '\n'
+ sleep 1
+ done
+) | timeout 60 script -q -c "$WORK/console-run" /dev/null \
+ > "$WORK/console-die.out" 2>&1 &
+console_job=$!
+i=0
+while ! grep -q 'Ctrl-] detaches' "$WORK/console-die.out" 2>/dev/null; do
+ [ "$i" -lt 15 ] || break
+ sleep 1
+ i=$((i + 1))
+done
+"$VMM" kill co1 >/dev/null 2>&1 || :
+wait "$console_job" 2>/dev/null || :
+
+rc=$(cat "$WORK/console.rc" 2>/dev/null || echo missing)
+case $rc in
+0 | 130)
+ ok "guest death detaches the console"
+ ;;
+*)
+ notok "guest death detaches the console" "rc=$rc"
+ ;;
+esac
+if grep -q 'cat:' "$WORK/console-die.out"; then
+ notok "guest death does not print cat errors" \
+ "$(cat "$WORK/console-die.out")"
+else
+ ok "guest death does not print cat errors"
+fi
+
+# The writer half of the console. No OS is needed to answer, only 15
+# bytes of real mode code that poll the 16550 status register, read the
+# byte and write it straight back:
+# BA FD 03 mov dx,0x3fd | EC in al,dx | A8 01 test al,1 | 74 F8 jz -8
+# BA F8 03 mov dx,0x3f8 | EC in al,dx | EE out dx,al | EB F1 jmp -15
+# SeaBIOS will not boot a disk of one sector, so the image is padded.
+if command -v qemu-system-x86_64 >/dev/null 2>&1; then
+ "$VMM" create co2 64M >/dev/null 2>&1
+ dd if=/dev/zero of="$VMMDIR/co2/disk.qcow2" bs=1M count=1 2>/dev/null
+ printf '\272\375\003\354\250\001\164\370\272\370\003\354\356\353\361' |
+ dd of="$VMMDIR/co2/disk.qcow2" conv=notrunc 2>/dev/null
+ printf '\125\252' |
+ dd of="$VMMDIR/co2/disk.qcow2" bs=1 seek=510 conv=notrunc 2>/dev/null
+ cfg co2 <<EOF
+ARCH=x86_64
+HWACCEL=no
+IMAGE_FORMAT=raw
+MEM=256
+CPUS=1
+EOF
+ if "$VMM" start co2 >/dev/null 2>&1; then
+ # One line per second until the console is attached to take
+ # one, as above. The console turns the terminal's own echo
+ # off, so whatever comes back came back from the guest.
+ (
+ i=0
+ while [ "$i" -lt 5 ]; do
+ sleep 1
+ printf 'ECHO123'
+ i=$((i + 1))
+ done
+ sleep 2
+ printf '\035'
+ ) | timeout 60 script -q -c "$VMM console co2" /dev/null \
+ > "$WORK/echo.out" 2>&1 || :
+ if grep -q ECHO123 "$WORK/echo.out"; then
+ ok "a keystroke reaches the guest and comes back"
+ else
+ notok "a keystroke reaches the guest and comes back" \
+ "$(cat "$WORK/echo.out")"
+ fi
+ "$VMM" kill co2 >/dev/null 2>&1 || :
+ else
+ skip 1 "co2 did not start"
+ fi
+else
+ skip 1 "no qemu-system-x86_64 for the echo guest"
+fi
diff --git a/tests/create b/tests/create
new file mode 100644
index 0000000..4d95c63
--- /dev/null
+++ b/tests/create
@@ -0,0 +1,48 @@
+# create makes exactly what it advertises.
+
+exits 0 "create makes a VM" create cr1 64M
+holds "create makes the disk it advertises" test -f "$VMMDIR/cr1/disk.qcow2"
+holds "create writes a uuid" test -s "$VMMDIR/cr1/uuid"
+holds "create writes no configurable primary disk path" \
+ test -z "$(grep '^IMAGE=' "$VMMDIR/cr1/config" || :)"
+exits 1 "create refuses an existing VM" create cr1 64M
+
+# shellcheck disable=SC2012 # the path is vmm's own and fixed, not user input
+perm=$(ls -ld "$VMMDIR/cr1" | cut -c1-10)
+if [ "$perm" = drwx------ ]; then
+ ok "VM directory is 0700"
+else
+ notok "VM directory is 0700" "got $perm"
+fi
+
+# The directory is the only authentication the monitor has, so every
+# file in it is 0600. ls is the whole check: awk lists any regular file
+# or FIFO whose mode is anything else.
+# shellcheck disable=SC2012 # the names are vmm's own, not user input
+bad=$(ls -l "$VMMDIR/cr1" | awk '$1 ~ /^[-p]/ && $1 !~ /^[-p]rw-------$/ { print $NF }')
+if [ -z "$bad" ]; then
+ ok "every file create writes is 0600"
+else
+ notok "every file create writes is 0600" "$bad"
+fi
+
+# Validation happens before mkdir. Later failures leave their work visible.
+exits 1 "create refuses a malformed size" create cr2 10GG
+holds "a refused create leaves nothing behind" test ! -e "$VMMDIR/cr2"
+exits 1 "create refuses a zero-sized disk" create cr2 0
+exits 1 "create refuses a zero with a suffix" create cr2 00G
+exits 1 "create refuses a leading zero" create cr2 01G
+exits 1 "create fails when qemu-img refuses the size" create cr3 99999999999999999999G
+holds "a failed qemu-img leaves an incomplete directory" \
+ test -d "$VMMDIR/cr3"
+holds "a failed qemu-img does not commit a config" \
+ test ! -e "$VMMDIR/cr3/config"
+
+# A directory without config is not guessed at or recovered automatically.
+mkdir "$VMMDIR/cr4"
+printf 'partial\n' > "$VMMDIR/cr4/junk"
+exits 1 "create refuses an incomplete directory" create cr4 64M
+holds "an incomplete directory remains untouched" \
+ test -f "$VMMDIR/cr4/junk"
+unlink "$VMMDIR/cr4/junk"
+rmdir "$VMMDIR/cr4"
diff --git a/tests/env b/tests/env
new file mode 100644
index 0000000..aa0ea9b
--- /dev/null
+++ b/tests/env
@@ -0,0 +1,32 @@
+# Environment variables reach qemu and the timeout loops, so they are
+# checked once for every verb rather than inside the one that uses them.
+
+"$VMM" create ev1 64M >/dev/null 2>&1
+
+# A differently spelled VMMDIR must name the same VMs, or a live guest
+# goes invisible and the next verb deletes its disk.
+a=$("$VMM" list -q | sort | tr '\n' ' ')
+b=$(VMMDIR="$VMMDIR/" "$VMM" list -q | sort | tr '\n' ' ')
+if [ "$a" = "$b" ]; then
+ ok "a trailing slash on VMMDIR names the same VMs"
+else
+ notok "a trailing slash on VMMDIR names the same VMs" "[$a] vs [$b]"
+fi
+
+out=$(VMMDIR=relative/path "$VMM" list 2>&1) && got=0 || got=$?
+if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'absolute path'; then
+ ok "a relative VMMDIR is refused"
+else
+ notok "a relative VMMDIR is refused" "exit $got: $out"
+fi
+
+for v in SHUTDOWN_TIMEOUT MONITOR_TIMEOUT; do
+ for bad in abc 0 00 99999999999999999999; do
+ out=$(env "$v=$bad" "$VMM" list 2>&1) && got=0 || got=$?
+ if [ "$got" = 1 ] && printf '%s' "$out" | grep -q "$v must be"; then
+ ok "$v refuses $bad"
+ else
+ notok "$v refuses $bad" "exit $got: $out"
+ fi
+ done
+done
diff --git a/tests/list b/tests/list
new file mode 100644
index 0000000..e62774d
--- /dev/null
+++ b/tests/list
@@ -0,0 +1,37 @@
+# list, status and logs. Read-only verbs that must stay read-only.
+
+"$VMM" create ls1 64M >/dev/null 2>&1
+
+outputs 'ls1' "list shows a VM" list
+outputs 'stopped' "list shows stopped state" list
+exits 1 "list rejects extra arguments" list bogus
+exits 3 "status of a stopped VM exits 3" status ls1
+exits 2 "logs of an unknown VM exits 2" logs nosuchvm
+exits 1 "logs refuses a zero line count" logs -n 00 ls1
+refuses 'invalid line count' "logs refuses a count too big for tail" \
+ logs -n 9999999999 ls1
+
+# A read only listing must never remove a pidfile.
+echo 99999999 > "$VMMDIR/ls1/pid"
+"$VMM" list >/dev/null 2>&1 || :
+"$VMM" status ls1 >/dev/null 2>&1 || :
+holds "list and status never unlink a pidfile" test -f "$VMMDIR/ls1/pid"
+rm -f "$VMMDIR/ls1/pid"
+
+# A live pid running something other than QEMU is stale, not this VM.
+# Use this test shell itself so there is no helper process to leak.
+echo "$$" > "$VMMDIR/ls1/pid"
+state=$("$VMM" list | awk '$1 == "ls1" { print $2 }')
+if [ "$state" = stopped ]; then
+ ok "a live non-QEMU pid reads as stopped"
+else
+ notok "a live non-QEMU pid reads as stopped" "got [$state]"
+fi
+rm -f "$VMMDIR/ls1/pid"
+
+# A pidfile naming a pid that is gone is the common case after a crash,
+# and reading /proc for it must not make the shell complain. The same
+# race happens on every kill.
+echo 999999 > "$VMMDIR/ls1/pid"
+omits cmdline "a pidfile naming a dead process is cleaned quietly" stop ls1
+holds "and the pidfile is cleaned up" test ! -e "$VMMDIR/ls1/pid"
diff --git a/tests/lock b/tests/lock
new file mode 100644
index 0000000..09c357f
--- /dev/null
+++ b/tests/lock
@@ -0,0 +1,51 @@
+# The VM directory is the mutex.
+
+"$VMM" create lk1 64M >/dev/null 2>&1
+
+# Every mutating verb serialises, including the two that destroy data.
+exec 9<"$VMMDIR/lk1"
+flock -n 9
+exits 4 "start refuses while the lock is held" start lk1
+exits 4 "delete refuses while the lock is held" delete -f lk1
+exits 4 "clone refuses while the lock is held" clone lk1 lk9
+flock -u 9
+exec 9>&-
+
+# The kernel releases the lock when vmm exits on an error.
+cfg lk1 <<EOF
+NOSUCHKEY=1
+EOF
+refuses 'unknown key' "start refuses a broken config" start lk1
+exits 0 "and gives back the lock it had already taken" stop lk1
+
+# Two real vmm processes, not a directory made by hand. A guest with no
+# OS ignores ACPI, so stop holds the lock for SHUTDOWN_TIMEOUT, which is
+# long enough to fire a start at it and require a refusal.
+"$VMM" create lk2 64M >/dev/null 2>&1
+cfg lk2 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+EOF
+if "$VMM" start lk2 >/dev/null 2>&1; then
+ "$VMM" stop lk2 >/dev/null 2>&1 &
+ stopper=$!
+ i=0
+ while flock -n "$VMMDIR/lk2" true && [ "$i" -lt 5 ]; do
+ sleep 1
+ i=$((i + 1))
+ done
+ # That loop ends on the lock being taken or on running out of
+ # patience, and only the first of those says anything about vmm.
+ if flock -n "$VMMDIR/lk2" true; then
+ skip 1 "the backgrounded stop never took the lock"
+ else
+ exits 4 "a second vmm is refused while the first holds the lock" \
+ start lk2
+ fi
+ wait "$stopper" 2>/dev/null || :
+ holds "and the lock is available once the first is done" \
+ flock -n "$VMMDIR/lk2" true
+else
+ skip 2 "lk2 did not start"
+fi
diff --git a/tests/net b/tests/net
new file mode 100644
index 0000000..af979e5
--- /dev/null
+++ b/tests/net
@@ -0,0 +1,70 @@
+# What the guest's network looks like from the host. The argv tests say
+# what vmm asked for; these say what the kernel then did with it. The
+# guest is emulated, so this file needs no accelerator.
+#
+# /proc/net/tcp lists the local address as ADDRESS:PORT in hex, the
+# address little endian, the port big endian, and state 0A is LISTEN.
+# 127.0.0.1 is 0100007F and 0.0.0.0 is 00000000.
+
+port=18022
+hex=$(printf '%04X' "$port")
+
+"$VMM" create nt1 64M >/dev/null 2>&1
+cfg nt1 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+HOSTFWD="$port:22"
+EOF
+
+if grep -qi ":$hex " /proc/net/tcp; then
+ skip 3 "port $port is already in use on this host"
+elif ! "$VMM" start nt1 >/dev/null 2>&1; then
+ skip 3 "nt1 did not start"
+else
+ holds "the two field form really listens" \
+ grep -qi ":$hex .* 0A " /proc/net/tcp
+ holds "and on 127.0.0.1, not on every interface" \
+ grep -qi "0100007F:$hex " /proc/net/tcp
+ "$VMM" kill nt1 >/dev/null 2>&1 || :
+ holds "and stops listening once the guest is gone" \
+ test -z "$(grep -i ":$hex .* 0A " /proc/net/tcp || :)"
+fi
+
+cfg nt1 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+HOSTFWD="0.0.0.0:$port:22"
+EOF
+
+if grep -qi ":$hex " /proc/net/tcp; then
+ skip 1 "port $port is already in use on this host"
+elif ! "$VMM" start nt1 >/dev/null 2>&1; then
+ skip 1 "nt1 did not start"
+else
+ holds "an explicit address is bound as written" \
+ grep -qi "00000000:$hex " /proc/net/tcp
+ "$VMM" kill nt1 >/dev/null 2>&1 || :
+fi
+
+# Every file the guest leaves in its directory is 0600 as well, FIFOs
+# included: the monitor is reachable by anyone who can write qmp.in.
+cfg nt1 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+EOF
+if "$VMM" start nt1 >/dev/null 2>&1; then
+ # shellcheck disable=SC2012 # the names are vmm's own, not user input
+ bad=$(ls -l "$VMMDIR/nt1" |
+ awk '$1 ~ /^[-p]/ && $1 !~ /^[-p]rw-------$/ { print $NF }')
+ if [ -z "$bad" ]; then
+ ok "a running guest leaves every file 0600"
+ else
+ notok "a running guest leaves every file 0600" "$bad"
+ fi
+ "$VMM" kill nt1 >/dev/null 2>&1 || :
+else
+ skip 1 "nt1 did not start"
+fi
diff --git a/tests/static b/tests/static
new file mode 100644
index 0000000..192f75b
--- /dev/null
+++ b/tests/static
@@ -0,0 +1,17 @@
+# Static checks on the script itself.
+
+if syn=$(sh -n "$VMM" 2>&1); then
+ ok "sh -n is clean"
+else
+ notok "sh -n is clean" "$syn"
+fi
+
+if command -v shellcheck >/dev/null 2>&1; then
+ if sc=$(shellcheck -s sh "$VMM" "$ROOT/test" "$ROOT"/tests/* 2>&1); then
+ ok "shellcheck is clean"
+ else
+ notok "shellcheck is clean" "$sc"
+ fi
+else
+ skip 1 "shellcheck not installed"
+fi
diff --git a/tests/usage b/tests/usage
new file mode 100644
index 0000000..25ec6e4
--- /dev/null
+++ b/tests/usage
@@ -0,0 +1,27 @@
+# Dispatch and name validation, before anything touches the filesystem.
+
+exits 1 "bare vmm prints usage"
+exits 1 "unknown command prints usage" nosuchcommand
+exits 2 "unknown VM exits 2" status nosuchvm
+
+# Every verb refuses a wrong argument count. The guards are one line
+# each, and an inverted one is invisible until someone types the wrong
+# thing: these run before any VM is named, so no VM has to exist.
+for v in edit start stop restart kill status console viewer logs dryrun; do
+ exits 1 "$v refuses two names" "$v" a b
+done
+exits 1 "list refuses an argument" list a
+exits 1 "clone refuses one name" clone a
+exits 1 "create refuses three arguments" create a 10G x
+exits 1 "monitor refuses no name at all" monitor
+
+exits 1 "rejects a traversing name" kill ../outside
+exits 1 "rejects an empty name" start ''
+exits 1 "rejects a leading dash" start -rf
+exits 1 "rejects a leading dot" start .hidden
+exits 1 "rejects a name with a slash" start a/b
+exits 1 "rejects a 33 character name" create aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+
+mkdir -p "$WORK/outside"
+"$VMM" create ../outside/pwned >/dev/null 2>&1 || :
+holds "traversal creates nothing outside VMMDIR" test ! -e "$WORK/outside/pwned"
diff --git a/tests/viewer b/tests/viewer
new file mode 100644
index 0000000..f0357f0
--- /dev/null
+++ b/tests/viewer
@@ -0,0 +1,44 @@
+# The URI is the whole of what viewer decides, and VIEWER=echo reports
+# it without a client anywhere. The guests here are emulated.
+
+"$VMM" create vw1 64M >/dev/null 2>&1
+
+exits 2 "viewer of an unknown VM exits 2" viewer nosuchvm
+exits 3 "viewer of a stopped VM exits 3" viewer vw1
+
+cfg vw1 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+EOF
+if "$VMM" start vw1 >/dev/null 2>&1; then
+ refuses 'has no display' "GRAPHICS=no leaves nothing to view" viewer vw1
+ "$VMM" kill vw1 >/dev/null 2>&1 || :
+else
+ skip 1 "vw1 did not start"
+fi
+
+# The URI names the socket that is really there, not what the config
+# says, so a display that failed to appear is an error rather than a
+# client left dialling nothing.
+for g in vnc spice; do
+ cfg vw1 <<EOF
+MEM=256
+CPUS=1
+HWACCEL=no
+GRAPHICS=$g
+EOF
+ if ! "$VMM" start vw1 >/dev/null 2>&1; then
+ skip 2 "this qemu cannot do GRAPHICS=$g"
+ continue
+ fi
+ out=$(env VIEWER=echo "$VMM" viewer vw1 2>&1) && got=0 || got=$?
+ if [ "$got" = 0 ] && [ "$out" = "$g+unix://$VMMDIR/vw1/$g.sock" ]; then
+ ok "$g is handed to the client as $g+unix"
+ else
+ notok "$g is handed to the client as $g+unix" \
+ "exit $got, wanted $g+unix://$VMMDIR/vw1/$g.sock" "got: $out"
+ fi
+ "$VMM" kill vw1 >/dev/null 2>&1 || :
+ exits 3 "and the killed $g guest has nothing to view" viewer vw1
+done