hooks.rb 13.5 KB
Newer Older
1
require 'fileutils'
2
require 'rb-inotify'
3
require 'time'
4
5
require 'tmpdir'

6
7
# Run once, before any feature
AfterConfiguration do |config|
8
9
  puts("Cucumber tags: #{config.tag_expressions}")

10
11
12
13
14
15
16
  # 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 = [
17
18
    # Features not using snapshots but using large amounts of scratch
    # space for other reasons:
19
    'features/untrusted_partitions.feature',
20
21
22
23
    # Features using temporary snapshots:
    'features/root_access_control.feature',
    'features/time_syncing.feature',
    'features/tor_bridges.feature',
24
25
    # Features using large amounts of scratch space for other reasons:
    'features/erase_memory.feature',
26
27
28
29
    # 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',
30
31
    # This feature uses a few temporary snapshots, a network-enabled
    # snapshot, and a large disk.
32
    'features/additional_software_packages.feature',
33
34
    # This feature needs a copy of the ISO and creates a new disk.
    'features/usb_upgrade.feature',
35
36
37
    # This feature needs a very big snapshot (USB install with persistence)
    # and another, network-enabled snapshot.
    'features/emergency_shutdown.feature',
38
39
  ]
  feature_files = config.feature_files
intrigeri's avatar
intrigeri committed
40
  # The &-intersection is specified to keep the element ordering of
41
42
  # the *left* operand.
  intersection = prioritized_features & feature_files
43
  unless intersection.empty?
44
45
46
47
48
    feature_files -= intersection
    feature_files = intersection + feature_files
    config.define_singleton_method(:feature_files) { feature_files }
  end

49
50
51
  # Used to keep track of when we start our first @product feature, when
  # we'll do some special things.
  $started_first_product_feature = false
52

53
  if File.exist?($config['TMPDIR'])
54
    unless File.directory?($config['TMPDIR'])
55
      raise "Temporary directory '#{$config['TMPDIR']}' exists but is not a " \
56
            'directory'
57
    end
58
    unless File.owned?($config['TMPDIR'])
59
      raise "Temporary directory '#{$config['TMPDIR']}' must be owned by the " \
60
            'current user'
61
    end
62
    FileUtils.chmod(0o755, $config['TMPDIR'])
63
64
  else
    begin
65
      FileUtils.mkdir_p($config['TMPDIR'])
66
    rescue Errno::EACCES => e
67
      raise "Cannot create temporary directory: #{e}"
68
69
    end
  end
70
71
end

anonym's avatar
anonym committed
72
73
74
75
# Common
########

After do
76
  @after_scenario_hooks&.each(&:call)
77
  @after_scenario_hooks = []
anonym's avatar
anonym committed
78
79
80
end

BeforeFeature('@product', '@source') do |feature|
81
  raise "Feature #{feature.file} is tagged both @product and @source, " \
82
        'which is an impossible combination'
anonym's avatar
anonym committed
83
84
85
end

at_exit do
86
  $vm&.destroy_and_undefine
87
88
89
  if $virt
    unless KEEP_SNAPSHOTS
      VM.remove_all_snapshots
90
      $vmstorage&.clear_pool
91
    end
92
    $vmnet&.destroy_and_undefine
93
94
    $virt.close
  end
anonym's avatar
anonym committed
95
96
  # The artifacts directory is empty (and useless) if it contains
  # nothing but the mandatory . and ..
97
  FileUtils.rmdir(ARTIFACTS_DIR) if Dir.entries(ARTIFACTS_DIR).size <= 2
anonym's avatar
anonym committed
98
99
end

100
101
102
# For @product tests
####################

103
def add_after_scenario_hook(&block)
104
  @after_scenario_hooks ||= []
anonym's avatar
anonym committed
105
  @after_scenario_hooks << block
106
107
end

108
109
def save_failure_artifact(desc, path)
  $failure_artifacts << [desc, path]
110
111
end

112
def _save_vm_file_content(file:, destfile:, desc:)
113
114
  destfile = $config['TMPDIR'] + '/' + destfile
  File.open(destfile, 'w') { |f| f.write($vm.file_content(file)) }
115
  save_failure_artifact(desc, destfile)
