summaryrefslogtreecommitdiffstats
path: root/debian/tests/utils/mock.pm
diff options
context:
space:
mode:
Diffstat (limited to 'debian/tests/utils/mock.pm')
-rw-r--r--debian/tests/utils/mock.pm347
1 files changed, 347 insertions, 0 deletions
diff --git a/debian/tests/utils/mock.pm b/debian/tests/utils/mock.pm
new file mode 100644
index 0000000..10db3e6
--- /dev/null
+++ b/debian/tests/utils/mock.pm
@@ -0,0 +1,347 @@
+# Mock terminal interaction on a guest system
+#
+# Copyright © 2021-2022 Guilhem Moulin <guilhem@debian.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+use v5.14.2;
+use warnings;
+use strict;
+
+our ($SERIAL, $CONSOLE, $MONITOR);
+our $PS1 = qr/root\@[\-\.0-9A-Z_a-z]+ : [~\/][\-\.\/0-9A-Z_a-z]* [\#\$]\ /aax;
+
+package CryptrootTest::Utils;
+
+use Socket qw/PF_UNIX SOCK_STREAM SOCK_CLOEXEC SOCK_NONBLOCK SHUT_RD SHUT_WR/;
+use Errno qw/EINTR ENOENT ECONNREFUSED/;
+use Time::HiRes ();
+
+my (%SOCKET, %BUFFER, $WBITS, $RBITS);
+
+BEGIN {
+ ($SERIAL, $CONSOLE, $MONITOR) = qw/ttyS0 hvc0 mon0/;
+ my $dir = $ARGV[1] =~ m#\A(/\p{Print}+)\z# ? $1 : die "Invalid base directory\n"; # untaint
+ my $epoch = Time::HiRes::time();
+ foreach my $id ($SERIAL, $CONSOLE, $MONITOR) {
+ my $path = $dir . "/" . $id;
+ my $sockaddr = Socket::pack_sockaddr_un($path) // die;
+ socket(my $socket, PF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) or die "socket: $!";
+
+ until (connect($socket, $sockaddr)) {
+ if ($! == EINTR) {
+ # try again immediatly if connect(2) was interrupted by a signal
+ } elsif (($! == ENOENT or $! == ECONNREFUSED) and Time::HiRes::time() - $epoch < 30) {
+ # wait a bit to give QEMU time to create the socket and mark it at listening
+ Time::HiRes::usleep(100_000);
+ } else {
+ die "connect($path): $!";
+ }
+ }
+
+ my $fd = fileno($socket) // die;
+ vec($WBITS, $fd, 1) = 1;
+ vec($RBITS, $fd, 1) = 1;
+ $SOCKET{$id} = $socket;
+ $BUFFER{$id} = "";
+ }
+}
+
+sub read_data($) {
+ my $bits = shift;
+ while (my ($chan, $fh) = each %SOCKET) {
+ next unless vec($bits, fileno($fh), 1); # nothing to read here
+ my $n = sysread($fh, my $buf, 4096) // die "read: $!";
+ if ($n > 0) {
+ STDOUT->printflush($buf);
+ $BUFFER{$chan} .= $buf;
+ } else {
+ #print STDERR "INFO done reading from $chan\n";
+ shutdown($fh, SHUT_RD) or die "shutdown: $!";
+ vec($RBITS, fileno($fh), 1) = 0;
+ }
+ }
+}
+
+sub expect(;$$) {
+ my ($chan, $prompt) = @_;
+
+ my $buffer = defined $chan ? \$BUFFER{$chan} : undef;
+ if (defined $buffer and $$buffer =~ $prompt) {
+ $$buffer = $' // die;
+ return %+;
+ }
+
+ while(unpack("b*", $RBITS) != 0) {
+ my $rout = $RBITS;
+ while (select($rout, undef, undef, undef) == -1) {
+ die "select: $!" unless $! == EINTR; # try again immediately if select(2) was interrupted
+ }
+ read_data($rout);
+ if (defined $buffer and $$buffer =~ $prompt) {
+ $$buffer = $' // die;
+ return %+;
+ }
+ }
+ #print STDERR "INFO done reading\n";
+}
+
+sub write_data($$%) {
+ my $chan = shift;
+ my $data = shift;
+
+ my %options = @_;
+ $options{echo} //= 1;
+ $options{eol} //= "\r";
+ $options{reol} //= "\r\n";
+ my $wdata = $data . $options{eol};
+
+ my $wfh = $SOCKET{$chan} // die;
+ my $wfd = fileno($wfh) // die;
+ vec(my $win, $wfd, 1) = 1;
+
+ for (my $offset = 0, my $length = length($wdata); $offset < $length;) {
+ my $wout = $win;
+ while (select(undef, $wout, undef, undef) == -1) {
+ die "select: $!" unless $! == EINTR; # try again immediately if select(2) was interrupted
+ }
+ if (vec($wout, $wfd, 1)) {
+ my $n = syswrite($wfh, $wdata, $length - $offset, $offset) // die "write: $!";
+ $offset += $n;
+ }
+ }
+
+ my $rdata = $options{echo} ? $data : "";
+ $rdata .= $options{reol};
+
+ if ($rdata ne "") {
+ my $buf = \$BUFFER{$chan};
+ my $rfh = $SOCKET{$chan} // die;
+ my $rfd = fileno($rfh) // die;
+ vec(my $rin, $rfd, 1) = 1;
+
+ my $rlen = length($rdata);
+ while($rlen > 0) {
+ my $rout = $rin;
+ while (select($rout, undef, undef, undef) == -1) {
+ die "select: $!" unless $! == EINTR; # try again immediately if select(2) was interrupted
+ }
+ read_data($rout);
+
+ my $got = substr($$buf, 0, $rlen);
+ my $n = length($got);
+ if ($got eq substr($rdata, -$rlen, $n)) {
+ $$buf = substr($$buf, $n); # consume the command
+ $rlen -= $n;
+ } else {
+ my $a = substr($rdata, 0, -$rlen) . substr($rdata, -$rlen, $n);
+ my $b = substr($rdata, 0, -$rlen) . $got;
+ s/[^\p{Graph} ]/"\\x".unpack("H*",$&)/ge foreach ($a, $b);
+ die "Wanted \"$a\", got \"$b\"";
+ }
+ }
+ }
+}
+
+package CryptrootTest::Mock;
+
+use Exporter qw/import/;
+BEGIN {
+ our @EXPORT = qw/
+ unlock_disk
+ login
+ shell
+ suspend
+ wakeup
+ hibernate
+ poweroff
+ expect
+ /;
+}
+
+*expect = \&CryptrootTest::Utils::expect;
+*write_data = \&CryptrootTest::Utils::write_data;
+
+sub unlock_disk($) {
+ my $passphrase = shift;
+ my %r = expect($SERIAL => qr/\A(?:.*(?:\r\n|\.\.\. ))?Please unlock disk (?<name>\p{Graph}+): \z/aams);
+ if ((my $ref = ref($passphrase)) ne "") {
+ my $name = $r{name};
+ unless (defined $name) {
+ undef $passphrase;
+ } elsif ($ref eq "CODE") {
+ $passphrase = $passphrase->($name);
+ } elsif ($ref eq "HASH") {
+ $passphrase = $passphrase->{$name};
+ } else {
+ die "Unsupported reference $ref";
+ }
+ }
+ die "Unable to unlock, aborting.\n" unless defined $passphrase;
+ write_data($SERIAL => $passphrase, echo => 0, reol => "\r");
+}
+
+sub login($;$) {
+ my ($username, $password) = @_;
+ expect($CONSOLE => qr/\r\ncryptroot-[[:alnum:]._-]+ login: \z/aams);
+ write_data($CONSOLE => $username, reol => "\r");
+
+ if (defined $password) {
+ expect($CONSOLE => qr/\A[\r\n]*Password: \z/aams);
+ write_data($CONSOLE => $username, echo => 0, reol => "\r");
+ }
+
+ # consume motd(5) or similar
+ expect($CONSOLE => qr/\r\n $PS1 \z/aamsx);
+}
+
+sub shell($%);
+sub shell($%) {
+ my $command = shift;
+ my %options = @_;
+
+ write_data($CONSOLE => $command);
+ my %r = expect($CONSOLE => qr/\A (?<out>.*) $PS1 \z/aamsx);
+ my $out = $r{out};
+
+ if (exists $options{rv}) {
+ my $rv = shell(q{echo $?});
+ unless ($rv =~ s/\r?\n\z// and $rv =~ /\A[0-9]+\z/ and $rv == $options{rv}) {
+ my @loc = caller;
+ die "ERROR: Command \`$command\` exited with status $rv != $options{rv}",
+ " at line $loc[2] in $loc[1]\n";
+ }
+ }
+ return $out;
+}
+
+# enter S3 sleep state (suspend to ram aka standby)
+sub suspend() {
+ write_data($CONSOLE => q{systemctl suspend});
+ # while the command is asynchronous the system might suspend before
+ # we have a chance to read the next $PS1
+
+ # wait for the SUSPEND event
+ QMP::wait_for_event("SUSPEND");
+
+ # double check that the guest is indeed suspended
+ my $resp = QMP::command(q{query-status});
+ die unless defined $resp->{status} and $resp->{status} eq "suspended" and
+ defined $resp->{running} and $resp->{running} == JSON::false();
+}
+
+sub wakeup() {
+ my $r = QMP::command(q{system_wakeup});
+ die if %$r;
+
+ # wait for the WAKEUP event
+ QMP::wait_for_event("WAKEUP");
+
+ # double check that the guest is indeed running
+ my $resp = QMP::command(q{query-status});
+ die unless defined $resp->{status} and $resp->{status} eq "running" and
+ defined $resp->{running} and $resp->{running} == JSON::true();
+}
+
+# enter S4 sleep state (suspend to disk aka hibernate)
+sub hibernate() {
+ # an alternative is to send {"execute":"guest-suspend-disk"} on the
+ # guest agent socket, but we don't want to require qemu-guest-agent
+ # on the guest so this will have to do
+ write_data($CONSOLE => q{systemctl hibernate});
+ # while the command is asynchronous the system might hibernate
+ # before we have a chance to read the next $PS1
+ QMP::wait_for_event("SUSPEND_DISK");
+ expect();# wait for QEMU to terminate
+}
+
+sub poweroff() {
+ # XXX would be nice to use the QEMU monitor here but the guest
+ # doesn't seem to respond to system_powerdown QMP commands
+ write_data($CONSOLE => q{poweroff});
+ # while the command is asynchronous the system might shutdown
+ # before we have a chance to read the next $PS1
+ QMP::wait_for_event("SHUTDOWN");
+ expect(); # wait for QEMU to terminate
+}
+
+
+package QMP;
+
+# QMP protocol
+# https://qemu.readthedocs.io/en/latest/interop/qemu-qmp-ref.html
+
+use JSON ();
+
+# read and decode a QMP server line
+sub getline() {
+ my %r = CryptrootTest::Utils::expect($MONITOR => qr/\A(?<str>.+?)\r\n/m);
+ my $str = $r{str} // die;
+ return JSON::->new->decode($str);
+}
+
+# send a QMP command and optional arguments
+sub command($;$) {
+ my ($command, $arguments) = @_;
+ my $cmd = { execute => $command };
+ $cmd->{arguments} = $arguments if defined $arguments;
+
+ $cmd = JSON::->new->encode($cmd);
+ STDOUT->printflush($cmd . "\n");
+ CryptrootTest::Utils::write_data($MONITOR => $cmd, eol => "\r\n", echo => 0, reol => "");
+
+ while(1) {
+ my $resp = QMP::getline() // next;
+ # ignore unsolicited server responses (such as events)
+ return $resp->{return} if exists $resp->{return};
+ }
+}
+
+# wait for the QMP greeting line
+my @CAPABILITIES;
+sub greeting() {
+ my $greeting = QMP::getline() // die;
+ $greeting = $greeting->{QMP} // die;
+ @CAPABILITIES = @{$greeting->{capabilities}} if defined $greeting->{capabilities};
+}
+
+# negotiate QMP capabilities
+sub capabilities(@) {
+ my $r = QMP::command(qmp_capabilities => {enable => \@_});
+ die if %$r;
+}
+
+BEGIN {
+ # https://gitlab.com/qemu-project/qemu/-/blob/master/docs/interop/qmp-spec.txt sec 4
+ QMP::greeting();
+ QMP::capabilities();
+}
+
+sub wait_for_event($) {
+ my $event_name = shift;
+ while(1) {
+ my $resp = QMP::getline() // next;
+ return if exists $resp->{event} and $resp->{event} eq $event_name;
+ }
+}
+
+sub quit() {
+ # don't use QMP::command() here since we might never receive a response
+ my $cmd = JSON::->new->encode({ execute => "quit" });
+ STDOUT->printflush($cmd . "\n");
+ CryptrootTest::Utils::write_data($MONITOR => $cmd, eol => "\r\n", echo => 0, reol => "");
+ CryptrootTest::Utils::expect(); # wait for QEMU to terminate
+}
+
+1;