5 require "#{GIT_DIR}/features/support/monkeypatches/extra_hooks.rb"
7 # Run once, before any feature
8 AfterConfiguration do |config|
9 puts("Cucumber tags: #{config.tag_expressions}")
11 # We'll need access to cucumber's options in some places where
12 # @__cucumber_runtime (which has a reference to it) is awkward to
13 # access, so we make a global reference to them for those
15 # Note that we use `instance_variable_get` to work around this
18 # Deprecated: Configuration#options will be removed from the
19 # next release of Cucumber. Please use the configuration object
22 # We'll revisit what to do then.
23 $cucumber_options = config.instance_variable_get('@options')
25 # Reorder the execution of some features. As we progress through a
26 # run we accumulate more and more snapshots and hence use more and
27 # more disk space, but some features will leave nothing behind
28 # and/or possibly use large amounts of disk space temporarily for
29 # various reasons. By running these first we minimize the amount of
31 prioritized_features = [
32 # Features not using snapshots but using large amounts of scratch
33 # space for other reasons:
34 'features/untrusted_partitions.feature',
35 # Features using temporary snapshots:
36 'features/root_access_control.feature',
37 'features/time_syncing.feature',
38 'features/tor_bridges.feature',
39 # Features using large amounts of scratch space for other reasons:
40 'features/erase_memory.feature',
41 # This feature needs the almost biggest snapshot (USB install,
42 # excluding persistence) and will create yet another disk and
43 # install Tails on it. This should be the peak of disk usage.
44 'features/usb_install.feature',
45 # This feature uses a few temporary snapshots, a network-enabled
46 # snapshot, and a large disk.
47 'features/additional_software_packages.feature',
48 # This feature needs a copy of the ISO and creates a new disk.
49 'features/usb_upgrade.feature',
50 # This feature needs a very big snapshot (USB install with persistence)
51 # and another, network-enabled snapshot.
52 'features/emergency_shutdown.feature',
54 feature_files = config.feature_files
55 # The &-intersection is specified to keep the element ordering of
57 intersection = prioritized_features & feature_files
58 unless intersection.empty?
59 feature_files -= intersection
60 feature_files = intersection + feature_files
61 config.define_singleton_method(:feature_files) { feature_files }
64 # Used to keep track of when we start our first @product feature, when
65 # we'll do some special things.
66 $started_first_product_feature = false
68 if File.exist?($config['TMPDIR'])
69 unless File.directory?($config['TMPDIR'])
70 raise "Temporary directory '#{$config['TMPDIR']}' exists but is not a " \
73 unless File.owned?($config['TMPDIR'])
74 raise "Temporary directory '#{$config['TMPDIR']}' must be owned by the " \
77 FileUtils.chmod(0o755, $config['TMPDIR'])
80 FileUtils.mkdir_p($config['TMPDIR'])
81 rescue Errno::EACCES => e
82 raise "Cannot create temporary directory: #{e}"
86 if config_bool('INTERACTIVE_DEBUGGING')
87 # This module extends exceptions so they contain the stack of
88 # bindings for all callers from the point that it was raised,
89 # which we use to restore the context of the failure when
90 # --interactive-debugging is enabled.
91 # BTW, upstream renamed the module to skiptrace five years ago but
92 # hasn't done any release since then.
101 @after_scenario_hooks&.each(&:call)
102 @after_scenario_hooks = []
105 BeforeFeature('@product', '@source') do |feature|
106 raise "Feature #{feature.file} is tagged both @product and @source, " \
107 'which is an impossible combination'
111 $vm&.destroy_and_undefine
113 unless KEEP_SNAPSHOTS
114 VM.remove_all_snapshots
115 $vmstorage&.clear_pool
117 $vmnet&.destroy_and_undefine
120 # The artifacts directory is empty (and useless) if it contains
121 # nothing but the mandatory . and ..
122 FileUtils.rmdir(ARTIFACTS_DIR) if Dir.entries(ARTIFACTS_DIR).size <= 2
128 def add_after_scenario_hook(&block)
129 @after_scenario_hooks ||= []
130 @after_scenario_hooks << block
133 def save_failure_artifact(desc, path, suffix: nil)
134 suffix ||= File.extname(path)
135 $failure_artifacts << [desc, path, suffix]
138 def record_scenario_skipped(scenario)
139 destfile = "#{ARTIFACTS_DIR}/skipped.txt"
140 File.open(destfile, 'a') { |f| f.write("#{scenario.location}\n") }
143 def _save_vm_file_content(file:, destfile:, suffix:, desc:)
144 destfile = "#{$config['TMPDIR']}/#{destfile}"
145 File.open(destfile, 'w') { |f| f.write($vm.file_content(file)) }
146 save_failure_artifact(desc, destfile, suffix:)
147 rescue StandardError => e
148 info_log("Exception thrown while trying to save #{destfile}: " \
149 "#{e.class.name}: #{e}")
152 def save_vm_command_output(command:, id:, suffix: nil, desc: nil) # rubocop:disable Naming/MethodParameterName
153 suffix ||= ".cmd_output_#{id}"
154 basename = "artifact#{suffix}"
155 $vm.execute("#{command} > /tmp/#{basename} 2>&1")
156 _save_vm_file_content(
157 file: "/tmp/#{basename}",
160 desc: desc || "Output of #{command}"
165 save_failure_artifact(
167 JournalDumper.instance.path,
172 def save_vm_file_content(file, desc: nil, suffix: nil)
173 suffix ||= ".file_content_#{file.gsub('/', '_').sub(/^_/, '')}"
174 _save_vm_file_content(
176 destfile: "artifact#{suffix}",
178 desc: desc || "Content of #{file}"
183 save_vm_file_content('/var/log/boot.log', desc: 'Boot log')
186 # Due to Tails' Tor enforcement, we only allow contacting hosts that
187 # are Tor nodes, located on the LAN, or allowed for some operational reason.
188 # However, when we try to verify that only such hosts are contacted we have
189 # a problem -- we run all Tor nodes (via Chutney) *and* LAN hosts (used on
190 # some tests) on the same host, the one running the test suite. Hence we
191 # need to always explicitly track which nodes are allowed or not.
193 # Warning: when a host is added via this function, it is only added
194 # for the current scenario. As such, if this is done before saving a
195 # snapshot, it will not remain after the snapshot is loaded.
196 def add_extra_allowed_host(ipaddr, port)
197 @extra_allowed_hosts ||= []
198 @extra_allowed_hosts << { address: ipaddr, port: }
201 def add_dns_to_extra_allowed_host
202 # Allow connections to the local DNS resolver
203 add_extra_allowed_host($vmnet.bridge_ip_addr, 53)
206 BeforeFeature('@product') do
207 images = { 'ISO' => TAILS_ISO, 'IMG' => TAILS_IMG }
208 images.each do |type, path|
210 raise "No Tails #{type} image specified, and none could be found " \
211 'in the current directory'
214 unless File.exist?(path)
215 raise "The specified Tails #{type} image '#{path}' does not exist"
218 if File.directory?(path)
219 raise "The specified Tails #{type} image '#{path}' is a directory"
222 # Workaround: when libvirt takes ownership of the ISO/IMG image it may
223 # become unreadable for the live user inside the guest in the
224 # host-to-guest share used for some tests.
226 unless File.world_readable?(path)
228 File.chmod(0o644, path)
230 raise "warning: the Tails #{type} image must be world readable " \
231 'or be owned by the current user to be available inside ' \
232 'the guest VM via host-to-guest shares, which is required ' \
237 unless File.exist?(OLD_TAILS_ISO)
238 raise "The specified old Tails ISO image '#{OLD_TAILS_ISO}' does not exist"
240 unless File.exist?(OLD_TAILS_IMG)
241 raise "The specified old Tails IMG image '#{OLD_TAILS_IMG}' does not exist"
244 unless $started_first_product_feature
245 $virt = Libvirt.open('qemu:///system')
246 VM.remove_all_snapshots unless KEEP_SNAPSHOTS
247 $vmnet = VMNet.new($virt, VM_XML_PATH)
248 $vmstorage = VMStorage.new($virt, VM_XML_PATH)
249 $started_first_product_feature = true
251 initialize_chutney unless config_bool('DISABLE_CHUTNEY')
254 AfterFeature('@product') do
255 unless KEEP_SNAPSHOTS
257 .select { |name, vals| vals[:temporary] && VM.snapshot_exists?(name) }
258 .each_key { |name| VM.remove_snapshot(name) }
262 .reject { |vol_name| vol_name == '__internal' }
263 .each { |vol_name| $vmstorage.delete_volume(vol_name) }
266 # Cucumber Before hooks are executed in the order they are listed, and
267 # we want this hook to always run first, so it must always be the
268 # *first* Before hook matching @product listed in this file.
269 Before('@product') do |scenario|
270 $failure_artifacts = []
271 if config_bool('CAPTURE')
272 video_name = sanitize_filename("#{scenario.name}.mkv")
273 @video_path = "#{ARTIFACTS_DIR}/#{video_name}"
274 debug_log("Starting video capture of '#{@video_path}'")
275 capture = IO.popen(['ffmpeg',
279 '-i', "#{$config['DISPLAY']}.0",
284 { err: ['/dev/null', 'w'] },])
285 @video_capture_pid = capture.pid
287 @screen = if config_bool('IMAGE_BUMPING_MODE')
288 ImageBumpingScreen.new
292 # English will be assumed if this is not overridden
296 # Passwords includes shell-special chars (space, "!")
297 # as a regression test for #17792
298 @sudo_password = 'asdf !'
299 @persistence_password = 'asdf !'
300 @changed_persistence_password = 'foo123'
301 @has_been_reset = false
302 # See comment for add_extra_allowed_host() above.
303 @extra_allowed_hosts ||= []
305 @user_wants_pluggable_transports = false
306 @tor_network_is_blocked = false
309 # Cucumber After hooks are executed in the *reverse* order they are
310 # listed, and we want this hook to always run second last, so it must always
311 # be the *second* After hook matching @product listed in this file --
312 # hooks added dynamically via add_after_scenario_hook() are supposed to
314 # rubocop:disable Metrics/BlockNesting
315 After('@product') do |scenario|
316 # we want this to always exist, even if it's empty
317 FileUtils.touch("#{ARTIFACTS_DIR}/skipped.txt")
319 if @video_capture_pid
320 # We can be incredibly fast at detecting errors sometimes, so the
321 # screen barely "settles" when we end up here and kill the video
322 # capture. Let's wait a few seconds more to make it easier to see
323 # what the error was.
324 sleep 3 if scenario.failed?
325 debug_log("Stopping video capture of '#{@video_path}'")
326 Process.kill('INT', @video_capture_pid)
327 Process.wait(@video_capture_pid)
328 save_failure_artifact('Video', @video_path)
331 time_of_fail = Time.now - TIME_AT_START
332 secs = format('%<secs>02d', secs: time_of_fail % 60)
333 mins = format('%<mins>02d', mins: (time_of_fail / 60) % 60)
334 hrs = format('%<hrs>02d', hrs: time_of_fail / (60 * 60))
335 elapsed = "#{hrs}:#{mins}:#{secs}"
336 info_log("SCENARIO FAILED: '#{scenario.name}' (at time #{elapsed})")
338 unless $vm.display.nil?
339 screenshot_path = sanitize_filename("#{scenario.name}.png")
340 $vm.display.screenshot(screenshot_path)
341 save_failure_artifact('Screenshot', screenshot_path)
343 if scenario.exception.is_a?(FirewallAssertionFailedError)
344 Dir.glob("#{$config['TMPDIR']}/*.pcap").each do |pcap_file|
345 save_failure_artifact('Network capture', pcap_file)
347 elsif scenario.exception.is_a?(TestSuiteRuntimeError)
348 info_log("Scenario must be retried: #{scenario.name}")
349 record_scenario_skipped(scenario)
350 elsif [TorBootstrapFailure, TimeSyncingError].any? \
351 { |c| scenario.exception.is_a?(c) }
352 if File.exist?("#{$config['TMPDIR']}/chutney-data")
353 chutney_logs = sanitize_filename(
354 "#{elapsed}_#{scenario.name}_chutney-data"
356 FileUtils.mkdir("#{ARTIFACTS_DIR}/#{chutney_logs}")
357 FileUtils.rm(Dir.glob("#{$config['TMPDIR']}/chutney-data/**/control"))
359 FileUtils.copy_entry(
360 "#{$config['TMPDIR']}/chutney-data",
361 "#{ARTIFACTS_DIR}/#{chutney_logs}"
363 rescue StandardError => e
364 info_log("Failed to copy Chutney data: #{e}")
367 info_log_artifact_location(
369 "#{ARTIFACTS_DIR}/#{chutney_logs}"
372 info_log('Found no Chutney data')
375 if $vm&.remote_shell_is_up?
377 save_failure_artifact('Tor logs', "#{$config['TMPDIR']}/log.tor")
378 if $vm.file_exist?('/var/lib/tor/pt_state/obfs4proxy.log')
379 File.open("#{$config['TMPDIR']}/log.obfs4proxy", 'w') do |f|
380 f.write($vm.file_content('/var/lib/tor/pt_state/obfs4proxy.log'))
382 save_failure_artifact('obfs4proxy logs',
383 "#{$config['TMPDIR']}/log.obfs4proxy")
386 if scenario.exception.instance_of?(HtpdateError)
387 content = if $vm.file_exist?('/var/log/htpdate.log')
388 $vm.file_content('/var/log/htpdate.log')
390 "The htpdate logs did not exist\n"
392 File.write("#{$config['TMPDIR']}/log.htpdate", content)
393 save_failure_artifact('Htpdate logs', "#{$config['TMPDIR']}/log.htpdate")
397 # Note that the remote shell isn't necessarily running at all
398 # times a scenario can fail (and a scenario failure could very
399 # well cause the remote shell to not respond any more, e.g. when
400 # we cause a system crash), so let's collect everything depending
401 # on the remote shell here:
402 if $vm&.remote_shell_is_up?
404 if scenario.feature.file \
405 == 'features/additional_software_packages.feature'
406 save_vm_command_output(
407 command: 'ls -lAR --full-time /var/cache/apt',
410 save_vm_command_output(
411 command: 'ls -lAR --full-time /var/lib/apt',
414 save_vm_command_output(
418 # When removing the logging below, also revert commit
419 # c8429eecf23570274b0bb2134a87ae1fcf72ce07
420 save_vm_command_output(
421 command: 'ls -lA --full-time /live/persistence/TailsData_unlocked',
422 id: 'persistent_volume'
424 save_vm_file_content('/run/live-additional-software/log')
427 # We give JournalDumper a little time to receive the journal
428 # entries for any remote shell interactions above before stopping
429 # it and saving the journal.
431 JournalDumper.instance.stop
432 $failure_artifacts.sort!
433 $failure_artifacts.each do |desc, file, suffix|
434 artifact_name = sanitize_filename(
435 "#{elapsed}_#{scenario.name}#{suffix}"
437 artifact_path = "#{ARTIFACTS_DIR}/#{artifact_name}"
438 assert(File.exist?(file))
439 FileUtils.mv(file, artifact_path)
441 info_log_artifact_location(desc, artifact_path)
443 if config_bool('INTERACTIVE_DEBUGGING')
445 "Scenario failed: #{scenario.name}. " \
446 "The error was: #{scenario.exception.class.name}: #{scenario.exception}",
447 exception: scenario.exception
450 elsif @video_path && File.exist?(@video_path) && !config_bool('CAPTURE_ALL')
451 FileUtils.rm(@video_path)
454 # If there are uncaught exceptions during the After hook (which
455 # happens sometimes, e.g. if the remote shell crashes) we still want
456 # to ensure that we do the things necessary to prevent leftovers
457 # from this scenario to interfere with the next scenario. Here we
458 # take extra care to prevent uncaught exceptions so as many of these
459 # are run as possible.
462 # We don't want a stray JournalDumper thread from a previous
463 # scenario interfering with a new thread it starts for a
464 # subsequent scenario.
465 JournalDumper.instance.stop
471 if $vm&.remote_shell_is_up?
472 # We gracefully stop tor in order to make the bridges/guards not
473 # keep sending packets that has a potential to bleed into the
474 # next scenario. It has been observed that this can cause the
475 # system under testing to send a TCP RST to the bridge/guard,
476 # which then may break the check that we only contact the
477 # expected bridges/guards.
478 $vm.execute('systemctl stop tor@default')
485 # If we don't shut down the system under testing it will continue to
486 # run during the next scenario's Before hooks, which we have seen
487 # causing trouble (for instance, packets from the previous scenario
488 # have failed scenarios tagged @check_tor_leaks).
494 # rubocop:enable Metrics/BlockNesting
496 Before('@product', '@check_tor_leaks') do |scenario|
497 @tor_leaks_sniffer = Sniffer.new(sanitize_filename(scenario.name), $vmnet)
498 @tor_leaks_sniffer.capture
499 add_after_scenario_hook do
500 @tor_leaks_sniffer.clear
504 After('@product', '@check_tor_leaks') do |scenario|
505 @tor_leaks_sniffer.stop
507 @allowed_dns_queries ||= []
509 allowed_nodes = @bridge_hosts || allowed_hosts_under_tor_enforcement
510 allowed_nodes += @extra_allowed_hosts
511 debug_log("[check_tor_leaks] Allowed hosts: #{allowed_nodes}")
512 debug_log("[check_tor_leaks] Allowed DNS queries: #{@allowed_dns_queries}")
513 assert_no_leaks(@tor_leaks_sniffer.pcap_file, allowed_nodes, @allowed_dns_queries)
523 @git_clone = Dir.mktmpdir 'tails-apt-tests'
528 After('@source') do |scenario|
530 FileUtils.remove_entry_secure @git_clone
531 if scenario.failed? && config_bool('INTERACTIVE_DEBUGGING')
533 "Scenario failed: #{scenario.name}. " \
534 "The error was: #{scenario.exception.class.name}: #{scenario.exception}",
535 exception: scenario.exception