anonym's avatar
anonym committed
116
rescue StandardError => e
anonym's avatar
anonym committed
117
  info_log("Exception thrown while trying to save #{destfile}: " \
anonym's avatar
anonym committed
118
           "#{e.class.name}: #{e}")
119
120
end

121
def save_vm_command_output(command:, id:, basename: nil, desc: nil) # rubocop:disable Naming/MethodParameterName
122
  basename ||= "artifact.cmd_output_#{id}"
123
124
  $vm.execute("#{command} > /tmp/#{basename} 2>&1")
  _save_vm_file_content(
anonym's avatar
anonym committed
125
    file:     "/tmp/#{basename}",
126
    destfile: basename,
anonym's avatar
anonym committed
127
    desc:     desc || "Output of #{command}"
128
129
130
131
132
  )
end

def save_journal
  save_vm_command_output(
anonym's avatar
anonym committed
133
134
    command:  'journalctl -a --no-pager',
    id:       'journal',
135
    basename: 'artifact.journal',
anonym's avatar
anonym committed
136
    desc:     'systemd Journal'
137
138
139
  )
end

140
def save_vm_file_content(file, desc: nil)
intrigeri's avatar
intrigeri committed
141
  _save_vm_file_content(
anonym's avatar
anonym committed
142
    file:     file,
intrigeri's avatar
intrigeri committed
143
    destfile: 'artifact.file_content_' + file.gsub('/', '_').sub(/^_/, ''),
anonym's avatar
anonym committed
144
    desc:     desc || "Content of #{file}"
intrigeri's avatar
intrigeri committed
145
146
  )
end
147

148
# Due to Tails' Tor enforcement, we only allow contacting hosts that
149
150
151
152
153
# are Tor nodes, located on the LAN, or allowed for some operational reason.
# However, when we try to verify that only such hosts are contacted we have
# a problem -- we run all Tor nodes (via Chutney) *and* LAN hosts (used on
# some tests) on the same host, the one running the test suite. Hence we
# need to always explicitly track which nodes are allowed or not.
154
155
156
157
#
# Warning: when a host is added via this function, it is only added
# for the current scenario. As such, if this is done before saving a
# snapshot, it will not remain after the snapshot is loaded.
158
159
160
def add_extra_allowed_host(ipaddr, port)
  @extra_allowed_hosts ||= []
  @extra_allowed_hosts << { address: ipaddr, port: port }
161
162
end

163
BeforeFeature('@product') do
164
  images = { 'ISO' => TAILS_ISO, 'IMG' => TAILS_IMG }
165
  images.each do |type, path|
166
    if path.nil?
167
      raise "No Tails #{type} image specified, and none could be found " \
168
            'in the current directory'
169
    end
170
171

    unless File.exist?(path)
172
      raise "The specified Tails #{type} image '#{path}' does not exist"
173
    end
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188

    # Workaround: when libvirt takes ownership of the ISO/IMG image it may
    # become unreadable for the live user inside the guest in the
    # host-to-guest share used for some tests.

    unless File.world_readable?(path)
      if File.owned?(path)
        File.chmod(0o644, path)
      else
        raise "warning: the Tails #{type} 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
189
  end
190
  unless File.exist?(OLD_TAILS_ISO)
191
192
    raise "The specified old Tails ISO image '#{OLD_TAILS_ISO}' does not exist"
  end
193
  unless File.exist?(OLD_TAILS_IMG)
194
195
    raise "The specified old Tails IMG image '#{OLD_TAILS_IMG}' does not exist"
  end
196

197
  unless $started_first_product_feature
198
    $virt = Libvirt.open('qemu:///system')
199
    VM.remove_all_snapshots unless KEEP_SNAPSHOTS
200
201
202
203
    $vmnet = VMNet.new($virt, VM_XML_PATH)
    $vmstorage = VMStorage.new($virt, VM_XML_PATH)
    $started_first_product_feature = true
  end
anonym's avatar
anonym committed
204
  ensure_chutney_is_running
205
206
207
end

AfterFeature('@product') do
208
  unless KEEP_SNAPSHOTS
209
210
211
    CHECKPOINTS
      .select   { |name, vals| vals[:temporary] && VM.snapshot_exists?(name) }
      .each_key { |name| VM.remove_snapshot(name) }
