summaryrefslogtreecommitdiffstats
path: root/cucumber
diff options
context:
space:
mode:
authorPhilip Hands <phil@hands.com>2016-05-11 17:11:01 +0200
committerPhilip Hands <phil@hands.com>2016-05-11 17:11:01 +0200
commita5d56e3b5443263b53b0487c81125123411bd0cf (patch)
tree71b1bdafc0a5978bca9073609eff33e228e29a12 /cucumber
parent555d9414f758cc0062eff700a0352ae177fd9be5 (diff)
downloadjenkins.debian.net-a5d56e3b5443263b53b0487c81125123411bd0cf.tar.xz
move cucumber things under cucumber/
Diffstat (limited to 'cucumber')
-rw-r--r--cucumber/README-sikuli-cucumber13
-rwxr-xr-xcucumber/bin/run_test_suite276
-rw-r--r--cucumber/features/config/defaults.yml36
-rw-r--r--cucumber/features/domains/default.xml59
-rw-r--r--cucumber/features/domains/default_net.xml13
-rw-r--r--cucumber/features/domains/disk.xml5
-rw-r--r--cucumber/features/domains/fs_share.xml6
-rw-r--r--cucumber/features/domains/storage_pool.xml6
-rw-r--r--cucumber/features/domains/volume.xml14
-rw-r--r--cucumber/features/images/DebianInstallerCountrySelection.pngbin0 -> 5303 bytes
-rw-r--r--cucumber/features/images/DebianInstallerDomainPrompt.pngbin0 -> 2225 bytes
-rw-r--r--cucumber/features/images/DebianInstallerHostnamePrompt.pngbin0 -> 5643 bytes
-rw-r--r--cucumber/features/images/DebianInstallerHttpProxy.pngbin0 -> 485 bytes
-rw-r--r--cucumber/features/images/DebianInstallerInstallingBaseSystem.pngbin0 -> 3554 bytes
-rw-r--r--cucumber/features/images/DebianInstallerMirrorCountry.pngbin0 -> 3769 bytes
-rw-r--r--cucumber/features/images/DebianInstallerNameOfUser.pngbin0 -> 3792 bytes
-rw-r--r--cucumber/features/images/DebianInstallerNoDiskFound.pngbin0 -> 4005 bytes
-rw-r--r--cucumber/features/images/DebianInstallerPartitioningMethod.pngbin0 -> 13953 bytes
-rw-r--r--cucumber/features/images/DebianInstallerPartitioningScheme.pngbin0 -> 2879 bytes
-rw-r--r--cucumber/features/images/DebianInstallerRootPassword.pngbin0 -> 3226 bytes
-rw-r--r--cucumber/features/images/DebianInstallerSelectDiskToPartition.pngbin0 -> 5403 bytes
-rw-r--r--cucumber/features/images/DebianInstallerSelectLangEnglish.pngbin0 -> 7229 bytes
-rw-r--r--cucumber/features/images/DebianInstallerSelectLangEnglishUK.pngbin0 -> 4853 bytes
-rw-r--r--cucumber/features/images/DebianInstallerUserPassword.pngbin0 -> 4892 bytes
-rw-r--r--cucumber/features/images/DebianLive7BootSplash.pngbin0 -> 13399 bytes
-rw-r--r--cucumber/features/images/DebianLive7BootSplashTabMsg.pngbin0 -> 4260 bytes
-rw-r--r--cucumber/features/images/DebianLive7Greeter.pngbin0 -> 3166 bytes
-rw-r--r--cucumber/features/images/DebianLiveBootSplash.pngbin0 -> 1569 bytes
-rw-r--r--cucumber/features/images/DebianLiveBootSplashTabMsg.pngbin0 -> 790 bytes
-rw-r--r--cucumber/features/images/DebianLoginPromptVT.pngbin0 -> 7466 bytes
-rw-r--r--cucumber/features/images/d-i8_bootsplash.pngbin0 -> 9930 bytes
-rw-r--r--cucumber/features/images/d-i_ArchiveMirror.pngbin0 -> 2908 bytes
-rw-r--r--cucumber/features/images/d-i_ChooseSoftware.pngbin0 -> 4204 bytes
-rw-r--r--cucumber/features/images/d-i_DesktopTask_No.pngbin0 -> 7161 bytes
-rw-r--r--cucumber/features/images/d-i_DesktopTask_Yes.pngbin0 -> 4152 bytes
-rw-r--r--cucumber/features/images/d-i_F12BootMenu.pngbin0 -> 482 bytes
-rw-r--r--cucumber/features/images/d-i_FinishPartitioning.pngbin0 -> 8734 bytes
-rw-r--r--cucumber/features/images/d-i_GRUBEnterDev.pngbin0 -> 2490 bytes
-rw-r--r--cucumber/features/images/d-i_GRUB_Debian.pngbin0 -> 387 bytes
-rw-r--r--cucumber/features/images/d-i_GRUBdev.pngbin0 -> 1765 bytes
-rw-r--r--cucumber/features/images/d-i_HttpProxy.pngbin0 -> 5324 bytes
-rw-r--r--cucumber/features/images/d-i_InstallComplete.pngbin0 -> 2740 bytes
-rw-r--r--cucumber/features/images/d-i_InstallGRUB.pngbin0 -> 6367 bytes
-rw-r--r--cucumber/features/images/d-i_No.pngbin0 -> 995 bytes
-rw-r--r--cucumber/features/images/d-i_ScanCD.pngbin0 -> 3216 bytes
-rw-r--r--cucumber/features/images/d-i_SelectBootDev.pngbin0 -> 559 bytes
-rw-r--r--cucumber/features/images/d-i_UseNetMirror.pngbin0 -> 2975 bytes
-rw-r--r--cucumber/features/images/d-i_Yes.pngbin0 -> 1163 bytes
-rw-r--r--cucumber/features/images/d-i_popcon.pngbin0 -> 3894 bytes
-rw-r--r--cucumber/features/install.feature14
-rw-r--r--cucumber/features/misc_files/sample.pdfbin0 -> 22347 bytes
-rw-r--r--cucumber/features/misc_files/sample.tex8
-rwxr-xr-xcucumber/features/scripts/otr-bot.py206
-rwxr-xr-xcucumber/features/scripts/vm-execute52
-rw-r--r--cucumber/features/step_definitions/apt.rb56
-rw-r--r--cucumber/features/step_definitions/browser.rb195
-rw-r--r--cucumber/features/step_definitions/build.rb115
-rw-r--r--cucumber/features/step_definitions/checks.rb252
-rw-r--r--cucumber/features/step_definitions/common_steps.rb1086
-rw-r--r--cucumber/features/step_definitions/dhcp.rb19
-rw-r--r--cucumber/features/step_definitions/electrum.rb52
-rw-r--r--cucumber/features/step_definitions/encryption.rb133
-rw-r--r--cucumber/features/step_definitions/evince.rb25
-rw-r--r--cucumber/features/step_definitions/firewall_leaks.rb56
-rw-r--r--cucumber/features/step_definitions/git.rb6
-rw-r--r--cucumber/features/step_definitions/icedove.rb94
-rw-r--r--cucumber/features/step_definitions/mac_spoofing.rb108
-rw-r--r--cucumber/features/step_definitions/pidgin.rb467
-rw-r--r--cucumber/features/step_definitions/po.rb8
-rw-r--r--cucumber/features/step_definitions/root_access_control.rb42
-rw-r--r--cucumber/features/step_definitions/snapshots.rb257
-rw-r--r--cucumber/features/step_definitions/ssh.rb122
-rw-r--r--cucumber/features/step_definitions/time_syncing.rb86
-rw-r--r--cucumber/features/step_definitions/tor.rb402
-rw-r--r--cucumber/features/step_definitions/torified_browsing.rb5
-rw-r--r--cucumber/features/step_definitions/torified_gnupg.rb208
-rw-r--r--cucumber/features/step_definitions/torified_misc.rb41
-rw-r--r--cucumber/features/step_definitions/totem.rb43
-rw-r--r--cucumber/features/step_definitions/unsafe_browser.rb189
-rw-r--r--cucumber/features/step_definitions/untrusted_partitions.rb61
-rw-r--r--cucumber/features/step_definitions/usb.rb596
-rw-r--r--cucumber/features/support/config.rb100
-rw-r--r--cucumber/features/support/env.rb90
-rw-r--r--cucumber/features/support/extra_hooks.rb165
-rw-r--r--cucumber/features/support/helpers/chatbot_helper.rb59
-rw-r--r--cucumber/features/support/helpers/ctcp_helper.rb126
-rw-r--r--cucumber/features/support/helpers/display_helper.rb48
-rw-r--r--cucumber/features/support/helpers/exec_helper.rb79
-rw-r--r--cucumber/features/support/helpers/firewall_helper.rb121
-rw-r--r--cucumber/features/support/helpers/misc_helpers.rb253
-rw-r--r--cucumber/features/support/helpers/sikuli_helper.rb213
-rw-r--r--cucumber/features/support/helpers/sniffing_helper.rb43
-rw-r--r--cucumber/features/support/helpers/sshd_helper.rb67
-rw-r--r--cucumber/features/support/helpers/storage_helper.rb216
-rw-r--r--cucumber/features/support/helpers/vm_helper.rb676
-rw-r--r--cucumber/features/support/hooks.rb280
96 files changed, 7968 insertions, 0 deletions
diff --git a/cucumber/README-sikuli-cucumber b/cucumber/README-sikuli-cucumber
new file mode 100644
index 00000000..24d90aaf
--- /dev/null
+++ b/cucumber/README-sikuli-cucumber
@@ -0,0 +1,13 @@
+Key names:
+
+ http://doc.sikuli.org/keys.html
+
+Running the thing by hand:
+
+ ./bin/lvc/run_test_suite --view --iso /var/lib/libvirt/images/debian-8.3.0-amd64-i386-netinst.iso --old-iso /var/lib/libvirt/images/debian-8.3.0-amd64-i386-netinst.iso DebianLive/apt.feature
+
+FIXME - I added a git repo to:
+
+ /var/lib/jenkins/workspace/lvc_debian-installer_jessie_standard_apt
+
+in order to shut-up Tail's script that looks for git repos to populate some variables -- need to strip that out, or make it overridable.
diff --git a/cucumber/bin/run_test_suite b/cucumber/bin/run_test_suite
new file mode 100755
index 00000000..5b40c389
--- /dev/null
+++ b/cucumber/bin/run_test_suite
@@ -0,0 +1,276 @@
+#!/bin/bash
+
+set -e
+set -u
+set -o pipefail
+
+NAME=$(basename ${0})
+
+GENERAL_DEPENDENCIES="
+cucumber
+devscripts
+dnsmasq-base
+gawk
+git
+i18nspector
+libav-tools
+libcap2-bin
+libsikuli-script-java
+libvirt-clients
+libvirt-daemon-system
+libvirt-dev
+libvirt0
+openjdk-7-jre
+openssh-server
+ovmf
+python-jabberbot
+python-potr
+qemu-kvm
+qemu-system-x86
+ruby-guestfs
+ruby-json
+ruby-libvirt
+ruby-net-irc
+ruby-packetfu
+ruby-rb-inotify
+ruby-rjb
+ruby-rspec
+ruby-test-unit
+seabios
+tcpdump
+unclutter
+virt-viewer
+xvfb
+"
+
+usage() {
+ echo "Usage: $NAME [OPTION]... [--] [CUCUMBER_ARGS]...
+Sets up an appropriate environment and invokes cucumber. Note that this script
+must be run from the Tails source directory root.
+
+Options for '@product' features:
+ --artifacts-base-uri URI
+ Pretend that the artifact is located at URI when printing
+ its location during a scenario failure. This is useful if
+ you intend to serve the artifacts via the web, for
+ instance.
+ --capture Captures failed scenarios into videos stored in the
+ temporary directory (see --tmpdir below) using x264
+ encoding. Requires x264.
+ --capture-all Keep videos for all scenarios, including those that
+ succeed (implies --capture).
+ --pause-on-fail On failure, pause test suite until pressing Enter. This is
+ useful for investigating the state of the VM guest to see
+ exactly why a test failed.
+ --keep-snapshots Don't ever delete any snapshots (including ones marked as
+ temporary). This can be a big time saver when debugging new
+ features.
+ --retry-find Print a warning whenever Sikuli fails to find an image
+ and allow *one* retry after pressing ENTER. This is useful
+ for updating outdated images.
+ --tmpdir Directory where various temporary files are written
+ during a test, e.g. VM snapshots and memory dumps,
+ failure screenshots, pcap files and disk images
+ (default is TMPDIR in the environment, and if unset,
+ /tmp/DebianToaster).
+ --view Shows the test session in a windows. Requires x11vnc
+ and xtightvncviewer.
+ --vnc-server-only Starts a VNC server for the test session. Requires x11vnc.
+ --iso IMAGE Test '@product' features using IMAGE.
+ --old-iso IMAGE For some '@product' features (e.g. usb_install) we need
+ an older version of Tails, which this options sets to
+ IMAGE. If none is given, it defaults to the same IMAGE
+ given by --iso, which will be good enough for most testing
+ purposes.
+
+Note that '@source' features has no relevant options.
+
+CUCUMBER_ARGS can be used to specify which features to be run, but also any
+cucumber option, although then you must pass \`--\` first to let this wrapper
+script know that we're done with *its* options. For debugging purposes, a
+'debug' formatter has been added so pretty debugging can be enabled with
+\`--format debug\`. You could even combine the default (pretty) formatter with
+pretty debugging printed to a file with \`--format pretty --format debug
+--out debug.log\`.
+"
+}
+
+error() {
+ echo "${NAME}: error: ${*}" >&2
+ usage
+ exit 1
+}
+
+package_installed() {
+ local ret
+ set +o pipefail
+ if dpkg -s "${1}" 2>/dev/null | grep -q "^Status:.*installed"; then
+ ret=0
+ else
+ ret=1
+ fi
+ set -o pipefail
+ return ${ret}
+}
+
+check_dependencies() {
+ while [ -n "${1:-}" ]; do
+ if ! which "${1}" >/dev/null && ! package_installed "${1}" ; then
+ error "'${1}' is missing, please install it and run again."
+ fi
+ shift
+ done
+}
+
+display_in_use() {
+ [ -e "/tmp/.X${1#:}-lock" ] || [ -e "/tmp/.X11-unix/X${1#:}" ]
+}
+
+next_free_display() {
+ display_nr=0
+ while display_in_use ":${display_nr}"; do
+ display_nr=$((display_nr+1))
+ done
+ echo ":${display_nr}"
+}
+
+test_suite_cleanup() {
+ (kill -0 ${XVFB_PID} 2>/dev/null && kill ${XVFB_PID}) || /bin/true
+}
+
+start_xvfb() {
+ Xvfb $TARGET_DISPLAY -screen 0 1024x768x24+32 >/dev/null 2>&1 &
+ XVFB_PID=$!
+ # Wait for Xvfb to run on TARGET_DISPLAY
+ until display_in_use $TARGET_DISPLAY; do
+ sleep 1
+ done
+ echo "Virtual X framebuffer started on display ${TARGET_DISPLAY}"
+ # Hide the mouse cursor so it won't mess up Sikuli's screen scanning
+ unclutter -display $TARGET_DISPLAY -root -idle 0 >/dev/null 2>&1 &
+}
+
+start_vnc_server() {
+ check_dependencies x11vnc
+ VNC_SERVER_PORT="$(x11vnc -listen localhost -display ${TARGET_DISPLAY} \
+ -bg -nopw -forever 2>&1 | \
+ grep -m 1 "^PORT=[0-9]\+" | sed 's/^PORT=//')"
+ echo "VNC server running on: localhost:${VNC_SERVER_PORT}"
+}
+
+start_vnc_viewer() {
+ check_dependencies xtightvncviewer
+ xtightvncviewer -viewonly localhost:${VNC_SERVER_PORT} 1>/dev/null 2>&1 &
+}
+
+capture_session() {
+ check_dependencies libvpx1
+ echo "Capturing guest display into ${CAPTURE_FILE}"
+ avconv -f x11grab -s 1024x768 -r 15 -i ${TARGET_DISPLAY}.0 -an \
+ -vcodec libvpx -y "${CAPTURE_FILE}" >/dev/null 2>&1 &
+}
+
+# main script
+
+# Unset all environment variables used by this script to pass options
+# to cucumber, except TMPDIR since we explicitly want to support
+# setting it that way.
+ARTIFACTS_BASE_URI=
+CAPTURE=
+CAPTURE_ALL=
+LOG_FILE=
+VNC_VIEWER=
+VNC_SERVER=
+PAUSE_ON_FAIL=
+KEEP_SNAPSHOTS=
+SIKULI_RETRY_FINDFAILED=
+ISO=
+OLD_ISO=
+
+LONGOPTS="artifacts-base-uri:,view,vnc-server-only,capture,capture-all,help,tmpdir:,keep-snapshots,retry-find,iso:,old-iso:,pause-on-fail"
+OPTS=$(getopt -o "" --longoptions $LONGOPTS -n "${NAME}" -- "$@")
+eval set -- "$OPTS"
+while [ $# -gt 0 ]; do
+ case $1 in
+ --artifacts-base-uri)
+ shift
+ export ARTIFACTS_BASE_URI="${1}"
+ ;;
+ --view)
+ VNC_VIEWER=yes
+ VNC_SERVER=yes
+ ;;
+ --vnc-server-only)
+ VNC_VIEWER=
+ VNC_SERVER=yes
+ ;;
+ --capture)
+ check_dependencies x264
+ export CAPTURE="yes"
+ ;;
+ --capture-all)
+ check_dependencies x264
+ export CAPTURE="yes"
+ export CAPTURE_ALL="yes"
+ ;;
+ --pause-on-fail)
+ export PAUSE_ON_FAIL="yes"
+ ;;
+ --keep-snapshots)
+ export KEEP_SNAPSHOTS="yes"
+ ;;
+ --retry-find)
+ export SIKULI_RETRY_FINDFAILED="yes"
+ ;;
+ --tmpdir)
+ shift
+ export TMPDIR="$(readlink -f $1)"
+ ;;
+ --iso)
+ shift
+ export ISO="$(readlink -f $1)"
+ ;;
+ --old-iso)
+ shift
+ export OLD_ISO="$(readlink -f $1)"
+ ;;
+ --help)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ esac
+ shift
+done
+
+trap "test_suite_cleanup" EXIT HUP INT QUIT TERM
+
+check_dependencies ${GENERAL_DEPENDENCIES}
+
+TARGET_DISPLAY=$(next_free_display)
+
+start_xvfb
+
+if [ -n "${VNC_SERVER:-}" ]; then
+ start_vnc_server
+fi
+if [ -n "${VNC_VIEWER:-}" ]; then
+ start_vnc_viewer
+fi
+
+export SIKULI_HOME="/usr/share/java"
+export SIKULI_IMAGE_PATH="/srv/jenkins/features/images/"
+export RUBYLIB="/srv/jenkins"
+export VM_XML_PATH="/srv/jenkins/features/domains"
+export DISPLAY=${TARGET_DISPLAY}
+check_dependencies cucumber
+
+# cludge ruby to stop buffering output
+RUBY_STDOUT_SYNC=$TMPDIR/.stdout-sync.rb
+echo STDOUT.sync = true > $RUBY_STDOUT_SYNC
+export RUBYOPT="-r $RUBY_STDOUT_SYNC"
+
+cucumber ${@}
diff --git a/cucumber/features/config/defaults.yml b/cucumber/features/config/defaults.yml
new file mode 100644
index 00000000..9c312146
--- /dev/null
+++ b/cucumber/features/config/defaults.yml
@@ -0,0 +1,36 @@
+CAPTURE: false
+CAPTURE_ALL: false
+MAX_NEW_TOR_CIRCUIT_RETRIES: 10
+PAUSE_ON_FAIL: false
+SIKULI_RETRY_FINDFAILED: false
+TMPDIR: "/tmp/DebianToaster"
+
+Unsafe_SSH_private_key: |
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEowIBAAKCAQEAvMUNgUUM/kyuo26m+Xw7igG6zgGFMFbS3u8m5StGsJOn7zLi
+ J8P5Mml/R+4tdOS6owVU4RaZTPsNZZK/ClYmOPhmNvJ04pVChk2DZ8AARg/TANj3
+ qjKs3D+MeKbk1bt6EsA55kgGsTUky5Ti8cc2Wna25jqjagIiyM822PGG9mmI6/zL
+ YR6QLUizNaciXrRM3Q4R4sQkEreVlHeonPEiGUs9zx0swCpLtPM5UIYte1PVHgkw
+ ePsU6vM8UqVTK/VwtLLgLanXnsMFuzq7DTAXPq49+XSFNq4JlxbEF6+PQXZvYZ5N
+ eW00Gq7NSpPP8uoHr6f1J+mMxxnM85jzYtRx+QIDAQABAoIBAA8Bs1MlhCTrP67q
+ awfGYo1UGd+qq0XugREL/hGV4SbEdkNDzkrO/46MaHv1aVOzo0q2b8r9Gu7NvoDm
+ q51Mv/kjdizEFZq1tvYqT1n+H4dyVpnopbe4E5nmy2oECokbQFchRPkTnMSVrvko
+ OupxpdaHPX8MBlW1GcLRBlE00j/gfK1SXX5rcxkF5EHVND1b6iHddTPearDbU8yr
+ wga1XO6WeohAYzqmGtMD0zk6lOk0LmnTNG6WvHiFTAc/0yTiKub6rNOIEMS/82+V
+ l437H0hKcIN/7/mf6FpqRNPJTuhOVFf+L4G/ZQ8zHoMGVIbhuTiIPqZ/KMu3NaUF
+ R634jckCgYEA+jJ31hom/d65LfxWPkmiSkNTEOTfjbfcgpfc7sS3enPsYnfnmn5L
+ O3JJzAKShSVP8NVuPN5Mg5FGp9QLKrN3kV6QWQ3EnqeW748DXMU6zKGJQ5wo7ZVm
+ w2DhJ/3PAuBTL/5X4mjPQL+dr86Aq2JBDC7LHJs40I8O7UbhnsdMxKcCgYEAwSXc
+ 3znAkAX8o2g37RiAl36HdONgxr2eaGK7OExp03pbKmoISw6bFbVpicBy6eTytn0A
+ 2PuFcBKJRfKrViHyiE8UfAJ31JbUaxpg4bFF6UEszN4CmgKS8fnwEe1aX0qSjvkE
+ NQSuhN5AfykXY/1WVIaWuC500uB7Ow6M16RDyF8CgYEAqFTeNYlg5Hs+Acd9SukF
+ rItBTuN92P5z+NUtyuNFQrjNuK5Nf68q9LL/Hag5ZiVldHZUddVmizpp3C6Y2MDo
+ WEDUQ2Y0/D1rGoAQ1hDIb7bbAEcHblmPSzJaKirkZV4B+g9Yl7bGghypfggkn6o6
+ c3TkKLnybrdhZpjC4a3bY48CgYBnWRYdD27c4Ycz/GDoaZLs/NQIFF5FGVL4cdPR
+ pPl/IdpEEKZNWwxaik5lWedjBZFlWe+pKrRUqmZvWhCZruJyUzYXwM5Tnz0b7epm
+ +Q76Z1hMaoKj27q65UyymvkfQey3ucCpic7D45RJNjiA1R5rbfSZqqnx6BGoIPn1
+ rLxkKwKBgDXiWeUKJCydj0NfHryGBkQvaDahDE3Yigcma63b8vMZPBrJSC4SGAHJ
+ NWema+bArbaF0rKVJpwvpkZWGcr6qRn94Ts0kJAzR+VIVTOjB9sVwdxjadwWHRs5
+ kKnpY0tnSF7hyVRwN7GOsNDJEaFjCW7k4+55D2ZNBy2iN3beW8CZ
+ -----END RSA PRIVATE KEY-----
+Unsafe_SSH_public_key: = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8xQ2BRQz+TK6jbqb5fDuKAbrOAYUwVtLe7yblK0awk6fvMuInw/kyaX9H7i105LqjBVThFplM+w1lkr8KViY4+GY28nTilUKGTYNnwABGD9MA2PeqMqzcP4x4puTVu3oSwDnmSAaxNSTLlOLxxzZadrbmOqNqAiLIzzbY8Yb2aYjr/MthHpAtSLM1pyJetEzdDhHixCQSt5WUd6ic8SIZSz3PHSzAKku08zlQhi17U9UeCTB4+xTq8zxSpVMr9XC0suAtqdeewwW7OrsNMBc+rj35dIU2rgmXFsQXr49Bdm9hnk15bTQars1Kk8/y6gevp/Un6YzHGczzmPNi1HH5 amnesia@amnesia"
diff --git a/cucumber/features/domains/default.xml b/cucumber/features/domains/default.xml
new file mode 100644
index 00000000..f1004dcf
--- /dev/null
+++ b/cucumber/features/domains/default.xml
@@ -0,0 +1,59 @@
+<domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
+ <name>DebianToaster</name>
+ <memory unit='KiB'>1310720</memory>
+ <currentMemory unit='KiB'>1310720</currentMemory>
+ <vcpu>1</vcpu>
+ <os>
+ <type arch='x86_64' machine='pc-0.15'>hvm</type>
+ <boot dev='cdrom'/>
+ </os>
+ <features>
+ <acpi/>
+ <apic/>
+ <pae/>
+ </features>
+ <cpu mode='host-model'/>
+ <clock offset='utc'/>
+ <on_poweroff>destroy</on_poweroff>
+ <on_reboot>restart</on_reboot>
+ <on_crash>restart</on_crash>
+ <devices>
+ <emulator>/usr/bin/qemu-system-x86_64</emulator>
+ <disk type='file' device='cdrom'>
+ <driver name='qemu' type='raw'/>
+ <source file=''/>
+ <target dev='hdc' bus='ide'/>
+ <readonly/>
+ </disk>
+ <controller type='usb' index='0' model='ich9-ehci1'/>
+ <controller type='usb' index='0' model='ich9-uhci1'>
+ <master startport='0'/>
+ </controller>
+ <controller type='ide' index='0'/>
+ <controller type='virtio-serial' index='0'/>
+ <interface type='network'>
+ <mac address='52:54:00:ac:dd:ee'/>
+ <source network='DebianToasterNet'/>
+ <model type='virtio'/>
+ <link state='up'/>
+ </interface>
+ <serial type='tcp'>
+ <source mode="bind" host='127.0.0.1' service='1337'/>
+ <target port='0'/>
+ </serial>
+ <input type='tablet' bus='usb'/>
+ <channel type='spicevmc'>
+ <target type='virtio' name='com.redhat.spice.0'/>
+ </channel>
+ <graphics type='spice' port='-1' tlsPort='-1' autoport='yes'>
+ <mouse mode='client'/>
+ </graphics>
+ <sound model='ich6'/>
+ <video>
+ <model type='qxl' ram='65536' vram='131072' vgamem='16384' heads='1'/>
+ <address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/>
+ </video>
+ <memballoon model='virtio'/>
+ </devices>
+</domain>
+
diff --git a/cucumber/features/domains/default_net.xml b/cucumber/features/domains/default_net.xml
new file mode 100644
index 00000000..fd2966eb
--- /dev/null
+++ b/cucumber/features/domains/default_net.xml
@@ -0,0 +1,13 @@
+<network>
+ <name>DebianToasterNet</name>
+ <forward mode='nat'/>
+ <bridge name='virbr10' stp='on' delay='0' />
+ <ip address='10.2.1.1' netmask='255.255.255.0'>
+ <dhcp>
+ <range start='10.2.1.2' end='10.2.1.254' />
+ <host mac="52:54:00:ac:dd:ee" name="amnesia" ip="10.2.1.2" />
+ </dhcp>
+ </ip>
+ <ip family="ipv6" address="fc00::1" prefix="7" />
+</network>
+
diff --git a/cucumber/features/domains/disk.xml b/cucumber/features/domains/disk.xml
new file mode 100644
index 00000000..8193fea3
--- /dev/null
+++ b/cucumber/features/domains/disk.xml
@@ -0,0 +1,5 @@
+<disk type='file' device='disk'>
+ <driver name='qemu' type=''/>
+ <source file=''/>
+ <target dev='' bus=''/>
+</disk>
diff --git a/cucumber/features/domains/fs_share.xml b/cucumber/features/domains/fs_share.xml
new file mode 100644
index 00000000..718755ea
--- /dev/null
+++ b/cucumber/features/domains/fs_share.xml
@@ -0,0 +1,6 @@
+<filesystem type='mount' accessmode='passthrough'>
+ <driver type='path' wrpolicy='immediate'/>
+ <source dir=''/>
+ <target dir=''/>
+ <readonly/>
+</filesystem>
diff --git a/cucumber/features/domains/storage_pool.xml b/cucumber/features/domains/storage_pool.xml
new file mode 100644
index 00000000..ce0a6915
--- /dev/null
+++ b/cucumber/features/domains/storage_pool.xml
@@ -0,0 +1,6 @@
+<pool type="dir">
+ <name>DebianToasterStorage</name>
+ <target>
+ <path></path>
+ </target>
+</pool>
diff --git a/cucumber/features/domains/volume.xml b/cucumber/features/domains/volume.xml
new file mode 100644
index 00000000..702d5a05
--- /dev/null
+++ b/cucumber/features/domains/volume.xml
@@ -0,0 +1,14 @@
+<volume>
+ <name></name>
+ <allocation>0</allocation>
+ <capacity unit="b"></capacity>
+ <target>
+ <path></path>
+ <format type='qcow2'/>
+ <permissions>
+ <owner></owner>
+ <group></group>
+ <mode>0664</mode>
+ </permissions>
+ </target>
+</volume>
diff --git a/cucumber/features/images/DebianInstallerCountrySelection.png b/cucumber/features/images/DebianInstallerCountrySelection.png
new file mode 100644
index 00000000..fe130993
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerCountrySelection.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerDomainPrompt.png b/cucumber/features/images/DebianInstallerDomainPrompt.png
new file mode 100644
index 00000000..d7fca5f8
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerDomainPrompt.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerHostnamePrompt.png b/cucumber/features/images/DebianInstallerHostnamePrompt.png
new file mode 100644
index 00000000..f1325c8d
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerHostnamePrompt.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerHttpProxy.png b/cucumber/features/images/DebianInstallerHttpProxy.png
new file mode 100644
index 00000000..04b3e13e
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerHttpProxy.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerInstallingBaseSystem.png b/cucumber/features/images/DebianInstallerInstallingBaseSystem.png
new file mode 100644
index 00000000..0b9e1c7f
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerInstallingBaseSystem.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerMirrorCountry.png b/cucumber/features/images/DebianInstallerMirrorCountry.png
new file mode 100644
index 00000000..9b4df5ea
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerMirrorCountry.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerNameOfUser.png b/cucumber/features/images/DebianInstallerNameOfUser.png
new file mode 100644
index 00000000..e37c7ec4
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerNameOfUser.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerNoDiskFound.png b/cucumber/features/images/DebianInstallerNoDiskFound.png
new file mode 100644
index 00000000..671f52d6
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerNoDiskFound.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerPartitioningMethod.png b/cucumber/features/images/DebianInstallerPartitioningMethod.png
new file mode 100644
index 00000000..9e44360e
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerPartitioningMethod.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerPartitioningScheme.png b/cucumber/features/images/DebianInstallerPartitioningScheme.png
new file mode 100644
index 00000000..97105b62
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerPartitioningScheme.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerRootPassword.png b/cucumber/features/images/DebianInstallerRootPassword.png
new file mode 100644
index 00000000..27368fd7
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerRootPassword.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerSelectDiskToPartition.png b/cucumber/features/images/DebianInstallerSelectDiskToPartition.png
new file mode 100644
index 00000000..1f14bb1a
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerSelectDiskToPartition.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerSelectLangEnglish.png b/cucumber/features/images/DebianInstallerSelectLangEnglish.png
new file mode 100644
index 00000000..85f848d5
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerSelectLangEnglish.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerSelectLangEnglishUK.png b/cucumber/features/images/DebianInstallerSelectLangEnglishUK.png
new file mode 100644
index 00000000..c0da761f
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerSelectLangEnglishUK.png
Binary files differ
diff --git a/cucumber/features/images/DebianInstallerUserPassword.png b/cucumber/features/images/DebianInstallerUserPassword.png
new file mode 100644
index 00000000..bf9964aa
--- /dev/null
+++ b/cucumber/features/images/DebianInstallerUserPassword.png
Binary files differ
diff --git a/cucumber/features/images/DebianLive7BootSplash.png b/cucumber/features/images/DebianLive7BootSplash.png
new file mode 100644
index 00000000..b64353aa
--- /dev/null
+++ b/cucumber/features/images/DebianLive7BootSplash.png
Binary files differ
diff --git a/cucumber/features/images/DebianLive7BootSplashTabMsg.png b/cucumber/features/images/DebianLive7BootSplashTabMsg.png
new file mode 100644
index 00000000..150830b7
--- /dev/null
+++ b/cucumber/features/images/DebianLive7BootSplashTabMsg.png
Binary files differ
diff --git a/cucumber/features/images/DebianLive7Greeter.png b/cucumber/features/images/DebianLive7Greeter.png
new file mode 100644
index 00000000..f1afaabe
--- /dev/null
+++ b/cucumber/features/images/DebianLive7Greeter.png
Binary files differ
diff --git a/cucumber/features/images/DebianLiveBootSplash.png b/cucumber/features/images/DebianLiveBootSplash.png
new file mode 100644
index 00000000..11ee1494
--- /dev/null
+++ b/cucumber/features/images/DebianLiveBootSplash.png
Binary files differ
diff --git a/cucumber/features/images/DebianLiveBootSplashTabMsg.png b/cucumber/features/images/DebianLiveBootSplashTabMsg.png
new file mode 100644
index 00000000..cdddaf1d
--- /dev/null
+++ b/cucumber/features/images/DebianLiveBootSplashTabMsg.png
Binary files differ
diff --git a/cucumber/features/images/DebianLoginPromptVT.png b/cucumber/features/images/DebianLoginPromptVT.png
new file mode 100644
index 00000000..ec267820
--- /dev/null
+++ b/cucumber/features/images/DebianLoginPromptVT.png
Binary files differ
diff --git a/cucumber/features/images/d-i8_bootsplash.png b/cucumber/features/images/d-i8_bootsplash.png
new file mode 100644
index 00000000..086c65cb
--- /dev/null
+++ b/cucumber/features/images/d-i8_bootsplash.png
Binary files differ
diff --git a/cucumber/features/images/d-i_ArchiveMirror.png b/cucumber/features/images/d-i_ArchiveMirror.png
new file mode 100644
index 00000000..7e53f189
--- /dev/null
+++ b/cucumber/features/images/d-i_ArchiveMirror.png
Binary files differ
diff --git a/cucumber/features/images/d-i_ChooseSoftware.png b/cucumber/features/images/d-i_ChooseSoftware.png
new file mode 100644
index 00000000..93447158
--- /dev/null
+++ b/cucumber/features/images/d-i_ChooseSoftware.png
Binary files differ
diff --git a/cucumber/features/images/d-i_DesktopTask_No.png b/cucumber/features/images/d-i_DesktopTask_No.png
new file mode 100644
index 00000000..6dbf9df4
--- /dev/null
+++ b/cucumber/features/images/d-i_DesktopTask_No.png
Binary files differ
diff --git a/cucumber/features/images/d-i_DesktopTask_Yes.png b/cucumber/features/images/d-i_DesktopTask_Yes.png
new file mode 100644
index 00000000..02cbaa5d
--- /dev/null
+++ b/cucumber/features/images/d-i_DesktopTask_Yes.png
Binary files differ
diff --git a/cucumber/features/images/d-i_F12BootMenu.png b/cucumber/features/images/d-i_F12BootMenu.png
new file mode 100644
index 00000000..67a21856
--- /dev/null
+++ b/cucumber/features/images/d-i_F12BootMenu.png
Binary files differ
diff --git a/cucumber/features/images/d-i_FinishPartitioning.png b/cucumber/features/images/d-i_FinishPartitioning.png
new file mode 100644
index 00000000..50396500
--- /dev/null
+++ b/cucumber/features/images/d-i_FinishPartitioning.png
Binary files differ
diff --git a/cucumber/features/images/d-i_GRUBEnterDev.png b/cucumber/features/images/d-i_GRUBEnterDev.png
new file mode 100644
index 00000000..6df484ed
--- /dev/null
+++ b/cucumber/features/images/d-i_GRUBEnterDev.png
Binary files differ
diff --git a/cucumber/features/images/d-i_GRUB_Debian.png b/cucumber/features/images/d-i_GRUB_Debian.png
new file mode 100644
index 00000000..3b67cfbe
--- /dev/null
+++ b/cucumber/features/images/d-i_GRUB_Debian.png
Binary files differ
diff --git a/cucumber/features/images/d-i_GRUBdev.png b/cucumber/features/images/d-i_GRUBdev.png
new file mode 100644
index 00000000..9d554d74
--- /dev/null
+++ b/cucumber/features/images/d-i_GRUBdev.png
Binary files differ
diff --git a/cucumber/features/images/d-i_HttpProxy.png b/cucumber/features/images/d-i_HttpProxy.png
new file mode 100644
index 00000000..4163a5b3
--- /dev/null
+++ b/cucumber/features/images/d-i_HttpProxy.png
Binary files differ
diff --git a/cucumber/features/images/d-i_InstallComplete.png b/cucumber/features/images/d-i_InstallComplete.png
new file mode 100644
index 00000000..a8564464
--- /dev/null
+++ b/cucumber/features/images/d-i_InstallComplete.png
Binary files differ
diff --git a/cucumber/features/images/d-i_InstallGRUB.png b/cucumber/features/images/d-i_InstallGRUB.png
new file mode 100644
index 00000000..e491fbd1
--- /dev/null
+++ b/cucumber/features/images/d-i_InstallGRUB.png
Binary files differ
diff --git a/cucumber/features/images/d-i_No.png b/cucumber/features/images/d-i_No.png
new file mode 100644
index 00000000..1108addc
--- /dev/null
+++ b/cucumber/features/images/d-i_No.png
Binary files differ
diff --git a/cucumber/features/images/d-i_ScanCD.png b/cucumber/features/images/d-i_ScanCD.png
new file mode 100644
index 00000000..5790bcce
--- /dev/null
+++ b/cucumber/features/images/d-i_ScanCD.png
Binary files differ
diff --git a/cucumber/features/images/d-i_SelectBootDev.png b/cucumber/features/images/d-i_SelectBootDev.png
new file mode 100644
index 00000000..7abef3ec
--- /dev/null
+++ b/cucumber/features/images/d-i_SelectBootDev.png
Binary files differ
diff --git a/cucumber/features/images/d-i_UseNetMirror.png b/cucumber/features/images/d-i_UseNetMirror.png
new file mode 100644
index 00000000..2b41228b
--- /dev/null
+++ b/cucumber/features/images/d-i_UseNetMirror.png
Binary files differ
diff --git a/cucumber/features/images/d-i_Yes.png b/cucumber/features/images/d-i_Yes.png
new file mode 100644
index 00000000..17fab5b9
--- /dev/null
+++ b/cucumber/features/images/d-i_Yes.png
Binary files differ
diff --git a/cucumber/features/images/d-i_popcon.png b/cucumber/features/images/d-i_popcon.png
new file mode 100644
index 00000000..ed0ba618
--- /dev/null
+++ b/cucumber/features/images/d-i_popcon.png
Binary files differ
diff --git a/cucumber/features/install.feature b/cucumber/features/install.feature
new file mode 100644
index 00000000..3b71b6c2
--- /dev/null
+++ b/cucumber/features/install.feature
@@ -0,0 +1,14 @@
+@product
+Feature: Doing a trivial d-i install
+ As a normal user
+ I should be able to do a text-mode install
+
+ Scenario Outline: Install Debian and boot to login prompt
+ Given I have installed <type> Debian
+ And I start the computer
+ Then I wait for a Login Prompt
+
+ Examples:
+ | type |
+ | Minimal |
+ | Gnome Desktop |
diff --git a/cucumber/features/misc_files/sample.pdf b/cucumber/features/misc_files/sample.pdf
new file mode 100644
index 00000000..d0cc9502
--- /dev/null
+++ b/cucumber/features/misc_files/sample.pdf
Binary files differ
diff --git a/cucumber/features/misc_files/sample.tex b/cucumber/features/misc_files/sample.tex
new file mode 100644
index 00000000..043faaec
--- /dev/null
+++ b/cucumber/features/misc_files/sample.tex
@@ -0,0 +1,8 @@
+\documentclass[12pt]{article}
+\title{Sample PDF document}
+\author{Tails developers}
+\date{March 12, 2013}
+\begin{document}
+\maketitle
+Does this PDF still have metadata?
+\end{document}
diff --git a/cucumber/features/scripts/otr-bot.py b/cucumber/features/scripts/otr-bot.py
new file mode 100755
index 00000000..0afd15a4
--- /dev/null
+++ b/cucumber/features/scripts/otr-bot.py
@@ -0,0 +1,206 @@
+#!/usr/bin/python
+import sys
+import jabberbot
+import xmpp
+import potr
+import logging
+from argparse import ArgumentParser
+
+class OtrContext(potr.context.Context):
+
+ def __init__(self, account, peer):
+ super(OtrContext, self).__init__(account, peer)
+
+ def getPolicy(self, key):
+ return True
+
+ def inject(self, msg, appdata = None):
+ mess = appdata["base_reply"]
+ mess.setBody(msg)
+ appdata["send_raw_message_fn"](mess)
+
+
+class BotAccount(potr.context.Account):
+
+ def __init__(self, jid, keyFilePath):
+ protocol = 'xmpp'
+ max_message_size = 10*1024
+ super(BotAccount, self).__init__(jid, protocol, max_message_size)
+ self.keyFilePath = keyFilePath
+
+ def loadPrivkey(self):
+ with open(self.keyFilePath, 'rb') as keyFile:
+ return potr.crypt.PK.parsePrivateKey(keyFile.read())[0]
+
+
+class OtrContextManager:
+
+ def __init__(self, jid, keyFilePath):
+ self.account = BotAccount(jid, keyFilePath)
+ self.contexts = {}
+
+ def start_context(self, other):
+ if not other in self.contexts:
+ self.contexts[other] = OtrContext(self.account, other)
+ return self.contexts[other]
+
+ def get_context_for_user(self, other):
+ return self.start_context(other)
+
+
+class OtrBot(jabberbot.JabberBot):
+
+ PING_FREQUENCY = 60
+
+ def __init__(self, account, password, otr_key_path,
+ connect_server = None, log_file = None):
+ self.__connect_server = connect_server
+ self.__password = password
+ self.__log_file = log_file
+ super(OtrBot, self).__init__(account, password)
+ self.__otr_manager = OtrContextManager(account, otr_key_path)
+ self.send_raw_message_fn = super(OtrBot, self).send_message
+ self.__default_otr_appdata = {
+ "send_raw_message_fn": self.send_raw_message_fn
+ }
+
+ def __otr_appdata_for_mess(self, mess):
+ appdata = self.__default_otr_appdata.copy()
+ appdata["base_reply"] = mess
+ return appdata
+
+ # Unfortunately Jabberbot's connect() is not very friendly to
+ # overriding in subclasses so we have to re-implement it
+ # completely (copy-paste mostly) in order to add support for using
+ # an XMPP "Connect Server".
+ def connect(self):
+ logging.basicConfig(filename = self.__log_file,
+ level = logging.DEBUG)
+ if not self.conn:
+ conn = xmpp.Client(self.jid.getDomain(), debug=[])
+ if self.__connect_server:
+ try:
+ conn_server, conn_port = self.__connect_server.split(":", 1)
+ except ValueError:
+ conn_server = self.__connect_server
+ conn_port = 5222
+ conres = conn.connect((conn_server, int(conn_port)))
+ else:
+ conres = conn.connect()
+ if not conres:
+ return None
+ authres = conn.auth(self.jid.getNode(), self.__password, self.res)
+ if not authres:
+ return None
+ self.conn = conn
+ self.conn.sendInitPresence()
+ self.roster = self.conn.Roster.getRoster()
+ for (handler, callback) in self.handlers:
+ self.conn.RegisterHandler(handler, callback)
+ return self.conn
+
+ # Wrap OTR encryption around Jabberbot's most low-level method for
+ # sending messages.
+ def send_message(self, mess):
+ body = str(mess.getBody())
+ user = str(mess.getTo().getStripped())
+ otrctx = self.__otr_manager.get_context_for_user(user)
+ if otrctx.state == potr.context.STATE_ENCRYPTED:
+ otrctx.sendMessage(potr.context.FRAGMENT_SEND_ALL, body,
+ appdata = self.__otr_appdata_for_mess(mess))
+ else:
+ self.send_raw_message_fn(mess)
+
+ # Wrap OTR decryption around Jabberbot's callback mechanism.
+ def callback_message(self, conn, mess):
+ body = str(mess.getBody())
+ user = str(mess.getFrom().getStripped())
+ otrctx = self.__otr_manager.get_context_for_user(user)
+ if mess.getType() == "chat":
+ try:
+ appdata = self.__otr_appdata_for_mess(mess.buildReply())
+ decrypted_body, tlvs = otrctx.receiveMessage(body,
+ appdata = appdata)
+ otrctx.processTLVs(tlvs)
+ except potr.context.NotEncryptedError:
+ otrctx.authStartV2(appdata = appdata)
+ return
+ except (potr.context.UnencryptedMessage, potr.context.NotOTRMessage):
+ decrypted_body = body
+ else:
+ decrypted_body = body
+ if decrypted_body == None:
+ return
+ if mess.getType() == "groupchat":
+ bot_prefix = self.jid.getNode() + ": "
+ if decrypted_body.startswith(bot_prefix):
+ decrypted_body = decrypted_body[len(bot_prefix):]
+ else:
+ return
+ mess.setBody(decrypted_body)
+ super(OtrBot, self).callback_message(conn, mess)
+
+ # Override Jabberbot quitting on keep alive failure.
+ def on_ping_timeout(self):
+ self.__lastping = None
+
+ @jabberbot.botcmd
+ def ping(self, mess, args):
+ """Why not just test it?"""
+ return "pong"
+
+ @jabberbot.botcmd
+ def say(self, mess, args):
+ """Unleash my inner parrot"""
+ return args
+
+ @jabberbot.botcmd
+ def clear_say(self, mess, args):
+ """Make me speak in the clear even if we're in an OTR chat"""
+ self.send_raw_message_fn(mess.buildReply(args))
+ return ""
+
+ @jabberbot.botcmd
+ def start_otr(self, mess, args):
+ """Make me *initiate* (but not refresh) an OTR session"""
+ if mess.getType() == "groupchat":
+ return
+ return "?OTRv2?"
+
+ @jabberbot.botcmd
+ def end_otr(self, mess, args):
+ """Make me gracefully end the OTR session if there is one"""
+ if mess.getType() == "groupchat":
+ return
+ user = str(mess.getFrom().getStripped())
+ self.__otr_manager.get_context_for_user(user).disconnect(appdata =
+ self.__otr_appdata_for_mess(mess.buildReply()))
+ return ""
+
+if __name__ == '__main__':
+ parser = ArgumentParser()
+ parser.add_argument("account",
+ help = "the user account, given as user@domain")
+ parser.add_argument("password",
+ help = "the user account's password")
+ parser.add_argument("otr_key_path",
+ help = "the path to the account's OTR key file")
+ parser.add_argument("-c", "--connect-server", metavar = 'ADDRESS',
+ help = "use a Connect Server, given as host[:port] " +
+ "(port defaults to 5222)")
+ parser.add_argument("-j", "--auto-join", nargs = '+', metavar = 'ROOMS',
+ help = "auto-join multi-user chatrooms on start")
+ parser.add_argument("-l", "--log-file", metavar = 'LOGFILE',
+ help = "Log to file instead of stderr")
+ args = parser.parse_args()
+ otr_bot_opt_args = dict()
+ if args.connect_server:
+ otr_bot_opt_args["connect_server"] = args.connect_server
+ if args.log_file:
+ otr_bot_opt_args["log_file"] = args.log_file
+ otr_bot = OtrBot(args.account, args.password, args.otr_key_path,
+ **otr_bot_opt_args)
+ if args.auto_join:
+ for room in args.auto_join:
+ otr_bot.join_room(room)
+ otr_bot.serve_forever()
diff --git a/cucumber/features/scripts/vm-execute b/cucumber/features/scripts/vm-execute
new file mode 100755
index 00000000..fc1bf459
--- /dev/null
+++ b/cucumber/features/scripts/vm-execute
@@ -0,0 +1,52 @@
+#!/usr/bin/env ruby
+
+require 'optparse'
+begin
+ require "#{`git rev-parse --show-toplevel`.chomp}/features/support/helpers/exec_helper.rb"
+rescue LoadError => e
+ raise "This script must be run from within Tails' Git directory."
+end
+$config = Hash.new
+
+def debug_log(*args) ; end
+
+class FakeVM
+ def get_remote_shell_port
+ 1337
+ end
+end
+
+cmd_opts = {
+ :spawn => false,
+ :user => "root"
+}
+
+opt_parser = OptionParser.new do |opts|
+ opts.banner = "Usage: features/scripts/vm-execute [opts] COMMAND"
+ opts.separator ""
+ opts.separator "Runs commands in the VM guest being tested. This script " \
+ "must be run from within Tails' Git directory."
+ opts.separator ""
+ opts.separator "Options:"
+
+ opts.on("-h", "--help", "Show this message") do
+ puts opts
+ exit
+ end
+
+ opts.on("-u", "--user USER", "Run command as USER") do |user|
+ cmd_opts[:user] = user
+ end
+
+ opts.on("-s", "--spawn",
+ "Run command in non-blocking mode") do |type|
+ cmd_opts[:spawn] = true
+ end
+end
+opt_parser.parse!(ARGV)
+cmd = ARGV.join(" ")
+c = VMCommand.new(FakeVM.new, cmd, cmd_opts)
+puts "Return status: #{c.returncode}"
+puts "STDOUT:\n#{c.stdout}"
+puts "STDERR:\n#{c.stderr}"
+exit c.returncode
diff --git a/cucumber/features/step_definitions/apt.rb b/cucumber/features/step_definitions/apt.rb
new file mode 100644
index 00000000..c69d2598
--- /dev/null
+++ b/cucumber/features/step_definitions/apt.rb
@@ -0,0 +1,56 @@
+require 'uri'
+
+Given /^the only hosts in APT sources are "([^"]*)"$/ do |hosts_str|
+ hosts = hosts_str.split(',')
+ $vm.file_content("/etc/apt/sources.list /etc/apt/sources.list.d/*").chomp.each_line { |line|
+ next if ! line.start_with? "deb"
+ source_host = URI(line.split[1]).host
+ if !hosts.include?(source_host)
+ raise "Bad APT source '#{line}'"
+ end
+ }
+end
+
+When /^I update APT using apt$/ do
+ Timeout::timeout(30*60) do
+ $vm.execute_successfully("echo #{@sudo_password} | " +
+ "sudo -S apt update", :user => LIVE_USER)
+ end
+end
+
+Then /^I should be able to install a package using apt$/ do
+ package = "cowsay"
+ Timeout::timeout(120) do
+ $vm.execute_successfully("echo #{@sudo_password} | " +
+ "sudo -S apt install #{package}",
+ :user => LIVE_USER)
+ end
+ step "package \"#{package}\" is installed"
+end
+
+When /^I update APT using Synaptic$/ do
+ @screen.click('SynapticReloadButton.png')
+ @screen.wait('SynapticReloadPrompt.png', 20)
+ @screen.waitVanish('SynapticReloadPrompt.png', 30*60)
+end
+
+Then /^I should be able to install a package using Synaptic$/ do
+ package = "cowsay"
+ try_for(60) do
+ @screen.wait_and_click('SynapticSearchButton.png', 10)
+ @screen.wait_and_click('SynapticSearchWindow.png', 10)
+ end
+ @screen.type(package + Sikuli::Key.ENTER)
+ @screen.wait_and_double_click('SynapticCowsaySearchResult.png', 20)
+ @screen.wait_and_click('SynapticApplyButton.png', 10)
+ @screen.wait('SynapticApplyPrompt.png', 60)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait('SynapticChangesAppliedPrompt.png', 240)
+ step "package \"#{package}\" is installed"
+end
+
+When /^I start Synaptic$/ do
+ step 'I start "Synaptic" via the GNOME "System" applications menu'
+ deal_with_polkit_prompt('PolicyKitAuthPrompt.png', @sudo_password)
+ @screen.wait('SynapticReloadButton.png', 30)
+end
diff --git a/cucumber/features/step_definitions/browser.rb b/cucumber/features/step_definitions/browser.rb
new file mode 100644
index 00000000..84ef1d35
--- /dev/null
+++ b/cucumber/features/step_definitions/browser.rb
@@ -0,0 +1,195 @@
+Then /^I see the (Unsafe|I2P) Browser start notification and wait for it to close$/ do |browser_type|
+ robust_notification_wait("#{browser_type}BrowserStartNotification.png", 60)
+end
+
+Then /^the (Unsafe|I2P) Browser has started$/ do |browser_type|
+ case browser_type
+ when 'Unsafe'
+ @screen.wait("UnsafeBrowserHomepage.png", 360)
+ when 'I2P'
+ step 'the I2P router console is displayed in I2P Browser'
+ end
+end
+
+When /^I start the (Unsafe|I2P) Browser(?: through the GNOME menu)?$/ do |browser_type|
+ step "I start \"#{browser_type}Browser\" via the GNOME \"Internet\" applications menu"
+end
+
+When /^I successfully start the (Unsafe|I2P) Browser$/ do |browser_type|
+ step "I start the #{browser_type} Browser"
+ step "I see and accept the Unsafe Browser start verification" unless browser_type == 'I2P'
+ step "I see the #{browser_type} Browser start notification and wait for it to close"
+ step "the #{browser_type} Browser has started"
+end
+
+When /^I close the (?:Unsafe|I2P) Browser$/ do
+ @screen.type("q", Sikuli::KeyModifier.CTRL)
+end
+
+Then /^I see the (Unsafe|I2P) Browser stop notification$/ do |browser_type|
+ robust_notification_wait("#{browser_type}BrowserStopNotification.png", 60)
+end
+
+def xul_application_info(application)
+ binary = $vm.execute_successfully(
+ 'echo ${TBB_INSTALL}/firefox', :libs => 'tor-browser'
+ ).stdout.chomp
+ address_bar_image = "BrowserAddressBar.png"
+ unused_tbb_libs = ['libnssdbm3.so']
+ case application
+ when "Tor Browser"
+ user = LIVE_USER
+ cmd_regex = "#{binary} .* -profile /home/#{user}/\.tor-browser/profile\.default"
+ chroot = ""
+ new_tab_button_image = "TorBrowserNewTabButton.png"
+ when "Unsafe Browser"
+ user = "clearnet"
+ cmd_regex = "#{binary} .* -profile /home/#{user}/\.unsafe-browser/profile\.default"
+ chroot = "/var/lib/unsafe-browser/chroot"
+ new_tab_button_image = "UnsafeBrowserNewTabButton.png"
+ when "I2P Browser"
+ user = "i2pbrowser"
+ cmd_regex = "#{binary} .* -profile /home/#{user}/\.i2p-browser/profile\.default"
+ chroot = "/var/lib/i2p-browser/chroot"
+ new_tab_button_image = "I2PBrowserNewTabButton.png"
+ when "Tor Launcher"
+ user = "tor-launcher"
+ # We do not enable AppArmor confinement for the Tor Launcher.
+ binary = "#{binary}-unconfined"
+ tor_launcher_install = $vm.execute_successfully(
+ 'echo ${TOR_LAUNCHER_INSTALL}', :libs => 'tor-browser'
+ ).stdout.chomp
+ cmd_regex = "#{binary}\s+-app #{tor_launcher_install}/application\.ini.*"
+ chroot = ""
+ new_tab_button_image = nil
+ address_bar_image = nil
+ # The standalone Tor Launcher uses fewer libs than the full
+ # browser.
+ unused_tbb_libs.concat(["libfreebl3.so", "libnssckbi.so", "libsoftokn3.so"])
+ else
+ raise "Invalid browser or XUL application: #{application}"
+ end
+ return {
+ :user => user,
+ :cmd_regex => cmd_regex,
+ :chroot => chroot,
+ :new_tab_button_image => new_tab_button_image,
+ :address_bar_image => address_bar_image,
+ :unused_tbb_libs => unused_tbb_libs,
+ }
+end
+
+When /^I open a new tab in the (.*)$/ do |browser|
+ info = xul_application_info(browser)
+ @screen.click(info[:new_tab_button_image])
+ @screen.wait(info[:address_bar_image], 10)
+end
+
+When /^I open the address "([^"]*)" in the (.*)$/ do |address, browser|
+ step "I open a new tab in the #{browser}"
+ info = xul_application_info(browser)
+ open_address = Proc.new do
+ @screen.click(info[:address_bar_image])
+ # This static here since we have no reliable visual indicators
+ # that we can watch to know when typing is "safe".
+ sleep 5
+ # The browser sometimes loses keypresses when suggestions are
+ # shown, which we work around by pasting the address from the
+ # clipboard, in one go.
+ $vm.set_clipboard(address)
+ @screen.type('v', Sikuli::KeyModifier.CTRL)
+ @screen.type(Sikuli::Key.ENTER)
+ end
+ open_address.call
+ if browser == "Tor Browser"
+ recovery_on_failure = Proc.new do
+ @screen.type(Sikuli::Key.ESC)
+ @screen.waitVanish('BrowserReloadButton.png', 3)
+ open_address.call
+ end
+ retry_tor(recovery_on_failure) do
+ @screen.wait('BrowserReloadButton.png', 120)
+ end
+ end
+end
+
+Then /^the (.*) has no plugins installed$/ do |browser|
+ step "I open the address \"about:plugins\" in the #{browser}"
+ step "I see \"TorBrowserNoPlugins.png\" after at most 30 seconds"
+end
+
+def xul_app_shared_lib_check(pid, chroot, expected_absent_tbb_libs = [])
+ absent_tbb_libs = []
+ unwanted_native_libs = []
+ tbb_libs = $vm.execute_successfully("ls -1 #{chroot}${TBB_INSTALL}/*.so",
+ :libs => 'tor-browser').stdout.split
+ firefox_pmap_info = $vm.execute("pmap --show-path #{pid}").stdout
+ for lib in tbb_libs do
+ lib_name = File.basename lib
+ if not /\W#{lib}$/.match firefox_pmap_info
+ absent_tbb_libs << lib_name
+ end
+ native_libs = $vm.execute_successfully(
+ "find /usr/lib /lib -name \"#{lib_name}\""
+ ).stdout.split
+ for native_lib in native_libs do
+ if /\W#{native_lib}$"/.match firefox_pmap_info
+ unwanted_native_libs << lib_name
+ end
+ end
+ end
+ absent_tbb_libs -= expected_absent_tbb_libs
+ assert(absent_tbb_libs.empty? && unwanted_native_libs.empty?,
+ "The loaded shared libraries for the firefox process are not the " +
+ "way we expect them.\n" +
+ "Expected TBB libs that are absent: #{absent_tbb_libs}\n" +
+ "Native libs that we don't want: #{unwanted_native_libs}")
+end
+
+Then /^the (.*) uses all expected TBB shared libraries$/ do |application|
+ info = xul_application_info(application)
+ pid = $vm.execute_successfully("pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'").stdout.chomp
+ assert(/\A\d+\z/.match(pid), "It seems like #{application} is not running")
+ xul_app_shared_lib_check(pid, info[:chroot], info[:unused_tbb_libs])
+end
+
+Then /^the (.*) chroot is torn down$/ do |browser|
+ info = xul_application_info(browser)
+ try_for(30, :msg => "The #{browser} chroot '#{info[:chroot]}' was " \
+ "not removed") do
+ !$vm.execute("test -d '#{info[:chroot]}'").success?
+ end
+end
+
+Then /^the (.*) runs as the expected user$/ do |browser|
+ info = xul_application_info(browser)
+ assert_vmcommand_success($vm.execute(
+ "pgrep --full --exact '#{info[:cmd_regex]}'"),
+ "The #{browser} is not running")
+ assert_vmcommand_success($vm.execute(
+ "pgrep --uid #{info[:user]} --full --exact '#{info[:cmd_regex]}'"),
+ "The #{browser} is not running as the #{info[:user]} user")
+end
+
+When /^I download some file in the Tor Browser$/ do
+ @some_file = 'tails-signing.key'
+ some_url = "https://tails.boum.org/#{@some_file}"
+ step "I open the address \"#{some_url}\" in the Tor Browser"
+end
+
+Then /^I get the browser download dialog$/ do
+ @screen.wait('BrowserDownloadDialog.png', 60)
+ @screen.wait('BrowserDownloadDialogSaveAsButton.png', 10)
+end
+
+When /^I save the file to the default Tor Browser download directory$/ do
+ @screen.click('BrowserDownloadDialogSaveAsButton.png')
+ @screen.wait('BrowserDownloadFileToDialog.png', 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Then /^the file is saved to the default Tor Browser download directory$/ do
+ assert_not_nil(@some_file)
+ expected_path = "/home/#{LIVE_USER}/Tor Browser/#{@some_file}"
+ try_for(10) { $vm.file_exist?(expected_path) }
+end
diff --git a/cucumber/features/step_definitions/build.rb b/cucumber/features/step_definitions/build.rb
new file mode 100644
index 00000000..fd001ff4
--- /dev/null
+++ b/cucumber/features/step_definitions/build.rb
@@ -0,0 +1,115 @@
+Given /^Tails ([[:alnum:].]+) has been released$/ do |version|
+ create_git unless git_exists?
+
+ old_branch = current_branch
+
+ fatal_system "git checkout --quiet stable"
+ old_entries = File.open('debian/changelog') { |f| f.read }
+ File.open('debian/changelog', 'w') do |changelog|
+ changelog.write(<<END_OF_CHANGELOG)
+tails (#{version}) stable; urgency=low
+
+ * New upstream release.
+
+ -- Tails developers <tails@boum.org> Tue, 31 Jan 2012 15:12:57 +0100
+
+#{old_entries}
+END_OF_CHANGELOG
+ end
+ fatal_system "git commit --quiet debian/changelog -m 'Release #{version}'"
+ fatal_system "git tag '#{version}'"
+
+ if old_branch != 'stable'
+ fatal_system "git checkout --quiet '#{old_branch}'"
+ fatal_system "git merge --quiet 'stable'"
+ end
+end
+
+Given /^Tails ([[:alnum:].-]+) has been tagged$/ do |version|
+ fatal_system "git tag '#{version}'"
+end
+
+Given /^Tails ([[:alnum:].]+) has not been released yet$/ do |version|
+ !File.exists? ".git/refs/tags/#{version}"
+end
+
+Given /^the last version mentioned in debian\/changelog is ([[:alnum:]~.]+)$/ do |version|
+ last = `dpkg-parsechangelog | awk '/^Version: / { print $2 }'`.strip
+ raise StandardError.new('dpkg-parsechangelog failed.') if $? != 0
+
+ if last != version
+ fatal_system "debchange -v '#{version}' 'New upstream release'"
+ end
+end
+
+Given %r{I am working on the ([[:alnum:]./_-]+) base branch$} do |branch|
+ create_git unless git_exists?
+
+ if current_branch != branch
+ fatal_system "git checkout --quiet '#{branch}'"
+ end
+
+ File.open('config/base_branch', 'w+') do |base_branch_file|
+ base_branch_file.write("#{branch}\n")
+ end
+end
+
+Given %r{I am working on the ([[:alnum:]./_-]+) branch based on ([[:alnum:]./_-]+)$} do |branch, base|
+ create_git unless git_exists?
+
+ if current_branch != branch
+ fatal_system "git checkout --quiet -b '#{branch}' '#{base}'"
+ end
+
+ File.open('config/base_branch', 'w+') do |base_branch_file|
+ base_branch_file.write("#{base}\n")
+ end
+end
+
+When /^I successfully run ([[:alnum:]-]+)$/ do |command|
+ @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}`
+ raise StandardError.new("#{command} failed. Exit code: #{$?}") if $? != 0
+end
+
+When /^I run ([[:alnum:]-]+)$/ do |command|
+ @output = `#{File.expand_path("../../../auto/scripts/#{command}", __FILE__)}`
+ @exit_code = $?.exitstatus
+end
+
+Then /^I should see the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite|
+ @output.should have_suite(suite)
+end
+
+Then /^I should see only the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite|
+ assert_equal(1, @output.lines.count)
+ @output.should have_suite(suite)
+end
+
+Then /^I should not see the ['"]?([[:alnum:].-]+)['"]? suite$/ do |suite|
+ @output.should_not have_suite(suite)
+end
+
+Given(/^the config\/APT_overlays\.d directory is empty$/) do
+ Dir.glob('config/APT_overlays.d/*').empty? \
+ or raise "config/APT_overlays.d/ is not empty"
+end
+
+Given(/^config\/APT_overlays\.d contains ['"]?([[:alnum:].-]+)['"]?$/) do |suite|
+ FileUtils.touch("config/APT_overlays.d/#{suite}")
+end
+
+Then(/^it should fail$/) do
+ assert_not_equal(0, @exit_code)
+end
+
+Given(/^the (config\/base_branch) file does not exist$/) do |file|
+ File.delete(file)
+end
+
+Given(/^the (config\/APT_overlays\.d) directory does not exist$/) do |dir|
+ Dir.rmdir(dir)
+end
+
+Given(/^the config\/base_branch file is empty$/) do
+ File.truncate('config/base_branch', 0)
+end
diff --git a/cucumber/features/step_definitions/checks.rb b/cucumber/features/step_definitions/checks.rb
new file mode 100644
index 00000000..423b8390
--- /dev/null
+++ b/cucumber/features/step_definitions/checks.rb
@@ -0,0 +1,252 @@
+def shipped_openpgp_keys
+ shipped_gpg_keys = $vm.execute_successfully('gpg --batch --with-colons --fingerprint --list-key', :user => LIVE_USER).stdout
+ openpgp_fingerprints = shipped_gpg_keys.scan(/^fpr:::::::::([A-Z0-9]+):$/).flatten
+ return openpgp_fingerprints
+end
+
+Then /^the OpenPGP keys shipped with Tails will be valid for the next (\d+) months$/ do |months|
+ invalid = Array.new
+ shipped_openpgp_keys.each do |key|
+ begin
+ step "the shipped OpenPGP key #{key} will be valid for the next #{months} months"
+ rescue Test::Unit::AssertionFailedError
+ invalid << key
+ next
+ end
+ end
+ assert(invalid.empty?, "The following key(s) will not be valid in #{months} months: #{invalid.join(', ')}")
+end
+
+Then /^the shipped (?:Debian repository key|OpenPGP key ([A-Z0-9]+)) will be valid for the next (\d+) months$/ do |fingerprint, max_months|
+ if fingerprint
+ cmd = 'gpg'
+ user = LIVE_USER
+ else
+ fingerprint = TAILS_DEBIAN_REPO_KEY
+ cmd = 'apt-key adv'
+ user = 'root'
+ end
+ shipped_sig_key_info = $vm.execute_successfully("#{cmd} --batch --list-key #{fingerprint}", :user => user).stdout
+ m = /\[expire[ds]: ([0-9-]*)\]/.match(shipped_sig_key_info)
+ if m
+ expiration_date = Date.parse(m[1])
+ assert((expiration_date << max_months.to_i) > DateTime.now,
+ "The shipped key #{fingerprint} will not be valid #{max_months} months from now.")
+ end
+end
+
+Then /^I double-click the Report an Error launcher on the desktop$/ do
+ @screen.wait_and_double_click('DesktopReportAnError.png', 30)
+end
+
+Then /^the live user has been setup by live\-boot$/ do
+ assert($vm.execute("test -e /var/lib/live/config/user-setup").success?,
+ "live-boot failed its user-setup")
+ actual_username = $vm.execute(". /etc/live/config/username.conf; " +
+ "echo $LIVE_USERNAME").stdout.chomp
+ assert_equal(LIVE_USER, actual_username)
+end
+
+Then /^the live user is a member of only its own group and "(.*?)"$/ do |groups|
+ expected_groups = groups.split(" ") << LIVE_USER
+ actual_groups = $vm.execute("groups #{LIVE_USER}").stdout.chomp.sub(/^#{LIVE_USER} : /, "").split(" ")
+ unexpected = actual_groups - expected_groups
+ missing = expected_groups - actual_groups
+ assert_equal(0, unexpected.size,
+ "live user in unexpected groups #{unexpected}")
+ assert_equal(0, missing.size,
+ "live user not in expected groups #{missing}")
+end
+
+Then /^the live user owns its home dir and it has normal permissions$/ do
+ home = "/home/#{LIVE_USER}"
+ assert($vm.execute("test -d #{home}").success?,
+ "The live user's home doesn't exist or is not a directory")
+ owner = $vm.execute("stat -c %U:%G #{home}").stdout.chomp
+ perms = $vm.execute("stat -c %a #{home}").stdout.chomp
+ assert_equal("#{LIVE_USER}:#{LIVE_USER}", owner)
+ assert_equal("700", perms)
+end
+
+Then /^no unexpected services are listening for network connections$/ do
+ netstat_cmd = $vm.execute("netstat -ltupn")
+ assert netstat_cmd.success?
+ for line in netstat_cmd.stdout.chomp.split("\n") do
+ splitted = line.split(/[[:blank:]]+/)
+ proto = splitted[0]
+ if proto == "tcp"
+ proc_index = 6
+ elsif proto == "udp"
+ proc_index = 5
+ else
+ next
+ end
+ laddr, lport = splitted[3].split(":")
+ proc = splitted[proc_index].split("/")[1]
+ # Services listening on loopback is not a threat
+ if /127(\.[[:digit:]]{1,3}){3}/.match(laddr).nil?
+ if SERVICES_EXPECTED_ON_ALL_IFACES.include? [proc, laddr, lport] or
+ SERVICES_EXPECTED_ON_ALL_IFACES.include? [proc, laddr, "*"]
+ puts "Service '#{proc}' is listening on #{laddr}:#{lport} " +
+ "but has an exception"
+ else
+ raise "Unexpected service '#{proc}' listening on #{laddr}:#{lport}"
+ end
+ end
+ end
+end
+
+When /^Tails has booted a 64-bit kernel$/ do
+ assert($vm.execute("uname -r | grep -qs 'amd64$'").success?,
+ "Tails has not booted a 64-bit kernel.")
+end
+
+Then /^there is no screenshot in the live user's Pictures directory$/ do
+ pictures_directory = "/home/#{LIVE_USER}/Pictures"
+ assert($vm.execute(
+ "find '#{pictures_directory}' -name 'Screenshot*.png' -maxdepth 1"
+ ).stdout.empty?,
+ "Existing screenshots were found in the live user's Pictures directory.")
+end
+
+Then /^a screenshot is saved to the live user's Pictures directory$/ do
+ pictures_directory = "/home/#{LIVE_USER}/Pictures"
+ try_for(10, :msg=> "No screenshot was created in #{pictures_directory}") do
+ !$vm.execute(
+ "find '#{pictures_directory}' -name 'Screenshot*.png' -maxdepth 1"
+ ).stdout.empty?
+ end
+end
+
+Then /^the VirtualBox guest modules are available$/ do
+ assert($vm.execute("modinfo vboxguest").success?,
+ "The vboxguest module is not available.")
+end
+
+Given /^I setup a filesystem share containing a sample PDF$/ do
+ shared_pdf_dir_on_host = "#{$config["TMPDIR"]}/shared_pdf_dir"
+ @shared_pdf_dir_on_guest = "/tmp/shared_pdf_dir"
+ FileUtils.mkdir_p(shared_pdf_dir_on_host)
+ Dir.glob("#{MISC_FILES_DIR}/*.pdf") do |pdf_file|
+ FileUtils.cp(pdf_file, shared_pdf_dir_on_host)
+ end
+ add_after_scenario_hook { FileUtils.rm_r(shared_pdf_dir_on_host) }
+ $vm.add_share(shared_pdf_dir_on_host, @shared_pdf_dir_on_guest)
+end
+
+Then /^the support documentation page opens in Tor Browser$/ do
+ @screen.wait("SupportDocumentation#{@language}.png", 120)
+end
+
+Then /^MAT can clean some sample PDF file$/ do
+ for pdf_on_host in Dir.glob("#{MISC_FILES_DIR}/*.pdf") do
+ pdf_name = File.basename(pdf_on_host)
+ pdf_on_guest = "/home/#{LIVE_USER}/#{pdf_name}"
+ step "I copy \"#{@shared_pdf_dir_on_guest}/#{pdf_name}\" to \"#{pdf_on_guest}\" as user \"#{LIVE_USER}\""
+ check_before = $vm.execute_successfully("mat --check '#{pdf_on_guest}'",
+ :user => LIVE_USER).stdout
+ assert(check_before.include?("#{pdf_on_guest} is not clean"),
+ "MAT failed to see that '#{pdf_on_host}' is dirty")
+ $vm.execute_successfully("mat '#{pdf_on_guest}'", :user => LIVE_USER)
+ check_after = $vm.execute_successfully("mat --check '#{pdf_on_guest}'",
+ :user => LIVE_USER).stdout
+ assert(check_after.include?("#{pdf_on_guest} is clean"),
+ "MAT failed to clean '#{pdf_on_host}'")
+ $vm.execute_successfully("rm '#{pdf_on_guest}'")
+ end
+end
+
+Then /^AppArmor is enabled$/ do
+ assert($vm.execute("aa-status").success?, "AppArmor is not enabled")
+end
+
+Then /^some AppArmor profiles are enforced$/ do
+ assert($vm.execute("aa-status --enforced").stdout.chomp.to_i > 0,
+ "No AppArmor profile is enforced")
+end
+
+def get_seccomp_status(process)
+ assert($vm.has_process?(process), "Process #{process} not running.")
+ pid = $vm.pidof(process)[0]
+ status = $vm.file_content("/proc/#{pid}/status")
+ return status.match(/^Seccomp:\s+([0-9])/)[1].chomp.to_i
+end
+
+def get_apparmor_status(pid)
+ apparmor_status = $vm.file_content("/proc/#{pid}/attr/current").chomp
+ if apparmor_status.include?(')')
+ # matches something like /usr/sbin/cupsd (enforce)
+ # and only returns what's in the parentheses
+ return apparmor_status.match(/[^\s]+\s+\((.+)\)$/)[1].chomp
+ else
+ return apparmor_status
+ end
+end
+
+Then /^the running process "(.+)" is confined with AppArmor in (complain|enforce) mode$/ do |process, mode|
+ if process == 'i2p'
+ $vm.execute_successfully('service i2p status')
+ pid = $vm.file_content('/run/i2p/i2p.pid').chomp
+ else
+ assert($vm.has_process?(process), "Process #{process} not running.")
+ pid = $vm.pidof(process)[0]
+ end
+ assert_equal(mode, get_apparmor_status(pid))
+end
+
+Then /^the running process "(.+)" is confined with Seccomp in (filter|strict) mode$/ do |process,mode|
+ status = get_seccomp_status(process)
+ if mode == 'strict'
+ assert_equal(1, status, "#{process} not confined with Seccomp in strict mode")
+ elsif mode == 'filter'
+ assert_equal(2, status, "#{process} not confined with Seccomp in filter mode")
+ else
+ raise "Unsupported mode #{mode} passed"
+ end
+end
+
+Then /^tails-debugging-info is not susceptible to symlink attacks$/ do
+ secret_file = '/secret'
+ secret_contents = 'T0P S3Cr1t -- 3yEs oN1y'
+ $vm.file_append(secret_file, secret_contents)
+ $vm.execute_successfully("chmod u=rw,go= #{secret_file}")
+ $vm.execute_successfully("chown root:root #{secret_file}")
+ script_path = '/usr/local/sbin/tails-debugging-info'
+ script_lines = $vm.file_content(script_path).split("\n")
+ script_lines.grep(/^debug_file\s+/).each do |line|
+ _, user, debug_file = line.split
+ # root can always mount symlink attacks
+ next if user == 'root'
+ # Remove quoting around the file
+ debug_file.gsub!(/["']/, '')
+ # Skip files that do not exist, or cannot be removed (e.g. the
+ # ones in /proc).
+ next if not($vm.execute("rm #{debug_file}").success?)
+ # Check what would happen *if* the amnesia user managed to replace
+ # the debugging file with a symlink to the secret.
+ $vm.execute_successfully("ln -s #{secret_file} #{debug_file}")
+ $vm.execute_successfully("chown --no-dereference #{LIVE_USER}:#{LIVE_USER} #{debug_file}")
+ if $vm.execute("sudo /usr/local/sbin/tails-debugging-info | " +
+ "grep '#{secret_contents}'",
+ :user => LIVE_USER).success?
+ raise "The secret was leaked by tails-debugging-info via '#{debug_file}'"
+ end
+ # Remove the secret so it cannot possibly interfere with the
+ # following iterations (even though it should not).
+ $vm.execute_successfully("echo > #{debug_file}")
+ end
+end
+
+When /^I disable all networking in the Tails Greeter$/ do
+ begin
+ @screen.click('TailsGreeterDisableAllNetworking.png')
+ rescue FindFailed
+ @screen.type(Sikuli::Key.PAGE_DOWN)
+ @screen.click('TailsGreeterDisableAllNetworking.png')
+ end
+end
+
+Then /^the Tor Status icon tells me that Tor is( not)? usable$/ do |not_usable|
+ picture = not_usable ? 'TorStatusNotUsable' : 'TorStatusUsable'
+ @screen.find("#{picture}.png")
+end
diff --git a/cucumber/features/step_definitions/common_steps.rb b/cucumber/features/step_definitions/common_steps.rb
new file mode 100644
index 00000000..bd03cebb
--- /dev/null
+++ b/cucumber/features/step_definitions/common_steps.rb
@@ -0,0 +1,1086 @@
+require 'fileutils'
+
+def post_vm_start_hook
+ # Sometimes the first click is lost (presumably it's used to give
+ # focus to virt-viewer or similar) so we do that now rather than
+ # having an important click lost. The point we click should be
+ # somewhere where no clickable elements generally reside.
+ @screen.click_point(@screen.w, @screen.h/2)
+end
+
+def activate_filesystem_shares
+ # XXX-9p: First of all, filesystem shares cannot be mounted while we
+ # do a snapshot save+restore, so unmounting+remounting them seems
+ # like a good idea. However, the 9p modules get into a broken state
+ # during the save+restore, so we also would like to unload+reload
+ # them, but loading of 9pnet_virtio fails after a restore with
+ # "probe of virtio2 failed with error -2" (in dmesg) which makes the
+ # shares unavailable. Hence we leave this code commented for now.
+ #for mod in ["9pnet_virtio", "9p"] do
+ # $vm.execute("modprobe #{mod}")
+ #end
+
+ $vm.list_shares.each do |share|
+ $vm.execute("mkdir -p #{share}")
+ $vm.execute("mount -t 9p -o trans=virtio #{share} #{share}")
+ end
+end
+
+def context_menu_helper(top, bottom, menu_item)
+ try_for(60) do
+ t = @screen.wait(top, 10)
+ b = @screen.wait(bottom, 10)
+ # In Sikuli, lower x == closer to the left, lower y == closer to the top
+ assert(t.y < b.y)
+ center = Sikuli::Location.new(((t.x + t.w) + b.x)/2,
+ ((t.y + t.h) + b.y)/2)
+ @screen.right_click(center)
+ @screen.hide_cursor
+ @screen.wait_and_click(menu_item, 10)
+ return
+ end
+end
+
+def deactivate_filesystem_shares
+ $vm.list_shares.each do |share|
+ $vm.execute("umount #{share}")
+ end
+
+ # XXX-9p: See XXX-9p above
+ #for mod in ["9p", "9pnet_virtio"] do
+ # $vm.execute("modprobe -r #{mod}")
+ #end
+end
+
+# This helper requires that the notification image is the one shown in
+# the notification applet's list, not the notification pop-up.
+def robust_notification_wait(notification_image, time_to_wait)
+ error_msg = "Didn't not manage to open the notification applet"
+ wait_start = Time.now
+ try_for(time_to_wait, :delay => 0, :msg => error_msg) do
+ @screen.hide_cursor
+ @screen.click("GnomeNotificationApplet.png")
+ @screen.wait("GnomeNotificationAppletOpened.png", 10)
+ end
+
+ error_msg = "Didn't not see notification '#{notification_image}'"
+ time_to_wait -= (Time.now - wait_start).ceil
+ try_for(time_to_wait, :delay => 0, :msg => error_msg) do
+ found = false
+ entries = @screen.findAll("GnomeNotificationEntry.png")
+ while(entries.hasNext) do
+ entry = entries.next
+ @screen.hide_cursor
+ @screen.click(entry)
+ close_entry = @screen.wait("GnomeNotificationEntryClose.png", 10)
+ if @screen.exists(notification_image)
+ found = true
+ @screen.click(close_entry)
+ break
+ else
+ @screen.click(entry)
+ end
+ end
+ found
+ end
+
+ # Click anywhere to close the notification applet
+ @screen.hide_cursor
+ @screen.click("GnomeApplicationsMenu.png")
+ @screen.hide_cursor
+end
+
+def post_snapshot_restore_hook
+ $vm.wait_until_remote_shell_is_up
+ post_vm_start_hook
+
+ # XXX-9p: See XXX-9p above
+ #activate_filesystem_shares
+
+ # debian-TODO: move to tor feature
+ # The guest's Tor's circuits' states are likely to get out of sync
+ # with the other relays, so we ensure that we have fresh circuits.
+ # Time jumps and incorrect clocks also confuses Tor in many ways.
+ #if $vm.has_network?
+ # if $vm.execute("systemctl --quiet is-active tor@default.service").success?
+ # $vm.execute("systemctl stop tor@default.service")
+ # $vm.execute("rm -f /var/log/tor/log")
+ # $vm.execute("systemctl --no-block restart tails-tor-has-bootstrapped.target")
+ # $vm.host_to_guest_time_sync
+ # $vm.spawn("restart-tor")
+ # wait_until_tor_is_working
+ # if $vm.file_content('/proc/cmdline').include?(' i2p')
+ # $vm.execute_successfully('/usr/local/sbin/tails-i2p stop')
+ # # we "killall tails-i2p" to prevent multiple
+ # # copies of the script from running
+ # $vm.execute_successfully('killall tails-i2p')
+ # $vm.spawn('/usr/local/sbin/tails-i2p start')
+ # end
+ # end
+ #else
+ # $vm.host_to_guest_time_sync
+ #end
+end
+
+Given /^a computer$/ do
+ $vm.destroy_and_undefine if $vm
+ $vm = VM.new($virt, VM_XML_PATH, $vmnet, $vmstorage, DISPLAY)
+end
+
+Given /^the computer has (\d+) ([[:alpha:]]+) of RAM$/ do |size, unit|
+ $vm.set_ram_size(size, unit)
+end
+
+Given /^the computer is set to boot from the Tails DVD$/ do
+ $vm.set_cdrom_boot(TAILS_ISO)
+end
+
+Given /^the computer is set to boot from (.+?) drive "(.+?)"$/ do |type, name|
+ $vm.set_disk_boot(name, type.downcase)
+end
+
+Given /^I (temporarily )?create a (\d+) ([[:alpha:]]+) disk named "([^"]+)"$/ do |temporary, size, unit, name|
+ $vm.storage.create_new_disk(name, {:size => size, :unit => unit,
+ :type => "qcow2"})
+ add_after_scenario_hook { $vm.storage.delete_volume(name) } if temporary
+end
+
+Given /^I plug (.+) drive "([^"]+)"$/ do |bus, name|
+ $vm.plug_drive(name, bus.downcase)
+ if $vm.is_running?
+ step "drive \"#{name}\" is detected by Tails"
+ end
+end
+
+Then /^drive "([^"]+)" is detected by Tails$/ do |name|
+ raise "Tails is not running" unless $vm.is_running?
+ try_for(10, :msg => "Drive '#{name}' is not detected by Tails") do
+ $vm.disk_detected?(name)
+ end
+end
+
+Given /^the network is plugged$/ do
+ $vm.plug_network
+end
+
+Given /^the network is unplugged$/ do
+ $vm.unplug_network
+end
+
+Given /^the hardware clock is set to "([^"]*)"$/ do |time|
+ $vm.set_hardware_clock(DateTime.parse(time).to_time)
+end
+
+Given /^I capture all network traffic$/ do
+ @sniffer = Sniffer.new("sniffer", $vmnet)
+ @sniffer.capture
+ add_after_scenario_hook do
+ @sniffer.stop
+ @sniffer.clear
+ end
+end
+
+Given /^I set Tails to boot with options "([^"]*)"$/ do |options|
+ @boot_options = options
+end
+
+When /^I start the computer$/ do
+ assert(!$vm.is_running?,
+ "Trying to start a VM that is already running")
+ $vm.start
+ post_vm_start_hook
+end
+
+Given /^I start Tails( from DVD)?( with network unplugged)?( and I login)?$/ do |dvd_boot, network_unplugged, do_login|
+ step "the computer is set to boot from the Tails DVD" if dvd_boot
+ if network_unplugged.nil?
+ step "the network is plugged"
+ else
+ step "the network is unplugged"
+ end
+ step "I start the computer"
+ step "the computer boots Tails"
+ if do_login
+ step "I log in to a new session"
+ step "Tails seems to have booted normally"
+ if network_unplugged.nil?
+ step "Tor is ready"
+ step "all notifications have disappeared"
+ step "available upgrades have been checked"
+ else
+ step "all notifications have disappeared"
+ end
+ end
+end
+
+Given /^I start Tails from (.+?) drive "(.+?)"(| with network unplugged)( and I login(| with(| read-only) persistence enabled))?$/ do |drive_type, drive_name, network_unplugged, do_login, persistence_on, persistence_ro|
+ step "the computer is set to boot from #{drive_type} drive \"#{drive_name}\""
+ if network_unplugged.empty?
+ step "the network is plugged"
+ else
+ step "the network is unplugged"
+ end
+ step "I start the computer"
+ step "the computer boots Tails"
+ if do_login
+ if ! persistence_on.empty?
+ if persistence_ro.empty?
+ step "I enable persistence"
+ else
+ step "I enable read-only persistence"
+ end
+ end
+ step "I log in to a new session"
+ step "Tails seems to have booted normally"
+ if network_unplugged.empty?
+ step "Tor is ready"
+ step "all notifications have disappeared"
+ step "available upgrades have been checked"
+ else
+ step "all notifications have disappeared"
+ end
+ end
+end
+
+When /^I power off the computer$/ do
+ assert($vm.is_running?,
+ "Trying to power off an already powered off VM")
+ $vm.power_off
+end
+
+When /^I cold reboot the computer$/ do
+ step "I power off the computer"
+ step "I start the computer"
+end
+
+When /^I destroy the computer$/ do
+ $vm.destroy_and_undefine
+end
+
+Given /^the computer (re)?boots DebianInstaller(|\d+)$/ do |reboot,version|
+
+ boot_timeout = 30
+ # We need some extra time for memory wiping if rebooting
+
+ @screen.wait("d-i8_bootsplash.png", boot_timeout)
+ @screen.type(Sikuli::Key.TAB)
+
+ @screen.type(' preseed/early_command="echo ttyS0::askfirst:-/bin/sh>>/etc/inittab;kill -HUP 1"' + " blacklist=psmouse #{@boot_options}" +
+ Sikuli::Key.ENTER)
+ $vm.wait_until_remote_shell_is_up
+end
+
+Given /^I select British English$/ do
+ @screen.wait("DebianInstallerSelectLangEnglish.png", 30)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("DebianInstallerCountrySelection.png", 10)
+ @screen.type(Sikuli::Key.UP)
+ @screen.waitVanish("DebianInstallerCountrySelection.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("DebianInstallerSelectLangEnglishUK.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I accept the hostname, using "([^"]*)" as the domain$/ do |domain|
+ @screen.wait("DebianInstallerHostnamePrompt.png", 5*60)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("DebianInstallerDomainPrompt.png", 10)
+ @screen.type(domain + Sikuli::Key.ENTER)
+ @screen.waitVanish("DebianInstallerDomainPrompt.png", 10)
+end
+
+Given /^I set the root password to "([^"]*)"$/ do |rootpw|
+# Root Password, twice
+ @screen.wait("DebianInstallerRootPassword.png", 30)
+ @screen.type(rootpw + Sikuli::Key.ENTER)
+ @screen.waitVanish("DebianInstallerRootPassword.png", 10)
+ @screen.type(rootpw + Sikuli::Key.ENTER)
+end
+
+Given /^I set the password for "([^"]*)" to be "([^"]*)"$/ do |fullname,password|
+# Username, and password twice
+ @screen.wait("DebianInstallerNameOfUser.png", 10)
+ @screen.type(fullname + Sikuli::Key.ENTER)
+ @screen.waitVanish("DebianInstallerNameOfUser.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("DebianInstallerUserPassword.png", 10)
+ @screen.type(password + Sikuli::Key.ENTER)
+ @screen.waitVanish("DebianInstallerUserPassword.png", 10)
+ @screen.type(password + Sikuli::Key.ENTER)
+end
+
+ #@screen.wait("DebianInstallerNoDiskFound.png", 60)
+
+Given /^I select full-disk, single-filesystem partitioning$/ do
+ @screen.wait("DebianInstallerPartitioningMethod.png", 60)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("DebianInstallerSelectDiskToPartition.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("DebianInstallerPartitioningScheme.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("d-i_FinishPartitioning.png", 10)
+ sleep(5) # FIXME -- why do we need this? It's weird that the wait is not enough
+ @screen.type(Sikuli::Key.ENTER)
+ # prompt about Writing Partitions to disk:
+ @screen.wait("d-i_No.png", 10)
+ @screen.type(Sikuli::Key.TAB)
+ @screen.wait("d-i_Yes.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I note that the Base system is being installed$/ do
+ @screen.wait("DebianInstallerInstallingBaseSystem.png", 30)
+ @screen.waitVanish("DebianInstallerInstallingBaseSystem.png", 15 * 60)
+end
+
+Given /^I accept the default mirror$/ do
+ @screen.wait("DebianInstallerMirrorCountry.png", 10 * 60)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("d-i_ArchiveMirror.png", 5)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("d-i_HttpProxy.png", 5)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I neglect to scan more CDs$/ do
+ @screen.wait("d-i_ScanCD.png", 15 * 60)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("d-i_UseNetMirror.png", 10)
+ @screen.wait("d-i_Yes.png", 10)
+ @screen.type(Sikuli::Key.TAB)
+ @screen.wait("d-i_No.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I ignore Popcon$/ do
+ #@screen.wait("d-i_popcon.png", 10 * 60)
+ @screen.wait("d-i_No.png", 10 * 60)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^we reach the Tasksel prompt$/ do
+ @screen.wait("d-i_ChooseSoftware.png", 5 * 60)
+end
+
+Given /^I hit ENTER$/ do
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I select the Desktop task$/ do
+ @screen.wait("d-i_ChooseSoftware.png", 10)
+ @screen.type(Sikuli::Key.SPACE)
+ @screen.type(Sikuli::Key.DOWN)
+ @screen.type(Sikuli::Key.SPACE)
+ @screen.wait("d-i_DesktopTask_Yes.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I install GRUB$/ do
+ @screen.wait("d-i_InstallGRUB.png", 80 * 60)
+ #@screen.wait("Install the GRUB", 80 * 60)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("d-i_GRUBEnterDev.png", 10 * 60)
+ @screen.type(Sikuli::Key.DOWN)
+ @screen.wait("d-i_GRUBdev.png", 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I allow reboot after the install is complete$/ do
+ @screen.wait("d-i_InstallComplete.png", 2 * 60)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+Given /^I wait for the reboot$/ do
+ @screen.wait(bootsplash, 10 * 60)
+end
+
+Given /^I make sure that we boot from disk$/ do
+ @screen.wait("d-i_GRUB_Debian.png", 5 * 60)
+end
+
+Given /^I wait for a Login Prompt$/ do
+ @screen.wait("DebianLoginPromptVT.png", 2 * 60)
+end
+
+def bootsplash
+ case @os_loader
+ when "UEFI"
+ 'TailsBootSplashUEFI.png'
+ else
+ 'd-i8_bootsplash.png'
+ end
+end
+
+def bootsplash_tab_msg
+ case @os_loader
+ when "UEFI"
+ 'TailsBootSplashTabMsgUEFI.png'
+ else
+ #if reboot
+ # bootsplash = 'TailsBootSplashPostReset.png'
+ # bootsplash_tab_msg = 'TailsBootSplashTabMsgPostReset.png'
+ # boot_timeout = 120
+ #else
+ #bootsplash = "DebianLive#{version}BootSplash.png"
+ bootsplash = "DebianLiveBootSplash.png"
+ bootsplash_tab_msg = "DebianLiveBootSplashTabMsg.png"
+ boot_timeout = 30
+ #end
+ end
+end
+
+Given /^the computer (re)?boots Tails$/ do |reboot|
+
+ boot_timeout = 30
+ # We need some extra time for memory wiping if rebooting
+ boot_timeout += 90 if reboot
+
+ @screen.wait(bootsplash, boot_timeout)
+ @screen.wait(bootsplash_tab_msg, 10)
+ @screen.type(Sikuli::Key.TAB)
+ @screen.waitVanish(bootsplash_tab_msg, 1)
+
+ @screen.type(" autotest_never_use_this_option blacklist=psmouse #{@boot_options}" +
+ Sikuli::Key.ENTER)
+ @screen.wait("DebianLive#{version}Greeter.png", 5*60)
+ @vm.wait_until_remote_shell_is_up
+ activate_filesystem_shares
+end
+
+Given /^I log in to a new session(?: in )?(|German)$/ do |lang|
+ case lang
+ when 'German'
+ @language = "German"
+ @screen.wait_and_click('TailsGreeterLanguage.png', 10)
+ @screen.wait_and_click("TailsGreeterLanguage#{@language}.png", 10)
+ @screen.wait_and_click("TailsGreeterLoginButton#{@language}.png", 10)
+ when ''
+ @screen.wait_and_click('TailsGreeterLoginButton.png', 10)
+ else
+ raise "Unsupported language: #{lang}"
+ end
+end
+
+Given /^I set sudo password "([^"]*)"$/ do |password|
+ @sudo_password = password
+ next if @skip_steps_while_restoring_background
+ #@screen.wait("TailsGreeterAdminPassword.png", 20)
+ @screen.type(@sudo_password)
+ @screen.type(Sikuli::Key.TAB)
+ @screen.type(@sudo_password)
+end
+
+Given /^Tails Greeter has dealt with the sudo password$/ do
+ f1 = "/etc/sudoers.d/tails-greeter"
+ f2 = "#{f1}-no-password-lecture"
+ try_for(20) {
+ $vm.execute("test -e '#{f1}' -o -e '#{f2}'").success?
+ }
+end
+
+Given /^the Tails desktop is ready$/ do
+ desktop_started_picture = "GnomeApplicationsMenu#{@language}.png"
+ # We wait for the Florence icon to be displayed to ensure reliable systray icon clicking.
+ @screen.wait("GnomeSystrayFlorence.png", 180)
+ @screen.wait(desktop_started_picture, 180)
+ # Disable screen blanking since we sometimes need to wait long
+ # enough for it to activate, which can mess with Sikuli wait():ing
+ # for some image.
+ $vm.execute_successfully(
+ 'gsettings set org.gnome.desktop.session idle-delay 0',
+ :user => LIVE_USER
+ )
+end
+
+Then /^Tails seems to have booted normally$/ do
+ step "the Tails desktop is ready"
+end
+
+When /^I see the 'Tor is ready' notification$/ do
+ robust_notification_wait('TorIsReadyNotification.png', 300)
+end
+
+Given /^Tor is ready$/ do
+ step "Tor has built a circuit"
+ step "the time has synced"
+ if $vm.execute('systemctl is-system-running').failure?
+ units_status = $vm.execute('systemctl').stdout
+ raise "At least one system service failed to start:\n#{units_status}"
+ end
+end
+
+Given /^Tor has built a circuit$/ do
+ wait_until_tor_is_working
+end
+
+Given /^the time has synced$/ do
+ ["/var/run/tordate/done", "/var/run/htpdate/success"].each do |file|
+ try_for(300) { $vm.execute("test -e #{file}").success? }
+ end
+end
+
+Given /^available upgrades have been checked$/ do
+ try_for(300) {
+ $vm.execute("test -e '/var/run/tails-upgrader/checked_upgrades'").success?
+ }
+end
+
+Given /^the Tor Browser has started$/ do
+ tor_browser_picture = "TorBrowserWindow.png"
+ @screen.wait(tor_browser_picture, 60)
+end
+
+Given /^the Tor Browser (?:has started and )?load(?:ed|s) the (startup page|Tails roadmap)$/ do |page|
+ case page
+ when "startup page"
+ picture = "TorBrowserStartupPage.png"
+ when "Tails roadmap"
+ picture = "TorBrowserTailsRoadmap.png"
+ else
+ raise "Unsupported page: #{page}"
+ end
+ step "the Tor Browser has started"
+ @screen.wait(picture, 120)
+end
+
+Given /^the Tor Browser has started in offline mode$/ do
+ @screen.wait("TorBrowserOffline.png", 60)
+end
+
+Given /^I add a bookmark to eff.org in the Tor Browser$/ do
+ url = "https://www.eff.org"
+ step "I open the address \"#{url}\" in the Tor Browser"
+ @screen.wait("TorBrowserOffline.png", 5)
+ @screen.type("d", Sikuli::KeyModifier.CTRL)
+ @screen.wait("TorBrowserBookmarkPrompt.png", 10)
+ @screen.type(url + Sikuli::Key.ENTER)
+end
+
+Given /^the Tor Browser has a bookmark to eff.org$/ do
+ @screen.type("b", Sikuli::KeyModifier.ALT)
+ @screen.wait("TorBrowserEFFBookmark.png", 10)
+end
+
+Given /^all notifications have disappeared$/ do
+ next if not(@screen.exists("GnomeNotificationApplet.png"))
+ @screen.click("GnomeNotificationApplet.png")
+ @screen.wait("GnomeNotificationAppletOpened.png", 10)
+ begin
+ entries = @screen.findAll("GnomeNotificationEntry.png")
+ while(entries.hasNext) do
+ entry = entries.next
+ @screen.hide_cursor
+ @screen.click(entry)
+ @screen.wait_and_click("GnomeNotificationEntryClose.png", 10)
+ end
+ rescue FindFailed
+ # No notifications, so we're good to go.
+ end
+ @screen.hide_cursor
+ # Click anywhere to close the notification applet
+ @screen.click("GnomeApplicationsMenu.png")
+ @screen.hide_cursor
+end
+
+Then /^I (do not )?see "([^"]*)" after at most (\d+) seconds$/ do |negation, image, time|
+ begin
+ @screen.wait(image, time.to_i)
+ raise "found '#{image}' while expecting not to" if negation
+ rescue FindFailed => e
+ raise e if not(negation)
+ end
+end
+
+Then /^all Internet traffic has only flowed through Tor$/ do
+ leaks = FirewallLeakCheck.new(@sniffer.pcap_file,
+ :accepted_hosts => get_all_tor_nodes)
+ leaks.assert_no_leaks
+end
+
+Given /^I enter the sudo password in the pkexec prompt$/ do
+ step "I enter the \"#{@sudo_password}\" password in the pkexec prompt"
+end
+
+def deal_with_polkit_prompt (image, password)
+ @screen.wait(image, 60)
+ @screen.type(password)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.waitVanish(image, 10)
+end
+
+Given /^I enter the "([^"]*)" password in the pkexec prompt$/ do |password|
+ deal_with_polkit_prompt('PolicyKitAuthPrompt.png', password)
+end
+
+Given /^process "([^"]+)" is (not )?running$/ do |process, not_running|
+ if not_running
+ assert(!$vm.has_process?(process), "Process '#{process}' is running")
+ else
+ assert($vm.has_process?(process), "Process '#{process}' is not running")
+ end
+end
+
+Given /^process "([^"]+)" is running within (\d+) seconds$/ do |process, time|
+ try_for(time.to_i, :msg => "Process '#{process}' is not running after " +
+ "waiting for #{time} seconds") do
+ $vm.has_process?(process)
+ end
+end
+
+Given /^process "([^"]+)" has stopped running after at most (\d+) seconds$/ do |process, time|
+ try_for(time.to_i, :msg => "Process '#{process}' is still running after " +
+ "waiting for #{time} seconds") do
+ not $vm.has_process?(process)
+ end
+end
+
+Given /^I kill the process "([^"]+)"$/ do |process|
+ $vm.execute("killall #{process}")
+ try_for(10, :msg => "Process '#{process}' could not be killed") {
+ !$vm.has_process?(process)
+ }
+end
+
+Then /^Tails eventually shuts down$/ do
+ nr_gibs_of_ram = convert_from_bytes($vm.get_ram_size_in_bytes, 'GiB').ceil
+ timeout = nr_gibs_of_ram*5*60
+ try_for(timeout, :msg => "VM is still running after #{timeout} seconds") do
+ ! $vm.is_running?
+ end
+end
+
+Then /^Tails eventually restarts$/ do
+ nr_gibs_of_ram = convert_from_bytes($vm.get_ram_size_in_bytes, 'GiB').ceil
+ @screen.wait('TailsBootSplash.png', nr_gibs_of_ram*5*60)
+end
+
+Given /^I shutdown Tails and wait for the computer to power off$/ do
+ $vm.spawn("poweroff")
+ step 'Tails eventually shuts down'
+end
+
+When /^I request a shutdown using the emergency shutdown applet$/ do
+ @screen.hide_cursor
+ @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10)
+ @screen.wait_and_click('TailsEmergencyShutdownHalt.png', 10)
+end
+
+When /^I warm reboot the computer$/ do
+ $vm.spawn("reboot")
+end
+
+When /^I request a reboot using the emergency shutdown applet$/ do
+ @screen.hide_cursor
+ @screen.wait_and_click('TailsEmergencyShutdownButton.png', 10)
+ @screen.wait_and_click('TailsEmergencyShutdownReboot.png', 10)
+end
+
+Given /^package "([^"]+)" is installed$/ do |package|
+ assert($vm.execute("dpkg -s '#{package}' 2>/dev/null | grep -qs '^Status:.*installed$'").success?,
+ "Package '#{package}' is not installed")
+end
+
+When /^I start the Tor Browser$/ do
+ step 'I start "TorBrowser" via the GNOME "Internet" applications menu'
+end
+
+When /^I request a new identity using Torbutton$/ do
+ @screen.wait_and_click('TorButtonIcon.png', 30)
+ @screen.wait_and_click('TorButtonNewIdentity.png', 30)
+end
+
+When /^I acknowledge Torbutton's New Identity confirmation prompt$/ do
+ @screen.wait('GnomeQuestionDialogIcon.png', 30)
+ step 'I type "y"'
+end
+
+When /^I start the Tor Browser in offline mode$/ do
+ step "I start the Tor Browser"
+ @screen.wait_and_click("TorBrowserOfflinePrompt.png", 10)
+ @screen.click("TorBrowserOfflinePromptStart.png")
+end
+
+Given /^I add a wired DHCP NetworkManager connection called "([^"]+)"$/ do |con_name|
+ con_content = <<EOF
+[802-3-ethernet]
+duplex=full
+
+[connection]
+id=#{con_name}
+uuid=bbc60668-1be0-11e4-a9c6-2f1ce0e75bf1
+type=802-3-ethernet
+timestamp=1395406011
+
+[ipv6]
+method=auto
+
+[ipv4]
+method=auto
+EOF
+ con_content.split("\n").each do |line|
+ $vm.execute("echo '#{line}' >> /tmp/NM.#{con_name}")
+ end
+ con_file = "/etc/NetworkManager/system-connections/#{con_name}"
+ $vm.execute("install -m 0600 '/tmp/NM.#{con_name}' '#{con_file}'")
+ $vm.execute_successfully("nmcli connection load '#{con_file}'")
+ try_for(10) {
+ nm_con_list = $vm.execute("nmcli --terse --fields NAME connection show").stdout
+ nm_con_list.split("\n").include? "#{con_name}"
+ }
+end
+
+Given /^I switch to the "([^"]+)" NetworkManager connection$/ do |con_name|
+ $vm.execute("nmcli connection up id #{con_name}")
+ try_for(60) do
+ $vm.execute("nmcli --terse --fields NAME,STATE connection show").stdout.chomp.split("\n").include?("#{con_name}:activated")
+ end
+end
+
+When /^I start and focus GNOME Terminal$/ do
+ step 'I start "Terminal" via the GNOME "Utilities" applications menu'
+ @screen.wait('GnomeTerminalWindow.png', 20)
+end
+
+When /^I run "([^"]+)" in GNOME Terminal$/ do |command|
+ if !$vm.has_process?("gnome-terminal-server")
+ step "I start and focus GNOME Terminal"
+ else
+ @screen.wait_and_click('GnomeTerminalWindow.png', 20)
+ end
+ @screen.type(command + Sikuli::Key.ENTER)
+end
+
+When /^the file "([^"]+)" exists(?:| after at most (\d+) seconds)$/ do |file, timeout|
+ timeout = 0 if timeout.nil?
+ try_for(
+ timeout.to_i,
+ :msg => "The file #{file} does not exist after #{timeout} seconds"
+ ) {
+ $vm.file_exist?(file)
+ }
+end
+
+When /^the file "([^"]+)" does not exist$/ do |file|
+ assert(! ($vm.file_exist?(file)))
+end
+
+When /^the directory "([^"]+)" exists$/ do |directory|
+ assert($vm.directory_exist?(directory))
+end
+
+When /^the directory "([^"]+)" does not exist$/ do |directory|
+ assert(! ($vm.directory_exist?(directory)))
+end
+
+When /^I copy "([^"]+)" to "([^"]+)" as user "([^"]+)"$/ do |source, destination, user|
+ c = $vm.execute("cp \"#{source}\" \"#{destination}\"", :user => LIVE_USER)
+ assert(c.success?, "Failed to copy file:\n#{c.stdout}\n#{c.stderr}")
+end
+
+def is_persistent?(app)
+ conf = get_persistence_presets(true)["#{app}"]
+ c = $vm.execute("findmnt --noheadings --output SOURCE --target '#{conf}'")
+ # This check assumes that we haven't enabled read-only persistence.
+ c.success? and c.stdout.chomp != "aufs"
+end
+
+Then /^persistence for "([^"]+)" is (|not )enabled$/ do |app, enabled|
+ case enabled
+ when ''
+ assert(is_persistent?(app), "Persistence should be enabled.")
+ when 'not '
+ assert(!is_persistent?(app), "Persistence should not be enabled.")
+ end
+end
+
+def gnome_app_menu_click_helper(click_me, verify_me = nil)
+ try_for(30) do
+ @screen.hide_cursor
+ # The sensitivity for submenus to open by just hovering past them
+ # is extremely high, and may result in the wrong one
+ # opening. Hence we better avoid hovering over undesired submenus
+ # entirely by "approaching" the menu strictly horizontally.
+ r = @screen.wait(click_me, 10)
+ @screen.hover_point(@screen.w, r.getY)
+ @screen.click(r)
+ @screen.wait(verify_me, 10) if verify_me
+ return
+ end
+end
+
+Given /^I start "([^"]+)" via the GNOME "([^"]+)" applications menu$/ do |app, submenu|
+ menu_button = "GnomeApplicationsMenu.png"
+ sub_menu_entry = "GnomeApplications" + submenu + ".png"
+ application_entry = "GnomeApplications" + app + ".png"
+ try_for(120) do
+ begin
+ gnome_app_menu_click_helper(menu_button, sub_menu_entry)
+ gnome_app_menu_click_helper(sub_menu_entry, application_entry)
+ gnome_app_menu_click_helper(application_entry)
+ rescue Exception => e
+ # Close menu, if still open
+ @screen.type(Sikuli::Key.ESC)
+ raise e
+ end
+ true
+ end
+end
+
+Given /^I start "([^"]+)" via the GNOME "([^"]+)"\/"([^"]+)" applications menu$/ do |app, submenu, subsubmenu|
+ menu_button = "GnomeApplicationsMenu.png"
+ sub_menu_entry = "GnomeApplications" + submenu + ".png"
+ sub_sub_menu_entry = "GnomeApplications" + subsubmenu + ".png"
+ application_entry = "GnomeApplications" + app + ".png"
+ try_for(120) do
+ begin
+ gnome_app_menu_click_helper(menu_button, sub_menu_entry)
+ gnome_app_menu_click_helper(sub_menu_entry, sub_sub_menu_entry)
+ gnome_app_menu_click_helper(sub_sub_menu_entry, application_entry)
+ gnome_app_menu_click_helper(application_entry)
+ rescue Exception => e
+ # Close menu, if still open
+ @screen.type(Sikuli::Key.ESC)
+ raise e
+ end
+ true
+ end
+end
+
+When /^I type "([^"]+)"$/ do |string|
+ @screen.type(string)
+end
+
+When /^I press the "([^"]+)" key$/ do |key|
+ begin
+ @screen.type(eval("Sikuli::Key.#{key}"))
+ rescue RuntimeError
+ raise "unsupported key #{key}"
+ end
+end
+
+Then /^the (amnesiac|persistent) Tor Browser directory (exists|does not exist)$/ do |persistent_or_not, mode|
+ case persistent_or_not
+ when "amnesiac"
+ dir = "/home/#{LIVE_USER}/Tor Browser"
+ when "persistent"
+ dir = "/home/#{LIVE_USER}/Persistent/Tor Browser"
+ end
+ step "the directory \"#{dir}\" #{mode}"
+end
+
+Then /^there is a GNOME bookmark for the (amnesiac|persistent) Tor Browser directory$/ do |persistent_or_not|
+ case persistent_or_not
+ when "amnesiac"
+ bookmark_image = 'TorBrowserAmnesicFilesBookmark.png'
+ when "persistent"
+ bookmark_image = 'TorBrowserPersistentFilesBookmark.png'
+ end
+ @screen.wait_and_click('GnomePlaces.png', 10)
+ @screen.wait(bookmark_image, 40)
+ @screen.type(Sikuli::Key.ESC)
+end
+
+Then /^there is no GNOME bookmark for the persistent Tor Browser directory$/ do
+ try_for(65) do
+ @screen.wait_and_click('GnomePlaces.png', 10)
+ @screen.wait("GnomePlacesWithoutTorBrowserPersistent.png", 10)
+ @screen.type(Sikuli::Key.ESC)
+ end
+end
+
+def pulseaudio_sink_inputs
+ pa_info = $vm.execute_successfully('pacmd info', :user => LIVE_USER).stdout
+ sink_inputs_line = pa_info.match(/^\d+ sink input\(s\) available\.$/)[0]
+ return sink_inputs_line.match(/^\d+/)[0].to_i
+end
+
+When /^(no|\d+) application(?:s?) (?:is|are) playing audio(?:| after (\d+) seconds)$/ do |nb, wait_time|
+ nb = 0 if nb == "no"
+ sleep wait_time.to_i if ! wait_time.nil?
+ assert_equal(nb.to_i, pulseaudio_sink_inputs)
+end
+
+When /^I double-click on the "Tails documentation" link on the Desktop$/ do
+ @screen.wait_and_double_click("DesktopTailsDocumentationIcon.png", 10)
+end
+
+When /^I click the blocked video icon$/ do
+ @screen.wait_and_click("TorBrowserBlockedVideo.png", 30)
+end
+
+When /^I accept to temporarily allow playing this video$/ do
+ @screen.wait_and_click("TorBrowserOkButton.png", 10)
+end
+
+When /^I click the HTML5 play button$/ do
+ @screen.wait_and_click("TorBrowserHtml5PlayButton.png", 30)
+end
+
+When /^I (can|cannot) save the current page as "([^"]+[.]html)" to the (.*) directory$/ do |should_work, output_file, output_dir|
+ should_work = should_work == 'can' ? true : false
+ @screen.type("s", Sikuli::KeyModifier.CTRL)
+ @screen.wait("TorBrowserSaveDialog.png", 10)
+ if output_dir == "persistent Tor Browser"
+ output_dir = "/home/#{LIVE_USER}/Persistent/Tor Browser"
+ @screen.wait_and_click("GtkTorBrowserPersistentBookmark.png", 10)
+ @screen.wait("GtkTorBrowserPersistentBookmarkSelected.png", 10)
+ # The output filename (without its extension) is already selected,
+ # let's use the keyboard shortcut to focus its field
+ @screen.type("n", Sikuli::KeyModifier.ALT)
+ @screen.wait("TorBrowserSaveOutputFileSelected.png", 10)
+ elsif output_dir == "default downloads"
+ output_dir = "/home/#{LIVE_USER}/Tor Browser"
+ else
+ @screen.type(output_dir + '/')
+ end
+ # Only the part of the filename before the .html extension can be easily replaced
+ # so we have to remove it before typing it into the arget filename entry widget.
+ @screen.type(output_file.sub(/[.]html$/, ''))
+ @screen.type(Sikuli::Key.ENTER)
+ if should_work
+ try_for(10, :msg => "The page was not saved to #{output_dir}/#{output_file}") {
+ $vm.file_exist?("#{output_dir}/#{output_file}")
+ }
+ else
+ @screen.wait("TorBrowserCannotSavePage.png", 10)
+ end
+end
+
+When /^I can print the current page as "([^"]+[.]pdf)" to the (default downloads|persistent Tor Browser) directory$/ do |output_file, output_dir|
+ if output_dir == "persistent Tor Browser"
+ output_dir = "/home/#{LIVE_USER}/Persistent/Tor Browser"
+ else
+ output_dir = "/home/#{LIVE_USER}/Tor Browser"
+ end
+ @screen.type("p", Sikuli::KeyModifier.CTRL)
+ @screen.wait("TorBrowserPrintDialog.png", 20)
+ @screen.wait_and_click("BrowserPrintToFile.png", 10)
+ @screen.wait_and_double_click("TorBrowserPrintOutputFile.png", 10)
+ @screen.hide_cursor
+ @screen.wait("TorBrowserPrintOutputFileSelected.png", 10)
+ # Only the file's basename is selected by double-clicking,
+ # so we type only the desired file's basename to replace it
+ @screen.type(output_dir + '/' + output_file.sub(/[.]pdf$/, '') + Sikuli::Key.ENTER)
+ try_for(30, :msg => "The page was not printed to #{output_dir}/#{output_file}") {
+ $vm.file_exist?("#{output_dir}/#{output_file}")
+ }
+end
+
+Given /^a web server is running on the LAN$/ do
+ web_server_ip_addr = $vmnet.bridge_ip_addr
+ web_server_port = 8000
+ @web_server_url = "http://#{web_server_ip_addr}:#{web_server_port}"
+ web_server_hello_msg = "Welcome to the LAN web server!"
+
+ # I've tested ruby Thread:s, fork(), etc. but nothing works due to
+ # various strange limitations in the ruby interpreter. For instance,
+ # apparently concurrent IO has serious limits in the thread
+ # scheduler (e.g. sikuli's wait() would block WEBrick from reading
+ # from its socket), and fork():ing results in a lot of complex
+ # cucumber stuff (like our hooks!) ending up in the child process,
+ # breaking stuff in the parent process. After asking some supposed
+ # ruby pros, I've settled on the following.
+ code = <<-EOF
+ require "webrick"
+ STDOUT.reopen("/dev/null", "w")
+ STDERR.reopen("/dev/null", "w")
+ server = WEBrick::HTTPServer.new(:BindAddress => "#{web_server_ip_addr}",
+ :Port => #{web_server_port},
+ :DocumentRoot => "/dev/null")
+ server.mount_proc("/") do |req, res|
+ res.body = "#{web_server_hello_msg}"
+ end
+ server.start
+EOF
+ proc = IO.popen(['ruby', '-e', code])
+ try_for(10, :msg => "It seems the LAN web server failed to start") do
+ Process.kill(0, proc.pid) == 1
+ end
+
+ add_after_scenario_hook { Process.kill("TERM", proc.pid) }
+
+ # It seems necessary to actually check that the LAN server is
+ # serving, possibly because it isn't doing so reliably when setting
+ # up. If e.g. the Unsafe Browser (which *should* be able to access
+ # the web server) tries to access it too early, Firefox seems to
+ # take some random amount of time to retry fetching. Curl gives a
+ # more consistent result, so let's rely on that instead. Note that
+ # this forces us to capture traffic *after* this step in case
+ # accessing this server matters, like when testing the Tor Browser..
+ try_for(30, :msg => "Something is wrong with the LAN web server") do
+ msg = $vm.execute_successfully("curl #{@web_server_url}",
+ :user => LIVE_USER).stdout.chomp
+ web_server_hello_msg == msg
+ end
+end
+
+When /^I open a page on the LAN web server in the (.*)$/ do |browser|
+ step "I open the address \"#{@web_server_url}\" in the #{browser}"
+end
+
+Given /^I wait (?:between (\d+) and )?(\d+) seconds$/ do |min, max|
+ if min
+ time = rand(max.to_i - min.to_i + 1) + min.to_i
+ else
+ time = max.to_i
+ end
+ puts "Slept for #{time} seconds"
+ sleep(time)
+end
+
+Given /^I (?:re)?start monitoring the AppArmor log of "([^"]+)"$/ do |profile|
+ # AppArmor log entries may be dropped if printk rate limiting is
+ # enabled.
+ $vm.execute_successfully('sysctl -w kernel.printk_ratelimit=0')
+ # We will only care about entries for this profile from this time
+ # and on.
+ guest_time = $vm.execute_successfully(
+ 'date +"%Y-%m-%d %H:%M:%S"').stdout.chomp
+ @apparmor_profile_monitoring_start ||= Hash.new
+ @apparmor_profile_monitoring_start[profile] = guest_time
+end
+
+When /^AppArmor has (not )?denied "([^"]+)" from opening "([^"]+)"(?: after at most (\d+) seconds)?$/ do |anti_test, profile, file, time|
+ assert(@apparmor_profile_monitoring_start &&
+ @apparmor_profile_monitoring_start[profile],
+ "It seems the profile '#{profile}' isn't being monitored by the " +
+ "'I monitor the AppArmor log of ...' step")
+ audit_line_regex = 'apparmor="DENIED" operation="open" profile="%s" name="%s"' % [profile, file]
+ block = Proc.new do
+ audit_log = $vm.execute(
+ "journalctl --full --no-pager " +
+ "--since='#{@apparmor_profile_monitoring_start[profile]}' " +
+ "SYSLOG_IDENTIFIER=kernel | grep -w '#{audit_line_regex}'"
+ ).stdout.chomp
+ assert(audit_log.empty? == (anti_test ? true : false))
+ true
+ end
+ begin
+ if time
+ try_for(time.to_i) { block.call }
+ else
+ block.call
+ end
+ rescue Timeout::Error, Test::Unit::AssertionFailedError => e
+ raise e, "AppArmor has #{anti_test ? "" : "not "}denied the operation"
+ end
+end
+
+Then /^I force Tor to use a new circuit$/ do
+ debug_log("Forcing new Tor circuit...")
+ $vm.execute_successfully('tor_control_send "signal NEWNYM"', :libs => 'tor')
+end
+
+When /^I eject the boot medium$/ do
+ dev = boot_device
+ dev_type = device_info(dev)['ID_TYPE']
+ case dev_type
+ when 'cd'
+ $vm.remove_cdrom
+ when 'disk'
+ boot_disk_name = $vm.disk_name(dev)
+ $vm.unplug_drive(boot_disk_name)
+ else
+ raise "Unsupported medium type '#{dev_type}' for boot device '#{dev}'"
+ end
+end
diff --git a/cucumber/features/step_definitions/dhcp.rb b/cucumber/features/step_definitions/dhcp.rb
new file mode 100644
index 00000000..ef4d9e15
--- /dev/null
+++ b/cucumber/features/step_definitions/dhcp.rb
@@ -0,0 +1,19 @@
+Then /^the hostname should not have been leaked on the network$/ do
+ hostname = $vm.execute("hostname").stdout.chomp
+ packets = PacketFu::PcapFile.new.file_to_array(:filename => @sniffer.pcap_file)
+ packets.each do |p|
+ # if PacketFu::TCPPacket.can_parse?(p)
+ # ipv4_tcp_packets << PacketFu::TCPPacket.parse(p)
+ if PacketFu::IPPacket.can_parse?(p)
+ payload = PacketFu::IPPacket.parse(p).payload
+ elsif PacketFu::IPv6Packet.can_parse?(p)
+ payload = PacketFu::IPv6Packet.parse(p).payload
+ else
+ @sniffer.save_pcap_file
+ raise "Found something in the pcap file that either is non-IP, or cannot be parsed"
+ end
+ if payload.match(hostname)
+ raise "Hostname leak detected"
+ end
+ end
+end
diff --git a/cucumber/features/step_definitions/electrum.rb b/cucumber/features/step_definitions/electrum.rb
new file mode 100644
index 00000000..447983d4
--- /dev/null
+++ b/cucumber/features/step_definitions/electrum.rb
@@ -0,0 +1,52 @@
+Then /^I start Electrum through the GNOME menu$/ do
+ step "I start \"Electrum\" via the GNOME \"Internet\" applications menu"
+end
+
+When /^a bitcoin wallet is (|not )present$/ do |existing|
+ wallet = "/home/#{LIVE_USER}/.electrum/wallets/default_wallet"
+ case existing
+ when ""
+ step "the file \"#{wallet}\" exists after at most 10 seconds"
+ when "not "
+ step "the file \"#{wallet}\" does not exist"
+ else
+ raise "Unknown value specified for #{existing}"
+ end
+end
+
+When /^I create a new bitcoin wallet$/ do
+ @screen.wait("ElectrumNoWallet.png", 10)
+ @screen.wait_and_click("ElectrumNextButton.png", 10)
+ @screen.wait("ElectrumWalletGenerationSeed.png", 15)
+ @screen.wait_and_click("ElectrumWalletSeedTextbox.png", 15)
+ @screen.type('a', Sikuli::KeyModifier.CTRL) # select wallet seed
+ @screen.type('c', Sikuli::KeyModifier.CTRL) # copy seed to clipboard
+ seed = $vm.get_clipboard
+ @screen.wait_and_click("ElectrumNextButton.png", 15)
+ @screen.wait("ElectrumWalletSeedTextbox.png", 15)
+ @screen.type(seed) # Confirm seed
+ @screen.wait_and_click("ElectrumNextButton.png", 10)
+ @screen.wait_and_click("ElectrumEncryptWallet.png", 10)
+ @screen.type("asdf" + Sikuli::Key.TAB) # set password
+ @screen.type("asdf" + Sikuli::Key.TAB) # confirm password
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait("ElectrumConnectServer.png", 20)
+ @screen.wait_and_click("ElectrumNextButton.png", 10)
+ @screen.wait("ElectrumPreferencesButton.png", 30)
+end
+
+Then /^I see a warning that Electrum is not persistent$/ do
+ @screen.wait('GnomeQuestionDialogIcon.png', 30)
+end
+
+Then /^I am prompted to create a new wallet$/ do
+ @screen.wait('ElectrumNoWallet.png', 60)
+end
+
+Then /^I see the main Electrum client window$/ do
+ @screen.wait('ElectrumPreferencesButton.png', 20)
+end
+
+Then /^Electrum successfully connects to the network$/ do
+ @screen.wait('ElectrumStatus.png', 180)
+end
diff --git a/cucumber/features/step_definitions/encryption.rb b/cucumber/features/step_definitions/encryption.rb
new file mode 100644
index 00000000..9f7f1b96
--- /dev/null
+++ b/cucumber/features/step_definitions/encryption.rb
@@ -0,0 +1,133 @@
+def seahorse_menu_click_helper(main, sub, verify = nil)
+ try_for(60) do
+ step "process \"#{verify}\" is running" if verify
+ @screen.hide_cursor
+ @screen.wait_and_click(main, 10)
+ @screen.wait_and_click(sub, 10)
+ return
+ end
+end
+
+Given /^I generate an OpenPGP key named "([^"]+)" with password "([^"]+)"$/ do |name, pwd|
+ @passphrase = pwd
+ @key_name = name
+ gpg_key_recipie = <<EOF
+ Key-Type: RSA
+ Key-Length: 4096
+ Subkey-Type: RSA
+ Subkey-Length: 4096
+ Name-Real: #{@key_name}
+ Name-Comment: Blah
+ Name-Email: #{@key_name}@test.org
+ Expire-Date: 0
+ Passphrase: #{pwd}
+ %commit
+EOF
+ gpg_key_recipie.split("\n").each do |line|
+ $vm.execute("echo '#{line}' >> /tmp/gpg_key_recipie", :user => LIVE_USER)
+ end
+ c = $vm.execute("gpg --batch --gen-key < /tmp/gpg_key_recipie",
+ :user => LIVE_USER)
+ assert(c.success?, "Failed to generate OpenPGP key:\n#{c.stderr}")
+end
+
+When /^I type a message into gedit$/ do
+ step 'I start "Gedit" via the GNOME "Accessories" applications menu'
+ @screen.wait_and_click("GeditWindow.png", 20)
+ # We don't have a good visual indicator for when we can continue. Without the
+ # sleep we may start typing in the gedit window far too soon, causing
+ # keystrokes to go missing.
+ sleep 5
+ @screen.type("ATTACK AT DAWN")
+end
+
+def maybe_deal_with_pinentry
+ begin
+ @screen.wait_and_click("PinEntryPrompt.png", 10)
+ # Without this sleep here (and reliable visual indicators) we can sometimes
+ # miss keystrokes by typing too soon. This sleep prevents this problem from
+ # coming up.
+ sleep 5
+ @screen.type(@passphrase + Sikuli::Key.ENTER)
+ rescue FindFailed
+ # The passphrase was cached or we wasn't prompted at all (e.g. when
+ # only encrypting to a public key)
+ end
+end
+
+def gedit_copy_all_text
+ context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditSelectAll.png')
+ context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditCopy.png')
+end
+
+def paste_into_a_new_tab
+ @screen.wait_and_click("GeditNewTab.png", 20)
+ context_menu_helper('GeditWindow.png', 'GeditStatusBar.png', 'GeditPaste.png')
+end
+
+def encrypt_sign_helper
+ gedit_copy_all_text
+ seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletSignEncrypt.png')
+ @screen.wait_and_click("GpgAppletChooseKeyWindow.png", 30)
+ # We don't have a good visual indicator for when we can continue without
+ # keystrokes being lost.
+ sleep 5
+ yield
+ maybe_deal_with_pinentry
+ paste_into_a_new_tab
+end
+
+def decrypt_verify_helper(icon)
+ gedit_copy_all_text
+ seahorse_menu_click_helper(icon, 'GpgAppletDecryptVerify.png')
+ maybe_deal_with_pinentry
+ @screen.wait("GpgAppletResults.png", 20)
+ @screen.wait("GpgAppletResultsMsg.png", 20)
+end
+
+When /^I encrypt the message using my OpenPGP key$/ do
+ encrypt_sign_helper do
+ @screen.type(@key_name + Sikuli::Key.ENTER + Sikuli::Key.ENTER)
+ end
+end
+
+Then /^I can decrypt the encrypted message$/ do
+ decrypt_verify_helper("GpgAppletIconEncrypted.png")
+ @screen.wait("GpgAppletResultsEncrypted.png", 20)
+end
+
+When /^I sign the message using my OpenPGP key$/ do
+ encrypt_sign_helper do
+ @screen.type(Sikuli::Key.TAB + Sikuli::Key.DOWN + Sikuli::Key.ENTER)
+ end
+end
+
+Then /^I can verify the message's signature$/ do
+ decrypt_verify_helper("GpgAppletIconSigned.png")
+ @screen.wait("GpgAppletResultsSigned.png", 20)
+end
+
+When /^I both encrypt and sign the message using my OpenPGP key$/ do
+ encrypt_sign_helper do
+ @screen.wait_and_click('GpgAppletEncryptionKey.png', 20)
+ @screen.type(Sikuli::Key.SPACE)
+ @screen.wait('GpgAppletKeySelected.png', 10)
+ @screen.type(Sikuli::Key.TAB + Sikuli::Key.DOWN + Sikuli::Key.ENTER)
+ @screen.type(Sikuli::Key.ENTER)
+ end
+end
+
+Then /^I can decrypt and verify the encrypted message$/ do
+ decrypt_verify_helper("GpgAppletIconEncrypted.png")
+ @screen.wait("GpgAppletResultsEncrypted.png", 20)
+ @screen.wait("GpgAppletResultsSigned.png", 20)
+end
+
+When /^I symmetrically encrypt the message with password "([^"]+)"$/ do |pwd|
+ @passphrase = pwd
+ gedit_copy_all_text
+ seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletEncryptPassphrase.png')
+ maybe_deal_with_pinentry # enter password
+ maybe_deal_with_pinentry # confirm password
+ paste_into_a_new_tab
+end
diff --git a/cucumber/features/step_definitions/evince.rb b/cucumber/features/step_definitions/evince.rb
new file mode 100644
index 00000000..9411ac4d
--- /dev/null
+++ b/cucumber/features/step_definitions/evince.rb
@@ -0,0 +1,25 @@
+When /^I(?:| try to) open "([^"]+)" with Evince$/ do |filename|
+ step "I run \"evince #{filename}\" in GNOME Terminal"
+end
+
+Then /^I can print the current document to "([^"]+)"$/ do |output_file|
+ @screen.type("p", Sikuli::KeyModifier.CTRL)
+ @screen.wait("EvincePrintDialog.png", 10)
+ @screen.wait_and_click("EvincePrintToFile.png", 10)
+ @screen.wait_and_click("EvincePrintOutputFileButton.png", 10)
+ @screen.wait("EvincePrintFileDialog.png", 10)
+ # Only the file's basename is selected by double-clicking,
+ # so we type only the desired file's basename to replace it
+ $vm.set_clipboard(output_file.sub(/[.]pdf$/, ''))
+ @screen.type('v', Sikuli::KeyModifier.CTRL)
+ @screen.type(Sikuli::Key.ENTER)
+ @screen.wait_and_click("EvincePrintButton.png", 10)
+ try_for(10, :msg => "The document was not printed to #{output_file}") {
+ $vm.file_exist?(output_file)
+ }
+end
+
+When /^I close Evince$/ do
+ @screen.type("w", Sikuli::KeyModifier.CTRL)
+ step 'process "evince" has stopped running after at most 20 seconds'
+end
diff --git a/cucumber/features/step_definitions/firewall_leaks.rb b/cucumber/features/step_definitions/firewall_leaks.rb
new file mode 100644
index 00000000..942d00b8
--- /dev/null
+++ b/cucumber/features/step_definitions/firewall_leaks.rb
@@ -0,0 +1,56 @@
+Then(/^the firewall leak detector has detected (.*?) leaks$/) do |type|
+ leaks = FirewallLeakCheck.new(@sniffer.pcap_file,
+ :accepted_hosts => get_all_tor_nodes)
+ case type.downcase
+ when 'ipv4 tcp'
+ if leaks.ipv4_tcp_leaks.empty?
+ leaks.save_pcap_file
+ raise "Couldn't detect any IPv4 TCP leaks"
+ end
+ when 'ipv4 non-tcp'
+ if leaks.ipv4_nontcp_leaks.empty?
+ leaks.save_pcap_file
+ raise "Couldn't detect any IPv4 non-TCP leaks"
+ end
+ when 'ipv6'
+ if leaks.ipv6_leaks.empty?
+ leaks.save_pcap_file
+ raise "Couldn't detect any IPv6 leaks"
+ end
+ when 'non-ip'
+ if leaks.nonip_leaks.empty?
+ leaks.save_pcap_file
+ raise "Couldn't detect any non-IP leaks"
+ end
+ else
+ raise "Incorrect packet type '#{type}'"
+ end
+end
+
+Given(/^I disable Tails' firewall$/) do
+ $vm.execute("/usr/local/lib/do_not_ever_run_me")
+ iptables = $vm.execute("iptables -L -n -v").stdout.chomp.split("\n")
+ for line in iptables do
+ if !line[/Chain (INPUT|OUTPUT|FORWARD) \(policy ACCEPT/] and
+ !line[/pkts[[:blank:]]+bytes[[:blank:]]+target/] and
+ !line.empty?
+ raise "The Tails firewall was not successfully disabled:\n#{iptables}"
+ end
+ end
+end
+
+When(/^I do a TCP DNS lookup of "(.*?)"$/) do |host|
+ lookup = $vm.execute("host -T #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER)
+ assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}")
+end
+
+When(/^I do a UDP DNS lookup of "(.*?)"$/) do |host|
+ lookup = $vm.execute("host #{host} #{SOME_DNS_SERVER}", :user => LIVE_USER)
+ assert(lookup.success?, "Failed to resolve #{host}:\n#{lookup.stdout}")
+end
+
+When(/^I send some ICMP pings$/) do
+ # We ping an IP address to avoid a DNS lookup
+ ping = $vm.execute("ping -c 5 #{SOME_DNS_SERVER}")
+ assert(ping.success?, "Failed to ping #{SOME_DNS_SERVER}:\n#{ping.stderr}")
+end
diff --git a/cucumber/features/step_definitions/git.rb b/cucumber/features/step_definitions/git.rb
new file mode 100644
index 00000000..bf6f869d
--- /dev/null
+++ b/cucumber/features/step_definitions/git.rb
@@ -0,0 +1,6 @@
+Then /^the Git repository "([\S]+)" has been cloned successfully$/ do |repo|
+ assert($vm.directory_exist?("/home/#{LIVE_USER}/#{repo}/.git"))
+ assert($vm.file_exist?("/home/#{LIVE_USER}/#{repo}/.git/config"))
+ $vm.execute_successfully("cd '/home/#{LIVE_USER}/#{repo}/' && git status",
+ :user => LIVE_USER)
+end
diff --git a/cucumber/features/step_definitions/icedove.rb b/cucumber/features/step_definitions/icedove.rb
new file mode 100644
index 00000000..d3672895
--- /dev/null
+++ b/cucumber/features/step_definitions/icedove.rb
@@ -0,0 +1,94 @@
+Then /^Icedove has started$/ do
+ step 'process "icedove" is running within 30 seconds'
+ @screen.wait('IcedoveMainWindow.png', 60)
+end
+
+When /^I have not configured an email account$/ do
+ icedove_prefs = $vm.file_content("/home/#{LIVE_USER}/.icedove/profile.default/prefs.js").chomp
+ assert(!icedove_prefs.include?('mail.accountmanager.accounts'))
+end
+
+Then /^I am prompted to setup an email account$/ do
+ $vm.focus_window('Mail Account Setup')
+ @screen.wait('IcedoveMailAccountSetup.png', 30)
+end
+
+Then /^IMAP is the default protocol$/ do
+ $vm.focus_window('Mail Account Setup')
+ @screen.wait('IcedoveProtocolIMAP.png', 10)
+end
+
+Then /^I cancel setting up an email account$/ do
+ $vm.focus_window('Mail Account Setup')
+ @screen.type(Sikuli::Key.ESC)
+ @screen.waitVanish('IcedoveMailAccountSetup.png', 10)
+end
+
+Then /^I open Icedove's Add-ons Manager$/ do
+ $vm.focus_window('Icedove')
+ @screen.wait_and_click('MozillaMenuButton.png', 10)
+ @screen.wait_and_click('IcedoveToolsMenuAddOns.png', 10)
+ @screen.wait('MozillaAddonsManagerExtensions.png', 30)
+end
+
+Then /^I click the extensions tab$/ do
+ @screen.wait_and_click('MozillaAddonsManagerExtensions.png', 10)
+end
+
+Then /^I see that Adblock is not installed in Icedove$/ do
+ if @screen.exists('MozillaExtensionsAdblockPlus.png')
+ raise 'Adblock should not be enabled within Icedove'
+ end
+end
+
+When /^I go into Enigmail's preferences$/ do
+ $vm.focus_window('Icedove')
+ @screen.type("a", Sikuli::KeyModifier.ALT)
+ @screen.wait_and_click('IcedoveEnigmailPreferences.png', 10)
+ @screen.wait('IcedoveEnigmailPreferencesWindow.png', 10)
+ @screen.click('IcedoveEnigmailExpertSettingsButton.png')
+ @screen.wait('IcedoveEnigmailKeyserverTab.png', 10)
+end
+
+When /^I click Enigmail's keyserver tab$/ do
+ @screen.wait_and_click('IcedoveEnigmailKeyserverTab.png', 10)
+end
+
+Then /^I see that Enigmail is configured to use the correct keyserver$/ do
+ @screen.wait('IcedoveEnigmailKeyserver.png', 10)
+end
+
+Then /^I click Enigmail's advanced tab$/ do
+ @screen.wait_and_click('IcedoveEnigmailAdvancedTab.png', 10)
+end
+
+Then /^I see that Enigmail is configured to use the correct SOCKS proxy$/ do
+ @screen.click('IcedoveEnigmailAdvancedParameters.png')
+ @screen.type(Sikuli::Key.END)
+ @screen.wait('IcedoveEnigmailProxy.png', 10)
+end
+
+Then /^I see that Torbirdy is configured to use Tor$/ do
+ @screen.wait('IcedoveTorbirdyEnabled.png', 10)
+end
+
+When /^I open Torbirdy's preferences$/ do
+ step "I open Icedove's Add-ons Manager"
+ step 'I click the extensions tab'
+ @screen.wait_and_click('MozillaExtensionsTorbirdy.png', 10)
+ @screen.type(Sikuli::Key.TAB) # Select 'More' link
+ @screen.type(Sikuli::Key.TAB) # Select 'Preferences' button
+ @screen.type(Sikuli::Key.SPACE) # Press 'Preferences' button
+ @screen.wait('GnomeQuestionDialogIcon.png', 10)
+ @screen.type(Sikuli::Key.ENTER)
+end
+
+When /^I test Torbirdy's proxy settings$/ do
+ @screen.wait('IcedoveTorbirdyPreferencesWindow.png', 10)
+ @screen.click('IcedoveTorbirdyTestProxySettingsButton.png')
+ @screen.wait('IcedoveTorbirdyCongratulationsTab.png', 180)
+end
+
+Then /^Torbirdy's proxy test is successful$/ do
+ @screen.wait('IcedoveTorbirdyCongratulationsTab.png', 180)
+end
diff --git a/cucumber/features/step_definitions/mac_spoofing.rb b/cucumber/features/step_definitions/mac_spoofing.rb
new file mode 100644
index 00000000..a4aa8714
--- /dev/null
+++ b/cucumber/features/step_definitions/mac_spoofing.rb
@@ -0,0 +1,108 @@
+def all_ethernet_nics
+ $vm.execute_successfully(
+ "get_all_ethernet_nics", :libs => 'hardware'
+ ).stdout.split
+end
+
+When /^I disable MAC spoofing in Tails Greeter$/ do
+ @screen.wait_and_click("TailsGreeterMACSpoofing.png", 30)
+end
+
+Then /^the network device has (its default|a spoofed) MAC address configured$/ do |mode|
+ is_spoofed = (mode == "a spoofed")
+ nic = "eth0"
+ assert_equal([nic], all_ethernet_nics,
+ "We only expected NIC #{nic} but these are present: " +
+ all_ethernet_nics.join(", "))
+ nic_real_mac = $vm.real_mac
+ nic_current_mac = $vm.execute_successfully(
+ "get_current_mac_of_nic #{nic}", :libs => 'hardware'
+ ).stdout.chomp
+ if is_spoofed
+ if nic_real_mac == nic_current_mac
+ save_pcap_file
+ raise "The MAC address was expected to be spoofed but wasn't"
+ end
+ else
+ if nic_real_mac != nic_current_mac
+ save_pcap_file
+ raise "The MAC address is spoofed but was expected to not be"
+ end
+ end
+end
+
+Then /^the real MAC address was (not )?leaked$/ do |mode|
+ is_leaking = mode.nil?
+ leaks = FirewallLeakCheck.new(@sniffer.pcap_file)
+ mac_leaks = leaks.mac_leaks
+ if is_leaking
+ if !mac_leaks.include?($vm.real_mac)
+ save_pcap_file
+ raise "The real MAC address was expected to leak but didn't. We " +
+ "observed the following MAC addresses: #{mac_leaks}"
+ end
+ else
+ if mac_leaks.include?($vm.real_mac)
+ save_pcap_file
+ raise "The real MAC address was leaked but was expected not to. We " +
+ "observed the following MAC addresses: #{mac_leaks}"
+ end
+ end
+end
+
+Given /^macchanger will fail by not spoofing and always returns ([\S]+)$/ do |mode|
+ $vm.execute_successfully("mv /usr/bin/macchanger /usr/bin/macchanger.orig")
+ $vm.execute_successfully("ln -s /bin/#{mode} /usr/bin/macchanger")
+end
+
+Given /^no network interface modules can be unloaded$/ do
+ # Note that the real /sbin/modprobe is a symlink to /bin/kmod, and
+ # for it to run in modprobe compatibility mode the name must be
+ # exactly "modprobe", so we just move it somewhere our of the path
+ # instead of renaming it ".real" or whatever we usuablly do when
+ # diverting executables for wrappers.
+ modprobe_divert = "/usr/local/lib/modprobe"
+ $vm.execute_successfully(
+ "dpkg-divert --add --rename --divert '#{modprobe_divert}' /sbin/modprobe"
+ )
+ fake_modprobe_wrapper = <<EOF
+#!/bin/sh
+if echo "${@}" | grep -q -- -r; then
+ exit 1
+fi
+exec '#{modprobe_divert}' "${@}"
+EOF
+ $vm.file_append('/sbin/modprobe', fake_modprobe_wrapper)
+ $vm.execute_successfully("chmod a+rx /sbin/modprobe")
+end
+
+When /^see the "Network card disabled" notification$/ do
+ robust_notification_wait("MACSpoofNetworkCardDisabled.png", 60)
+end
+
+When /^see the "All networking disabled" notification$/ do
+ robust_notification_wait("MACSpoofNetworkingDisabled.png", 60)
+end
+
+Then /^(\d+|no) network interface(?:s)? (?:is|are) enabled$/ do |expected_nr_nics|
+ # note that "no".to_i => 0 in Ruby.
+ expected_nr_nics = expected_nr_nics.to_i
+ nr_nics = all_ethernet_nics.size
+ assert_equal(expected_nr_nics, nr_nics)
+end
+
+Then /^the MAC spoofing panic mode disabled networking$/ do
+ nm_state = $vm.execute_successfully('systemctl show NetworkManager').stdout
+ nm_is_disabled = $vm.pidof('NetworkManager').empty? &&
+ nm_state[/^LoadState=masked$/] &&
+ nm_state[/^ActiveState=inactive$/]
+ assert(nm_is_disabled, "NetworkManager was not disabled")
+ all_ethernet_nics.each do |nic|
+ ["nic_ipv4_addr", "nic_ipv6_addr"].each do |function|
+ addr = $vm.execute_successfully(
+ "#{function} #{nic}", :libs => 'hardware'
+ ).stdout.chomp
+ assert_equal("", addr, "NIC #{nic} was assigned address #{addr}")
+ end
+ end
+end
diff --git a/cucumber/features/step_definitions/pidgin.rb b/cucumber/features/step_definitions/pidgin.rb
new file mode 100644
index 00000000..3f5ed931
--- /dev/null
+++ b/cucumber/features/step_definitions/pidgin.rb
@@ -0,0 +1,467 @@
+# Extracts the secrets for the XMMP account `account_name`.
+def xmpp_account(account_name, required_options = [])
+ begin
+ account = $config["Pidgin"]["Accounts"]["XMPP"][account_name]
+ check_keys = ["username", "domain", "password"] + required_options
+ for key in check_keys do
+ assert(account.has_key?(key))
+ assert_not_nil(account[key])
+ assert(!account[key].empty?)
+ end
+ rescue NoMethodError, Test::Unit::AssertionFailedError
+ raise(
+<<EOF
+Your Pidgin:Accounts:XMPP:#{account} is incorrect or missing from your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format.
+EOF
+)
+ end
+ return account
+end
+
+def wait_and_focus(img, time = 10, window)
+ begin
+ @screen.wait(img, time)
+ rescue FindFailed
+ $vm.focus_window(window)
+ @screen.wait(img, time)
+ end
+end
+
+def focus_pidgin_irc_conversation_window(account)
+ if account == 'I2P'
+ # After connecting to Irc2P messages are sent from services. Most of the
+ # time the services will send their messages right away. If there's lag we
+ # may in fact join the channel _before_ the message is received. We'll look
+ # for a message from InfoServ first then default to looking for '#i2p'
+ try_for(20) do
+ begin
+ $vm.focus_window('InfoServ')
+ rescue ExecutionFailedInVM
+ $vm.focus_window('#i2p')
+ end
+ end
+ else
+ account = account.sub(/^irc\./, '')
+ try_for(20) do
+ $vm.focus_window(".*#{Regexp.escape(account)}$")
+ end
+ end
+end
+
+When /^I create my XMPP account$/ do
+ account = xmpp_account("Tails_account")
+ @screen.click("PidginAccountManagerAddButton.png")
+ @screen.wait("PidginAddAccountWindow.png", 20)
+ @screen.click_mid_right_edge("PidginAddAccountProtocolLabel.png")
+ @screen.click("PidginAddAccountProtocolXMPP.png")
+ # We first wait for some field that is shown for XMPP but not the
+ # default (IRC) since we otherwise may decide where we click before
+ # the GUI has updated after switching protocol.
+ @screen.wait("PidginAddAccountXMPPDomain.png", 5)
+ @screen.click_mid_right_edge("PidginAddAccountXMPPUsername.png")
+ @screen.type(account["username"])
+ @screen.click_mid_right_edge("PidginAddAccountXMPPDomain.png")
+ @screen.type(account["domain"])
+ @screen.click_mid_right_edge("PidginAddAccountXMPPPassword.png")
+ @screen.type(account["password"])
+ @screen.click("PidginAddAccountXMPPRememberPassword.png")
+ if account["connect_server"]
+ @screen.click("PidginAddAccountXMPPAdvancedTab.png")
+ @screen.click_mid_right_edge("PidginAddAccountXMPPConnectServer.png")
+ @screen.type(account["connect_server"])
+ end
+ @screen.click("PidginAddAccountXMPPAddButton.png")
+end
+
+Then /^Pidgin automatically enables my XMPP account$/ do
+ $vm.focus_window('Buddy List')
+ @screen.wait("PidginAvailableStatus.png", 60*3)
+end
+
+Given /^my XMPP friend goes online( and joins the multi-user chat)?$/ do |join_chat|
+ account = xmpp_account("Friend_account", ["otr_key"])
+ bot_opts = account.select { |k, v| ["connect_server"].include?(k) }
+ if join_chat
+ bot_opts["auto_join"] = [@chat_room_jid]
+ end
+ @friend_name = account["username"]
+ @chatbot = ChatBot.new(account["username"] + "@" + account["domain"],
+ account["password"], account["otr_key"], bot_opts)
+ @chatbot.start
+ add_after_scenario_hook { @chatbot.stop }
+ $vm.focus_window('Buddy List')
+ @screen.wait("PidginFriendOnline.png", 60)
+end
+
+When /^I start a conversation with my friend$/ do
+ $vm.focus_window('Buddy List')
+ # Clicking the middle, bottom of this image should query our
+ # friend, given it's the only subscribed user that's online, which
+ # we assume.
+ r = @screen.find("PidginFriendOnline.png")
+ bottom_left = r.getBottomLeft()
+ x = bottom_left.getX + r.getW/2
+ y = bottom_left.getY
+ @screen.doubleClick_point(x, y)
+ # Since Pidgin sets the window name to the contact, we have no good
+ # way to identify the conversation window. Let's just look for the
+ # expected menu bar.
+ @screen.wait("PidginConversationWindowMenuBar.png", 10)
+end
+
+And /^I say something to my friend( in the multi-user chat)?$/ do |multi_chat|
+ msg = "ping" + Sikuli::Key.ENTER
+ if multi_chat
+ $vm.focus_window(@chat_room_jid.split("@").first)
+ msg = @friend_name + ": " + msg
+ else
+ $vm.focus_window(@friend_name)
+ end
+ @screen.type(msg)
+end
+
+Then /^I receive a response from my friend( in the multi-user chat)?$/ do |multi_chat|
+ if multi_chat
+ $vm.focus_window(@chat_room_jid.split("@").first)
+ else
+ $vm.focus_window(@friend_name)
+ end
+ @screen.wait("PidginFriendExpectedAnswer.png", 20)
+end
+
+When /^I start an OTR session with my friend$/ do
+ $vm.focus_window(@friend_name)
+ @screen.click("PidginConversationOTRMenu.png")
+ @screen.hide_cursor
+ @screen.click("PidginOTRMenuStartSession.png")
+end
+
+Then /^Pidgin automatically generates an OTR key$/ do
+ @screen.wait("PidginOTRKeyGenPrompt.png", 30)
+ @screen.wait_and_click("PidginOTRKeyGenPromptDoneButton.png", 30)
+end
+
+Then /^an OTR session was successfully started with my friend$/ do
+ $vm.focus_window(@friend_name)
+ @screen.wait("PidginConversationOTRUnverifiedSessionStarted.png", 10)
+end
+
+# The reason the chat must be empty is to guarantee that we don't mix
+# up messages/events from other users with the ones we expect from the
+# bot.
+When /^I join some empty multi-user chat$/ do
+ $vm.focus_window('Buddy List')
+ @screen.click("PidginBuddiesMenu.png")
+ @screen.wait_and_click("PidginBuddiesMenuJoinChat.png", 10)
+ @screen.wait_and_click("PidginJoinChatWindow.png", 10)
+ @screen.click_mid_right_edge("PidginJoinChatRoomLabel.png")
+ account = xmpp_account("Tails_account")
+ if account.has_key?("chat_room") && \
+ !account["chat_room"].nil? && \
+ !account["chat_room"].empty?
+ chat_room = account["chat_room"]
+ else
+ chat_room = random_alnum_string(10, 15)
+ end
+ @screen.type(chat_room)
+
+ # We will need the conference server later, when starting the bot.
+ @screen.click_mid_right_edge("PidginJoinChatServerLabel.png")
+ @screen.type("a", Sikuli::KeyModifier.CTRL)
+ @screen.type("c", Sikuli::KeyModifier.CTRL)
+ conference_server =
+ $vm.execute_successfully("xclip -o", :user => LIVE_USER).stdout.chomp
+ @chat_room_jid = chat_room + "@" + conference_server
+
+ @screen.click("PidginJoinChatButton.png")
+ # The following will both make sure that the we joined the chat, and
+ # that it is empty. We'll also deal with the *potential* "Create New
+ # Room" prompt that Pidgin shows for some server configurations.
+ images = ["PidginCreateNewRoomPrompt.png",
+ "PidginChat1UserInRoom.png"]
+ image_found, _ = @screen.waitAny(images, 30)
+ if image_found == "PidginCreateNewRoomPrompt.png"
+ @screen.click("PidginCreateNewRoomAcceptDefaultsButton.png")
+ end
+ $vm.focus_window(@chat_room_jid)
+ @screen.wait("PidginChat1UserInRoom.png", 10)
+end
+
+# Since some servers save the scrollback, and sends it when joining,
+# it's safer to clear it so we do not get false positives from old
+# messages when looking for a particular response, or similar.
+When /^I clear the multi-user chat's scrollback$/ do
+ $vm.focus_window(@chat_room_jid)
+ @screen.click("PidginConversationMenu.png")
+ @screen.wait_and_click("PidginConversationMenuClearScrollback.png", 10)
+end
+
+Then /^I can see that my friend joined the multi-user chat$/ do
+ $vm.focus_window(@chat_room_jid)
+ @screen.wait("PidginChat2UsersInRoom.png", 60)
+end
+
+def configured_pidgin_accounts
+ accounts = Hash.new
+ xml = REXML::Document.new($vm.file_content('$HOME/.purple/accounts.xml',
+ LIVE_USER))
+ xml.elements.each("account/account") do |e|
+ account = e.elements["name"].text
+ account_name, network = account.split("@")
+ protocol = e.elements["protocol"].text
+ port = e.elements["settings/setting[@name='port']"].text
+ nickname = e.elements["settings/setting[@name='username']"].text
+ real_name = e.elements["settings/setting[@name='realname']"].text
+ accounts[network] = {
+ 'name' => account_name,
+ 'network' => network,
+ 'protocol' => protocol,
+ 'port' => port,
+ 'nickname' => nickname,
+ 'real_name' => real_name,
+ }
+ end
+
+ return accounts
+end
+
+def chan_image (account, channel, image)
+ images = {
+ 'irc.oftc.net' => {
+ '#tails' => {
+ 'roster' => 'PidginTailsChannelEntry',
+ 'conversation_tab' => 'PidginTailsConversationTab',
+ 'welcome' => 'PidginTailsChannelWelcome',
+ }
+ },
+ 'I2P' => {
+ '#i2p' => {
+ 'roster' => 'PidginI2PChannelEntry',
+ 'conversation_tab' => 'PidginI2PConversationTab',
+ 'welcome' => 'PidginI2PChannelWelcome',
+ }
+ }
+ }
+ return images[account][channel][image] + ".png"
+end
+
+def default_chan (account)
+ chans = {
+ 'irc.oftc.net' => '#tails',
+ 'I2P' => '#i2p',
+ }
+ return chans[account]
+end
+
+def pidgin_otr_keys
+ return $vm.file_content('$HOME/.purple/otr.private_key', LIVE_USER)
+end
+
+Given /^Pidgin has the expected accounts configured with random nicknames$/ do
+ expected = [
+ ["irc.oftc.net", "prpl-irc", "6697"],
+ ["127.0.0.1", "prpl-irc", "6668"],
+ ]
+ configured_pidgin_accounts.values.each() do |account|
+ assert(account['nickname'] != "XXX_NICK_XXX", "Nickname was no randomised")
+ assert_equal(account['nickname'], account['real_name'],
+ "Nickname and real name are not identical: " +
+ account['nickname'] + " vs. " + account['real_name'])
+ assert_equal(account['name'], account['nickname'],
+ "Account name and nickname are not identical: " +
+ account['name'] + " vs. " + account['nickname'])
+ candidate = [account['network'], account['protocol'], account['port']]
+ assert(expected.include?(candidate), "Unexpected account: #{candidate}")
+ expected.delete(candidate)
+ end
+ assert(expected.empty?, "These Pidgin accounts are not configured: " +
+ "#{expected}")
+end
+
+When /^I start Pidgin through the GNOME menu$/ do
+ step 'I start "Pidgin" via the GNOME "Internet" applications menu'
+end
+
+When /^I open Pidgin's account manager window$/ do
+ @screen.wait_and_click('PidginMenuAccounts.png', 20)
+ @screen.wait_and_click('PidginMenuManageAccounts.png', 20)
+ step "I see Pidgin's account manager window"
+end
+
+When /^I see Pidgin's account manager window$/ do
+ @screen.wait("PidginAccountWindow.png", 40)
+end
+
+When /^I close Pidgin's account manager window$/ do
+ @screen.wait_and_click("PidginAccountManagerCloseButton.png", 10)
+end
+
+When /^I (de)?activate the "([^"]+)" Pidgin account$/ do |deactivate, account|
+ @screen.click("PidginAccount_#{account}.png")
+ @screen.type(Sikuli::Key.LEFT + Sikuli::Key.SPACE)
+ if deactivate
+ @screen.waitVanish('PidginAccountEnabledCheckbox.png', 5)
+ else
+ # wait for the Pidgin to be connecting, otherwise sometimes the step
+ # that closes the account management dialog happens before the account
+ # is actually enabled
+ @screen.waitAny(['PidginConnecting.png', 'PidginAvailableStatus.png'], 5)
+ end
+end
+
+def deactivate_and_activate_pidgin_account(account)
+ debug_log("Deactivating and reactivating Pidgin account #{account}")
+ step "I open Pidgin's account manager window"
+ step "I deactivate the \"#{account}\" Pidgin account"
+ step "I close Pidgin's account manager window"
+ step "I open Pidgin's account manager window"
+ step "I activate the \"#{account}\" Pidgin account"
+ step "I close Pidgin's account manager window"
+end
+
+
+
+Then /^Pidgin successfully connects to the "([^"]+)" account$/ do |account|
+ expected_channel_entry = chan_image(account, default_chan(account), 'roster')
+ reconnect_button = 'PidginReconnect.png'
+ recovery_on_failure = Proc.new do
+ if @screen.exists('PidginReconnect.png')
+ @screen.click('PidginReconnect.png')
+ else
+ deactivate_and_activate_pidgin_account(account)
+ end
+ end
+ retrier_method = account == 'I2P' ? method(:retry_i2p) : method(:retry_tor)
+ retrier_method.call(recovery_on_failure) do
+ begin
+ $vm.focus_window('Buddy List')
+ rescue ExecutionFailedInVM
+ # Sometimes focusing the window with xdotool will fail with the
+ # conversation window right on top of it. We'll try to close the
+ # conversation window. At worst, the test will still fail...
+ close_pidgin_conversation_window(account)
+ end
+ on_screen, _ = @screen.waitAny([expected_channel_entry, reconnect_button], 60)
+ unless on_screen == expected_channel_entry
+ raise "Connecting to account #{account} failed."
+ end
+ end
+end
+
+Then /^the "([^"]*)" account only responds to PING and VERSION CTCP requests$/ do |irc_server|
+ ctcp_cmds = [
+ "CLIENTINFO", "DATE", "ERRMSG", "FINGER", "PING", "SOURCE", "TIME",
+ "USERINFO", "VERSION"
+ ]
+ expected_ctcp_replies = {
+ "PING" => /^\d+$/,
+ "VERSION" => /^Purple IRC$/
+ }
+ spam_target = configured_pidgin_accounts[irc_server]["nickname"]
+ ctcp_check = CtcpChecker.new(irc_server, 6667, spam_target, ctcp_cmds,
+ expected_ctcp_replies)
+ ctcp_check.verify_ctcp_responses
+end
+
+Then /^I can join the "([^"]+)" channel on "([^"]+)"$/ do |channel, account|
+ @screen.doubleClick( chan_image(account, channel, 'roster'))
+ @screen.hide_cursor
+ focus_pidgin_irc_conversation_window(account)
+ try_for(60) do
+ begin
+ @screen.wait_and_click(chan_image(account, channel, 'conversation_tab'), 5)
+ rescue FindFailed => e
+ # If the channel tab can't be found it could be because there were
+ # multiple connection attempts and the channel tab we want is off the
+ # screen. We'll try closing tabs until the one we want can be found.
+ @screen.type("w", Sikuli::KeyModifier.CTRL)
+ raise e
+ end
+ end
+ @screen.hide_cursor
+ @screen.wait( chan_image(account, channel, 'welcome'), 10)
+end
+
+Then /^I take note of the configured Pidgin accounts$/ do
+ @persistent_pidgin_accounts = configured_pidgin_accounts
+end
+
+Then /^I take note of the OTR key for Pidgin's "([^"]+)" account$/ do |account_name|
+ @persistent_pidgin_otr_keys = pidgin_otr_keys
+end
+
+Then /^Pidgin has the expected persistent accounts configured$/ do
+ current_accounts = configured_pidgin_accounts
+ assert(current_accounts <=> @persistent_pidgin_accounts,
+ "Currently configured Pidgin accounts do not match the persistent ones:\n" +
+ "Current:\n#{current_accounts}\n" +
+ "Persistent:\n#{@persistent_pidgin_accounts}"
+ )
+end
+
+Then /^Pidgin has the expected persistent OTR keys$/ do
+ assert_equal(pidgin_otr_keys, @persistent_pidgin_otr_keys)
+end
+
+def pidgin_add_certificate_from (cert_file)
+ # Here, we need a certificate that is not already in the NSS database
+ step "I copy \"/usr/share/ca-certificates/spi-inc.org/spi-cacert-2008.crt\" to \"#{cert_file}\" as user \"amnesia\""
+
+ $vm.focus_window('Buddy List')
+ @screen.wait_and_click('PidginToolsMenu.png', 10)
+ @screen.wait_and_click('PidginCertificatesMenuItem.png', 10)
+ @screen.wait('PidginCertificateManagerDialog.png', 10)
+ @screen.wait_and_click('PidginCertificateAddButton.png', 10)
+ begin
+ @screen.wait_and_click('GtkFileChooserDesktopButton.png', 10)
+ rescue FindFailed
+ # The first time we're run, the file chooser opens in the Recent
+ # view, so we have to browse a directory before we can use the
+ # "Type file name" button. But on subsequent runs, the file
+ # chooser is already in the Desktop directory, so we don't need to
+ # do anything. Hence, this noop exception handler.
+ end
+ @screen.wait_and_click('GtkFileTypeFileNameButton.png', 10)
+ @screen.type("l", Sikuli::KeyModifier.ALT) # "Location" field
+ @screen.type(cert_file + Sikuli::Key.ENTER)
+end
+
+Then /^I can add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir|
+ pidgin_add_certificate_from("#{cert_dir}/test.crt")
+ wait_and_focus('PidginCertificateAddHostnameDialog.png', 10, 'Certificate Import')
+ @screen.type("XXX test XXX" + Sikuli::Key.ENTER)
+ wait_and_focus('PidginCertificateTestItem.png', 10, 'Certificate Manager')
+end
+
+Then /^I cannot add a certificate from the "([^"]+)" directory to Pidgin$/ do |cert_dir|
+ pidgin_add_certificate_from("#{cert_dir}/test.crt")
+ wait_and_focus('PidginCertificateImportFailed.png', 10, 'Import Error')
+end
+
+When /^I close Pidgin's certificate manager$/ do
+ wait_and_focus('PidginCertificateManagerDialog.png', 10, 'Certificate Manager')
+ @screen.type(Sikuli::Key.ESC)
+ # @screen.wait_and_click('PidginCertificateManagerClose.png', 10)
+ @screen.waitVanish('PidginCertificateManagerDialog.png', 10)
+end
+
+When /^I close Pidgin's certificate import failure dialog$/ do
+ @screen.type(Sikuli::Key.ESC)
+ # @screen.wait_and_click('PidginCertificateManagerClose.png', 10)
+ @screen.waitVanish('PidginCertificateImportFailed.png', 10)
+end
+
+When /^I see the Tails roadmap URL$/ do
+ try_for(60) do
+ begin
+ @screen.find('PidginTailsRoadmapUrl.png')
+ rescue FindFailed => e
+ @screen.type(Sikuli::Key.PAGE_UP)
+ raise e
+ end
+ end
+end
+
+When /^I click on the Tails roadmap URL$/ do
+ @screen.click('PidginTailsRoadmapUrl.png')
+end
diff --git a/cucumber/features/step_definitions/po.rb b/cucumber/features/step_definitions/po.rb
new file mode 100644
index 00000000..c73bacef
--- /dev/null
+++ b/cucumber/features/step_definitions/po.rb
@@ -0,0 +1,8 @@
+Given /^I am in the Git branch being tested$/ do
+ Dir.chdir(GIT_DIR)
+end
+
+Then /^all the PO files should be correct$/ do
+ File.exists?('./submodules/jenkins-tools/slaves/check_po')
+ cmd_helper(['./submodules/jenkins-tools/slaves/check_po'])
+end
diff --git a/cucumber/features/step_definitions/root_access_control.rb b/cucumber/features/step_definitions/root_access_control.rb
new file mode 100644
index 00000000..ff1bdfcc
--- /dev/null
+++ b/cucumber/features/step_definitions/root_access_control.rb
@@ -0,0 +1,42 @@
+Then /^I should be able to run administration commands as the live user$/ do
+ stdout = $vm.execute("echo #{@sudo_password} | sudo -S whoami",
+ :user => LIVE_USER).stdout
+ actual_user = stdout.sub(/^\[sudo\] password for #{LIVE_USER}: /, "").chomp
+ assert_equal("root", actual_user, "Could not use sudo")
+end
+
+Then /^I should not be able to run administration commands as the live user with the "([^"]*)" password$/ do |password|
+ stderr = $vm.execute("echo #{password} | sudo -S whoami",
+ :user => LIVE_USER).stderr
+ sudo_failed = stderr.include?("The administration password is disabled") || stderr.include?("is not allowed to execute")
+ assert(sudo_failed, "The administration password is not disabled:" + stderr)
+end
+
+When /^running a command as root with pkexec requires PolicyKit administrator privileges$/ do
+ action = 'org.freedesktop.policykit.exec'
+ action_details = $vm.execute("pkaction --verbose --action-id #{action}").stdout
+ assert(action_details[/\s+implicit any:\s+auth_admin$/],
+ "Expected 'auth_admin' for 'any':\n#{action_details}")
+ assert(action_details[/\s+implicit inactive:\s+auth_admin$/],
+ "Expected 'auth_admin' for 'inactive':\n#{action_details}")
+ assert(action_details[/\s+implicit active:\s+auth_admin$/],
+ "Expected 'auth_admin' for 'active':\n#{action_details}")
+end
+
+Then /^I should be able to run a command as root with pkexec$/ do
+ step "I run \"pkexec touch /root/pkexec-test\" in GNOME Terminal"
+ step 'I enter the sudo password in the pkexec prompt'
+ try_for(10, :msg => 'The /root/pkexec-test file was not created.') {
+ $vm.execute('ls /root/pkexec-test').success?
+ }
+end
+
+Then /^I should not be able to run a command as root with pkexec and the standard passwords$/ do
+ step "I run \"pkexec touch /root/pkexec-test\" in GNOME Terminal"
+ ['', 'live', 'amnesia'].each do |password|
+ step "I enter the \"#{password}\" password in the pkexec prompt"
+ @screen.wait('PolicyKitAuthFailure.png', 20)
+ end
+ @screen.type(Sikuli::Key.ESC)
+ @screen.wait('PolicyKitAuthCompleteFailure.png', 20)
+end
diff --git a/cucumber/features/step_definitions/snapshots.rb b/cucumber/features/step_definitions/snapshots.rb
new file mode 100644
index 00000000..13e4a5b6
--- /dev/null
+++ b/cucumber/features/step_definitions/snapshots.rb
@@ -0,0 +1,257 @@
+def checkpoints
+ {
+ 'boot-d-i-to-tasksel' => {
+ :description => "I have started Debian Installer and stopped at the Tasksel prompt",
+ #:parent_checkpoint => 'no-network-logged-in',
+ :steps => [
+ 'I create a 8 GiB disk named "target"',
+ 'I plug ide drive "target"',
+ 'I start the computer',
+ 'the computer boots DebianInstaller',
+ 'I select British English',
+ 'I accept the hostname, using "example.com" as the domain',
+ 'I set the root password to "rootme"',
+ 'I set the password for "Philip Hands" to be "verysecret"',
+ 'I select full-disk, single-filesystem partitioning',
+ 'I note that the Base system is being installed',
+ 'I accept the default mirror',
+ 'I ignore Popcon',
+ 'we reach the Tasksel prompt',
+ ],
+ },
+
+ 'debian-minimal-install' => {
+ :description => "I have installed Minimal Debian",
+ :parent_checkpoint => 'boot-d-i-to-tasksel',
+ :steps => [
+ 'I hit ENTER',
+ 'I install GRUB',
+ 'I allow reboot after the install is complete',
+ 'I wait for the reboot',
+ 'I power off the computer',
+ 'the computer is set to boot from ide drive "target"',
+ ],
+ },
+
+ 'debian-gnome-install' => {
+ :description => "I have installed Gnome Desktop Debian",
+ :parent_checkpoint => 'boot-d-i-to-tasksel',
+ :steps => [
+ 'I select the Desktop task',
+ 'I install GRUB',
+ 'I allow reboot after the install is complete',
+ 'I wait for the reboot',
+ 'I power off the computer',
+ 'the computer is set to boot from ide drive "target"',
+ ],
+ },
+
+ 'tails-greeter' => {
+ :description => "I have started Tails from DVD without network and stopped at Tails Greeter's login screen",
+ :parent_checkpoint => nil,
+ :steps => [
+ 'the network is unplugged',
+ 'I start the computer',
+ 'the computer boots Tails'
+ ],
+ },
+
+ 'no-network-logged-in' => {
+ :description => "I have started Tails from DVD without network and logged in",
+ :parent_checkpoint => "tails-greeter",
+ :steps => [
+ 'I log in to a new session',
+ 'Tails Greeter has dealt with the sudo password',
+ 'the Tails desktop is ready',
+ ],
+ },
+
+ 'with-no-network-and-i2p' => {
+ :temporary => true,
+ :description => 'I have started Tails from DVD with I2P enabled and logged in',
+ :steps => [
+ 'I set Tails to boot with options "i2p"',
+ 'the network is unplugged',
+ 'I start the computer',
+ 'the computer boots Tails',
+ 'I log in to a new session',
+ 'the Tails desktop is ready',
+ ],
+ },
+
+ 'with-network-and-i2p' => {
+ :temporary => true,
+ :description => 'I have started Tails from DVD with I2P enabled and logged in and the network is connected',
+ :parent_checkpoint => "with-no-network-and-i2p",
+ :steps => [
+ 'the network is plugged',
+ 'Tor is ready',
+ 'I2P is running',
+ 'all notifications have disappeared',
+ 'available upgrades have been checked',
+ "I2P's reseeding completed",
+ ],
+ },
+
+ 'with-network-logged-in' => {
+ :description => "I have started Tails from DVD and logged in and the network is connected",
+ :parent_checkpoint => "no-network-logged-in",
+ :steps => [
+ 'the network is plugged',
+ 'Tor is ready',
+ 'all notifications have disappeared',
+ 'available upgrades have been checked',
+ ],
+ },
+
+ 'no-network-bridge-mode' => {
+ :temporary => true,
+ :description => "I have started Tails from DVD without network and logged in with bridge mode enabled",
+ :parent_checkpoint => "tails-greeter",
+ :steps => [
+ 'I enable more Tails Greeter options',
+ 'I enable the specific Tor configuration option',
+ 'I log in to a new session',
+ 'Tails Greeter has dealt with the sudo password',
+ 'the Tails desktop is ready',
+ 'all notifications have disappeared',
+ ],
+ },
+
+ 'no-network-logged-in-sudo-passwd' => {
+ :temporary => true,
+ :description => "I have started Tails from DVD without network and logged in with an administration password",
+ :parent_checkpoint => "tails-greeter",
+ :steps => [
+ 'I enable more Tails Greeter options',
+ 'I set an administration password',
+ 'I log in to a new session',
+ 'Tails Greeter has dealt with the sudo password',
+ 'the Tails desktop is ready',
+ ],
+ },
+
+ 'with-network-logged-in-sudo-passwd' => {
+ :temporary => true,
+ :description => "I have started Tails from DVD and logged in with an administration password and the network is connected",
+ :parent_checkpoint => "no-network-logged-in-sudo-passwd",
+ :steps => [
+ 'the network is plugged',
+ 'Tor is ready',
+ 'all notifications have disappeared',
+ 'available upgrades have been checked',
+ ],
+ },
+
+ 'usb-install-tails-greeter' => {
+ :description => "I have started Tails without network from a USB drive without a persistent partition and stopped at Tails Greeter's login screen",
+ :parent_checkpoint => 'no-network-logged-in',
+ :steps => [
+ 'I create a 4 GiB disk named "__internal"',
+ 'I plug USB drive "__internal"',
+ 'I "Clone & Install" Tails to USB drive "__internal"',
+ 'the running Tails is installed on USB drive "__internal"',
+ 'there is no persistence partition on USB drive "__internal"',
+ 'I shutdown Tails and wait for the computer to power off',
+ 'I start Tails from USB drive "__internal" with network unplugged',
+ 'the boot device has safe access rights',
+ 'Tails is running from USB drive "__internal"',
+ 'there is no persistence partition on USB drive "__internal"',
+ 'process "udev-watchdog" is running',
+ 'udev-watchdog is monitoring the correct device',
+ ],
+ },
+
+ 'usb-install-logged-in' => {
+ :description => "I have started Tails without network from a USB drive without a persistent partition and logged in",
+ :parent_checkpoint => 'usb-install-tails-greeter',
+ :steps => [
+ 'I log in to a new session',
+ 'the Tails desktop is ready',
+ ],
+ },
+
+ 'usb-install-with-persistence-tails-greeter' => {
+ :description => "I have started Tails without network from a USB drive with a persistent partition and stopped at Tails Greeter's login screen",
+ :parent_checkpoint => 'usb-install-logged-in',
+ :steps => [
+ 'I create a persistent partition',
+ 'a Tails persistence partition exists on USB drive "__internal"',
+ 'I shutdown Tails and wait for the computer to power off',
+ 'I start Tails from USB drive "__internal" with network unplugged',
+ 'the boot device has safe access rights',
+ 'Tails is running from USB drive "__internal"',
+ 'process "udev-watchdog" is running',
+ 'udev-watchdog is monitoring the correct device',
+ ],
+ },
+
+ 'usb-install-with-persistence-logged-in' => {
+ :description => "I have started Tails without network from a USB drive with a persistent partition enabled and logged in",
+ :parent_checkpoint => 'usb-install-with-persistence-tails-greeter',
+ :steps => [
+ 'I enable persistence',
+ 'I log in to a new session',
+ 'the Tails desktop is ready',
+ 'all persistence presets are enabled',
+ 'all persistent filesystems have safe access rights',
+ 'all persistence configuration files have safe access rights',
+ 'all persistent directories have safe access rights',
+ ],
+ },
+ }
+end
+
+def reach_checkpoint(name)
+ scenario_indent = " "*4
+ step_indent = " "*6
+
+ step "a computer"
+ if VM.snapshot_exists?(name)
+ $vm.restore_snapshot(name)
+ post_snapshot_restore_hook
+ else
+ checkpoint = checkpoints[name]
+ checkpoint_description = checkpoint[:description]
+ parent_checkpoint = checkpoint[:parent_checkpoint]
+ steps = checkpoint[:steps]
+ if parent_checkpoint
+ if VM.snapshot_exists?(parent_checkpoint)
+ $vm.restore_snapshot(parent_checkpoint)
+ else
+ reach_checkpoint(parent_checkpoint)
+ end
+ post_snapshot_restore_hook
+ end
+ debug_log(scenario_indent + "Checkpoint: #{checkpoint_description}",
+ :color => :white)
+ step_action = "Given"
+ if parent_checkpoint
+ parent_description = checkpoints[parent_checkpoint][:description]
+ debug_log(step_indent + "#{step_action} #{parent_description}",
+ :color => :green)
+ step_action = "And"
+ end
+ steps.each do |s|
+ begin
+ step(s)
+ rescue Exception => e
+ debug_log(scenario_indent +
+ "Step failed while creating checkpoint: #{s}",
+ :color => :red)
+ raise e
+ end
+ debug_log(step_indent + "#{step_action} #{s}", :color => :green)
+ step_action = "And"
+ end
+ $vm.save_snapshot(name)
+ end
+end
+
+# For each checkpoint we generate a step to reach it.
+checkpoints.each do |name, desc|
+ step_regex = Regexp.new("^#{Regexp.escape(desc[:description])}$")
+ Given step_regex do
+ reach_checkpoint(name)
+ end
+end
diff --git a/cucumber/features/step_definitions/ssh.rb b/cucumber/features/step_definitions/ssh.rb
new file mode 100644
index 00000000..038b2977
--- /dev/null
+++ b/cucumber/features/step_definitions/ssh.rb
@@ -0,0 +1,122 @@
+require 'socket'
+
+def assert_not_ipaddr(s)
+ err_msg = "'#{s}' looks like a LAN IP address."
+ assert_raise(IPAddr::InvalidAddressError, err_msg) do
+ IPAddr.new(s)
+ end
+end
+
+def read_and_validate_ssh_config srv_type
+ conf = $config[srv_type]
+ begin
+ required_settings = ["private_key", "public_key", "username", "hostname"]
+ required_settings.each do |key|
+ assert(conf.has_key?(key))
+ assert_not_nil(conf[key])
+ assert(!conf[key].empty?)
+ end
+ rescue NoMethodError
+ raise(
+ <<EOF
+Your #{srv_type} config is incorrect or missing from your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format.
+EOF
+ )
+ end
+
+ case srv_type
+ when 'SSH'
+ @ssh_host = conf["hostname"]
+ @ssh_port = conf["port"].to_i if conf["port"]
+ @ssh_username = conf["username"]
+ assert_not_ipaddr(@ssh_host)
+ when 'SFTP'
+ @sftp_host = conf["hostname"]
+ @sftp_port = conf["port"].to_i if conf["port"]
+ @sftp_username = conf["username"]
+ assert_not_ipaddr(@sftp_host)
+ end
+end
+
+Given /^I have the SSH key pair for an? (Git|SSH|SFTP) (?:repository|server)( on the LAN)?$/ do |server_type, lan|
+ $vm.execute_successfully("install -m 0700 -d '/home/#{LIVE_USER}/.ssh/'",
+ :user => LIVE_USER)
+ unless server_type == 'Git' || lan
+ read_and_validate_ssh_config server_type
+ secret_key = $config[server_type]["private_key"]
+ public_key = $config[server_type]["public_key"]
+ else
+ secret_key = $config["Unsafe_SSH_private_key"]
+ public_key = $config["Unsafe_SSH_public_key"]
+ end
+
+ $vm.execute_successfully("echo '#{secret_key}' > '/home/#{LIVE_USER}/.ssh/id_rsa'",
+ :user => LIVE_USER)
+ $vm.execute_successfully("echo '#{public_key}' > '/home/#{LIVE_USER}/.ssh/id_rsa.pub'",
+ :user => LIVE_USER)
+ $vm.execute_successfully("chmod 0600 '/home/#{LIVE_USER}/.ssh/'id*",
+ :user => LIVE_USER)
+end
+
+Given /^I (?:am prompted to )?verify the SSH fingerprint for the (?:Git|SSH) (?:repository|server)$/ do
+ @screen.wait("SSHFingerprint.png", 60)
+ @screen.type('yes' + Sikuli::Key.ENTER)
+end
+
+def get_free_tcp_port
+ server = TCPServer.new('127.0.0.1', 0)
+ return server.addr[1]
+ensure
+ server.close
+end
+
+Given /^an SSH server is running on the LAN$/ do
+ @sshd_server_port = get_free_tcp_port
+ @sshd_server_host = $vmnet.bridge_ip_addr
+ sshd = SSHServer.new(@sshd_server_host, @sshd_server_port)
+ sshd.start
+ add_after_scenario_hook { sshd.stop }
+end
+
+When /^I connect to an SSH server on the (Internet|LAN)$/ do |location|
+
+ case location
+ when 'Internet'
+ read_and_validate_ssh_config "SSH"
+ when 'LAN'
+ @ssh_port = @sshd_server_port
+ @ssh_username = 'user'
+ @ssh_host = @sshd_server_host
+ end
+
+ ssh_port_suffix = "-p #{@ssh_port}" if @ssh_port
+
+ cmd = "ssh #{@ssh_username}@#{@ssh_host} #{ssh_port_suffix}"
+
+ step 'process "ssh" is not running'
+ step "I run \"#{cmd}\" in GNOME Terminal"
+ step 'process "ssh" is running within 10 seconds'
+end
+
+Then /^I have sucessfully logged into the SSH server$/ do
+ @screen.wait('SSHLoggedInPrompt.png', 60)
+end
+
+Then /^I connect to an SFTP server on the Internet$/ do
+ read_and_validate_ssh_config "SFTP"
+ @sftp_port ||= 22
+ @sftp_port = @sftp_port.to_s
+ step 'I start "Files" via the GNOME "Accessories" applications menu'
+ @screen.wait_and_click("GnomeFilesConnectToServer.png", 10)
+ @screen.wait("GnomeConnectToServerWindow.png", 10)
+ @screen.type("sftp://" + @sftp_username + "@" + @sftp_host + ":" + @sftp_port)
+ @screen.wait_and_click("GnomeConnectToServerConnectButton.png", 10)
+end
+
+Then /^I verify the SSH fingerprint for the SFTP server$/ do
+ @screen.wait_and_click("GnomeSSHVerificationConfirm.png", 60)
+end
+
+Then /^I successfully connect to the SFTP server$/ do
+ @screen.wait("GnomeSSHSuccess.png", 60)
+end
diff --git a/cucumber/features/step_definitions/time_syncing.rb b/cucumber/features/step_definitions/time_syncing.rb
new file mode 100644
index 00000000..319fb521
--- /dev/null
+++ b/cucumber/features/step_definitions/time_syncing.rb
@@ -0,0 +1,86 @@
+# In some steps below we allow some slack when verifying that the date
+# was set appropriately because it may take time to send the `date`
+# command over the remote shell and get the answer back, parsing and
+# post-processing of the result, etc.
+def max_time_drift
+ 10
+end
+
+When /^I set the system time to "([^"]+)"$/ do |time|
+ $vm.execute_successfully("date -s '#{time}'")
+ new_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time
+ expected_time_lower_bound = DateTime.parse(time).to_time
+ expected_time_upper_bound = expected_time_lower_bound + max_time_drift
+ assert(expected_time_lower_bound <= new_time &&
+ new_time <= expected_time_upper_bound,
+ "The guest's time was supposed to be set to " \
+ "'#{expected_time_lower_bound}' but is '#{new_time}'")
+end
+
+When /^I bump the (hardware clock's|system) time with "([^"]+)"$/ do |clock_type, timediff|
+ case clock_type
+ when "hardware clock's"
+ old_time = DateTime.parse($vm.execute_successfully("hwclock -r").stdout).to_time
+ $vm.execute_successfully("hwclock --set --date 'now #{timediff}'")
+ new_time = DateTime.parse($vm.execute_successfully("hwclock -r").stdout).to_time
+ when 'system'
+ old_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time
+ $vm.execute_successfully("date -s 'now #{timediff}'")
+ new_time = DateTime.parse($vm.execute_successfully("date").stdout).to_time
+ end
+ expected_time_lower_bound = DateTime.parse(
+ cmd_helper(["date", "-d", "#{old_time} #{timediff}"])).to_time
+ expected_time_upper_bound = expected_time_lower_bound + max_time_drift
+ assert(expected_time_lower_bound <= new_time &&
+ new_time <= expected_time_upper_bound,
+ "The #{clock_type} time was supposed to be bumped to " \
+ "'#{expected_time_lower_bound}' but is '#{new_time}'")
+end
+
+Then /^Tails clock is less than (\d+) minutes incorrect$/ do |max_diff_mins|
+ guest_time_str = $vm.execute("date --rfc-2822").stdout.chomp
+ guest_time = Time.rfc2822(guest_time_str)
+ host_time = Time.now
+ diff = (host_time - guest_time).abs
+ assert(diff < max_diff_mins.to_i*60,
+ "The guest's clock is off by #{diff} seconds (#{guest_time})")
+ puts "Time was #{diff} seconds off"
+end
+
+Then /^the system clock is just past Tails' build date$/ do
+ system_time_str = $vm.execute_successfully('date').to_s
+ system_time = DateTime.parse(system_time_str).to_time
+ build_time_cmd = 'sed -n -e "1s/^.* - \([0-9]\+\)$/\1/p;q" ' +
+ '/etc/amnesia/version'
+ build_time_str = $vm.execute_successfully(build_time_cmd).to_s
+ build_time = DateTime.parse(build_time_str).to_time
+ diff = system_time - build_time # => in seconds
+ # Half an hour should be enough to boot Tails on any reasonable
+ # hardware and VM setup.
+ max_diff = 30*60
+ assert(diff > 0,
+ "The system time (#{system_time}) is before the Tails " +
+ "build date (#{build_time})")
+ assert(diff <= max_diff,
+ "The system time (#{system_time}) is more than #{max_diff} seconds " +
+ "past the build date (#{build_time})")
+end
+
+Then /^Tails' hardware clock is close to the host system's time$/ do
+ host_time = Time.now
+ hwclock_time_str = $vm.execute('hwclock -r').stdout.chomp
+ hwclock_time = DateTime.parse(hwclock_time_str).to_time
+ diff = (hwclock_time - host_time).abs
+ assert(diff <= max_time_drift)
+end
+
+Then /^the hardware clock is still off by "([^"]+)"$/ do |timediff|
+ hwclock = DateTime.parse($vm.execute_successfully("hwclock -r").stdout.chomp).to_time
+ expected_time_lower_bound = DateTime.parse(
+ cmd_helper(["date", "-d", "now #{timediff}"])).to_time - max_time_drift
+ expected_time_upper_bound = expected_time_lower_bound + max_time_drift
+ assert(expected_time_lower_bound <= hwclock &&
+ hwclock <= expected_time_upper_bound,
+ "The host's hwclock should be approximately " \
+ "'#{expected_time_lower_bound}' but is actually '#{hwclock}'")
+end
diff --git a/cucumber/features/step_definitions/tor.rb b/cucumber/features/step_definitions/tor.rb
new file mode 100644
index 00000000..ac12fd4c
--- /dev/null
+++ b/cucumber/features/step_definitions/tor.rb
@@ -0,0 +1,402 @@
+def iptables_chains_parse(iptables, table = "filter", &block)
+ assert(block_given?)
+ cmd = "#{iptables}-save -c -t #{table} | iptables-xml"
+ xml_str = $vm.execute_successfully(cmd).stdout
+ rexml = REXML::Document.new(xml_str)
+ rexml.get_elements('iptables-rules/table/chain').each do |element|
+ yield(
+ element.attribute('name').to_s,
+ element.attribute('policy').to_s,
+ element.get_elements('rule')
+ )
+ end
+end
+
+def ip4tables_chains(table = "filter", &block)
+ iptables_chains_parse('iptables', table, &block)
+end
+
+def ip6tables_chains(table = "filter", &block)
+ iptables_chains_parse('ip6tables', table, &block)
+end
+
+def iptables_rules_parse(iptables, chain, table)
+ iptables_chains_parse(iptables, table) do |name, _, rules|
+ return rules if name == chain
+ end
+ return nil
+end
+
+def iptables_rules(chain, table = "filter")
+ iptables_rules_parse("iptables", chain, table)
+end
+
+def ip6tables_rules(chain, table = "filter")
+ iptables_rules_parse("ip6tables", chain, table)
+end
+
+def ip4tables_packet_counter_sum(filters = {})
+ pkts = 0
+ ip4tables_chains do |name, _, rules|
+ next if filters[:tables] && not(filters[:tables].include?(name))
+ rules.each do |rule|
+ next if filters[:uid] && not(rule.elements["conditions/owner/uid-owner[text()=#{filters[:uid]}]"])
+ pkts += rule.attribute('packet-count').to_s.to_i
+ end
+ end
+ return pkts
+end
+
+def try_xml_element_text(element, xpath, default = nil)
+ node = element.elements[xpath]
+ (node.nil? or not(node.has_text?)) ? default : node.text
+end
+
+Then /^the firewall's policy is to (.+) all IPv4 traffic$/ do |expected_policy|
+ expected_policy.upcase!
+ ip4tables_chains do |name, policy, _|
+ if ["INPUT", "FORWARD", "OUTPUT"].include?(name)
+ assert_equal(expected_policy, policy,
+ "Chain #{name} has unexpected policy #{policy}")
+ end
+ end
+end
+
+Then /^the firewall is configured to only allow the (.+) users? to connect directly to the Internet over IPv4$/ do |users_str|
+ users = users_str.split(/, | and /)
+ expected_uids = Set.new
+ users.each do |user|
+ expected_uids << $vm.execute_successfully("id -u #{user}").stdout.to_i
+ end
+ allowed_output = iptables_rules("OUTPUT").find_all do |rule|
+ out_iface = rule.elements['conditions/match/o']
+ is_maybe_accepted = rule.get_elements('actions/*').find do |action|
+ not(["DROP", "REJECT", "LOG"].include?(action.name))
+ end
+ is_maybe_accepted &&
+ (
+ # nil => match all interfaces according to iptables-xml
+ out_iface.nil? ||
+ ((out_iface.text == 'lo') == (out_iface.attribute('invert').to_s == '1'))
+ )
+ end
+ uids = Set.new
+ allowed_output.each do |rule|
+ rule.elements.each('actions/*') do |action|
+ destination = try_xml_element_text(rule, "conditions/match/d")
+ if action.name == "ACCEPT"
+ # nil == 0.0.0.0/0 according to iptables-xml
+ assert(destination == '0.0.0.0/0' || destination.nil?,
+ "The following rule has an unexpected destination:\n" +
+ rule.to_s)
+ state_cond = try_xml_element_text(rule, "conditions/state/state")
+ next if state_cond == "RELATED,ESTABLISHED"
+ assert_not_nil(rule.elements['conditions/owner/uid-owner'])
+ rule.elements.each('conditions/owner/uid-owner') do |owner|
+ uid = owner.text.to_i
+ uids << uid
+ assert(expected_uids.include?(uid),
+ "The following rule allows uid #{uid} to access the " +
+ "network, but we only expect uids #{expected_uids.to_a} " +
+ "(#{users_str}) to have such access:\n#{rule.to_s}")
+ end
+ elsif action.name == "call" && action.elements[1].name == "lan"
+ lan_subnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
+ assert(lan_subnets.include?(destination),
+ "The following lan-targeted rule's destination is " +
+ "#{destination} which may not be a private subnet:\n" +
+ rule.to_s)
+ else
+ raise "Unexpected iptables OUTPUT chain rule:\n#{rule.to_s}"
+ end
+ end
+ end
+ uids_not_found = expected_uids - uids
+ assert(uids_not_found.empty?,
+ "Couldn't find rules allowing uids #{uids_not_found.to_a.to_s} " \
+ "access to the network")
+end
+
+Then /^the firewall's NAT rules only redirect traffic for Tor's TransPort and DNSPort$/ do
+ loopback_address = "127.0.0.1/32"
+ tor_onion_addr_space = "127.192.0.0/10"
+ tor_trans_port = "9040"
+ dns_port = "53"
+ tor_dns_port = "5353"
+ ip4tables_chains('nat') do |name, _, rules|
+ if name == "OUTPUT"
+ good_rules = rules.find_all do |rule|
+ redirect = rule.get_elements('actions/*').all? do |action|
+ action.name == "REDIRECT"
+ end
+ destination = try_xml_element_text(rule, "conditions/match/d")
+ redir_port = try_xml_element_text(rule, "actions/REDIRECT/to-ports")
+ redirected_to_trans_port = redir_port == tor_trans_port
+ udp_destination_port = try_xml_element_text(rule, "conditions/udp/dport")
+ dns_redirected_to_tor_dns_port = (udp_destination_port == dns_port) &&
+ (redir_port == tor_dns_port)
+ redirect &&
+ (
+ (destination == tor_onion_addr_space && redirected_to_trans_port) ||
+ (destination == loopback_address && dns_redirected_to_tor_dns_port)
+ )
+ end
+ bad_rules = rules - good_rules
+ assert(bad_rules.empty?,
+ "The NAT table's OUTPUT chain contains some unexpected " +
+ "rules:\n#{bad_rules}")
+ else
+ assert(rules.empty?,
+ "The NAT table contains unexpected rules for the #{name} " +
+ "chain:\n#{rules}")
+ end
+ end
+end
+
+Then /^the firewall is configured to block all external IPv6 traffic$/ do
+ ip6_loopback = '::1/128'
+ expected_policy = "DROP"
+ ip6tables_chains do |name, policy, rules|
+ assert_equal(expected_policy, policy,
+ "The IPv6 #{name} chain has policy #{policy} but we " \
+ "expected #{expected_policy}")
+ good_rules = rules.find_all do |rule|
+ ["DROP", "REJECT", "LOG"].any? do |target|
+ rule.elements["actions/#{target}"]
+ end \
+ ||
+ ["s", "d"].all? do |x|
+ try_xml_element_text(rule, "conditions/match/#{x}") == ip6_loopback
+ end
+ end
+ bad_rules = rules - good_rules
+ assert(bad_rules.empty?,
+ "The IPv6 table's #{name} chain contains some unexpected rules:\n" +
+ (bad_rules.map { |r| r.to_s }).join("\n"))
+ end
+end
+
+def firewall_has_dropped_packet_to?(proto, host, port)
+ regex = "^Dropped outbound packet: .* "
+ regex += "DST=#{Regexp.escape(host)} .* "
+ regex += "PROTO=#{Regexp.escape(proto)} "
+ regex += ".* DPT=#{port} " if port
+ $vm.execute("journalctl --dmesg --output=cat | grep -qP '#{regex}'").success?
+end
+
+When /^I open an untorified (TCP|UDP|ICMP) connections to (\S*)(?: on port (\d+))? that is expected to fail$/ do |proto, host, port|
+ assert(!firewall_has_dropped_packet_to?(proto, host, port),
+ "A #{proto} packet to #{host}" +
+ (port.nil? ? "" : ":#{port}") +
+ " has already been dropped by the firewall")
+ @conn_proto = proto
+ @conn_host = host
+ @conn_port = port
+ case proto
+ when "TCP"
+ assert_not_nil(port)
+ cmd = "echo | netcat #{host} #{port}"
+ user = LIVE_USER
+ when "UDP"
+ assert_not_nil(port)
+ cmd = "echo | netcat -u #{host} #{port}"
+ user = LIVE_USER
+ when "ICMP"
+ cmd = "ping -c 5 #{host}"
+ user = 'root'
+ end
+ @conn_res = $vm.execute(cmd, :user => user)
+end
+
+Then /^the untorified connection fails$/ do
+ case @conn_proto
+ when "TCP"
+ expected_in_stderr = "Connection refused"
+ conn_failed = !@conn_res.success? &&
+ @conn_res.stderr.chomp.end_with?(expected_in_stderr)
+ when "UDP", "ICMP"
+ conn_failed = !@conn_res.success?
+ end
+ assert(conn_failed,
+ "The untorified #{@conn_proto} connection didn't fail as expected:\n" +
+ @conn_res.to_s)
+end
+
+Then /^the untorified connection is logged as dropped by the firewall$/ do
+ assert(firewall_has_dropped_packet_to?(@conn_proto, @conn_host, @conn_port),
+ "No #{@conn_proto} packet to #{@conn_host}" +
+ (@conn_port.nil? ? "" : ":#{@conn_port}") +
+ " was dropped by the firewall")
+end
+
+When /^the system DNS is(?: still)? using the local DNS resolver$/ do
+ resolvconf = $vm.file_content("/etc/resolv.conf")
+ bad_lines = resolvconf.split("\n").find_all do |line|
+ !line.start_with?("#") && !/^nameserver\s+127\.0\.0\.1$/.match(line)
+ end
+ assert_empty(bad_lines,
+ "The following bad lines were found in /etc/resolv.conf:\n" +
+ bad_lines.join("\n"))
+end
+
+def stream_isolation_info(application)
+ case application
+ when "htpdate"
+ {
+ :grep_monitor_expr => '/curl\>',
+ :socksport => 9062
+ }
+ when "tails-security-check", "tails-upgrade-frontend-wrapper"
+ # We only grep connections with ESTABLISHED state since `perl`
+ # is also used by monkeysphere's validation agent, which LISTENs
+ {
+ :grep_monitor_expr => '\<ESTABLISHED\>.\+/perl\>',
+ :socksport => 9062
+ }
+ when "Tor Browser"
+ {
+ :grep_monitor_expr => '/firefox\>',
+ :socksport => 9150
+ }
+ when "Gobby"
+ {
+ :grep_monitor_expr => '/gobby\>',
+ :socksport => 9050
+ }
+ when "SSH"
+ {
+ :grep_monitor_expr => '/\(connect-proxy\|ssh\)\>',
+ :socksport => 9050
+ }
+ when "whois"
+ {
+ :grep_monitor_expr => '/whois\>',
+ :socksport => 9050
+ }
+ else
+ raise "Unknown application '#{application}' for the stream isolation tests"
+ end
+end
+
+When /^I monitor the network connections of (.*)$/ do |application|
+ @process_monitor_log = "/tmp/netstat.log"
+ info = stream_isolation_info(application)
+ $vm.spawn("while true; do " +
+ " netstat -taupen | grep \"#{info[:grep_monitor_expr]}\"; " +
+ " sleep 0.1; " +
+ "done > #{@process_monitor_log}")
+end
+
+Then /^I see that (.+) is properly stream isolated$/ do |application|
+ expected_port = stream_isolation_info(application)[:socksport]
+ assert_not_nil(@process_monitor_log)
+ log_lines = $vm.file_content(@process_monitor_log).split("\n")
+ assert(log_lines.size > 0,
+ "Couldn't see any connection made by #{application} so " \
+ "something is wrong")
+ log_lines.each do |line|
+ addr_port = line.split(/\s+/)[4]
+ assert_equal("127.0.0.1:#{expected_port}", addr_port,
+ "#{application} should use SocksPort #{expected_port} but " \
+ "was seen connecting to #{addr_port}")
+ end
+end
+
+And /^I re-run tails-security-check$/ do
+ $vm.execute_successfully("tails-security-check", :user => LIVE_USER)
+end
+
+And /^I re-run htpdate$/ do
+ $vm.execute_successfully("service htpdate stop && " \
+ "rm -f /var/run/htpdate/* && " \
+ "systemctl --no-block start htpdate.service")
+ step "the time has synced"
+end
+
+And /^I re-run tails-upgrade-frontend-wrapper$/ do
+ $vm.execute_successfully("tails-upgrade-frontend-wrapper", :user => LIVE_USER)
+end
+
+When /^I connect Gobby to "([^"]+)"$/ do |host|
+ @screen.wait("GobbyWindow.png", 30)
+ @screen.wait("GobbyWelcomePrompt.png", 10)
+ @screen.click("GnomeCloseButton.png")
+ @screen.wait("GobbyWindow.png", 10)
+ # This indicates that Gobby has finished initializing itself
+ # (generating DH parameters, etc.) -- before, the UI is not responsive
+ # and our CTRL-t is lost.
+ @screen.wait("GobbyFailedToShareDocuments.png", 30)
+ @screen.type("t", Sikuli::KeyModifier.CTRL)
+ @screen.wait("GobbyConnectPrompt.png", 10)
+ @screen.type(host + Sikuli::Key.ENTER)
+ @screen.wait("GobbyConnectionComplete.png", 60)
+end
+
+When /^the Tor Launcher autostarts$/ do
+ @screen.wait('TorLauncherWindow.png', 60)
+end
+
+When /^I configure some (\w+) pluggable transports in Tor Launcher$/ do |bridge_type|
+ bridge_type.downcase!
+ bridge_type.capitalize!
+ begin
+ @bridges = $config["Tor"]["Transports"][bridge_type]
+ assert_not_nil(@bridges)
+ assert(!@bridges.empty?)
+ rescue NoMethodError, Test::Unit::AssertionFailedError
+ raise(
+<<EOF
+It seems no '#{bridge_type}' pluggable transports are defined in your local configuration file (#{LOCAL_CONFIG_FILE}). See wiki/src/contribute/release_process/test/usage.mdwn for the format.
+EOF
+)
+ end
+ @bridge_hosts = []
+ for bridge in @bridges do
+ @bridge_hosts << bridge["ipv4_address"]
+ end
+
+ @screen.wait_and_click('TorLauncherConfigureButton.png', 10)
+ @screen.wait('TorLauncherBridgePrompt.png', 10)
+ @screen.wait_and_click('TorLauncherYesRadioOption.png', 10)
+ @screen.wait_and_click('TorLauncherNextButton.png', 10)
+ @screen.wait_and_click('TorLauncherBridgeList.png', 10)
+ for bridge in @bridges do
+ bridge_line = bridge_type.downcase + " " +
+ bridge["ipv4_address"] + ":" +
+ bridge["ipv4_port"].to_s
+ bridge_line += " " + bridge["fingerprint"].to_s if bridge["fingerprint"]
+ bridge_line += " " + bridge["extra"].to_s if bridge["extra"]
+ @screen.type(bridge_line + Sikuli::Key.ENTER)
+ end
+ @screen.wait_and_click('TorLauncherNextButton.png', 10)
+ @screen.hide_cursor
+ @screen.wait_and_click('TorLauncherFinishButton.png', 10)
+ @screen.wait('TorLauncherConnectingWindow.png', 10)
+ @screen.waitVanish('TorLauncherConnectingWindow.png', 120)
+end
+
+When /^all Internet traffic has only flowed through the configured pluggable transports$/ do
+ assert_not_nil(@bridge_hosts, "No bridges has been configured via the " +
+ "'I configure some ... bridges in Tor Launcher' step")
+ leaks = FirewallLeakCheck.new(@sniffer.pcap_file,
+ :accepted_hosts => @bridge_hosts)
+ leaks.assert_no_leaks
+end
+
+Then /^the Tor binary is configured to use the expected Tor authorities$/ do
+ tor_auths = Set.new
+ tor_binary_orport_strings = $vm.execute_successfully(
+ "strings /usr/bin/tor | grep -E 'orport=[0-9]+'").stdout.chomp.split("\n")
+ tor_binary_orport_strings.each do |potential_auth_string|
+ auth_regex = /^\S+ orport=\d+( bridge)?( no-v2)?( v3ident=[A-Z0-9]{40})? ([0-9\.]+):\d+( [A-Z0-9]{4}){10}$/
+ m = auth_regex.match(potential_auth_string)
+ if m
+ auth_ipv4_addr = m[4]
+ tor_auths << auth_ipv4_addr
+ end
+ end
+ expected_tor_auths = Set.new(TOR_AUTHORITIES)
+ assert_equal(expected_tor_auths, tor_auths,
+ "The Tor binary does not have the expected Tor authorities " +
+ "configured")
+end
diff --git a/cucumber/features/step_definitions/torified_browsing.rb b/cucumber/features/step_definitions/torified_browsing.rb
new file mode 100644
index 00000000..c8f3ff1d
--- /dev/null
+++ b/cucumber/features/step_definitions/torified_browsing.rb
@@ -0,0 +1,5 @@
+When /^no traffic has flowed to the LAN$/ do
+ leaks = FirewallLeakCheck.new(@sniffer.pcap_file, :ignore_lan => false)
+ assert(not(leaks.ipv4_tcp_leaks.include?(@lan_host)),
+ "Traffic was sent to LAN host #{@lan_host}")
+end
diff --git a/cucumber/features/step_definitions/torified_gnupg.rb b/cucumber/features/step_definitions/torified_gnupg.rb
new file mode 100644
index 00000000..4b4cc040
--- /dev/null
+++ b/cucumber/features/step_definitions/torified_gnupg.rb
@@ -0,0 +1,208 @@
+class OpenPGPKeyserverCommunicationError < StandardError
+end
+
+def count_gpg_signatures(key)
+ output = $vm.execute_successfully("gpg --batch --list-sigs #{key}",
+ :user => LIVE_USER).stdout
+ output.scan(/^sig/).count
+end
+
+def check_for_seahorse_error
+ if @screen.exists('GnomeCloseButton.png')
+ raise OpenPGPKeyserverCommunicationError.new(
+ "Found GnomeCloseButton.png' on the screen"
+ )
+ end
+end
+
+def start_or_restart_seahorse
+ assert_not_nil(@withgpgapplet)
+ if @withgpgapplet
+ seahorse_menu_click_helper('GpgAppletIconNormal.png', 'GpgAppletManageKeys.png')
+ else
+ step 'I start "Seahorse" via the GNOME "Utilities" applications menu'
+ end
+ step 'Seahorse has opened'
+end
+
+Then /^the key "([^"]+)" has (only|more than) (\d+) signatures$/ do |key, qualifier, num|
+ count = count_gpg_signatures(key)
+ case qualifier
+ when 'only'
+ assert_equal(count, num.to_i, "Expected #{num} signatures but instead found #{count}")
+ when 'more than'
+ assert(count > num.to_i, "Expected more than #{num} signatures but found #{count}")
+ else
+ raise "Unknown operator #{qualifier} passed"
+ end
+end
+
+When /^the "([^"]+)" OpenPGP key is not in the live user's public keyring$/ do |keyid|
+ assert(!$vm.execute("gpg --batch --list-keys '#{keyid}'",
+ :user => LIVE_USER).success?,
+ "The '#{keyid}' key is in the live user's public keyring.")
+end
+
+When /^I fetch the "([^"]+)" OpenPGP key using the GnuPG CLI( without any signatures)?$/ do |keyid, without|
+ # Make keyid an instance variable so we can reference it in the Seahorse
+ # keysyncing step.
+ @fetched_openpgp_keyid = keyid
+ if without
+ importopts = '--keyserver-options import-clean'
+ else
+ importopts = ''
+ end
+ retry_tor do
+ @gnupg_recv_key_res = $vm.execute_successfully(
+ "timeout 120 gpg --batch #{importopts} --recv-key '#{@fetched_openpgp_keyid}'",
+ :user => LIVE_USER)
+ if @gnupg_recv_key_res.failure?
+ raise "Fetching keys with the GnuPG CLI failed with:\n" +
+ "#{@gnupg_recv_key_res.stdout}\n" +
+ "#{@gnupg_recv_key_res.stderr}"
+ end
+ end
+end
+
+When /^the GnuPG fetch is successful$/ do
+ assert(@gnupg_recv_key_res.success?,
+ "gpg keyserver fetch failed:\n#{@gnupg_recv_key_res.stderr}")
+end
+
+When /^the Seahorse operation is successful$/ do
+ !@screen.exists('GnomeCloseButton.png')
+ $vm.has_process?('seahorse')
+end
+
+When /^GnuPG uses the configured keyserver$/ do
+ assert(@gnupg_recv_key_res.stderr[CONFIGURED_KEYSERVER_HOSTNAME],
+ "GnuPG's stderr did not mention keyserver #{CONFIGURED_KEYSERVER_HOSTNAME}")
+end
+
+When /^the "([^"]+)" key is in the live user's public keyring(?: after at most (\d) seconds)?$/ do |keyid, delay|
+ delay = 10 unless delay
+ try_for(delay.to_i, :msg => "The '#{keyid}' key is not in the live user's public keyring") {
+ $vm.execute("gpg --batch --list-keys '#{keyid}'",
+ :user => LIVE_USER).success?
+ }
+end
+
+When /^I start Seahorse( via the Tails OpenPGP Applet)?$/ do |withgpgapplet|
+ @withgpgapplet = !!withgpgapplet
+ start_or_restart_seahorse
+end
+
+Then /^Seahorse has opened$/ do
+ @screen.wait('SeahorseWindow.png', 20)
+end
+
+Then /^I enable key synchronization in Seahorse$/ do
+ step 'process "seahorse" is running'
+ @screen.wait_and_click("SeahorseWindow.png", 10)
+ seahorse_menu_click_helper('GnomeEditMenu.png', 'SeahorseEditPreferences.png', 'seahorse')
+ @screen.wait('SeahorsePreferences.png', 20)
+ @screen.type("p", Sikuli::KeyModifier.ALT) # Option: "Publish keys to...".
+ @screen.type(Sikuli::Key.DOWN) # select HKP server
+ @screen.type("c", Sikuli::KeyModifier.ALT) # Button: "Close"
+end
+
+Then /^I synchronize keys in Seahorse$/ do
+ recovery_proc = Proc.new do
+ # The versions of Seahorse in Wheezy and Jessie will abort with a
+ # segmentation fault whenever there's any sort of network error while
+ # syncing keys. This will usually happens after clicking away the error
+ # message. This does not appear to be a problem in Stretch.
+ #
+ # We'll kill the Seahorse process to avoid waiting for the inevitable
+ # segfault. We'll also make sure the process is still running (= hasn't
+ # yet segfaulted) before terminating it.
+ if @screen.exists('GnomeCloseButton.png') || !$vm.has_process?('seahorse')
+ step 'I kill the process "seahorse"' if $vm.has_process?('seahorse')
+ debug_log('Restarting Seahorse.')
+ start_or_restart_seahorse
+ end
+ end
+
+ def change_of_status?
+ # Due to a lack of visual feedback in Seahorse we'll break out of the
+ # try_for loop below by returning "true" when there's something we can act
+ # upon.
+ if count_gpg_signatures(@fetched_openpgp_keyid) > 2 || \
+ @screen.exists('GnomeCloseButton.png') || \
+ !$vm.has_process?('seahorse')
+ true
+ end
+ end
+
+ retry_tor(recovery_proc) do
+ @screen.wait_and_click("SeahorseWindow.png", 10)
+ seahorse_menu_click_helper('SeahorseRemoteMenu.png',
+ 'SeahorseRemoteMenuSync.png',
+ 'seahorse')
+ @screen.wait('SeahorseSyncKeys.png', 20)
+ @screen.type("s", Sikuli::KeyModifier.ALT) # Button: Sync
+ # There's no visual feedback of Seahorse in Tails/Jessie, except on error.
+ try_for(120) {
+ change_of_status?
+ }
+ check_for_seahorse_error
+ raise OpenPGPKeyserverCommunicationError.new(
+ 'Seahorse crashed with a segfault.') unless $vm.has_process?('seahorse')
+ end
+end
+
+When /^I fetch the "([^"]+)" OpenPGP key using Seahorse( via the Tails OpenPGP Applet)?$/ do |keyid, withgpgapplet|
+ step "I start Seahorse#{withgpgapplet}"
+
+ def change_of_status?(keyid)
+ # Due to a lack of visual feedback in Seahorse we'll break out of the
+ # try_for loop below by returning "true" when there's something we can act
+ # upon.
+ if $vm.execute_successfully(
+ "gpg --batch --list-keys '#{keyid}'", :user => LIVE_USER) ||
+ @screen.exists('GnomeCloseButton.png')
+ true
+ end
+ end
+
+ recovery_proc = Proc.new do
+ @screen.click('GnomeCloseButton.png') if @screen.exists('GnomeCloseButton.png')
+ @screen.type("w", Sikuli::KeyModifier.CTRL)
+ end
+ retry_tor(recovery_proc) do
+ @screen.wait_and_click("SeahorseWindow.png", 10)
+ seahorse_menu_click_helper('SeahorseRemoteMenu.png',
+ 'SeahorseRemoteMenuFind.png',
+ 'seahorse')
+ @screen.wait('SeahorseFindKeysWindow.png', 10)
+ # Seahorse doesn't seem to support searching for fingerprints
+ @screen.type(keyid + Sikuli::Key.ENTER)
+ begin
+ @screen.waitAny(['SeahorseFoundKeyResult.png',
+ 'GnomeCloseButton.png'], 120)
+ rescue FindAnyFailed
+ # We may end up here if Seahorse appears to be "frozen".
+ # Sometimes--but not always--if we click another window
+ # the main Seahorse window will unfreeze, allowing us
+ # to continue normally.
+ @screen.click("SeahorseSearch.png")
+ end
+ check_for_seahorse_error
+ @screen.click("SeahorseKeyResultWindow.png")
+ @screen.click("SeahorseFoundKeyResult.png")
+ @screen.click("SeahorseImport.png")
+ try_for(120) do
+ change_of_status?(keyid)
+ end
+ check_for_seahorse_error
+ end
+end
+
+Then /^Seahorse is configured to use the correct keyserver$/ do
+ @gnome_keyservers = YAML.load($vm.execute_successfully('gsettings get org.gnome.crypto.pgp keyservers',
+ :user => LIVE_USER).stdout)
+ assert_equal(1, @gnome_keyservers.count, 'Seahorse should only have one keyserver configured.')
+ # Seahorse doesn't support hkps so that part of the domain is stripped out.
+ # We also insert hkp:// to the beginning of the domain.
+ assert_equal(CONFIGURED_KEYSERVER_HOSTNAME.sub('hkps.', 'hkp://'), @gnome_keyservers[0])
+end
diff --git a/cucumber/features/step_definitions/torified_misc.rb b/cucumber/features/step_definitions/torified_misc.rb
new file mode 100644
index 00000000..7112776a
--- /dev/null
+++ b/cucumber/features/step_definitions/torified_misc.rb
@@ -0,0 +1,41 @@
+When /^I query the whois directory service for "([^"]+)"$/ do |domain|
+ retry_tor do
+ @vm_execute_res = $vm.execute("whois '#{domain}'", :user => LIVE_USER)
+ if @vm_execute_res.failure? || @vm_execute_res.stdout['LIMIT EXCEEDED']
+ raise "Looking up whois info for #{domain} failed with:\n" +
+ "#{@vm_execute_res.stdout}\n" +
+ "#{@vm_execute_res.stderr}"
+ end
+ end
+end
+
+When /^I wget "([^"]+)" to stdout(?:| with the '([^']+)' options)$/ do |url, options|
+ arguments = "-O - '#{url}'"
+ arguments = "#{options} #{arguments}" if options
+ retry_tor do
+ @vm_execute_res = $vm.execute("wget #{arguments}", :user => LIVE_USER)
+ if @vm_execute_res.failure?
+ raise "wget:ing #{url} with options #{options} failed with:\n" +
+ "#{@vm_execute_res.stdout}\n" +
+ "#{@vm_execute_res.stderr}"
+ end
+ end
+end
+
+Then /^the (wget|whois) command is successful$/ do |command|
+ assert(
+ @vm_execute_res.success?,
+ "#{command} failed:\n" +
+ "#{@vm_execute_res.stdout}\n" +
+ "#{@vm_execute_res.stderr}"
+ )
+end
+
+Then /^the (wget|whois) standard output contains "([^"]+)"$/ do |command, text|
+ assert(
+ @vm_execute_res.stdout[text],
+ "The #{command} standard output does not contain #{text}:\n" +
+ "#{@vm_execute_res.stdout}\n" +
+ "#{@vm_execute_res.stderr}"
+ )
+end
diff --git a/cucumber/features/step_definitions/totem.rb b/cucumber/features/step_definitions/totem.rb
new file mode 100644
index 00000000..72698dde
--- /dev/null
+++ b/cucumber/features/step_definitions/totem.rb
@@ -0,0 +1,43 @@
+Given /^I create sample videos$/ do
+ @shared_video_dir_on_host = "#{$config["TMPDIR"]}/shared_video_dir"
+ @shared_video_dir_on_guest = "/tmp/shared_video_dir"
+ FileUtils.mkdir_p(@shared_video_dir_on_host)
+ add_after_scenario_hook { FileUtils.rm_r(@shared_video_dir_on_host) }
+ fatal_system("avconv -loop 1 -t 30 -f image2 " +
+ "-i 'features/images/TailsBootSplash.png' " +
+ "-an -vcodec libx264 -y " +
+ '-filter:v "crop=in_w-mod(in_w\,2):in_h-mod(in_h\,2)" ' +
+ "'#{@shared_video_dir_on_host}/video.mp4' >/dev/null 2>&1")
+end
+
+Given /^I setup a filesystem share containing sample videos$/ do
+ $vm.add_share(@shared_video_dir_on_host, @shared_video_dir_on_guest)
+end
+
+Given /^I copy the sample videos to "([^"]+)" as user "([^"]+)"$/ do |destination, user|
+ for video_on_host in Dir.glob("#{@shared_video_dir_on_host}/*.mp4") do
+ video_name = File.basename(video_on_host)
+ src_on_guest = "#{@shared_video_dir_on_guest}/#{video_name}"
+ dst_on_guest = "#{destination}/#{video_name}"
+ step "I copy \"#{src_on_guest}\" to \"#{dst_on_guest}\" as user \"amnesia\""
+ end
+end
+
+When /^I(?:| try to) open "([^"]+)" with Totem$/ do |filename|
+ step "I run \"totem #{filename}\" in GNOME Terminal"
+end
+
+When /^I close Totem$/ do
+ step 'I kill the process "totem"'
+end
+
+Then /^I can watch a WebM video over HTTPs$/ do
+ test_url = 'https://webm.html5.org/test.webm'
+ recovery_on_failure = Proc.new do
+ step 'I close Totem'
+ end
+ retry_tor(recovery_on_failure) do
+ step "I open \"#{test_url}\" with Totem"
+ @screen.wait("SampleRemoteWebMVideoFrame.png", 120)
+ end
+end
diff --git a/cucumber/features/step_definitions/unsafe_browser.rb b/cucumber/features/step_definitions/unsafe_browser.rb
new file mode 100644
index 00000000..b8c04983
--- /dev/null
+++ b/cucumber/features/step_definitions/unsafe_browser.rb
@@ -0,0 +1,189 @@
+When /^I see and accept the Unsafe Browser start verification$/ do
+ @screen.wait('GnomeQuestionDialogIcon.png', 30)
+ @screen.type(Sikuli::Key.ESC)
+end
+
+def supported_torbrowser_languages
+ localization_descriptions = "#{Dir.pwd}/config/chroot_local-includes/usr/share/tails/browser-localization/descriptions"
+ File.read(localization_descriptions).split("\n").map do |line|
+ # The line will be of the form "xx:YY:..." or "xx-YY:YY:..."
+ first, second = line.sub('-', '_').split(':')
+ candidates = ["#{first}_#{second}.utf8", "#{first}.utf8",
+ "#{first}_#{second}", first]
+ when_not_found = Proc.new { raise "Could not find a locale for '#{line}'" }
+ candidates.find(when_not_found) do |candidate|
+ $vm.directory_exist?("/usr/lib/locale/#{candidate}")
+ end
+ end
+end
+
+Then /^I start the Unsafe Browser in the "([^"]+)" locale$/ do |loc|
+ step "I run \"LANG=#{loc} LC_ALL=#{loc} sudo unsafe-browser\" in GNOME Terminal"
+ step "I see and accept the Unsafe Browser start verification"
+end
+
+Then /^the Unsafe Browser works in all supported languages$/ do
+ failed = Array.new
+ supported_torbrowser_languages.each do |lang|
+ step "I start the Unsafe Browser in the \"#{lang}\" locale"
+ begin
+ step "the Unsafe Browser has started"
+ rescue RuntimeError
+ failed << lang
+ next
+ end
+ step "I close the Unsafe Browser"
+ step "the Unsafe Browser chroot is torn down"
+ end
+ assert(failed.empty?, "Unsafe Browser failed to launch in the following locale(s): #{failed.join(', ')}")
+end
+
+Then /^the Unsafe Browser has no add-ons installed$/ do
+ step "I open the address \"about:addons\" in the Unsafe Browser"
+ step "I see \"UnsafeBrowserNoAddons.png\" after at most 30 seconds"
+end
+
+Then /^the Unsafe Browser has only Firefox's default bookmarks configured$/ do
+ info = xul_application_info("Unsafe Browser")
+ # "Show all bookmarks"
+ @screen.type("o", Sikuli::KeyModifier.SHIFT + Sikuli::KeyModifier.CTRL)
+ @screen.wait_and_click("UnsafeBrowserExportBookmarksButton.png", 20)
+ @screen.wait_and_click("UnsafeBrowserExportBookmarksMenuEntry.png", 20)
+ @screen.wait("UnsafeBrowserExportBookmarksSavePrompt.png", 20)
+ path = "/home/#{info[:user]}/bookmarks"
+ @screen.type(path + Sikuli::Key.ENTER)
+ chroot_path = "#{info[:chroot]}/#{path}.json"
+ try_for(10) { $vm.file_exist?(chroot_path) }
+ dump = JSON.load($vm.file_content(chroot_path))
+
+ def check_bookmarks_helper(a)
+ mozilla_uris_counter = 0
+ places_uris_counter = 0
+ a.each do |h|
+ h.each_pair do |k, v|
+ if k == "children"
+ m, p = check_bookmarks_helper(v)
+ mozilla_uris_counter += m
+ places_uris_counter += p
+ elsif k == "uri"
+ uri = v
+ if uri.match("^https://www\.mozilla\.org/")
+ mozilla_uris_counter += 1
+ elsif uri.match("^place:(sort|folder|type)=")
+ places_uris_counter += 1
+ else
+ raise "Unexpected Unsafe Browser bookmark for '#{uri}'"
+ end
+ end
+ end
+ end
+ return [mozilla_uris_counter, places_uris_counter]
+ end
+
+ mozilla_uris_counter, places_uris_counter =
+ check_bookmarks_helper(dump["children"])
+ assert_equal(5, mozilla_uris_counter,
+ "Unexpected number (#{mozilla_uris_counter}) of mozilla " \
+ "bookmarks")
+ assert_equal(3, places_uris_counter,
+ "Unexpected number (#{places_uris_counter}) of places " \
+ "bookmarks")
+ @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT)
+end
+
+Then /^the Unsafe Browser has a red theme$/ do
+ @screen.wait("UnsafeBrowserRedTheme.png", 10)
+end
+
+Then /^the Unsafe Browser shows a warning as its start page$/ do
+ @screen.wait("UnsafeBrowserStartPage.png", 10)
+end
+
+Then /^I see a warning about another instance already running$/ do
+ @screen.wait('UnsafeBrowserWarnAlreadyRunning.png', 10)
+end
+
+Then /^I can start the Unsafe Browser again$/ do
+ step "I start the Unsafe Browser"
+end
+
+Then /^I cannot configure the Unsafe Browser to use any local proxies$/ do
+ socks_proxy = 'c' # Alt+c for socks proxy
+ no_proxy = 'y' # Alt+y for no proxy
+ proxies = [[no_proxy, nil, nil]]
+ socksport_lines =
+ $vm.execute_successfully('grep -w "^SocksPort" /etc/tor/torrc').stdout
+ assert(socksport_lines.size >= 4, "We got fewer than four Tor SocksPorts")
+ socksports = socksport_lines.scan(/^SocksPort\s([^:]+):(\d+)/)
+ proxies += socksports.map { |host, port| [socks_proxy, host, port] }
+
+ proxies.each do |proxy_type, proxy_host, proxy_port|
+ @screen.hide_cursor
+
+ # Open proxy settings and select manual proxy configuration
+ @screen.click('UnsafeBrowserMenuButton.png')
+ @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10)
+ @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10)
+ hit, _ = @screen.waitAny(['UnsafeBrowserNetworkTabAlreadySelected.png',
+ 'UnsafeBrowserNetworkTab.png'], 10)
+ @screen.click(hit) if hit == 'UnsafeBrowserNetworkTab.png'
+ @screen.wait_and_click('UnsafeBrowserNetworkTabSettingsButton.png', 10)
+ @screen.wait_and_click('UnsafeBrowserProxySettingsWindow.png', 10)
+ @screen.type("m", Sikuli::KeyModifier.ALT)
+
+ # Configure the proxy
+ @screen.type(proxy_type, Sikuli::KeyModifier.ALT) # Select correct proxy type
+ @screen.type(proxy_host + Sikuli::Key.TAB + proxy_port) if proxy_type != no_proxy
+
+ # Close settings
+ @screen.click('UnsafeBrowserProxySettingsOkButton.png')
+ @screen.waitVanish('UnsafeBrowserProxySettingsWindow.png', 10)
+
+ # Test that the proxy settings work as they should
+ step "I open the address \"https://check.torproject.org\" in the Unsafe Browser"
+ if proxy_type == no_proxy
+ @screen.wait('UnsafeBrowserTorCheckFail.png', 60)
+ else
+ @screen.wait('UnsafeBrowserProxyRefused.png', 60)
+ end
+ end
+end
+
+Then /^the Unsafe Browser has no proxy configured$/ do
+ @screen.click('UnsafeBrowserMenuButton.png')
+ @screen.wait_and_click('UnsafeBrowserPreferencesButton.png', 10)
+ @screen.wait_and_click('UnsafeBrowserAdvancedSettingsButton.png', 10)
+ @screen.wait_and_click('UnsafeBrowserNetworkTab.png', 10)
+ @screen.wait_and_click('UnsafeBrowserNetworkTabSettingsButton.png', 10)
+ @screen.wait('UnsafeBrowserProxySettingsWindow.png', 10)
+ @screen.wait('UnsafeBrowserNoProxySelected.png', 10)
+ @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT)
+ @screen.type("w", Sikuli::KeyModifier.CTRL)
+end
+
+Then /^the Unsafe Browser complains that no DNS server is configured$/ do
+ @screen.wait("UnsafeBrowserDNSError.png", 30)
+end
+
+Then /^I configure the Unsafe Browser to check for updates more frequently$/ do
+ prefs = '/usr/share/tails/chroot-browsers/unsafe-browser/prefs.js'
+ $vm.file_append(prefs, 'pref("app.update.idletime", 1);')
+ $vm.file_append(prefs, 'pref("app.update.promptWaitTime", 1);')
+ $vm.file_append(prefs, 'pref("app.update.interval", 5);')
+end
+
+But /^checking for updates is disabled in the Unsafe Browser's configuration$/ do
+ prefs = '/usr/share/tails/chroot-browsers/common/prefs.js'
+ assert($vm.file_content(prefs).include?('pref("app.update.enabled", false)'))
+end
+
+Then /^the clearnet user has (|not )sent packets out to the Internet$/ do |sent|
+ uid = $vm.execute_successfully("id -u clearnet").stdout.chomp.to_i
+ pkts = ip4tables_packet_counter_sum(:tables => ['OUTPUT'], :uid => uid)
+ case sent
+ when ''
+ assert(pkts > 0, "Packets have not gone out to the internet.")
+ when 'not'
+ assert_equal(pkts, 0, "Packets have gone out to the internet.")
+ end
+end
diff --git a/cucumber/features/step_definitions/untrusted_partitions.rb b/cucumber/features/step_definitions/untrusted_partitions.rb
new file mode 100644
index 00000000..43453b2f
--- /dev/null
+++ b/cucumber/features/step_definitions/untrusted_partitions.rb
@@ -0,0 +1,61 @@
+Given /^I create an? ([[:alnum:]]+) swap partition on disk "([^"]+)"$/ do |parttype, name|
+ $vm.storage.disk_mkswap(name, parttype)
+end
+
+Then /^an? "([^"]+)" partition was detected by Tails on drive "([^"]+)"$/ do |type, name|
+ part_info = $vm.execute_successfully(
+ "blkid '#{$vm.disk_dev(name)}'").stdout.strip
+ assert(part_info.split.grep(/^TYPE=\"#{Regexp.escape(type)}\"$/),
+ "No #{type} partition was detected by Tails on disk '#{name}'")
+end
+
+Then /^Tails has no disk swap enabled$/ do
+ # Skip first line which contain column headers
+ swap_info = $vm.execute_successfully("tail -n+2 /proc/swaps").stdout
+ assert(swap_info.empty?,
+ "Disk swapping is enabled according to /proc/swaps:\n" + swap_info)
+ mem_info = $vm.execute_successfully("grep '^Swap' /proc/meminfo").stdout
+ assert(mem_info.match(/^SwapTotal:\s+0 kB$/),
+ "Disk swapping is enabled according to /proc/meminfo:\n" +
+ mem_info)
+end
+
+Given /^I create an? ([[:alnum:]]+) partition( labeled "([^"]+)")? with an? ([[:alnum:]]+) filesystem( encrypted with password "([^"]+)")? on disk "([^"]+)"$/ do |parttype, has_label, label, fstype, is_encrypted, luks_password, name|
+ opts = {}
+ opts.merge!(:label => label) if has_label
+ opts.merge!(:luks_password => luks_password) if is_encrypted
+ $vm.storage.disk_mkpartfs(name, parttype, fstype, opts)
+end
+
+Given /^I cat an ISO of the Tails image to disk "([^"]+)"$/ do |name|
+ src_disk = {
+ :path => TAILS_ISO,
+ :opts => {
+ :format => "raw",
+ :readonly => true
+ }
+ }
+ dest_disk = {
+ :path => $vm.storage.disk_path(name),
+ :opts => {
+ :format => $vm.storage.disk_format(name)
+ }
+ }
+ $vm.storage.guestfs_disk_helper(src_disk, dest_disk) do |g, src_disk_handle, dest_disk_handle|
+ g.copy_device_to_device(src_disk_handle, dest_disk_handle, {})
+ end
+end
+
+Then /^drive "([^"]+)" is not mounted$/ do |name|
+ dev = $vm.disk_dev(name)
+ assert(!$vm.execute("grep -qs '^#{dev}' /proc/mounts").success?,
+ "an untrusted partition from drive '#{name}' was automounted")
+end
+
+Then /^Tails Greeter has( not)? detected a persistence partition$/ do |no_persistence|
+ expecting_persistence = no_persistence.nil?
+ @screen.find('TailsGreeter.png')
+ found_persistence = ! @screen.exists('TailsGreeterPersistence.png').nil?
+ assert_equal(expecting_persistence, found_persistence,
+ "Persistence is unexpectedly#{no_persistence} enabled")
+end
diff --git a/cucumber/features/step_definitions/usb.rb b/cucumber/features/step_definitions/usb.rb
new file mode 100644
index 00000000..76f94d2f
--- /dev/null
+++ b/cucumber/features/step_definitions/usb.rb
@@ -0,0 +1,596 @@
+# Returns a hash that for each preset the running Tails is aware of
+# maps the source to the destination.
+def get_persistence_presets(skip_links = false)
+ # Perl script that prints all persistence presets (one per line) on
+ # the form: <mount_point>:<comma-separated-list-of-options>
+ script = <<-EOF
+ use strict;
+ use warnings FATAL => "all";
+ use Tails::Persistence::Configuration::Presets;
+ foreach my $preset (Tails::Persistence::Configuration::Presets->new()->all) {
+ say $preset->destination, ":", join(",", @{$preset->options});
+ }
+EOF
+ # VMCommand:s cannot handle newlines, and they're irrelevant in the
+ # above perl script any way
+ script.delete!("\n")
+ presets = $vm.execute_successfully("perl -E '#{script}'").stdout.chomp.split("\n")
+ assert presets.size >= 10, "Got #{presets.size} persistence presets, " +
+ "which is too few"
+ persistence_mapping = Hash.new
+ for line in presets
+ destination, options_str = line.split(":")
+ options = options_str.split(",")
+ is_link = options.include? "link"
+ next if is_link and skip_links
+ source_str = options.find { |option| /^source=/.match option }
+ # If no source is given as an option, live-boot's persistence
+ # feature defaults to the destination minus the initial "/".
+ if source_str.nil?
+ source = destination.partition("/").last
+ else
+ source = source_str.split("=")[1]
+ end
+ persistence_mapping[source] = destination
+ end
+ return persistence_mapping
+end
+
+def persistent_dirs
+ get_persistence_presets
+end
+
+def persistent_mounts
+ get_persistence_presets(true)
+end
+
+def persistent_volumes_mountpoints
+ $vm.execute("ls -1 -d /live/persistence/*_unlocked/").stdout.chomp.split
+end
+
+Given /^I clone USB drive "([^"]+)" to a new USB drive "([^"]+)"$/ do |from, to|
+ $vm.storage.clone_to_new_disk(from, to)
+end
+
+Given /^I unplug USB drive "([^"]+)"$/ do |name|
+ $vm.unplug_drive(name)
+end
+
+Given /^the computer is set to boot from the old Tails DVD$/ do
+ $vm.set_cdrom_boot(OLD_TAILS_ISO)
+end
+
+Given /^the computer is set to boot in UEFI mode$/ do
+ $vm.set_os_loader('UEFI')
+ @os_loader = 'UEFI'
+end
+
+class UpgradeNotSupported < StandardError
+end
+
+def usb_install_helper(name)
+ @screen.wait('USBTailsLogo.png', 10)
+ if @screen.exists("USBCannotUpgrade.png")
+ raise UpgradeNotSupported
+ end
+ @screen.wait_and_click('USBCreateLiveUSB.png', 10)
+ @screen.wait('USBCreateLiveUSBConfirmWindow.png', 10)
+ @screen.wait_and_click('USBCreateLiveUSBConfirmYes.png', 10)
+ @screen.wait('USBInstallationComplete.png', 30*60)
+end
+
+When /^I start Tails Installer$/ do
+ step 'I start "TailsInstaller" via the GNOME "Tails" applications menu'
+ @screen.wait('USBCloneAndInstall.png', 30)
+end
+
+When /^I start Tails Installer in "([^"]+)" mode$/ do |mode|
+ step 'I start Tails Installer'
+ case mode
+ when 'Clone & Install'
+ @screen.wait_and_click('USBCloneAndInstall.png', 10)
+ when 'Clone & Upgrade'
+ @screen.wait_and_click('USBCloneAndUpgrade.png', 10)
+ when 'Upgrade from ISO'
+ @screen.wait_and_click('USBUpgradeFromISO.png', 10)
+ else
+ raise "Unsupported mode '#{mode}'"
+ end
+end
+
+Then /^Tails Installer detects that a device is too small$/ do
+ @screen.wait('TailsInstallerTooSmallDevice.png', 10)
+end
+
+When /^I "Clone & Install" Tails to USB drive "([^"]+)"$/ do |name|
+ step 'I start Tails Installer in "Clone & Install" mode'
+ usb_install_helper(name)
+end
+
+When /^I "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name|
+ step 'I start Tails Installer in "Clone & Upgrade" mode'
+ usb_install_helper(name)
+end
+
+When /^I try a "Clone & Upgrade" Tails to USB drive "([^"]+)"$/ do |name|
+ begin
+ step "I \"Clone & Upgrade\" Tails to USB drive \"#{name}\""
+ rescue UpgradeNotSupported
+ # this is what we expect
+ else
+ raise "The USB installer should not succeed"
+ end
+end
+
+When /^I try to "Upgrade from ISO" USB drive "([^"]+)"$/ do |name|
+ begin
+ step "I do a \"Upgrade from ISO\" on USB drive \"#{name}\""
+ rescue UpgradeNotSupported
+ # this is what we expect
+ else
+ raise "The USB installer should not succeed"
+ end
+end
+
+When /^I am suggested to do a "Clone & Install"$/ do
+ @screen.find("USBCannotUpgrade.png")
+end
+
+When /^I am told that the destination device cannot be upgraded$/ do
+ @screen.find("USBCannotUpgrade.png")
+end
+
+Given /^I setup a filesystem share containing the Tails ISO$/ do
+ shared_iso_dir_on_host = "#{$config["TMPDIR"]}/shared_iso_dir"
+ @shared_iso_dir_on_guest = "/tmp/shared_iso_dir"
+ FileUtils.mkdir_p(shared_iso_dir_on_host)
+ FileUtils.cp(TAILS_ISO, shared_iso_dir_on_host)
+ add_after_scenario_hook { FileUtils.rm_r(shared_iso_dir_on_host) }
+ $vm.add_share(shared_iso_dir_on_host, @shared_iso_dir_on_guest)
+end
+
+When /^I do a "Upgrade from ISO" on USB drive "([^"]+)"$/ do |name|
+ step 'I start Tails Installer in "Upgrade from ISO" mode'
+ @screen.wait('USBUseLiveSystemISO.png', 10)
+ match = @screen.find('USBUseLiveSystemISO.png')
+ @screen.click(match.getCenter.offset(0, match.h*2))
+ @screen.wait('USBSelectISO.png', 10)
+ @screen.wait_and_click('GnomeFileDiagHome.png', 10)
+ @screen.type("l", Sikuli::KeyModifier.CTRL)
+ @screen.wait('GnomeFileDiagTypeFilename.png', 10)
+ iso = "#{@shared_iso_dir_on_guest}/#{File.basename(TAILS_ISO)}"
+ @screen.type(iso)
+ @screen.wait_and_click('GnomeFileDiagOpenButton.png', 10)
+ usb_install_helper(name)
+end
+
+Given /^I enable all persistence presets$/ do
+ @screen.wait('PersistenceWizardPresets.png', 20)
+ # Select the "Persistent" folder preset, which is checked by default.
+ @screen.type(Sikuli::Key.TAB)
+ # Check all non-default persistence presets, i.e. all *after* the
+ # "Persistent" folder, which are unchecked by default.
+ (persistent_dirs.size - 1).times do
+ @screen.type(Sikuli::Key.TAB + Sikuli::Key.SPACE)
+ end
+ @screen.wait_and_click('PersistenceWizardSave.png', 10)
+ @screen.wait('PersistenceWizardDone.png', 30)
+ @screen.type(Sikuli::Key.F4, Sikuli::KeyModifier.ALT)
+end
+
+Given /^I create a persistent partition$/ do
+ step 'I start "ConfigurePersistentVolume" via the GNOME "Tails" applications menu'
+ @screen.wait('PersistenceWizardStart.png', 20)
+ @screen.type(@persistence_password + "\t" + @persistence_password + Sikuli::Key.ENTER)
+ @screen.wait('PersistenceWizardPresets.png', 300)
+ step "I enable all persistence presets"
+end
+
+def check_disk_integrity(name, dev, scheme)
+ info = $vm.execute("udisksctl info --block-device '#{dev}'").stdout
+ info_split = info.split("\n org\.freedesktop\.UDisks2\.PartitionTable:\n")
+ dev_info = info_split[0]
+ part_table_info = info_split[1]
+ assert(part_table_info.match("^ Type: +#{scheme}$"),
+ "Unexpected partition scheme on USB drive '#{name}', '#{dev}'")
+end
+
+def check_part_integrity(name, dev, usage, fs_type, part_label, part_type = nil)
+ info = $vm.execute("udisksctl info --block-device '#{dev}'").stdout
+ info_split = info.split("\n org\.freedesktop\.UDisks2\.Partition:\n")
+ dev_info = info_split[0]
+ part_info = info_split[1]
+ assert(dev_info.match("^ IdUsage: +#{usage}$"),
+ "Unexpected device field 'usage' on USB drive '#{name}', '#{dev}'")
+ assert(dev_info.match("^ IdType: +#{fs_type}$"),
+ "Unexpected device field 'IdType' on USB drive '#{name}', '#{dev}'")
+ assert(part_info.match("^ Name: +#{part_label}$"),
+ "Unexpected partition label on USB drive '#{name}', '#{dev}'")
+ if part_type
+ assert(part_info.match("^ Type: +#{part_type}$"),
+ "Unexpected partition type on USB drive '#{name}', '#{dev}'")
+ end
+end
+
+def tails_is_installed_helper(name, tails_root, loader)
+ disk_dev = $vm.disk_dev(name)
+ part_dev = disk_dev + "1"
+ check_disk_integrity(name, disk_dev, "gpt")
+ check_part_integrity(name, part_dev, "filesystem", "vfat", "Tails",
+ # EFI System Partition
+ 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b')
+
+ target_root = "/mnt/new"
+ $vm.execute("mkdir -p #{target_root}")
+ $vm.execute("mount #{part_dev} #{target_root}")
+
+ c = $vm.execute("diff -qr '#{tails_root}/live' '#{target_root}/live'")
+ assert(c.success?,
+ "USB drive '#{name}' has differences in /live:\n#{c.stdout}\n#{c.stderr}")
+
+ syslinux_files = $vm.execute("ls -1 #{target_root}/syslinux").stdout.chomp.split
+ # We deal with these files separately
+ ignores = ["syslinux.cfg", "exithelp.cfg", "ldlinux.c32", "ldlinux.sys"]
+ for f in syslinux_files - ignores do
+ c = $vm.execute("diff -q '#{tails_root}/#{loader}/#{f}' " +
+ "'#{target_root}/syslinux/#{f}'")
+ assert(c.success?, "USB drive '#{name}' has differences in " +
+ "'/syslinux/#{f}'")
+ end
+
+ # The main .cfg is named differently vs isolinux
+ c = $vm.execute("diff -q '#{tails_root}/#{loader}/#{loader}.cfg' " +
+ "'#{target_root}/syslinux/syslinux.cfg'")
+ assert(c.success?, "USB drive '#{name}' has differences in " +
+ "'/syslinux/syslinux.cfg'")
+
+ $vm.execute("umount #{target_root}")
+ $vm.execute("sync")
+end
+
+Then /^the running Tails is installed on USB drive "([^"]+)"$/ do |target_name|
+ loader = boot_device_type == "usb" ? "syslinux" : "isolinux"
+ tails_is_installed_helper(target_name, "/lib/live/mount/medium", loader)
+end
+
+Then /^the ISO's Tails is installed on USB drive "([^"]+)"$/ do |target_name|
+ iso = "#{@shared_iso_dir_on_guest}/#{File.basename(TAILS_ISO)}"
+ iso_root = "/mnt/iso"
+ $vm.execute("mkdir -p #{iso_root}")
+ $vm.execute("mount -o loop #{iso} #{iso_root}")
+ tails_is_installed_helper(target_name, iso_root, "isolinux")
+ $vm.execute("umount #{iso_root}")
+end
+
+Then /^there is no persistence partition on USB drive "([^"]+)"$/ do |name|
+ data_part_dev = $vm.disk_dev(name) + "2"
+ assert(!$vm.execute("test -b #{data_part_dev}").success?,
+ "USB drive #{name} has a partition '#{data_part_dev}'")
+end
+
+Then /^a Tails persistence partition exists on USB drive "([^"]+)"$/ do |name|
+ dev = $vm.disk_dev(name) + "2"
+ check_part_integrity(name, dev, "crypto", "crypto_LUKS", "TailsData")
+
+ # The LUKS container may already be opened, e.g. by udisks after
+ # we've run tails-persistence-setup.
+ c = $vm.execute("ls -1 /dev/mapper/")
+ if c.success?
+ for candidate in c.stdout.split("\n")
+ luks_info = $vm.execute("cryptsetup status #{candidate}")
+ if luks_info.success? and luks_info.stdout.match("^\s+device:\s+#{dev}$")
+ luks_dev = "/dev/mapper/#{candidate}"
+ break
+ end
+ end
+ end
+ if luks_dev.nil?
+ c = $vm.execute("echo #{@persistence_password} | " +
+ "cryptsetup luksOpen #{dev} #{name}")
+ assert(c.success?, "Couldn't open LUKS device '#{dev}' on drive '#{name}'")
+ luks_dev = "/dev/mapper/#{name}"
+ end
+
+ # Adapting check_part_integrity() seems like a bad idea so here goes
+ info = $vm.execute("udisksctl info --block-device '#{luks_dev}'").stdout
+ assert info.match("^ CryptoBackingDevice: +'/[a-zA-Z0-9_/]+'$")
+ assert info.match("^ IdUsage: +filesystem$")
+ assert info.match("^ IdType: +ext[34]$")
+ assert info.match("^ IdLabel: +TailsData$")
+
+ mount_dir = "/mnt/#{name}"
+ $vm.execute("mkdir -p #{mount_dir}")
+ c = $vm.execute("mount #{luks_dev} #{mount_dir}")
+ assert(c.success?,
+ "Couldn't mount opened LUKS device '#{dev}' on drive '#{name}'")
+
+ $vm.execute("umount #{mount_dir}")
+ $vm.execute("sync")
+ $vm.execute("cryptsetup luksClose #{name}")
+end
+
+Given /^I enable persistence$/ do
+ @screen.wait('TailsGreeterPersistence.png', 10)
+ @screen.type(Sikuli::Key.SPACE)
+ @screen.wait('TailsGreeterPersistencePassphrase.png', 10)
+ match = @screen.find('TailsGreeterPersistencePassphrase.png')
+ @screen.click(match.getCenter.offset(match.w*2, match.h/2))
+ @screen.type(@persistence_password)
+end
+
+def tails_persistence_enabled?
+ persistence_state_file = "/var/lib/live/config/tails.persistence"
+ return $vm.execute("test -e '#{persistence_state_file}'").success? &&
+ $vm.execute(". '#{persistence_state_file}' && " +
+ 'test "$TAILS_PERSISTENCE_ENABLED" = true').success?
+end
+
+Given /^all persistence presets(| from the old Tails version) are enabled$/ do |old_tails|
+ try_for(120, :msg => "Persistence is disabled") do
+ tails_persistence_enabled?
+ end
+ # Check that all persistent directories are mounted
+ if old_tails.empty?
+ expected_mounts = persistent_mounts
+ else
+ assert_not_nil($remembered_persistence_mounts)
+ expected_mounts = $remembered_persistence_mounts
+ end
+ mount = $vm.execute("mount").stdout.chomp
+ for _, dir in expected_mounts do
+ assert(mount.include?("on #{dir} "),
+ "Persistent directory '#{dir}' is not mounted")
+ end
+end
+
+Given /^persistence is disabled$/ do
+ assert(!tails_persistence_enabled?, "Persistence is enabled")
+end
+
+Given /^I enable read-only persistence$/ do
+ step "I enable persistence"
+ @screen.wait_and_click('TailsGreeterPersistenceReadOnly.png', 10)
+end
+
+def boot_device
+ # Approach borrowed from
+ # config/chroot_local_includes/lib/live/config/998-permissions
+ boot_dev_id = $vm.execute("udevadm info --device-id-of-file=/lib/live/mount/medium").stdout.chomp
+ boot_dev = $vm.execute("readlink -f /dev/block/'#{boot_dev_id}'").stdout.chomp
+ return boot_dev
+end
+
+def device_info(dev)
+ # Approach borrowed from
+ # config/chroot_local_includes/lib/live/config/998-permissions
+ info = $vm.execute("udevadm info --query=property --name='#{dev}'").stdout.chomp
+ info.split("\n").map { |e| e.split('=') } .to_h
+end
+
+def boot_device_type
+ device_info(boot_device)['ID_BUS']
+end
+
+Then /^Tails is running from (.*) drive "([^"]+)"$/ do |bus, name|
+ bus = bus.downcase
+ case bus
+ when "ide"
+ expected_bus = "ata"
+ else
+ expected_bus = bus
+ end
+ assert_equal(expected_bus, boot_device_type)
+ actual_dev = boot_device
+ # The boot partition differs between a "normal" install using the
+ # USB installer and isohybrid installations
+ expected_dev_normal = $vm.disk_dev(name) + "1"
+ expected_dev_isohybrid = $vm.disk_dev(name) + "4"
+ assert(actual_dev == expected_dev_normal ||
+ actual_dev == expected_dev_isohybrid,
+ "We are running from device #{actual_dev}, but for #{bus} drive " +
+ "'#{name}' we expected to run from either device " +
+ "#{expected_dev_normal} (when installed via the USB installer) " +
+ "or #{expected_dev_isohybrid} (when installed from an isohybrid)")
+end
+
+Then /^the boot device has safe access rights$/ do
+
+ super_boot_dev = boot_device.sub(/[[:digit:]]+$/, "")
+ devs = $vm.execute("ls -1 #{super_boot_dev}*").stdout.chomp.split
+ assert(devs.size > 0, "Could not determine boot device")
+ all_users = $vm.execute("cut -d':' -f1 /etc/passwd").stdout.chomp.split
+ all_users_with_groups = all_users.collect do |user|
+ groups = $vm.execute("groups #{user}").stdout.chomp.sub(/^#{user} : /, "").split(" ")
+ [user, groups]
+ end
+ for dev in devs do
+ dev_owner = $vm.execute("stat -c %U #{dev}").stdout.chomp
+ dev_group = $vm.execute("stat -c %G #{dev}").stdout.chomp
+ dev_perms = $vm.execute("stat -c %a #{dev}").stdout.chomp
+ assert_equal("root", dev_owner)
+ assert(dev_group == "disk" || dev_group == "root",
+ "Boot device '#{dev}' owned by group '#{dev_group}', expected " +
+ "'disk' or 'root'.")
+ assert_equal("660", dev_perms)
+ for user, groups in all_users_with_groups do
+ next if user == "root"
+ assert(!(groups.include?(dev_group)),
+ "Unprivileged user '#{user}' is in group '#{dev_group}' which " +
+ "owns boot device '#{dev}'")
+ end
+ end
+
+ info = $vm.execute("udisksctl info --block-device '#{super_boot_dev}'").stdout
+ assert(info.match("^ HintSystem: +true$"),
+ "Boot device '#{super_boot_dev}' is not system internal for udisks")
+end
+
+Then /^all persistent filesystems have safe access rights$/ do
+ persistent_volumes_mountpoints.each do |mountpoint|
+ fs_owner = $vm.execute("stat -c %U #{mountpoint}").stdout.chomp
+ fs_group = $vm.execute("stat -c %G #{mountpoint}").stdout.chomp
+ fs_perms = $vm.execute("stat -c %a #{mountpoint}").stdout.chomp
+ assert_equal("root", fs_owner)
+ assert_equal("root", fs_group)
+ assert_equal('775', fs_perms)
+ end
+end
+
+Then /^all persistence configuration files have safe access rights$/ do
+ persistent_volumes_mountpoints.each do |mountpoint|
+ assert($vm.execute("test -e #{mountpoint}/persistence.conf").success?,
+ "#{mountpoint}/persistence.conf does not exist, while it should")
+ assert($vm.execute("test ! -e #{mountpoint}/live-persistence.conf").success?,
+ "#{mountpoint}/live-persistence.conf does exist, while it should not")
+ $vm.execute(
+ "ls -1 #{mountpoint}/persistence.conf #{mountpoint}/live-*.conf"
+ ).stdout.chomp.split.each do |f|
+ file_owner = $vm.execute("stat -c %U '#{f}'").stdout.chomp
+ file_group = $vm.execute("stat -c %G '#{f}'").stdout.chomp
+ file_perms = $vm.execute("stat -c %a '#{f}'").stdout.chomp
+ assert_equal("tails-persistence-setup", file_owner)
+ assert_equal("tails-persistence-setup", file_group)
+ assert_equal("600", file_perms)
+ end
+ end
+end
+
+Then /^all persistent directories(| from the old Tails version) have safe access rights$/ do |old_tails|
+ if old_tails.empty?
+ expected_dirs = persistent_dirs
+ else
+ assert_not_nil($remembered_persistence_dirs)
+ expected_dirs = $remembered_persistence_dirs
+ end
+ persistent_volumes_mountpoints.each do |mountpoint|
+ expected_dirs.each do |src, dest|
+ full_src = "#{mountpoint}/#{src}"
+ assert_vmcommand_success $vm.execute("test -d #{full_src}")
+ dir_perms = $vm.execute_successfully("stat -c %a '#{full_src}'").stdout.chomp
+ dir_owner = $vm.execute_successfully("stat -c %U '#{full_src}'").stdout.chomp
+ if dest.start_with?("/home/#{LIVE_USER}")
+ expected_perms = "700"
+ expected_owner = LIVE_USER
+ else
+ expected_perms = "755"
+ expected_owner = "root"
+ end
+ assert_equal(expected_perms, dir_perms,
+ "Persistent source #{full_src} has permission " \
+ "#{dir_perms}, expected #{expected_perms}")
+ assert_equal(expected_owner, dir_owner,
+ "Persistent source #{full_src} has owner " \
+ "#{dir_owner}, expected #{expected_owner}")
+ end
+ end
+end
+
+When /^I write some files expected to persist$/ do
+ persistent_mounts.each do |_, dir|
+ owner = $vm.execute("stat -c %U #{dir}").stdout.chomp
+ assert($vm.execute("touch #{dir}/XXX_persist", :user => owner).success?,
+ "Could not create file in persistent directory #{dir}")
+ end
+end
+
+When /^I remove some files expected to persist$/ do
+ persistent_mounts.each do |_, dir|
+ owner = $vm.execute("stat -c %U #{dir}").stdout.chomp
+ assert($vm.execute("rm #{dir}/XXX_persist", :user => owner).success?,
+ "Could not remove file in persistent directory #{dir}")
+ end
+end
+
+When /^I write some files not expected to persist$/ do
+ persistent_mounts.each do |_, dir|
+ owner = $vm.execute("stat -c %U #{dir}").stdout.chomp
+ assert($vm.execute("touch #{dir}/XXX_gone", :user => owner).success?,
+ "Could not create file in persistent directory #{dir}")
+ end
+end
+
+When /^I take note of which persistence presets are available$/ do
+ $remembered_persistence_mounts = persistent_mounts
+ $remembered_persistence_dirs = persistent_dirs
+end
+
+Then /^the expected persistent files(| created with the old Tails version) are present in the filesystem$/ do |old_tails|
+ if old_tails.empty?
+ expected_mounts = persistent_mounts
+ else
+ assert_not_nil($remembered_persistence_mounts)
+ expected_mounts = $remembered_persistence_mounts
+ end
+ expected_mounts.each do |_, dir|
+ assert($vm.execute("test -e #{dir}/XXX_persist").success?,
+ "Could not find expected file in persistent directory #{dir}")
+ assert(!$vm.execute("test -e #{dir}/XXX_gone").success?,
+ "Found file that should not have persisted in persistent directory #{dir}")
+ end
+end
+
+Then /^only the expected files are present on the persistence partition on USB drive "([^"]+)"$/ do |name|
+ assert(!$vm.is_running?)
+ disk = {
+ :path => $vm.storage.disk_path(name),
+ :opts => {
+ :format => $vm.storage.disk_format(name),
+ :readonly => true
+ }
+ }
+ $vm.storage.guestfs_disk_helper(disk) do |g, disk_handle|
+ partitions = g.part_list(disk_handle).map do |part_desc|
+ disk_handle + part_desc["part_num"].to_s
+ end
+ partition = partitions.find do |part|
+ g.blkid(part)["PART_ENTRY_NAME"] == "TailsData"
+ end
+ assert_not_nil(partition, "Could not find the 'TailsData' partition " \
+ "on disk '#{disk_handle}'")
+ luks_mapping = File.basename(partition) + "_unlocked"
+ g.luks_open(partition, @persistence_password, luks_mapping)
+ luks_dev = "/dev/mapper/#{luks_mapping}"
+ mount_point = "/"
+ g.mount(luks_dev, mount_point)
+ assert_not_nil($remembered_persistence_mounts)
+ $remembered_persistence_mounts.each do |dir, _|
+ # Guestfs::exists may have a bug; if the file exists, 1 is
+ # returned, but if it doesn't exist false is returned. It seems
+ # the translation of C types into Ruby types is glitchy.
+ assert(g.exists("/#{dir}/XXX_persist") == 1,
+ "Could not find expected file in persistent directory #{dir}")
+ assert(g.exists("/#{dir}/XXX_gone") != 1,
+ "Found file that should not have persisted in persistent directory #{dir}")
+ end
+ g.umount(mount_point)
+ g.luks_close(luks_dev)
+ end
+end
+
+When /^I delete the persistent partition$/ do
+ step 'I start "DeletePersistentVolume" via the GNOME "Tails" applications menu'
+ @screen.wait("PersistenceWizardDeletionStart.png", 20)
+ @screen.type(" ")
+ @screen.wait("PersistenceWizardDone.png", 120)
+end
+
+Then /^Tails has started in UEFI mode$/ do
+ assert($vm.execute("test -d /sys/firmware/efi").success?,
+ "/sys/firmware/efi does not exist")
+ end
+
+Given /^I create a ([[:alpha:]]+) label on disk "([^"]+)"$/ do |type, name|
+ $vm.storage.disk_mklabel(name, type)
+end
+
+Then /^a suitable USB device is (?:still )?not found$/ do
+ @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30)
+end
+
+Then /^the "(?:[^"]+)" USB drive is selected$/ do
+ @screen.wait("TailsInstallerQEMUHardDisk.png", 30)
+end
+
+Then /^no USB drive is selected$/ do
+ @screen.wait("TailsInstallerNoQEMUHardDisk.png", 30)
+end
diff --git a/cucumber/features/support/config.rb b/cucumber/features/support/config.rb
new file mode 100644
index 00000000..25c107b4
--- /dev/null
+++ b/cucumber/features/support/config.rb
@@ -0,0 +1,100 @@
+require 'fileutils'
+require 'yaml'
+require "features/support/helpers/misc_helpers.rb"
+
+# These files deal with options like some of the settings passed
+# to the `run_test_suite` script, and "secrets" like credentials
+# (passwords, SSH keys) to be used in tests.
+CONFIG_DIR = "/srv/jenkins/features/config"
+DEFAULTS_CONFIG_FILE = "#{CONFIG_DIR}/defaults.yml"
+LOCAL_CONFIG_FILE = "#{CONFIG_DIR}/local.yml"
+LOCAL_CONFIG_DIRS_FILES_GLOB = "#{CONFIG_DIR}/*.d/*.yml"
+
+# Dynamic
+$tails_iso = ENV['ISO'] || get_newest_iso
+$old_tails_iso = ENV['OLD_ISO'] || get_oldest_iso
+$tmp_dir = ENV['PWD']
+$vm_xml_path = ENV['VM_XML_PATH']
+$misc_files_dir = "features/misc_files"
+$keep_snapshots = !ENV['KEEP_SNAPSHOTS'].nil?
+$x_display = ENV['DISPLAY']
+$debug = !ENV['DEBUG'].nil?
+$pause_on_fail = !ENV['PAUSE_ON_FAIL'].nil?
+$time_at_start = Time.now
+$live_user = "user"
+$sikuli_retry_findfailed = !ENV['SIKULI_RETRY_FINDFAILED'].nil?
+
+assert File.exists?(DEFAULTS_CONFIG_FILE)
+$config = YAML.load(File.read(DEFAULTS_CONFIG_FILE))
+config_files = Dir.glob(LOCAL_CONFIG_DIRS_FILES_GLOB).sort
+config_files.insert(0, LOCAL_CONFIG_FILE) if File.exists?(LOCAL_CONFIG_FILE)
+config_files.each do |config_file|
+ yaml_struct = YAML.load(File.read(config_file)) || Hash.new
+ if not(yaml_struct.instance_of?(Hash))
+ raise "Local configuration file '#{config_file}' is malformed"
+ end
+ $config.merge!(yaml_struct)
+end
+# Options passed to the `run_test_suite` script will always take
+# precedence. The way we import these keys is only safe for values
+# with types boolean or string. If we need more, we'll have to invoke
+# YAML's type autodetection on ENV some how.
+$config.merge!(ENV)
+
+# Export TMPDIR back to the environment for subprocesses that we start
+# (e.g. guestfs). Note that this export will only make a difference if
+# TMPDIR wasn't already set and --tmpdir wasn't passed, i.e. only when
+# we use the default.
+ENV['TMPDIR'] = $config['TMPDIR']
+
+# Dynamic constants initialized through the environment or similar,
+# e.g. options we do not want to be configurable through the YAML
+# configuration files.
+DEBUG_LOG_PSEUDO_FIFO = "#{$config["TMPDIR"]}/debug_log_pseudo_fifo"
+DISPLAY = ENV['DISPLAY']
+GIT_DIR = ENV['PWD']
+KEEP_SNAPSHOTS = !ENV['KEEP_SNAPSHOTS'].nil?
+LIVE_USER = "live_user"
+TAILS_ISO = ENV['ISO']
+OLD_TAILS_ISO = ENV['OLD_ISO'] || TAILS_ISO
+TIME_AT_START = Time.now
+loop do
+ ARTIFACTS_DIR = $config['TMPDIR'] + "/results"
+ if not(File.exist?(ARTIFACTS_DIR))
+ FileUtils.mkdir_p(ARTIFACTS_DIR)
+ break
+ end
+end
+
+# Constants that are statically initialized.
+CONFIGURED_KEYSERVER_HOSTNAME = 'hkps.pool.sks-keyservers.net'
+LIBVIRT_DOMAIN_NAME = "DebianToaster"
+LIBVIRT_DOMAIN_UUID = "203552d5-819c-41f3-800e-2c8ef2545404"
+LIBVIRT_NETWORK_NAME = "DebianToasterNet"
+LIBVIRT_NETWORK_UUID = "f2305af3-2a64-4f16-afe6-b9dbf02a597e"
+MISC_FILES_DIR = "/srv/jenkins/features/misc_files"
+SERVICES_EXPECTED_ON_ALL_IFACES =
+ [
+ ["cupsd", "0.0.0.0", "631"],
+ ["dhclient", "0.0.0.0", "*"]
+ ]
+# OpenDNS
+SOME_DNS_SERVER = "208.67.222.222"
+TOR_AUTHORITIES =
+ # List grabbed from Tor's sources, src/or/config.c:~750.
+ [
+ "86.59.21.38",
+ "128.31.0.39",
+ "194.109.206.212",
+ "82.94.251.203",
+ "199.254.238.52",
+ "131.188.40.189",
+ "193.23.244.244",
+ "208.83.223.34",
+ "171.25.193.9",
+ "154.35.175.225",
+ ]
+VM_XML_PATH = "/srv/jenkins/features/domains"
+
+#TAILS_SIGNING_KEY = cmd_helper(". #{Dir.pwd}/config/amnesia; echo ${AMNESIA_DEV_KEYID}").tr(' ', '').chomp
+TAILS_DEBIAN_REPO_KEY = "221F9A3C6FA3E09E182E060BC7988EA7A358D82E"
diff --git a/cucumber/features/support/env.rb b/cucumber/features/support/env.rb
new file mode 100644
index 00000000..2e17ae76
--- /dev/null
+++ b/cucumber/features/support/env.rb
@@ -0,0 +1,90 @@
+require 'rubygems'
+require "features/support/extra_hooks.rb"
+require 'time'
+require 'rspec'
+
+# Force UTF-8. Ruby will default to the system locale, and if it is
+# non-UTF-8, String-methods will fail when operating on non-ASCII
+# strings.
+Encoding.default_external = Encoding::UTF_8
+Encoding.default_internal = Encoding::UTF_8
+
+def fatal_system(str)
+ unless system(str)
+ raise StandardError.new("Command exited with #{$?}")
+ end
+end
+
+def git_exists?
+ File.exists? '.git'
+end
+
+def create_git
+ Dir.mkdir 'config'
+ FileUtils.touch('config/base_branch')
+ Dir.mkdir('config/APT_overlays.d')
+ Dir.mkdir 'debian'
+ File.open('debian/changelog', 'w') do |changelog|
+ changelog.write(<<END_OF_CHANGELOG)
+tails (0) stable; urgency=low
+
+ * First release.
+
+ -- Tails developers <tails@boum.org> Mon, 30 Jan 2012 01:00:00 +0000
+END_OF_CHANGELOG
+ end
+
+ fatal_system "git init --quiet"
+ fatal_system "git config user.email 'tails@boum.org'"
+ fatal_system "git config user.name 'Tails developers'"
+ fatal_system "git add debian/changelog"
+ fatal_system "git commit --quiet debian/changelog -m 'First release'"
+ fatal_system "git branch -M stable"
+ fatal_system "git branch testing stable"
+ fatal_system "git branch devel stable"
+ fatal_system "git branch feature/jessie devel"
+end
+
+def current_branch
+ cmd = 'git rev-parse --symbolic-full-name --abbrev-ref HEAD'.split
+ branch = cmd_helper(cmd).strip
+ assert_not_equal("HEAD", branch, "We are in 'detached HEAD' state")
+ return branch
+end
+
+# In order: if git HEAD is tagged, return its name; if a branch is
+# checked out, return its name; otherwise we are in 'detached HEAD'
+# state, and we return the empty string.
+def describe_git_head
+ cmd_helper("git describe --tags --exact-match #{current_commit}".split).strip
+rescue Test::Unit::AssertionFailedError
+ begin
+ current_branch
+ rescue Test::Unit::AssertionFailedError
+ ""
+ end
+end
+
+def current_commit
+ cmd_helper('git rev-parse HEAD'.split).strip
+end
+
+def current_short_commit
+ current_commit[0, 7]
+end
+
+RSpec::Matchers.define :have_suite do |suite|
+ match do |string|
+ # e.g.: `deb http://deb.tails.boum.org/ 0.10 main contrib non-free`
+ %r{^deb +http://deb\.tails\.boum\.org/ +#{Regexp.escape(suite)} main}.match(string)
+ end
+ failure_message_for_should do |string|
+ "expected the sources to include #{suite}\nCurrent sources : #{string}"
+ end
+ failure_message_for_should_not do |string|
+ "expected the sources to exclude #{suite}\nCurrent sources : #{string}"
+ end
+ description do
+ "expected an output with #{suite}"
+ end
+end
diff --git a/cucumber/features/support/extra_hooks.rb b/cucumber/features/support/extra_hooks.rb
new file mode 100644
index 00000000..16196a55
--- /dev/null
+++ b/cucumber/features/support/extra_hooks.rb
@@ -0,0 +1,165 @@
+# Make the code below work with cucumber >= 2.0. Once we stop
+# supporting <2.0 we should probably do this differently, but this way
+# we can easily support both at the same time.
+begin
+ if not(Cucumber::Core::Ast::Feature.instance_methods.include?(:accept_hook?))
+ require 'gherkin/tag_expression'
+ class Cucumber::Core::Ast::Feature
+ # Code inspired by Cucumber::Core::Test::Case.match_tags?() in
+ # cucumber-ruby-core 1.1.3, lib/cucumber/core/test/case.rb:~59.
+ def accept_hook?(hook)
+ tag_expr = Gherkin::TagExpression.new(hook.tag_expressions.flatten)
+ tags = @tags.map do |t|
+ Gherkin::Formatter::Model::Tag.new(t.name, t.line)
+ end
+ tag_expr.evaluate(tags)
+ end
+ end
+ end
+rescue NameError => e
+ raise e if e.to_s != "uninitialized constant Cucumber::Core"
+end
+
+# Sort of inspired by Cucumber::RbSupport::RbHook (from cucumber
+# < 2.0) but really we just want an object with a 'tag_expressions'
+# attribute to make accept_hook?() (used below) happy.
+class SimpleHook
+ attr_reader :tag_expressions
+
+ def initialize(tag_expressions, proc)
+ @tag_expressions = tag_expressions
+ @proc = proc
+ end
+
+ def invoke(arg)
+ @proc.call(arg)
+ end
+end
+
+def BeforeFeature(*tag_expressions, &block)
+ $before_feature_hooks ||= []
+ $before_feature_hooks << SimpleHook.new(tag_expressions, block)
+end
+
+def AfterFeature(*tag_expressions, &block)
+ $after_feature_hooks ||= []
+ $after_feature_hooks << SimpleHook.new(tag_expressions, block)
+end
+
+require 'cucumber/formatter/console'
+if not($at_exit_print_artifacts_dir_patching_done)
+ module Cucumber::Formatter::Console
+ if method_defined?(:print_stats)
+ alias old_print_stats print_stats
+ end
+ def print_stats(*args)
+ if Dir.exists?(ARTIFACTS_DIR) and Dir.entries(ARTIFACTS_DIR).size > 2
+ @io.puts "Artifacts directory: #{ARTIFACTS_DIR}"
+ @io.puts
+ end
+ if self.class.method_defined?(:old_print_stats)
+ old_print_stats(*args)
+ end
+ end
+ end
+ $at_exit_print_artifacts_dir_patching_done = true
+end
+
+def info_log(message = "", options = {})
+ options[:color] = :clear
+ # This trick allows us to use a module's (~private) method on a
+ # one-off basis.
+ cucumber_console = Class.new.extend(Cucumber::Formatter::Console)
+ puts cucumber_console.format_string(message, options[:color])
+end
+
+def debug_log(message, options = {})
+ $debug_log_fns.each { |fn| fn.call(message, options) } if $debug_log_fns
+end
+
+require 'cucumber/formatter/pretty'
+# Backport part of commit af940a8 from the cucumber-ruby repo. This
+# fixes the "out hook output" for the Pretty formatter so stuff
+# written via `puts` after a Scenario has run its last step will be
+# written, instead of delayed to the next Feature/Scenario (if any) or
+# dropped completely (if not).
+# XXX: This can be removed once we stop supporting Debian Jessie
+# around when Debian Stretch is released.
+if Gem::Version.new(Cucumber::VERSION) < Gem::Version.new('2.0.0.beta.4')
+ module Cucumber
+ module Formatter
+ class Pretty
+ def after_feature_element(feature_element)
+ print_messages
+ @io.puts
+ @io.flush
+ end
+ end
+ end
+ end
+end
+
+module ExtraFormatters
+ # This is a null formatter in the sense that it doesn't ever output
+ # anything. We only use it do hook into the correct events so we can
+ # add our extra hooks.
+ class ExtraHooks
+ def initialize(*args)
+ # We do not care about any of the arguments.
+ end
+
+ def before_feature(feature)
+ if $before_feature_hooks
+ $before_feature_hooks.each do |hook|
+ hook.invoke(feature) if feature.accept_hook?(hook)
+ end
+ end
+ end
+
+ def after_feature(feature)
+ if $after_feature_hooks
+ $after_feature_hooks.reverse.each do |hook|
+ hook.invoke(feature) if feature.accept_hook?(hook)
+ end
+ end
+ end
+ end
+
+ # The pretty formatter with debug logging mixed into its output.
+ class PrettyDebug < Cucumber::Formatter::Pretty
+ def initialize(*args)
+ super(*args)
+ $debug_log_fns ||= []
+ $debug_log_fns << self.method(:debug_log)
+ end
+
+ def debug_log(message, options)
+ options[:color] ||= :blue
+ @io.puts(format_string(message, options[:color]))
+ @io.flush
+ end
+ end
+
+end
+
+module Cucumber
+ module Cli
+ class Options
+ BUILTIN_FORMATS['pretty_debug'] =
+ [
+ 'ExtraFormatters::PrettyDebug',
+ 'Prints the feature with debugging information - in colours.'
+ ]
+ BUILTIN_FORMATS['debug'] = BUILTIN_FORMATS['pretty_debug']
+ end
+ end
+end
+
+AfterConfiguration do |config|
+ # Cucumber may read this file multiple times, and hence run this
+ # AfterConfiguration hook multiple times. We only want our
+ # ExtraHooks formatter to be loaded once, otherwise the hooks would
+ # be run miltiple times.
+ extra_hooks = ['ExtraFormatters::ExtraHooks', '/dev/null']
+ config.formats << extra_hooks if not(config.formats.include?(extra_hooks))
+end
diff --git a/cucumber/features/support/helpers/chatbot_helper.rb b/cucumber/features/support/helpers/chatbot_helper.rb
new file mode 100644
index 00000000..23ce3e1a
--- /dev/null
+++ b/cucumber/features/support/helpers/chatbot_helper.rb
@@ -0,0 +1,59 @@
+require 'tempfile'
+
+class ChatBot
+
+ def initialize(account, password, otr_key, opts = Hash.new)
+ @account = account
+ @password = password
+ @otr_key = otr_key
+ @opts = opts
+ @pid = nil
+ @otr_key_file = nil
+ end
+
+ def start
+ @otr_key_file = Tempfile.new("otr_key.", $config["TMPDIR"])
+ @otr_key_file << @otr_key
+ @otr_key_file.close
+
+ cmd_helper(['/usr/bin/convertkey', @otr_key_file.path])
+ cmd_helper(["mv", "#{@otr_key_file.path}3", @otr_key_file.path])
+
+ cmd = [
+ "#{GIT_DIR}/features/scripts/otr-bot.py",
+ @account,
+ @password,
+ @otr_key_file.path
+ ]
+ cmd += ["--connect-server", @opts["connect_server"]] if @opts["connect_server"]
+ cmd += ["--auto-join"] + @opts["auto_join"] if @opts["auto_join"]
+ cmd += ["--log-file", DEBUG_LOG_PSEUDO_FIFO]
+
+ job = IO.popen(cmd)
+ @pid = job.pid
+ end
+
+ def stop
+ @otr_key_file.delete
+ begin
+ Process.kill("TERM", @pid)
+ rescue
+ # noop
+ end
+ end
+
+ def active?
+ begin
+ ret = Process.kill(0, @pid)
+ rescue Errno::ESRCH => e
+ if e.message == "No such process"
+ return false
+ else
+ raise e
+ end
+ end
+ assert_equal(1, ret, "This shouldn't happen")
+ return true
+ end
+
+end
diff --git a/cucumber/features/support/helpers/ctcp_helper.rb b/cucumber/features/support/helpers/ctcp_helper.rb
new file mode 100644
index 00000000..ee5180ab
--- /dev/null
+++ b/cucumber/features/support/helpers/ctcp_helper.rb
@@ -0,0 +1,126 @@
+require 'net/irc'
+require 'timeout'
+
+class CtcpChecker < Net::IRC::Client
+
+ CTCP_SPAM_DELAY = 5
+
+ # `spam_target`: the nickname of the IRC user to CTCP spam.
+ # `ctcp_cmds`: the Array of CTCP commands to send.
+ # `expected_ctcp_replies`: Hash where the keys are the exact set of replies
+ # we expect, and their values a regex the reply data must match.
+ def initialize(host, port, spam_target, ctcp_cmds, expected_ctcp_replies)
+ @spam_target = spam_target
+ @ctcp_cmds = ctcp_cmds
+ @expected_ctcp_replies = expected_ctcp_replies
+ nickname = self.class.random_irc_nickname
+ opts = {
+ :nick => nickname,
+ :user => nickname,
+ :real => nickname,
+ }
+ opts[:logger] = Logger.new(DEBUG_LOG_PSEUDO_FIFO)
+ super(host, port, opts)
+ end
+
+ # Makes sure that only the expected CTCP replies are received.
+ def verify_ctcp_responses
+ @sent_ctcp_cmds = Set.new
+ @received_ctcp_replies = Set.new
+
+ # Give 60 seconds for connecting to the server and other overhead
+ # beyond the expected time to spam all CTCP commands.
+ expected_ctcp_spam_time = @ctcp_cmds.length * CTCP_SPAM_DELAY
+ timeout = expected_ctcp_spam_time + 60
+
+ begin
+ Timeout::timeout(timeout) do
+ start
+ end
+ rescue Timeout::Error
+ # Do nothing as we'll check for errors below.
+ ensure
+ finish
+ end
+
+ ctcp_cmds_not_sent = @ctcp_cmds - @sent_ctcp_cmds.to_a
+ expected_ctcp_replies_not_received =
+ @expected_ctcp_replies.keys - @received_ctcp_replies.to_a
+
+ if !ctcp_cmds_not_sent.empty? || !expected_ctcp_replies_not_received.empty?
+ raise "Failed to spam all CTCP commands and receive the expected " +
+ "replies within #{timeout} seconds.\n" +
+ (ctcp_cmds_not_sent.empty? ? "" :
+ "CTCP commands not sent: #{ctcp_cmds_not_sent}\n") +
+ (expected_ctcp_replies_not_received.empty? ? "" :
+ "Expected CTCP replies not received: " +
+ expected_ctcp_replies_not_received.to_s)
+ end
+
+ end
+
+ # Generate a random IRC nickname, in this case an alpha-numeric
+ # string with length 10 to 15. To make it legal, the first character
+ # is forced to be alpha.
+ def self.random_irc_nickname
+ random_alpha_string(1) + random_alnum_string(9, 14)
+ end
+
+ def spam(spam_target)
+ post(NOTICE, spam_target, "Hi! I'm gonna test your CTCP capabilities now.")
+ @ctcp_cmds.each do |cmd|
+ sleep CTCP_SPAM_DELAY
+ full_cmd = cmd
+ case cmd
+ when "PING"
+ full_cmd += " #{Time.now.to_i}"
+ when "ACTION"
+ full_cmd += " barfs on the floor."
+ when "ERRMSG"
+ full_cmd += " Pidgin should not respond to this."
+ end
+ post(PRIVMSG, spam_target, ctcp_encode(full_cmd))
+ @sent_ctcp_cmds << cmd
+ end
+ end
+
+ def on_rpl_welcome(m)
+ super
+ Thread.new { spam(@spam_target) }
+ end
+
+ def on_message(m)
+ if m.command == ERR_NICKNAMEINUSE
+ finish
+ new_nick = self.class.random_irc_nickname
+ @opts.marshal_load({
+ :nick => new_nick,
+ :user => new_nick,
+ :real => new_nick,
+ })
+ start
+ return
+ end
+
+ if m.ctcp? and /^:#{Regexp.escape(@spam_target)}!/.match(m)
+ m.ctcps.each do |ctcp_reply|
+ reply_type, _, reply_data = ctcp_reply.partition(" ")
+ if @expected_ctcp_replies.has_key?(reply_type)
+ if @expected_ctcp_replies[reply_type].match(reply_data)
+ @received_ctcp_replies << reply_type
+ else
+ raise "Received expected CTCP reply '#{reply_type}' but with " +
+ "unexpected data '#{reply_data}' "
+ end
+ else
+ raise "Received unexpected CTCP reply '#{reply_type}' with " +
+ "data '#{reply_data}'"
+ end
+ end
+ end
+ if Set.new(@ctcp_cmds) == @sent_ctcp_cmds && \
+ Set.new(@expected_ctcp_replies.keys) == @received_ctcp_replies
+ finish
+ end
+ end
+end
diff --git a/cucumber/features/support/helpers/display_helper.rb b/cucumber/features/support/helpers/display_helper.rb
new file mode 100644
index 00000000..b4dce733
--- /dev/null
+++ b/cucumber/features/support/helpers/display_helper.rb
@@ -0,0 +1,48 @@
+
+class Display
+
+ def initialize(domain, x_display)
+ @domain = domain
+ @x_display = x_display
+ end
+
+ def active?
+ p = IO.popen(["xprop", "-display", @x_display,
+ "-name", "#{@domain} (1) - Virt Viewer",
+ :err => ["/dev/null", "w"]])
+ Process.wait(p.pid)
+ $?.success?
+ end
+
+ def start
+ @virtviewer = IO.popen(["virt-viewer", "--direct",
+ "--kiosk",
+ "--reconnect",
+ "--connect", "qemu:///system",
+ "--display", @x_display,
+ @domain,
+ :err => ["/dev/null", "w"]])
+ # We wait for the display to be active to not lose actions
+ # (e.g. key presses via sikuli) that come immediately after
+ # starting (or restoring) a vm
+ try_for(20, { :delay => 0.1, :msg => "virt-viewer failed to start"}) {
+ active?
+ }
+ end
+
+ def stop
+ return if @virtviewer.nil?
+ Process.kill("TERM", @virtviewer.pid)
+ @virtviewer.close
+ rescue IOError
+ # IO.pid throws this if the process wasn't started yet. Possibly
+ # there's a race when doing a start() and then quickly running
+ # stop().
+ end
+
+ def restart
+ stop
+ start
+ end
+
+end
diff --git a/cucumber/features/support/helpers/exec_helper.rb b/cucumber/features/support/helpers/exec_helper.rb
new file mode 100644
index 00000000..14e12269
--- /dev/null
+++ b/cucumber/features/support/helpers/exec_helper.rb
@@ -0,0 +1,79 @@
+require 'json'
+require 'socket'
+require 'io/wait'
+
+class VMCommand
+
+ attr_reader :cmd, :returncode, :stdout, :stderr
+
+ def initialize(vm, cmd, options = {})
+ @cmd = cmd
+ @returncode, @stdout, @stderr = VMCommand.execute(vm, cmd, options)
+ end
+
+ def VMCommand.wait_until_remote_shell_is_up(vm, timeout = 90)
+ try_for(timeout, :msg => "Remote shell seems to be down") do
+ sleep(20)
+ Timeout::timeout(10) do
+ VMCommand.execute(vm, "echo 'true'")
+ end
+ end
+ end
+
+ # The parameter `cmd` cannot contain newlines. Separate multiple
+ # commands using ";" instead.
+ # If `:spawn` is false the server will block until it has finished
+ # executing `cmd`. If it's true the server won't block, and the
+ # response will always be [0, "", ""] (only used as an
+ # ACK). execute() will always block until a response is received,
+ # though. Spawning is useful when starting processes in the
+ # background (or running scripts that does the same) like our
+ # onioncircuits wrapper, or any application we want to interact with.
+ def VMCommand.execute(vm, cmd, options = {})
+ options[:user] ||= "root"
+ options[:spawn] ||= false
+ type = options[:spawn] ? "spawn" : "call"
+ socket = TCPSocket.new("127.0.0.1", vm.get_remote_shell_port)
+ debug_log("#{type}ing as #{options[:user]}: #{cmd}")
+ begin
+ #socket.puts(JSON.dump([type, options[:user], cmd]))
+ socket.puts( "\n")
+ sleep(1)
+ socket.puts( "\003")
+ sleep(1)
+ socket.puts( cmd + "\n")
+ sleep(1)
+ while socket.ready?
+ s = socket.readline(sep = "\n").chomp("\n")
+ debug_log("#{type} read: #{s}") if not(options[:spawn])
+ if ('true' == s) then
+ break
+ end
+ end
+ ensure
+ socket.close
+ end
+ if ('true' == s)
+ return true
+ else
+ return VMCommand.execute(vm, cmd, options)
+ end
+ end
+
+ def success?
+ return @returncode == 0
+ end
+
+ def failure?
+ return not(success?)
+ end
+
+ def to_s
+ "Return status: #{@returncode}\n" +
+ "STDOUT:\n" +
+ @stdout +
+ "STDERR:\n" +
+ @stderr
+ end
+
+end
diff --git a/cucumber/features/support/helpers/firewall_helper.rb b/cucumber/features/support/helpers/firewall_helper.rb
new file mode 100644
index 00000000..fce363c5
--- /dev/null
+++ b/cucumber/features/support/helpers/firewall_helper.rb
@@ -0,0 +1,121 @@
+require 'packetfu'
+require 'ipaddr'
+
+# Extent IPAddr with a private/public address space checks
+class IPAddr
+ PrivateIPv4Ranges = [
+ IPAddr.new("10.0.0.0/8"),
+ IPAddr.new("172.16.0.0/12"),
+ IPAddr.new("192.168.0.0/16"),
+ IPAddr.new("255.255.255.255/32")
+ ]
+
+ PrivateIPv6Ranges = [
+ IPAddr.new("fc00::/7")
+ ]
+
+ def private?
+ private_ranges = self.ipv4? ? PrivateIPv4Ranges : PrivateIPv6Ranges
+ private_ranges.any? { |range| range.include?(self) }
+ end
+
+ def public?
+ !private?
+ end
+end
+
+class FirewallLeakCheck
+ attr_reader :ipv4_tcp_leaks, :ipv4_nontcp_leaks, :ipv6_leaks, :nonip_leaks, :mac_leaks
+
+ def initialize(pcap_file, options = {})
+ options[:accepted_hosts] ||= []
+ options[:ignore_lan] ||= true
+ @pcap_file = pcap_file
+ packets = PacketFu::PcapFile.new.file_to_array(:filename => @pcap_file)
+ mac_leaks = Set.new
+ ipv4_tcp_packets = []
+ ipv4_nontcp_packets = []
+ ipv6_packets = []
+ nonip_packets = []
+ packets.each do |p|
+ if PacketFu::EthPacket.can_parse?(p)
+ packet = PacketFu::EthPacket.parse(p)
+ mac_leaks << packet.eth_saddr
+ mac_leaks << packet.eth_daddr
+ end
+
+ if PacketFu::TCPPacket.can_parse?(p)
+ ipv4_tcp_packets << PacketFu::TCPPacket.parse(p)
+ elsif PacketFu::IPPacket.can_parse?(p)
+ ipv4_nontcp_packets << PacketFu::IPPacket.parse(p)
+ elsif PacketFu::IPv6Packet.can_parse?(p)
+ ipv6_packets << PacketFu::IPv6Packet.parse(p)
+ elsif PacketFu::Packet.can_parse?(p)
+ nonip_packets << PacketFu::Packet.parse(p)
+ else
+ save_pcap_file
+ raise "Found something in the pcap file that cannot be parsed"
+ end
+ end
+ ipv4_tcp_hosts = filter_hosts_from_ippackets(ipv4_tcp_packets,
+ options[:ignore_lan])
+ accepted = Set.new(options[:accepted_hosts])
+ @mac_leaks = mac_leaks
+ @ipv4_tcp_leaks = ipv4_tcp_hosts.select { |host| !accepted.member?(host) }
+ @ipv4_nontcp_leaks = filter_hosts_from_ippackets(ipv4_nontcp_packets,
+ options[:ignore_lan])
+ @ipv6_leaks = filter_hosts_from_ippackets(ipv6_packets,
+ options[:ignore_lan])
+ @nonip_leaks = nonip_packets
+ end
+
+ def save_pcap_file
+ save_failure_artifact("Network capture", @pcap_file)
+ end
+
+ # Returns a list of all unique destination IP addresses found in
+ # `packets`. Exclude LAN hosts if ignore_lan is set.
+ def filter_hosts_from_ippackets(packets, ignore_lan)
+ hosts = []
+ packets.each do |p|
+ candidate = nil
+ if p.kind_of?(PacketFu::IPPacket)
+ candidate = p.ip_daddr
+ elsif p.kind_of?(PacketFu::IPv6Packet)
+ candidate = p.ipv6_header.ipv6_daddr
+ else
+ save_pcap_file
+ raise "Expected an IP{v4,v6} packet, but got something else:\n" +
+ p.peek_format
+ end
+ if candidate != nil and (not(ignore_lan) or IPAddr.new(candidate).public?)
+ hosts << candidate
+ end
+ end
+ hosts.uniq
+ end
+
+ def assert_no_leaks
+ err = ""
+ if !@ipv4_tcp_leaks.empty?
+ err += "The following IPv4 TCP non-Tor Internet hosts were " +
+ "contacted:\n" + ipv4_tcp_leaks.join("\n")
+ end
+ if !@ipv4_nontcp_leaks.empty?
+ err += "The following IPv4 non-TCP Internet hosts were contacted:\n" +
+ ipv4_nontcp_leaks.join("\n")
+ end
+ if !@ipv6_leaks.empty?
+ err += "The following IPv6 Internet hosts were contacted:\n" +
+ ipv6_leaks.join("\n")
+ end
+ if !@nonip_leaks.empty?
+ err += "Some non-IP packets were sent\n"
+ end
+ if !err.empty?
+ save_pcap_file
+ raise err
+ end
+ end
+
+end
diff --git a/cucumber/features/support/helpers/misc_helpers.rb b/cucumber/features/support/helpers/misc_helpers.rb
new file mode 100644
index 00000000..7e09411f
--- /dev/null
+++ b/cucumber/features/support/helpers/misc_helpers.rb
@@ -0,0 +1,253 @@
+require 'date'
+require 'timeout'
+require 'test/unit'
+
+# Test::Unit adds an at_exit hook which, among other things, consumes
+# the command-line arguments that were intended for cucumber. If
+# e.g. `--format` was passed it will throw an error since it's not a
+# valid option for Test::Unit, and it throwing an error at this time
+# (at_exit) will make Cucumber think it failed and consequently exit
+# with an error. Fooling Test::Unit that this hook has already run
+# works around this craziness.
+Test::Unit.run = true
+
+# Make all the assert_* methods easily accessible in any context.
+include Test::Unit::Assertions
+
+def assert_vmcommand_success(p, msg = nil)
+ assert(p.success?, msg.nil? ? "Command failed: #{p.cmd}\n" + \
+ "error code: #{p.returncode}\n" \
+ "stderr: #{p.stderr}" : \
+ msg)
+end
+
+# It's forbidden to throw this exception (or subclasses) in anything
+# but try_for() below. Just don't use it anywhere else!
+class UniqueTryForTimeoutError < Exception
+end
+
+# Call block (ignoring any exceptions it may throw) repeatedly with
+# one second breaks until it returns true, or until `timeout` seconds have
+# passed when we throw a Timeout::Error exception.
+def try_for(timeout, options = {})
+ options[:delay] ||= 1
+ last_exception = nil
+ # Create a unique exception used only for this particular try_for
+ # call's Timeout to allow nested try_for:s. If we used the same one,
+ # the innermost try_for would catch all outer ones', creating a
+ # really strange situation.
+ unique_timeout_exception = Class.new(UniqueTryForTimeoutError)
+ Timeout::timeout(timeout, unique_timeout_exception) do
+ loop do
+ begin
+ return if yield
+ rescue NameError, UniqueTryForTimeoutError => e
+ # NameError most likely means typos, and hiding that is rarely
+ # (never?) a good idea, so we rethrow them. See below why we
+ # also rethrow *all* the unique exceptions.
+ raise e
+ rescue Exception => e
+ # All other exceptions are ignored while trying the
+ # block. Well we save the last exception so we can print it in
+ # case of a timeout.
+ last_exception = e
+ end
+ sleep options[:delay]
+ end
+ end
+ # At this point the block above either succeeded and we'll return,
+ # or we are throwing an exception. If the latter, we either have a
+ # NameError that we'll not catch (and will any try_for below us in
+ # the stack), or we have a unique exception. That can mean one of
+ # two things:
+ # 1. it's the one unique to this try_for, and in that case we'll
+ # catch it, rethrowing it as something that will be ignored by
+ # inside the blocks of all try_for:s below us in the stack.
+ # 2. it's an exception unique to another try_for. Assuming that we
+ # do not throw the unique exceptions in any other place or way
+ # than we do it in this function, this means that there is a
+ # try_for below us in the stack to which this exception must be
+ # unique to.
+ # Let 1 be the base step, and 2 the inductive step, and we sort of
+ # an inductive proof for the correctness of try_for when it's
+ # nested. It shows that for an infinite stack of try_for:s, any of
+ # the unique exceptions will be caught only by the try_for instance
+ # it is unique to, and all try_for:s in between will ignore it so it
+ # ends up there immediately.
+rescue unique_timeout_exception => e
+ msg = options[:msg] || 'try_for() timeout expired'
+ if last_exception
+ msg += "\nLast ignored exception was: " +
+ "#{last_exception.class}: #{last_exception}"
+ end
+ raise Timeout::Error.new(msg)
+end
+
+class TorFailure < StandardError
+end
+
+class MaxRetriesFailure < StandardError
+end
+
+# This will retry the block up to MAX_NEW_TOR_CIRCUIT_RETRIES
+# times. The block must raise an exception for a run to be considered
+# as a failure. After a failure recovery_proc will be called (if
+# given) and the intention with it is to bring us back to the state
+# expected by the block, so it can be retried.
+def retry_tor(recovery_proc = nil, &block)
+ tor_recovery_proc = Proc.new do
+ force_new_tor_circuit
+ recovery_proc.call if recovery_proc
+ end
+
+ retry_action($config['MAX_NEW_TOR_CIRCUIT_RETRIES'],
+ :recovery_proc => tor_recovery_proc,
+ :operation_name => 'Tor operation', &block)
+end
+
+def retry_i2p(recovery_proc = nil, &block)
+ retry_action(15, :recovery_proc => recovery_proc,
+ :operation_name => 'I2P operation', &block)
+end
+
+def retry_action(max_retries, options = {}, &block)
+ assert(max_retries.is_a?(Integer), "max_retries must be an integer")
+ options[:recovery_proc] ||= nil
+ options[:operation_name] ||= 'Operation'
+
+ retries = 1
+ loop do
+ begin
+ block.call
+ return
+ rescue Exception => e
+ if retries <= max_retries
+ debug_log("#{options[:operation_name]} failed (Try #{retries} of " +
+ "#{max_retries}) with:\n" +
+ "#{e.class}: #{e.message}")
+ options[:recovery_proc].call if options[:recovery_proc]
+ retries += 1
+ else
+ raise MaxRetriesFailure.new("#{options[:operation_name]} failed (despite retrying " +
+ "#{max_retries} times) with\n" +
+ "#{e.class}: #{e.message}")
+ end
+ end
+ end
+end
+
+def wait_until_tor_is_working
+ try_for(270) { $vm.execute('/usr/local/sbin/tor-has-bootstrapped').success? }
+rescue Timeout::Error => e
+ c = $vm.execute("journalctl SYSLOG_IDENTIFIER=restart-tor")
+ if c.success?
+ debug_log("From the journal:\n" + c.stdout.sub(/^/, " "))
+ else
+ debug_log("Nothing was in the journal about 'restart-tor'")
+ end
+ raise e
+end
+
+def convert_bytes_mod(unit)
+ case unit
+ when "bytes", "b" then mod = 1
+ when "KB" then mod = 10**3
+ when "k", "KiB" then mod = 2**10
+ when "MB" then mod = 10**6
+ when "M", "MiB" then mod = 2**20
+ when "GB" then mod = 10**9
+ when "G", "GiB" then mod = 2**30
+ when "TB" then mod = 10**12
+ when "T", "TiB" then mod = 2**40
+ else
+ raise "invalid memory unit '#{unit}'"
+ end
+ return mod
+end
+
+def convert_to_bytes(size, unit)
+ return (size*convert_bytes_mod(unit)).to_i
+end
+
+def convert_to_MiB(size, unit)
+ return (size*convert_bytes_mod(unit) / (2**20)).to_i
+end
+
+def convert_from_bytes(size, unit)
+ return size.to_f/convert_bytes_mod(unit).to_f
+end
+
+def cmd_helper(cmd)
+ if cmd.instance_of?(Array)
+ cmd << {:err => [:child, :out]}
+ elsif cmd.instance_of?(String)
+ cmd += " 2>&1"
+ end
+ IO.popen(cmd) do |p|
+ out = p.readlines.join("\n")
+ p.close
+ ret = $?
+ assert_equal(0, ret, "Command failed (returned #{ret}): #{cmd}:\n#{out}")
+ return out
+ end
+end
+
+# This command will grab all router IP addresses from the Tor
+# consensus in the VM + the hardcoded TOR_AUTHORITIES.
+def get_all_tor_nodes
+ cmd = 'awk "/^r/ { print \$6 }" /var/lib/tor/cached-microdesc-consensus'
+ $vm.execute(cmd).stdout.chomp.split("\n") + TOR_AUTHORITIES
+end
+
+def get_free_space(machine, path)
+ case machine
+ when 'host'
+ assert(File.exists?(path), "Path '#{path}' not found on #{machine}.")
+ free = cmd_helper(["df", path])
+ when 'guest'
+ assert($vm.file_exist?(path), "Path '#{path}' not found on #{machine}.")
+ free = $vm.execute_successfully("df '#{path}'")
+ else
+ raise 'Unsupported machine type #{machine} passed.'
+ end
+ output = free.split("\n").last
+ return output.match(/[^\s]\s+[0-9]+\s+[0-9]+\s+([0-9]+)\s+.*/)[1].chomp.to_i
+end
+
+def random_string_from_set(set, min_len, max_len)
+ len = (min_len..max_len).to_a.sample
+ len ||= min_len
+ (0..len-1).map { |n| set.sample }.join
+end
+
+def random_alpha_string(min_len, max_len = 0)
+ alpha_set = ('A'..'Z').to_a + ('a'..'z').to_a
+ random_string_from_set(alpha_set, min_len, max_len)
+end
+
+def random_alnum_string(min_len, max_len = 0)
+ alnum_set = ('A'..'Z').to_a + ('a'..'z').to_a + (0..9).to_a.map { |n| n.to_s }
+ random_string_from_set(alnum_set, min_len, max_len)
+end
+
+# Sanitize the filename from unix-hostile filename characters
+def sanitize_filename(filename, options = {})
+ options[:replacement] ||= '_'
+ bad_unix_filename_chars = Regexp.new("[^A-Za-z0-9_\\-.,+:]")
+ filename.gsub(bad_unix_filename_chars, options[:replacement])
+end
+
+def info_log_artifact_location(type, path)
+ if $config['ARTIFACTS_BASE_URI']
+ # Remove any trailing slashes, we'll add one ourselves
+ base_url = $config['ARTIFACTS_BASE_URI'].gsub(/\/*$/, "")
+ path = "#{base_url}/#{File.basename(path)}"
+ end
+ info_log("#{type.capitalize}: #{path}")
+end
+
+def pause(message = "Paused")
+ STDERR.puts
+ STDERR.puts "#{message} (Press ENTER to continue!)"
+ STDIN.gets
+end
diff --git a/cucumber/features/support/helpers/sikuli_helper.rb b/cucumber/features/support/helpers/sikuli_helper.rb
new file mode 100644
index 00000000..486b0e2e
--- /dev/null
+++ b/cucumber/features/support/helpers/sikuli_helper.rb
@@ -0,0 +1,213 @@
+require 'rjb'
+require 'rjbextension'
+$LOAD_PATH << ENV['SIKULI_HOME']
+require 'sikuli-script.jar'
+Rjb::load
+
+package_members = [
+ "java.io.FileOutputStream",
+ "java.io.PrintStream",
+ "java.lang.System",
+ "org.sikuli.script.Finder",
+ "org.sikuli.script.Key",
+ "org.sikuli.script.KeyModifier",
+ "org.sikuli.script.Location",
+ "org.sikuli.script.Match",
+ "org.sikuli.script.Pattern",
+ "org.sikuli.script.Region",
+ "org.sikuli.script.Screen",
+ "org.sikuli.script.Settings",
+ ]
+
+translations = Hash[
+ "org.sikuli.script", "Sikuli",
+ "java.lang", "Java::Lang",
+ "java.io", "Java::Io",
+ ]
+
+for p in package_members
+ imported_class = Rjb::import(p)
+ package, ignore, class_name = p.rpartition(".")
+ next if ! translations.include? package
+ mod_name = translations[package]
+ mod = mod_name.split("::").inject(Object) do |parent_obj, child_name|
+ if parent_obj.const_defined?(child_name, false)
+ parent_obj.const_get(child_name, false)
+ else
+ child_obj = Module.new
+ parent_obj.const_set(child_name, child_obj)
+ end
+ end
+ mod.const_set(class_name, imported_class)
+end
+
+# Bind Java's stdout to debug_log() via our magical pseudo fifo
+# logger.
+def bind_java_to_pseudo_fifo_logger
+ file_output_stream = Java::Io::FileOutputStream.new(DEBUG_LOG_PSEUDO_FIFO)
+ print_stream = Java::Io::PrintStream.new(file_output_stream)
+ Java::Lang::System.setOut(print_stream)
+end
+
+def findfailed_hook(pic)
+ pause("FindFailed for: '#{pic}'")
+end
+
+# Since rjb imports Java classes without creating a corresponding
+# Ruby class (it's just an instance of Rjb_JavaProxy) we can't
+# monkey patch any class, so additional methods must be added
+# to each Screen object.
+#
+# All Java classes' methods are immediately available in the proxied
+# Ruby classes, but care has to be given to match their type. For a
+# list of methods, see: <http://doc.sikuli.org/javadoc/index.html>.
+# The type "PRSML" is a union of Pattern, Region, Screen, Match and
+# Location.
+#
+# Also, due to limitations in Ruby's syntax we can't do:
+# def Sikuli::Screen.new
+# so we work around it with the following vairable.
+sikuli_script_proxy = Sikuli::Screen
+$_original_sikuli_screen_new ||= Sikuli::Screen.method :new
+
+# For waitAny()/findAny() we are forced to throw this exception since
+# Rjb::throw doesn't block until the Java exception has been received
+# by Ruby, so strange things can happen.
+class FindAnyFailed < StandardError
+end
+
+def sikuli_script_proxy.new(*args)
+ s = $_original_sikuli_screen_new.call(*args)
+
+ if $config["SIKULI_RETRY_FINDFAILED"]
+ # The usage of `_invoke()` below exemplifies how one can wrap
+ # around Java objects' methods when they're imported using RJB. It
+ # isn't pretty. The seconds argument is the parameter signature,
+ # which can be obtained by creating the intended Java object using
+ # RJB, and then calling its `java_methods` method.
+
+ def s.wait(pic, time)
+ self._invoke('wait', 'Ljava.lang.Object;D', pic, time)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('wait', 'Ljava.lang.Object;D', pic, time)
+ end
+
+ def s.find(pic)
+ self._invoke('find', 'Ljava.lang.Object;', pic)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('find', 'Ljava.lang.Object;', pic)
+ end
+
+ def s.waitVanish(pic, time)
+ self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('waitVanish', 'Ljava.lang.Object;D', pic, time)
+ end
+
+ def s.click(pic)
+ self._invoke('click', 'Ljava.lang.Object;', pic)
+ rescue FindFailed => e
+ findfailed_hook(pic)
+ self._invoke('click', 'Ljava.lang.Object;', pic)
+ end
+ end
+
+ def s.click_point(x, y)
+ self.click(Sikuli::Location.new(x, y))
+ end
+
+ def s.doubleClick_point(x, y)
+ self.doubleClick(Sikuli::Location.new(x, y))
+ end
+
+ def s.click_mid_right_edge(pic)
+ r = self.find(pic)
+ top_right = r.getTopRight()
+ x = top_right.getX
+ y = top_right.getY + r.getH/2
+ self.click_point(x, y)
+ end
+
+ def s.wait_and_click(pic, time)
+ self.click(self.wait(pic, time))
+ end
+
+ def s.wait_and_double_click(pic, time)
+ self.doubleClick(self.wait(pic, time))
+ end
+
+ def s.wait_and_right_click(pic, time)
+ self.rightClick(self.wait(pic, time))
+ end
+
+ def s.wait_and_hover(pic, time)
+ self.hover(self.wait(pic, time))
+ end
+
+ def s.existsAny(images)
+ images.each do |image|
+ region = self.exists(image)
+ return [image, region] if region
+ end
+ return nil
+ end
+
+ def s.findAny(images)
+ images.each do |image|
+ begin
+ return [image, self.find(image)]
+ rescue FindFailed
+ # Ignore. We deal we'll throw an appropriate exception after
+ # having looped through all images and found none of them.
+ end
+ end
+ # If we've reached this point, none of the images could be found.
+ raise FindAnyFailed.new("can not find any of the images #{images} on the " +
+ "screen")
+ end
+
+ def s.waitAny(images, time)
+ Timeout::timeout(time) do
+ loop do
+ result = self.existsAny(images)
+ return result if result
+ end
+ end
+ rescue Timeout::Error
+ raise FindAnyFailed.new("can not find any of the images #{images} on the " +
+ "screen")
+ end
+
+ def s.hover_point(x, y)
+ self.hover(Sikuli::Location.new(x, y))
+ end
+
+ def s.hide_cursor
+ self.hover_point(self.w, self.h/2)
+ end
+
+ s
+end
+
+# Configure sikuli
+
+# ruby and rjb doesn't play well together when it comes to static
+# fields (and possibly methods) so we instantiate and access the field
+# via objects instead. It actually works inside this file, but when
+# it's required from "outside", and the file has been completely
+# required, ruby's require method complains that the method for the
+# field accessor is missing.
+sikuli_settings = Sikuli::Settings.new
+sikuli_settings.OcrDataPath = $config["TMPDIR"]
+# sikuli_ruby, which we used before, defaulted to 0.9 minimum
+# similarity, so all our current images are adapted to that value.
+# Also, Sikuli's default of 0.7 is simply too low (many false
+# positives).
+sikuli_settings.MinSimilarity = 0.9
+sikuli_settings.ActionLogs = true
+sikuli_settings.DebugLogs = false
+sikuli_settings.InfoLogs = true
+sikuli_settings.ProfileLogs = true
diff --git a/cucumber/features/support/helpers/sniffing_helper.rb b/cucumber/features/support/helpers/sniffing_helper.rb
new file mode 100644
index 00000000..213411eb
--- /dev/null
+++ b/cucumber/features/support/helpers/sniffing_helper.rb
@@ -0,0 +1,43 @@
+#
+# Sniffer is a very dumb wrapper to start and stop tcpdumps instances, possibly
+# with customized filters. Captured traffic is stored in files whose name
+# depends on the sniffer name. The resulting captured packets for each sniffers
+# can be accessed as an array through its `packets` method.
+#
+# Use of more rubyish internal ways to sniff a network like with pcap-able gems
+# is waaay to much resource consumming, notmuch reliable and soooo slow. Let's
+# not bother too much with that. :)
+#
+# Should put all that in a Module.
+
+class Sniffer
+
+ attr_reader :name, :pcap_file, :pid
+
+ def initialize(name, vmnet)
+ @name = name
+ @vmnet = vmnet
+ pcap_name = sanitize_filename("#{name}.pcap")
+ @pcap_file = "#{$config["TMPDIR"]}/#{pcap_name}"
+ end
+
+ def capture(filter="not ether src host #{@vmnet.bridge_mac} and not ether proto \\arp and not ether proto \\rarp")
+ job = IO.popen(["/usr/sbin/tcpdump", "-n", "-i", @vmnet.bridge_name, "-w",
+ @pcap_file, "-U", filter, :err => ["/dev/null", "w"]])
+ @pid = job.pid
+ end
+
+ def stop
+ begin
+ Process.kill("TERM", @pid)
+ rescue
+ # noop
+ end
+ end
+
+ def clear
+ if File.exist?(@pcap_file)
+ File.delete(@pcap_file)
+ end
+ end
+end
diff --git a/cucumber/features/support/helpers/sshd_helper.rb b/cucumber/features/support/helpers/sshd_helper.rb
new file mode 100644
index 00000000..2e0069c0
--- /dev/null
+++ b/cucumber/features/support/helpers/sshd_helper.rb
@@ -0,0 +1,67 @@
+require 'tempfile'
+
+class SSHServer
+ def initialize(sshd_host, sshd_port, authorized_keys = nil)
+ @sshd_host = sshd_host
+ @sshd_port = sshd_port
+ @authorized_keys = authorized_keys
+ @pid = nil
+ end
+
+ def start
+ @sshd_key_file = Tempfile.new("ssh_host_rsa_key", $config["TMPDIR"])
+ # 'hack' to prevent ssh-keygen from prompting to overwrite the file
+ File.delete(@sshd_key_file.path)
+ cmd_helper(['ssh-keygen', '-t', 'rsa', '-N', "", '-f', "#{@sshd_key_file.path}"])
+ @sshd_key_file.close
+
+ sshd_config =<<EOF
+Port #{@sshd_port}
+ListenAddress #{@sshd_host}
+UsePrivilegeSeparation no
+HostKey #{@sshd_key_file.path}
+Pidfile #{$config['TMPDIR']}/ssh.pid
+EOF
+
+ @sshd_config_file = Tempfile.new("sshd_config", $config["TMPDIR"])
+ @sshd_config_file.write(sshd_config)
+
+ if @authorized_keys
+ @authorized_keys_file = Tempfile.new("authorized_keys", $config['TMPDIR'])
+ @authorized_keys_file.write(@authorized_keys)
+ @authorized_keys_file.close
+ @sshd_config_file.write("AuthorizedKeysFile #{@authorized_keys_file.path}")
+ end
+
+ @sshd_config_file.close
+
+ cmd = ["/usr/sbin/sshd", "-4", "-f", @sshd_config_file.path, "-D"]
+
+ job = IO.popen(cmd)
+ @pid = job.pid
+ end
+
+ def stop
+ File.delete("#{@sshd_key_file.path}.pub")
+ File.delete("#{$config['TMPDIR']}/ssh.pid")
+ begin
+ Process.kill("TERM", @pid)
+ rescue
+ # noop
+ end
+ end
+
+ def active?
+ begin
+ ret = Process.kill(0, @pid)
+ rescue Errno::ESRCH => e
+ if e.message == "No such process"
+ return false
+ else
+ raise e
+ end
+ end
+ assert_equal(1, ret, "This shouldn't happen")
+ return true
+ end
+end
diff --git a/cucumber/features/support/helpers/storage_helper.rb b/cucumber/features/support/helpers/storage_helper.rb
new file mode 100644
index 00000000..21537a92
--- /dev/null
+++ b/cucumber/features/support/helpers/storage_helper.rb
@@ -0,0 +1,216 @@
+# Helper class for manipulating VM storage *volumes*, i.e. it deals
+# only with creation of images and keeps a name => volume path lookup
+# table (plugging drives or getting info of plugged devices is done in
+# the VM class). We'd like better coupling, but given the ridiculous
+# disconnect between Libvirt::StoragePool and Libvirt::Domain (hint:
+# they have nothing with each other to do whatsoever) it's what makes
+# sense.
+
+require 'libvirt'
+require 'guestfs'
+require 'rexml/document'
+require 'etc'
+
+class VMStorage
+
+ def initialize(virt, xml_path)
+ @virt = virt
+ @xml_path = xml_path
+ pool_xml = REXML::Document.new(File.read("#{@xml_path}/storage_pool.xml"))
+ pool_name = pool_xml.elements['pool/name'].text
+ @pool_path = "#{$config["TMPDIR"]}/#{pool_name}"
+ begin
+ @pool = @virt.lookup_storage_pool_by_name(pool_name)
+ rescue Libvirt::RetrieveError
+ @pool = nil
+ end
+ if @pool and not(KEEP_SNAPSHOTS)
+ VMStorage.clear_storage_pool(@pool)
+ @pool = nil
+ end
+ unless @pool
+ pool_xml.elements['pool/target/path'].text = @pool_path
+ @pool = @virt.define_storage_pool_xml(pool_xml.to_s)
+ if not(Dir.exists?(@pool_path))
+ # We'd like to use @pool.build, which will just create the
+ # @pool_path directory, but it does so with root:root as owner
+ # (at least with libvirt 1.2.21-2). libvirt itself can handle
+ # that situation, but guestfs (at least with <=
+ # 1:1.28.12-1+b3) cannot when invoked by a non-root user,
+ # which we want to support.
+ FileUtils.mkdir(@pool_path)
+ FileUtils.chown(nil, 'libvirt-qemu', @pool_path)
+ FileUtils.chmod("ug+wrx", @pool_path)
+ end
+ end
+ @pool.create unless @pool.active?
+ @pool.refresh
+ end
+
+ def VMStorage.clear_storage_pool_volumes(pool)
+ was_not_active = !pool.active?
+ if was_not_active
+ pool.create
+ end
+ pool.list_volumes.each do |vol_name|
+ vol = pool.lookup_volume_by_name(vol_name)
+ vol.delete
+ end
+ if was_not_active
+ pool.destroy
+ end
+ rescue
+ # Some of the above operations can fail if the pool's path was
+ # deleted by external means; let's ignore that.
+ end
+
+ def VMStorage.clear_storage_pool(pool)
+ VMStorage.clear_storage_pool_volumes(pool)
+ pool.destroy if pool.active?
+ pool.undefine
+ end
+
+ def clear_pool
+ VMStorage.clear_storage_pool(@pool)
+ end
+
+ def clear_volumes
+ VMStorage.clear_storage_pool_volumes(@pool)
+ end
+
+ def delete_volume(name)
+ @pool.lookup_volume_by_name(name).delete
+ end
+
+ def create_new_disk(name, options = {})
+ options[:size] ||= 2
+ options[:unit] ||= "GiB"
+ options[:type] ||= "qcow2"
+ # Require 'slightly' more space to be available to give a bit more leeway
+ # with rounding, temp file creation, etc.
+ reserved = 500
+ needed = convert_to_MiB(options[:size].to_i, options[:unit])
+ avail = convert_to_MiB(get_free_space('host', @pool_path), "KiB")
+ assert(avail - reserved >= needed,
+ "Error creating disk \"#{name}\" in \"#{@pool_path}\". " \
+ "Need #{needed} MiB but only #{avail} MiB is available of " \
+ "which #{reserved} MiB is reserved for other temporary files.")
+ begin
+ old_vol = @pool.lookup_volume_by_name(name)
+ rescue Libvirt::RetrieveError
+ # noop
+ else
+ old_vol.delete
+ end
+ uid = Etc::getpwnam("libvirt-qemu").uid
+ gid = Etc::getgrnam("libvirt-qemu").gid
+ vol_xml = REXML::Document.new(File.read("#{@xml_path}/volume.xml"))
+ vol_xml.elements['volume/name'].text = name
+ size_b = convert_to_bytes(options[:size].to_f, options[:unit])
+ vol_xml.elements['volume/capacity'].text = size_b.to_s
+ vol_xml.elements['volume/target/format'].attributes["type"] = options[:type]
+ vol_xml.elements['volume/target/path'].text = "#{@pool_path}/#{name}"
+ vol_xml.elements['volume/target/permissions/owner'].text = uid.to_s
+ vol_xml.elements['volume/target/permissions/group'].text = gid.to_s
+ vol = @pool.create_volume_xml(vol_xml.to_s)
+ @pool.refresh
+ end
+
+ def clone_to_new_disk(from, to)
+ begin
+ old_to_vol = @pool.lookup_volume_by_name(to)
+ rescue Libvirt::RetrieveError
+ # noop
+ else
+ old_to_vol.delete
+ end
+ from_vol = @pool.lookup_volume_by_name(from)
+ xml = REXML::Document.new(from_vol.xml_desc)
+ pool_path = REXML::Document.new(@pool.xml_desc).elements['pool/target/path'].text
+ xml.elements['volume/name'].text = to
+ xml.elements['volume/target/path'].text = "#{pool_path}/#{to}"
+ @pool.create_volume_xml_from(xml.to_s, from_vol)
+ end
+
+ def disk_format(name)
+ vol = @pool.lookup_volume_by_name(name)
+ vol_xml = REXML::Document.new(vol.xml_desc)
+ return vol_xml.elements['volume/target/format'].attributes["type"]
+ end
+
+ def disk_path(name)
+ @pool.lookup_volume_by_name(name).path
+ end
+
+ def disk_mklabel(name, parttype)
+ disk = {
+ :path => disk_path(name),
+ :opts => {
+ :format => disk_format(name)
+ }
+ }
+ guestfs_disk_helper(disk) do |g, disk_handle|
+ g.part_init(disk_handle, parttype)
+ end
+ end
+
+ def disk_mkpartfs(name, parttype, fstype, opts = {})
+ opts[:label] ||= nil
+ opts[:luks_password] ||= nil
+ disk = {
+ :path => disk_path(name),
+ :opts => {
+ :format => disk_format(name)
+ }
+ }
+ guestfs_disk_helper(disk) do |g, disk_handle|
+ g.part_disk(disk_handle, parttype)
+ g.part_set_name(disk_handle, 1, opts[:label]) if opts[:label]
+ primary_partition = g.list_partitions()[0]
+ if opts[:luks_password]
+ g.luks_format(primary_partition, opts[:luks_password], 0)
+ luks_mapping = File.basename(primary_partition) + "_unlocked"
+ g.luks_open(primary_partition, opts[:luks_password], luks_mapping)
+ luks_dev = "/dev/mapper/#{luks_mapping}"
+ g.mkfs(fstype, luks_dev)
+ g.luks_close(luks_dev)
+ else
+ g.mkfs(fstype, primary_partition)
+ end
+ end
+ end
+
+ def disk_mkswap(name, parttype)
+ disk = {
+ :path => disk_path(name),
+ :opts => {
+ :format => disk_format(name)
+ }
+ }
+ guestfs_disk_helper(disk) do |g, disk_handle|
+ g.part_disk(disk_handle, parttype)
+ primary_partition = g.list_partitions()[0]
+ g.mkswap(primary_partition)
+ end
+ end
+
+ def guestfs_disk_helper(*disks)
+ assert(block_given?)
+ g = Guestfs::Guestfs.new()
+ g.set_trace(1)
+ message_callback = Proc.new do |event, _, message, _|
+ debug_log("libguestfs: #{Guestfs.event_to_string(event)}: #{message}")
+ end
+ g.set_event_callback(message_callback,
+ Guestfs::EVENT_TRACE)
+ g.set_autosync(1)
+ disks.each do |disk|
+ g.add_drive_opts(disk[:path], disk[:opts])
+ end
+ g.launch()
+ yield(g, *g.list_devices())
+ ensure
+ g.close
+ end
+
+end
diff --git a/cucumber/features/support/helpers/vm_helper.rb b/cucumber/features/support/helpers/vm_helper.rb
new file mode 100644
index 00000000..6d7204d4
--- /dev/null
+++ b/cucumber/features/support/helpers/vm_helper.rb
@@ -0,0 +1,676 @@
+require 'libvirt'
+require 'rexml/document'
+
+class ExecutionFailedInVM < StandardError
+end
+
+class VMNet
+
+ attr_reader :net_name, :net
+
+ def initialize(virt, xml_path)
+ @virt = virt
+ @net_name = LIBVIRT_NETWORK_NAME
+ net_xml = File.read("#{xml_path}/default_net.xml")
+ rexml = REXML::Document.new(net_xml)
+ rexml.elements['network'].add_element('name')
+ rexml.elements['network/name'].text = @net_name
+ rexml.elements['network'].add_element('uuid')
+ rexml.elements['network/uuid'].text = LIBVIRT_NETWORK_UUID
+ update(rexml.to_s)
+ rescue Exception => e
+ destroy_and_undefine
+ raise e
+ end
+
+ # We lookup by name so we also catch networks from previous test
+ # suite runs that weren't properly cleaned up (e.g. aborted).
+ def destroy_and_undefine
+ begin
+ old_net = @virt.lookup_network_by_name(@net_name)
+ old_net.destroy if old_net.active?
+ old_net.undefine
+ rescue
+ end
+ end
+
+ def update(xml)
+ destroy_and_undefine
+ @net = @virt.define_network_xml(xml)
+ @net.create
+ end
+
+ def bridge_name
+ @net.bridge_name
+ end
+
+ def bridge_ip_addr
+ net_xml = REXML::Document.new(@net.xml_desc)
+ IPAddr.new(net_xml.elements['network/ip'].attributes['address']).to_s
+ end
+
+ def guest_real_mac
+ net_xml = REXML::Document.new(@net.xml_desc)
+ net_xml.elements['network/ip/dhcp/host/'].attributes['mac']
+ end
+
+ def bridge_mac
+ File.open("/sys/class/net/#{bridge_name}/address", "rb").read.chomp
+ end
+end
+
+
+class VM
+
+ attr_reader :domain, :display, :vmnet, :storage
+
+ def initialize(virt, xml_path, vmnet, storage, x_display)
+ @virt = virt
+ @xml_path = xml_path
+ @vmnet = vmnet
+ @storage = storage
+ @domain_name = LIBVIRT_DOMAIN_NAME
+ default_domain_xml = File.read("#{@xml_path}/default.xml")
+ rexml = REXML::Document.new(default_domain_xml)
+ rexml.elements['domain'].add_element('name')
+ rexml.elements['domain/name'].text = @domain_name
+ rexml.elements['domain'].add_element('uuid')
+ rexml.elements['domain/uuid'].text = LIBVIRT_DOMAIN_UUID
+ update(rexml.to_s)
+ @display = Display.new(@domain_name, x_display)
+ set_cdrom_boot(TAILS_ISO)
+ plug_network
+ rescue Exception => e
+ destroy_and_undefine
+ raise e
+ end
+
+ def update(xml)
+ destroy_and_undefine
+ @domain = @virt.define_domain_xml(xml)
+ end
+
+ # We lookup by name so we also catch domains from previous test
+ # suite runs that weren't properly cleaned up (e.g. aborted).
+ def destroy_and_undefine
+ @display.stop if @display && @display.active?
+ begin
+ old_domain = @virt.lookup_domain_by_name(@domain_name)
+ old_domain.destroy if old_domain.active?
+ old_domain.undefine
+ rescue
+ end
+ end
+
+ def real_mac
+ @vmnet.guest_real_mac
+ end
+
+ def set_hardware_clock(time)
+ assert(not(is_running?), 'The hardware clock cannot be set when the ' +
+ 'VM is running')
+ assert(time.instance_of?(Time), "Argument must be of type 'Time'")
+ adjustment = (time - Time.now).to_i
+ domain_rexml = REXML::Document.new(@domain.xml_desc)
+ clock_rexml_element = domain_rexml.elements['domain'].add_element('clock')
+ clock_rexml_element.add_attributes('offset' => 'variable',
+ 'basis' => 'utc',
+ 'adjustment' => adjustment.to_s)
+ update(domain_rexml.to_s)
+ end
+
+ def set_network_link_state(state)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/devices/interface/link'].attributes['state'] = state
+ if is_running?
+ @domain.update_device(domain_xml.elements['domain/devices/interface'].to_s)
+ else
+ update(domain_xml.to_s)
+ end
+ end
+
+ def plug_network
+ set_network_link_state('up')
+ end
+
+ def unplug_network
+ set_network_link_state('down')
+ end
+
+ def set_boot_device(dev)
+ if is_running?
+ raise "boot settings can only be set for inactive vms"
+ end
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/os/boot'].attributes['dev'] = dev
+ update(domain_xml.to_s)
+ end
+
+ def set_cdrom_image(image)
+ image = nil if image == ''
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ if e.attribute('device').to_s == "cdrom"
+ if image.nil?
+ e.elements.delete('source')
+ else
+ if ! e.elements['source']
+ e.add_element('source')
+ end
+ e.elements['source'].attributes['file'] = image
+ end
+ if is_running?
+ @domain.update_device(e.to_s)
+ else
+ update(domain_xml.to_s)
+ end
+ end
+ end
+ end
+
+ def remove_cdrom
+ set_cdrom_image(nil)
+ rescue Libvirt::Error => e
+ # While the CD-ROM is removed successfully we still get this
+ # error, so let's ignore it.
+ acceptable_error =
+ "Call to virDomainUpdateDeviceFlags failed: internal error: unable to " +
+ "execute QEMU command 'eject': (Tray of device '.*' is not open|" +
+ "Device '.*' is locked)"
+ raise e if not(Regexp.new(acceptable_error).match(e.to_s))
+ end
+
+ def set_cdrom_boot(image)
+ if is_running?
+ raise "boot settings can only be set for inactive vms"
+ end
+ set_boot_device('cdrom')
+ set_cdrom_image(image)
+ end
+
+ def list_disk_devs
+ ret = []
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ ret << e.elements['target'].attribute('dev').to_s
+ end
+ return ret
+ end
+
+ def plug_drive(name, type)
+ if disk_plugged?(name)
+ raise "disk '#{name}' already plugged"
+ end
+ removable_usb = nil
+ case type
+ when "removable usb", "usb"
+ type = "usb"
+ removable_usb = "on"
+ when "non-removable usb"
+ type = "usb"
+ removable_usb = "off"
+ end
+ # Get the next free /dev/sdX on guest
+ letter = 'a'
+ dev = "sd" + letter
+ while list_disk_devs.include?(dev)
+ letter = (letter[0].ord + 1).chr
+ dev = "sd" + letter
+ end
+ assert letter <= 'z'
+
+ xml = REXML::Document.new(File.read("#{@xml_path}/disk.xml"))
+ xml.elements['disk/source'].attributes['file'] = @storage.disk_path(name)
+ xml.elements['disk/driver'].attributes['type'] = @storage.disk_format(name)
+ xml.elements['disk/target'].attributes['dev'] = dev
+ xml.elements['disk/target'].attributes['bus'] = type
+ xml.elements['disk/target'].attributes['removable'] = removable_usb if removable_usb
+
+ if is_running?
+ @domain.attach_device(xml.to_s)
+ else
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/devices'].add_element(xml)
+ update(domain_xml.to_s)
+ end
+ end
+
+ def disk_xml_desc(name)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ begin
+ if e.elements['source'].attribute('file').to_s == @storage.disk_path(name)
+ return e.to_s
+ end
+ rescue
+ next
+ end
+ end
+ return nil
+ end
+
+ def disk_rexml_desc(name)
+ xml = disk_xml_desc(name)
+ if xml
+ return REXML::Document.new(xml)
+ else
+ return nil
+ end
+ end
+
+ def unplug_drive(name)
+ xml = disk_xml_desc(name)
+ @domain.detach_device(xml)
+ end
+
+ def disk_type(dev)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ if e.elements['target'].attribute('dev').to_s == dev
+ return e.elements['driver'].attribute('type').to_s
+ end
+ end
+ raise "No such disk device '#{dev}'"
+ end
+
+ def disk_dev(name)
+ rexml = disk_rexml_desc(name) or return nil
+ return "/dev/" + rexml.elements['disk/target'].attribute('dev').to_s
+ end
+
+ def disk_name(dev)
+ dev = File.basename(dev)
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ if /^#{e.elements['target'].attribute('dev').to_s}/.match(dev)
+ return File.basename(e.elements['source'].attribute('file').to_s)
+ end
+ end
+ raise "No such disk device '#{dev}'"
+ end
+
+ def udisks_disk_dev(name)
+ return disk_dev(name).gsub('/dev/', '/org/freedesktop/UDisks/devices/')
+ end
+
+ def disk_detected?(name)
+ dev = disk_dev(name) or return false
+ return execute("test -b #{dev}").success?
+ end
+
+ def disk_plugged?(name)
+ return not(disk_xml_desc(name).nil?)
+ end
+
+ def set_disk_boot(name, type)
+ if is_running?
+ raise "boot settings can only be set for inactive vms"
+ end
+ plug_drive(name, type) if not(disk_plugged?(name))
+ set_boot_device('hd')
+ # For some reason setting the boot device doesn't prevent cdrom
+ # boot unless it's empty
+ remove_cdrom
+ end
+
+ # XXX-9p: Shares don't work together with snapshot save+restore. See
+ # XXX-9p in common_steps.rb for more information.
+ def add_share(source, tag)
+ if is_running?
+ raise "shares can only be added to inactive vms"
+ end
+ # The complete source directory must be group readable by the user
+ # running the virtual machine, and world readable so the user inside
+ # the VM can access it (since we use the passthrough security model).
+ FileUtils.chown_R(nil, "libvirt-qemu", source)
+ FileUtils.chmod_R("go+rX", source)
+ xml = REXML::Document.new(File.read("#{@xml_path}/fs_share.xml"))
+ xml.elements['filesystem/source'].attributes['dir'] = source
+ xml.elements['filesystem/target'].attributes['dir'] = tag
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/devices'].add_element(xml)
+ update(domain_xml.to_s)
+ end
+
+ def list_shares
+ list = []
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/filesystem') do |e|
+ list << e.elements['target'].attribute('dir').to_s
+ end
+ return list
+ end
+
+ def set_ram_size(size, unit = "KiB")
+ raise "System memory can only be added to inactive vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/memory'].text = size
+ domain_xml.elements['domain/memory'].attributes['unit'] = unit
+ domain_xml.elements['domain/currentMemory'].text = size
+ domain_xml.elements['domain/currentMemory'].attributes['unit'] = unit
+ update(domain_xml.to_s)
+ end
+
+ def get_ram_size_in_bytes
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ unit = domain_xml.elements['domain/memory'].attribute('unit').to_s
+ size = domain_xml.elements['domain/memory'].text.to_i
+ return convert_to_bytes(size, unit)
+ end
+
+ def set_arch(arch)
+ raise "System architecture can only be set to inactive vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/os/type'].attributes['arch'] = arch
+ update(domain_xml.to_s)
+ end
+
+ def add_hypervisor_feature(feature)
+ raise "Hypervisor features can only be added to inactive vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/features'].add_element(feature)
+ update(domain_xml.to_s)
+ end
+
+ def drop_hypervisor_feature(feature)
+ raise "Hypervisor features can only be fropped from inactive vms" if is_running?
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/features'].delete_element(feature)
+ update(domain_xml.to_s)
+ end
+
+ def disable_pae_workaround
+ # add_hypervisor_feature("nonpae") results in a libvirt error, and
+ # drop_hypervisor_feature("pae") alone won't disable pae. Hence we
+ # use this workaround.
+ xml = <<EOF
+ <qemu:commandline xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
+ <qemu:arg value='-cpu'/>
+ <qemu:arg value='qemu32,-pae'/>
+ </qemu:commandline>
+EOF
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain'].add_element(REXML::Document.new(xml))
+ update(domain_xml.to_s)
+ end
+
+ def set_os_loader(type)
+ if is_running?
+ raise "boot settings can only be set for inactive vms"
+ end
+ if type == 'UEFI'
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements['domain/os'].add_element(REXML::Document.new(
+ '<loader>/usr/share/ovmf/OVMF.fd</loader>'
+ ))
+ update(domain_xml.to_s)
+ else
+ raise "unsupported OS loader type"
+ end
+ end
+
+ def is_running?
+ begin
+ return @domain.active?
+ rescue
+ return false
+ end
+ end
+
+ def execute(cmd, options = {})
+ options[:user] ||= "root"
+ options[:spawn] ||= false
+ if options[:libs]
+ libs = options[:libs]
+ options.delete(:libs)
+ libs = [libs] if not(libs.methods.include? :map)
+ cmds = libs.map do |lib_name|
+ ". /usr/local/lib/tails-shell-library/#{lib_name}.sh"
+ end
+ cmds << cmd
+ cmd = cmds.join(" && ")
+ end
+ return VMCommand.new(self, cmd, options)
+ end
+
+ def execute_successfully(*args)
+ p = execute(*args)
+ begin
+ assert_vmcommand_success(p)
+ rescue Test::Unit::AssertionFailedError => e
+ raise ExecutionFailedInVM.new(e)
+ end
+ return p
+ end
+
+ def spawn(cmd, options = {})
+ options[:spawn] = true
+ return execute(cmd, options)
+ end
+
+ def wait_until_remote_shell_is_up(timeout = 90)
+ VMCommand.wait_until_remote_shell_is_up(self, timeout)
+ end
+
+ def host_to_guest_time_sync
+ host_time= DateTime.now.strftime("%s").to_s
+ execute("date -s '@#{host_time}'").success?
+ end
+
+ def has_network?
+ return execute("/sbin/ifconfig eth0 | grep -q 'inet addr'").success?
+ end
+
+ def has_process?(process)
+ return execute("pidof -x -o '%PPID' " + process).success?
+ end
+
+ def pidof(process)
+ return execute("pidof -x -o '%PPID' " + process).stdout.chomp.split
+ end
+
+ def select_virtual_desktop(desktop_number, user = LIVE_USER)
+ assert(desktop_number >= 0 && desktop_number <=3,
+ "Only values between 0 and 3 are valid virtual desktop numbers")
+ execute_successfully(
+ "xdotool set_desktop '#{desktop_number}'",
+ :user => user
+ )
+ end
+
+ def focus_window(window_title, user = LIVE_USER)
+ def do_focus(window_title, user)
+ execute_successfully(
+ "xdotool search --name '#{window_title}' windowactivate --sync",
+ :user => user
+ )
+ end
+
+ begin
+ do_focus(window_title, user)
+ rescue ExecutionFailedInVM
+ # Often when xdotool fails to focus a window it'll work when retried
+ # after redrawing the screen. Switching to a new virtual desktop then
+ # back seems to be a reliable way to handle this.
+ select_virtual_desktop(3)
+ select_virtual_desktop(0)
+ sleep 5 # there aren't any visual indicators which can be used here
+ do_focus(window_title, user)
+ end
+ end
+
+ def file_exist?(file)
+ execute("test -e '#{file}'").success?
+ end
+
+ def directory_exist?(directory)
+ execute("test -d '#{directory}'").success?
+ end
+
+ def file_content(file, user = 'root')
+ # We don't quote #{file} on purpose: we sometimes pass environment variables
+ # or globs that we want to be interpreted by the shell.
+ cmd = execute("cat #{file}", :user => user)
+ assert(cmd.success?,
+ "Could not cat '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}")
+ return cmd.stdout
+ end
+
+ def file_append(file, lines, user = 'root')
+ lines = lines.split("\n") if lines.class == String
+ lines.each do |line|
+ cmd = execute("echo '#{line}' >> '#{file}'", :user => user)
+ assert(cmd.success?,
+ "Could not append to '#{file}':\n#{cmd.stdout}\n#{cmd.stderr}")
+ end
+ end
+
+ def set_clipboard(text)
+ execute_successfully("echo -n '#{text}' | xsel --input --clipboard",
+ :user => LIVE_USER)
+ end
+
+ def get_clipboard
+ execute_successfully("xsel --output --clipboard", :user => LIVE_USER).stdout
+ end
+
+ def internal_snapshot_xml(name)
+ disk_devs = list_disk_devs
+ disks_xml = " <disks>\n"
+ for dev in disk_devs
+ snapshot_type = disk_type(dev) == "qcow2" ? 'internal' : 'no'
+ disks_xml +=
+ " <disk name='#{dev}' snapshot='#{snapshot_type}'></disk>\n"
+ end
+ disks_xml += " </disks>"
+ return <<-EOF
+<domainsnapshot>
+ <name>#{name}</name>
+ <description>Snapshot for #{name}</description>
+#{disks_xml}
+ </domainsnapshot>
+EOF
+ end
+
+ def VM.ram_only_snapshot_path(name)
+ return "#{$config["TMPDIR"]}/#{name}-snapshot.memstate"
+ end
+
+ def save_snapshot(name)
+ # If we have no qcow2 disk device, we'll use "memory state"
+ # snapshots, and if we have at least one qcow2 disk device, we'll
+ # use internal "system checkpoint" (memory + disks) snapshots. We
+ # have to do this since internal snapshots don't work when no
+ # such disk is available. We can do this with external snapshots,
+ # which are better in many ways, but libvirt doesn't know how to
+ # restore (revert back to) them yet.
+ # WARNING: If only transient disks, i.e. disks that were plugged
+ # after starting the domain, are used then the memory state will
+ # be dropped. External snapshots would also fix this.
+ internal_snapshot = false
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/disk') do |e|
+ if e.elements['driver'].attribute('type').to_s == "qcow2"
+ internal_snapshot = true
+ break
+ end
+ end
+
+ # Note: In this case the "opposite" of `internal_snapshot` is not
+ # anything relating to external snapshots, but actually "memory
+ # state"(-only) snapshots.
+ if internal_snapshot
+ xml = internal_snapshot_xml(name)
+ @domain.snapshot_create_xml(xml)
+ else
+ snapshot_path = VM.ram_only_snapshot_path(name)
+ @domain.save(snapshot_path)
+ # For consistency with the internal snapshot case (which is
+ # "live", so the domain doesn't go down) we immediately restore
+ # the snapshot.
+ # Assumption: that *immediate* save + restore doesn't mess up
+ # with network state and similar, and is fast enough to not make
+ # the clock drift too much.
+ restore_snapshot(name)
+ end
+ end
+
+ def restore_snapshot(name)
+ @domain.destroy if is_running?
+ @display.stop if @display and @display.active?
+ # See comment in save_snapshot() for details on why we use two
+ # different type of snapshots.
+ potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name)
+ if File.exist?(potential_ram_only_snapshot_path)
+ Libvirt::Domain::restore(@virt, potential_ram_only_snapshot_path)
+ @domain = @virt.lookup_domain_by_name(@domain_name)
+ else
+ begin
+ potential_internal_snapshot = @domain.lookup_snapshot_by_name(name)
+ @domain.revert_to_snapshot(potential_internal_snapshot)
+ rescue Libvirt::RetrieveError
+ raise "No such (internal nor external) snapshot #{name}"
+ end
+ end
+ @display.start
+ end
+
+ def VM.remove_snapshot(name)
+ old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME)
+ potential_ram_only_snapshot_path = VM.ram_only_snapshot_path(name)
+ if File.exist?(potential_ram_only_snapshot_path)
+ File.delete(potential_ram_only_snapshot_path)
+ else
+ snapshot = old_domain.lookup_snapshot_by_name(name)
+ snapshot.delete
+ end
+ end
+
+ def VM.snapshot_exists?(name)
+ return true if File.exist?(VM.ram_only_snapshot_path(name))
+ old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME)
+ snapshot = old_domain.lookup_snapshot_by_name(name)
+ return snapshot != nil
+ rescue Libvirt::RetrieveError
+ return false
+ end
+
+ def VM.remove_all_snapshots
+ Dir.glob("#{$config["TMPDIR"]}/*-snapshot.memstate").each do |file|
+ File.delete(file)
+ end
+ old_domain = $virt.lookup_domain_by_name(LIBVIRT_DOMAIN_NAME)
+ old_domain.list_all_snapshots.each { |snapshot| snapshot.delete }
+ rescue Libvirt::RetrieveError
+ # No such domain, so no snapshots either.
+ end
+
+ def start
+ return if is_running?
+ @domain.create
+ @display.start
+ end
+
+ def reset
+ @domain.reset if is_running?
+ end
+
+ def power_off
+ @domain.destroy if is_running?
+ @display.stop
+ end
+
+ def take_screenshot(description)
+ @display.take_screenshot(description)
+ end
+
+ def get_remote_shell_port
+ domain_xml = REXML::Document.new(@domain.xml_desc)
+ domain_xml.elements.each('domain/devices/serial') do |e|
+ if e.attribute('type').to_s == "tcp"
+ return e.elements['source'].attribute('service').to_s.to_i
+ end
+ end
+ end
+
+end
diff --git a/cucumber/features/support/hooks.rb b/cucumber/features/support/hooks.rb
new file mode 100644
index 00000000..b3bdecef
--- /dev/null
+++ b/cucumber/features/support/hooks.rb
@@ -0,0 +1,280 @@
+require 'fileutils'
+require 'rb-inotify'
+require 'time'
+require 'tmpdir'
+
+# Run once, before any feature
+AfterConfiguration do |config|
+ # Reorder the execution of some features. As we progress through a
+ # run we accumulate more and more snapshots and hence use more and
+ # more disk space, but some features will leave nothing behind
+ # and/or possibly use large amounts of disk space temporarily for
+ # various reasons. By running these first we minimize the amount of
+ # disk space needed.
+ prioritized_features = [
+ # Features not using snapshots but using large amounts of scratch
+ # space for other reasons:
+ 'features/erase_memory.feature',
+ 'features/untrusted_partitions.feature',
+ # Features using temporary snapshots:
+ 'features/apt.feature',
+ 'features/i2p.feature',
+ 'features/root_access_control.feature',
+ 'features/time_syncing.feature',
+ 'features/tor_bridges.feature',
+ # This feature needs the almost biggest snapshot (USB install,
+ # excluding persistence) and will create yet another disk and
+ # install Tails on it. This should be the peak of disk usage.
+ 'features/usb_install.feature',
+ ]
+ feature_files = config.feature_files
+ # The &-intersection is specified to keep the element ordering of
+ # the *left* operand.
+ intersection = prioritized_features & feature_files
+ if not intersection.empty?
+ feature_files -= intersection
+ feature_files = intersection + feature_files
+ config.define_singleton_method(:feature_files) { feature_files }
+ end
+
+ # Used to keep track of when we start our first @product feature, when
+ # we'll do some special things.
+ $started_first_product_feature = false
+
+ if File.exist?($config["TMPDIR"])
+ if !File.directory?($config["TMPDIR"])
+ raise "Temporary directory '#{$config["TMPDIR"]}' exists but is not a " +
+ "directory"
+ end
+ if !File.owned?($config["TMPDIR"])
+ raise "Temporary directory '#{$config["TMPDIR"]}' must be owned by the " +
+ "current user"
+ end
+ FileUtils.chmod(0755, $config["TMPDIR"])
+ else
+ begin
+ FileUtils.mkdir_p($config["TMPDIR"])
+ rescue Errno::EACCES => e
+ raise "Cannot create temporary directory: #{e.to_s}"
+ end
+ end
+
+ # Start a thread that monitors a pseudo fifo file and debug_log():s
+ # anything written to it "immediately" (well, as fast as inotify
+ # detects it). We're forced to a convoluted solution like this
+ # because CRuby's thread support is horribly as soon as IO is mixed
+ # in (other threads get blocked).
+ FileUtils.rm(DEBUG_LOG_PSEUDO_FIFO) if File.exist?(DEBUG_LOG_PSEUDO_FIFO)
+ FileUtils.touch(DEBUG_LOG_PSEUDO_FIFO)
+ at_exit do
+ FileUtils.rm(DEBUG_LOG_PSEUDO_FIFO) if File.exist?(DEBUG_LOG_PSEUDO_FIFO)
+ end
+ Thread.new do
+ File.open(DEBUG_LOG_PSEUDO_FIFO) do |fd|
+ watcher = INotify::Notifier.new
+ watcher.watch(DEBUG_LOG_PSEUDO_FIFO, :modify) do
+ line = fd.read.chomp
+ debug_log(line) if line and line.length > 0
+ end
+ watcher.run
+ end
+ end
+ # Fix Sikuli's debug_log():ing.
+ bind_java_to_pseudo_fifo_logger
+end
+
+# Common
+########
+
+After do
+ if @after_scenario_hooks
+ @after_scenario_hooks.each { |block| block.call }
+ end
+ @after_scenario_hooks = Array.new
+end
+
+BeforeFeature('@product', '@source') do |feature|
+ raise "Feature #{feature.file} is tagged both @product and @source, " +
+ "which is an impossible combination"
+end
+
+at_exit do
+ $vm.destroy_and_undefine if $vm
+ if $virt
+ unless KEEP_SNAPSHOTS
+ VM.remove_all_snapshots
+ $vmstorage.clear_pool
+ end
+ $vmnet.destroy_and_undefine
+ $virt.close
+ end
+ # The artifacts directory is empty (and useless) if it contains
+ # nothing but the mandatory . and ..
+ if Dir.entries(ARTIFACTS_DIR).size <= 2
+ FileUtils.rmdir(ARTIFACTS_DIR)
+ end
+end
+
+# For @product tests
+####################
+
+def add_after_scenario_hook(&block)
+ @after_scenario_hooks ||= Array.new
+ @after_scenario_hooks << block
+end
+
+def save_failure_artifact(type, path)
+ $failure_artifacts << [type, path]
+end
+
+BeforeFeature('@product') do |feature|
+ if TAILS_ISO.nil?
+ raise "No ISO image specified, and none could be found in the " +
+ "current directory"
+ end
+ if File.exist?(TAILS_ISO)
+ # Workaround: when libvirt takes ownership of the ISO image it may
+ # become unreadable for the live user inside the guest in the
+ # host-to-guest share used for some tests.
+
+ if !File.world_readable?(TAILS_ISO)
+ if File.owned?(TAILS_ISO)
+ File.chmod(0644, TAILS_ISO)
+ else
+ raise "warning: the Tails ISO image must be world readable or be " +
+ "owned by the current user to be available inside the guest " +
+ "VM via host-to-guest shares, which is required by some tests"
+ end
+ end
+ else
+ raise "The specified Tails ISO image '#{TAILS_ISO}' does not exist"
+ end
+ if !File.exist?(OLD_TAILS_ISO)
+ raise "The specified old Tails ISO image '#{OLD_TAILS_ISO}' does not exist"
+ end
+ if not($started_first_product_feature)
+ $virt = Libvirt::open("qemu:///system")
+ VM.remove_all_snapshots if !KEEP_SNAPSHOTS
+ $vmnet = VMNet.new($virt, VM_XML_PATH)
+ $vmstorage = VMStorage.new($virt, VM_XML_PATH)
+ $started_first_product_feature = true
+ end
+end
+
+AfterFeature('@product') do
+ unless KEEP_SNAPSHOTS
+ checkpoints.each do |name, vals|
+ if vals[:temporary] and VM.snapshot_exists?(name)
+ VM.remove_snapshot(name)
+ end
+ end
+ end
+end
+
+# Cucumber Before hooks are executed in the order they are listed, and
+# we want this hook to always run first, so it must always be the
+# *first* Before hook matching @product listed in this file.
+Before('@product') do |scenario|
+ $failure_artifacts = Array.new
+ if $config["CAPTURE"]
+ video_name = sanitize_filename("#{scenario.name}.mkv")
+ @video_path = "#{ARTIFACTS_DIR}/#{video_name}"
+ capture = IO.popen(['avconv',
+ '-f', 'x11grab',
+ '-s', '1024x768',
+ '-r', '15',
+ '-i', "#{$config['DISPLAY']}.0",
+ '-an',
+ '-c:v', 'libx264',
+ '-y',
+ @video_path,
+ :err => ['/dev/null', 'w'],
+ ])
+ @video_capture_pid = capture.pid
+ end
+ @screen = Sikuli::Screen.new
+ # English will be assumed if this is not overridden
+ @language = ""
+ @os_loader = "MBR"
+ @sudo_password = "asdf"
+ @persistence_password = "asdf"
+end
+
+# Cucumber After hooks are executed in the *reverse* order they are
+# listed, and we want this hook to always run second last, so it must always
+# be the *second* After hook matching @product listed in this file --
+# hooks added dynamically via add_after_scenario_hook() are supposed to
+# truly be last.
+After('@product') do |scenario|
+ if @video_capture_pid
+ # We can be incredibly fast at detecting errors sometimes, so the
+ # screen barely "settles" when we end up here and kill the video
+ # capture. Let's wait a few seconds more to make it easier to see
+ # what the error was.
+ sleep 3 if scenario.failed?
+ Process.kill("INT", @video_capture_pid)
+ save_failure_artifact("Video", @video_path)
+ end
+ if scenario.failed?
+ time_of_fail = Time.now - TIME_AT_START
+ secs = "%02d" % (time_of_fail % 60)
+ mins = "%02d" % ((time_of_fail / 60) % 60)
+ hrs = "%02d" % (time_of_fail / (60*60))
+ elapsed = "#{hrs}:#{mins}:#{secs}"
+ info_log("Scenario failed at time #{elapsed}")
+ screen_capture = @screen.capture
+ save_failure_artifact("Screenshot", screen_capture.getFilename)
+ $failure_artifacts.sort!
+ $failure_artifacts.each do |type, file|
+ artifact_name = sanitize_filename("#{elapsed}_#{scenario.name}#{File.extname(file)}")
+ artifact_path = "#{ARTIFACTS_DIR}/#{artifact_name}"
+ assert(File.exist?(file))
+ FileUtils.mv(file, artifact_path)
+ info_log
+ info_log_artifact_location(type, artifact_path)
+ end
+ pause("Scenario failed") if $config["PAUSE_ON_FAIL"]
+ else
+ if @video_path && File.exist?(@video_path) && not($config['CAPTURE_ALL'])
+ FileUtils.rm(@video_path)
+ end
+ end
+end
+
+Before('@product', '@check_tor_leaks') do |scenario|
+ @tor_leaks_sniffer = Sniffer.new(sanitize_filename(scenario.name), $vmnet)
+ @tor_leaks_sniffer.capture
+ add_after_scenario_hook do
+ @tor_leaks_sniffer.clear
+ end
+end
+
+After('@product', '@check_tor_leaks') do |scenario|
+ @tor_leaks_sniffer.stop
+ if scenario.passed?
+ if @bridge_hosts.nil?
+ expected_tor_nodes = get_all_tor_nodes
+ else
+ expected_tor_nodes = @bridge_hosts
+ end
+ leaks = FirewallLeakCheck.new(@tor_leaks_sniffer.pcap_file,
+ :accepted_hosts => expected_tor_nodes)
+ leaks.assert_no_leaks
+ end
+end
+
+# For @source tests
+###################
+
+# BeforeScenario
+Before('@source') do
+ @orig_pwd = Dir.pwd
+ @git_clone = Dir.mktmpdir 'tails-apt-tests'
+ Dir.chdir @git_clone
+end
+
+# AfterScenario
+After('@source') do
+ Dir.chdir @orig_pwd
+ FileUtils.remove_entry_secure @git_clone
+end