212
  end
213
214
215
216
  $vmstorage
    .list_volumes
    .reject { |vol_name| vol_name == '__internal' }
    .each   { |vol_name| $vmstorage.delete_volume(vol_name) }
217
218
end

anonym's avatar
anonym committed
219
220
# 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
anonym's avatar
anonym committed
221
# *first* Before hook matching @product listed in this file.
anonym's avatar
anonym committed
222
Before('@product') do |scenario|
223
  $failure_artifacts = []
224
  if $config['CAPTURE']
225
226
    video_name = sanitize_filename("#{scenario.name}.mkv")
    @video_path = "#{ARTIFACTS_DIR}/#{video_name}"
227
    capture = IO.popen(['ffmpeg',
anonym's avatar
anonym committed
228
229
230
231
232
                        '-f', 'x11grab',
                        '-s', '1024x768',
                        '-r', '15',
                        '-i', "#{$config['DISPLAY']}.0",
                        '-an',
233
                        '-c:v', 'libx264',
anonym's avatar
anonym committed
234
235
                        '-y',
                        @video_path,
236
                        err: ['/dev/null', 'w'],])
anonym's avatar
anonym committed
237
238
    @video_capture_pid = capture.pid
  end
239
240
241
242
243
  @screen = if $config['IMAGE_BUMPING_MODE']
              ImageBumpingScreen.new
            else
              Screen.new
            end
244
  # English will be assumed if this is not overridden
245
246
  $language = ''
  @os_loader = 'MBR'
247
248
249
250
  # Passwords includes shell-special chars (space, "!")
  # as a regression test for #17792
  @sudo_password = 'asdf !'
  @persistence_password = 'asdf !'
251
  @has_been_reset = false
252
253
  # See comment for add_extra_allowed_host() above.
  @extra_allowed_hosts ||= []
254
255
end

anonym's avatar
anonym committed
256
# Cucumber After hooks are executed in the *reverse* order they are
anonym's avatar
anonym committed
257
258
259
260
# 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.
261
After('@product') do |scenario|
anonym's avatar
anonym committed
262
  if @video_capture_pid
263
264
265
266
267
    # 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?
268
    Process.kill('INT', @video_capture_pid)
269
    Process.wait(@video_capture_pid)
270
    save_failure_artifact('Video', @video_path)
anonym's avatar
anonym committed
271
  end
272
  if scenario.failed?
273
    time_of_fail = Time.now - TIME_AT_START
intrigeri's avatar
intrigeri committed
274
275
276
    secs = format('%<secs>02d', secs: time_of_fail % 60)
    mins = format('%<mins>02d', mins: (time_of_fail / 60) % 60)
    hrs  = format('%<hrs>02d',  hrs: time_of_fail / (60 * 60))
277
278
    elapsed = "#{hrs}:#{mins}:#{secs}"
    info_log("Scenario failed at time #{elapsed}")
intrigeri's avatar
intrigeri committed
279
    unless $vm.display.nil?
280
281
282
283
      screenshot_path = sanitize_filename("#{scenario.name}.png")
      $vm.display.screenshot(screenshot_path)
      save_failure_artifact('Screenshot', screenshot_path)
    end
284
285
286
    exception_name = scenario.exception.class.name
    case exception_name
    when 'FirewallAssertionFailedError'
287
      Dir.glob("#{$config['TMPDIR']}/*.pcap").each do |pcap_file|
288
        save_failure_artifact('Network capture', pcap_file)
289
      end
290
    when 'TorBootstrapFailure'
291
      save_failure_artifact('Tor logs', "#{$config['TMPDIR']}/log.tor")
292
293
294
      chutney_logs = sanitize_filename(
        "#{elapsed}_#{scenario.name}_chutney-data"
      )
295
      FileUtils.mkdir("#{ARTIFACTS_DIR}/#{chutney_logs}")
296
      FileUtils.rm(Dir.glob("#{$config['TMPDIR']}/chutney-data/**/control"))
297
      FileUtils.copy_entry(
298
        "#{$config['TMPDIR']}/chutney-data",
299
300
        "#{ARTIFACTS_DIR}/#{chutney_logs}"
      )
301
      info_log
302
      info_log_artifact_location(
303
        'Chutney logs',
304
305
        "#{ARTIFACTS_DIR}/#{chutney_logs}"
      )
306
    when 'TimeSyncingError'
307
      save_failure_artifact('Htpdate logs', "#{$config['TMPDIR']}/log.htpdate")
308
    end
309
310
311
312
313
    # Note that the remote shell isn't necessarily running at all
    # times a scenario can fail (and a scenario failure could very
    # well cause the remote shell to not respond any more, e.g. when
    # we cause a system crash), so let's collect everything depending
    # on the remote shell here:
anonym's avatar
anonym committed
314
    if $vm&.remote_shell_is_up?
315
      save_journal
316
317
318
319
      if scenario.feature.file \
         == 'features/additional_software_packages.feature'
        save_vm_command_output(
          command: 'ls -lAR --full-time /var/lib/apt',
anonym's avatar
anonym committed
320
          id:      'var_lib_apt'
321
322
323
        )
        save_vm_command_output(
          command: 'mount',
anonym's avatar
anonym committed
324
          id:      'mount'
325
        )
anonym's avatar
anonym committed
326
327
        # When removing the logging below, also revert commit
        # c8429eecf23570274b0bb2134a87ae1fcf72ce07
328
329
        save_vm_command_output(
          command: 'ls -lA --full-time /live/persistence/TailsData_unlocked',
anonym's avatar
anonym committed
330
          id:      'persistent_volume'
331
332
333
334
        )
        save_vm_file_content('/var/log/live-persist')
        save_vm_file_content('/run/live-additional-software/log')
      end
335
    end
336
    $failure_artifacts.sort!
anonym's avatar
anonym committed
337
    $failure_artifacts.each do |desc, file|
338
339
340
      artifact_name = sanitize_filename(
        "#{elapsed}_#{scenario.name}#{File.extname(file)}"
      )
341
      artifact_path = "#{ARTIFACTS_DIR}/#{artifact_name}"
342
343
      assert(File.exist?(file))
      FileUtils.mv(file, artifact_path)
anonym's avatar
anonym committed
344
      info_log
345
      info_log_artifact_location(desc, artifact_path)
346
    end
347
    if $config['INTERACTIVE_DEBUGGING']
348
      pause(
349
        "Scenario failed: #{scenario.name}. " \
350
351
352
        "The error was: #{scenario.exception.class.name}: #{scenario.exception}"
      )
    end
353
354
  elsif @video_path && File.exist?(@video_path) && !(($config['CAPTURE_ALL']))
    FileUtils.rm(@video_path)
355
  end
356
357
358
359
  # If we don't shut down the system under testing it will continue to
  # run during the next scenario's Before hooks, which we have seen
  # causing trouble (for instance, packets from the previous scenario
  # have failed scenarios tagged @check_tor_leaks).
360
  $vm&.power_off
361
end
362

363
Before('@product', '@check_tor_leaks') do |scenario|
364
  @tor_leaks_sniffer = Sniffer.new(sanitize_filename(scenario.name), $vmnet)
365
  @tor_leaks_sniffer.capture
366
367
368
  add_after_scenario_hook do
    @tor_leaks_sniffer.clear
  end
369
370
371
end

After('@product', '@check_tor_leaks') do |scenario|
372
373
  @tor_leaks_sniffer.stop
  if scenario.passed?
374
    allowed_nodes = @bridge_hosts || allowed_hosts_under_tor_enforcement
anonym's avatar
anonym committed
375
    assert_all_connections(@tor_leaks_sniffer.pcap_file) do |c|
376
      allowed_nodes.include?({ address: c.daddr, port: c.dport })
377
    end
378
  end
379
end
380
381
382
383
384
385

# For @source tests
###################

# BeforeScenario
Before('@source') do
386
387
388
389
390
  @orig_pwd = Dir.pwd
  @git_clone = Dir.mktmpdir 'tails-apt-tests'
  Dir.chdir @git_clone
end

391
392
# AfterScenario
After('@source') do
393
  Dir.chdir @orig_pwd
394
  FileUtils.remove_entry_secure @git_clone
395
end