pwn 0.5.787 → 0.5.790
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/documentation/Backup-Retention.md +10 -6
- data/lib/pwn/backups.rb +46 -14
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/mission_namespace_spec.rb +11 -0
- data/spec/lib/pwn/backups_spec.rb +64 -0
- data/spec/lib/pwn/migrate_spec.rb +36 -2
- data/spec/lib/pwn/plugins/capability_broker/daemon_spec.rb +9 -1
- data/spec/support/mission_namespace.rb +65 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43bc28ac60d5883af330355e4b3c12a7d0b8d553f194021a934eab172efe0d51
|
|
4
|
+
data.tar.gz: b2c8133b2a5fd7a7be73e2245df3a6e2a35e5eb6319cde8676b109f9b423577a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 316290237f20ebf205c02adcada75525462ac152d9c8ce0db918ac87b7d17457b8d1461c81bfad3cf19fc5bb649a6dfec5c05bed384e4389e2dcf19a9985267d
|
|
7
|
+
data.tar.gz: 99d090c82e60a2641cf1c53b0a54a4ce09d7cede595d9c03d9958861ab48785a8022e17184bc3fd9b9ec935149477180dbf531668e60695005a7dde3d4001d80
|
|
@@ -34,12 +34,16 @@ Maintenance still defers when the Learning writer is busy.
|
|
|
34
34
|
|
|
35
35
|
Before a new folder is admitted, oldest completed generations may be expired
|
|
36
36
|
to make room. That includes a capture pin that is not listed in the retention
|
|
37
|
-
policy and not named by current evidence.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
policy and not named by current evidence. It also includes a published
|
|
38
|
+
pre-generation snapshot named `YYYYMMDD_HHMMSS` or `YYYYMMDD_HHMMSS_<8 hex>`
|
|
39
|
+
that has no interrupted generation record, and an unpublished `.partial-`
|
|
40
|
+
copy that was never renamed into a restore name. Those are not certified as
|
|
41
|
+
complete; they are only eligible so one verified snapshot can be published.
|
|
42
|
+
Prepared/interrupted generations, retention-policy pins and current literal
|
|
43
|
+
evidence references remain protected. Malformed reference data fails closed
|
|
44
|
+
when expiry would be needed. If those protected folders occupy the seven-folder
|
|
45
|
+
limit, the next producer fails before modifying its source. Unknown folder
|
|
46
|
+
names still count toward capacity and are not silently expired.
|
|
43
47
|
|
|
44
48
|
Transactions publish `prepared` before payload/source changes and `complete`
|
|
45
49
|
only after successful return. Exceptions, interrupted/blocked results and failed
|
data/lib/pwn/backups.rb
CHANGED
|
@@ -12,6 +12,10 @@ module PWN
|
|
|
12
12
|
module Backups
|
|
13
13
|
LIMIT = 7
|
|
14
14
|
GENERATION = /\A\d{8}_\d{6}_[a-f0-9]{8}\z/
|
|
15
|
+
# Published Migrate.backup! names from before .generation.json. Not an unknown folder.
|
|
16
|
+
LEGACY_SNAPSHOT = /\A\d{8}_\d{6}\z/
|
|
17
|
+
# Unpublished housekeeping-era copy. Rename was the publish step, so this is not a restore name.
|
|
18
|
+
PARTIAL_SNAPSHOT = /\A\.partial-\d{8}_\d{6}(?:_[a-f0-9]{8})?\z/
|
|
15
19
|
|
|
16
20
|
# Supported Method Parameters::
|
|
17
21
|
# PWN::Backups.active?
|
|
@@ -69,23 +73,13 @@ module PWN
|
|
|
69
73
|
pins = names.length >= LIMIT ? reference_pins(root: root) : []
|
|
70
74
|
raise IOError, 'Pinned backup missing' unless (pins - names).empty?
|
|
71
75
|
|
|
72
|
-
candidates = names.select
|
|
73
|
-
next false unless name.match?(GENERATION) && !pins.include?(name)
|
|
74
|
-
|
|
75
|
-
path = File.join(root, name)
|
|
76
|
-
safe_directory(path: path)
|
|
77
|
-
state = File.join(path, '.generation.json')
|
|
78
|
-
next false unless File.file?(state) && !File.symlink?(state)
|
|
79
|
-
|
|
80
|
-
record = JSON.parse(File.binread(state))
|
|
81
|
-
# Capture pins that nothing references may be expired. Policy pins and
|
|
82
|
-
# evidence references are already in `pins`. Interrupted generations are not.
|
|
83
|
-
record['version'] == 1 && record['state'] == 'complete'
|
|
84
|
-
end
|
|
76
|
+
candidates = names.select { |name| expirable_name?(name: name, root: root, pins: pins) }
|
|
85
77
|
count = [names.length - LIMIT + 1, 0].max
|
|
86
78
|
raise IOError, 'Backup capacity occupied by legacy, pinned or interrupted generations' if candidates.length < count
|
|
87
79
|
|
|
88
|
-
candidates.sort_by { |name| File.mtime(File.join(root, name)).to_r }.first(count).each
|
|
80
|
+
candidates.sort_by { |name| [File.mtime(File.join(root, name)).to_r, name] }.first(count).each do |name|
|
|
81
|
+
FileUtils.remove_entry_secure(File.join(root, name))
|
|
82
|
+
end
|
|
89
83
|
context[:directory] = File.join(root, "#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}")
|
|
90
84
|
Dir.mkdir(context[:directory], 0o700)
|
|
91
85
|
write_state(context: context, state: 'prepared')
|
|
@@ -285,6 +279,44 @@ module PWN
|
|
|
285
279
|
raise IOError, "Unreadable backup references: #{e.class}"
|
|
286
280
|
end
|
|
287
281
|
|
|
282
|
+
# Unreferenced complete generations, published pre-generation snapshots, and
|
|
283
|
+
# unpublished partials may be expired. Policy pins, evidence references,
|
|
284
|
+
# interrupted generations, and unknown folder names may not.
|
|
285
|
+
private_class_method def self.expirable_name?(opts = {})
|
|
286
|
+
name = opts[:name].to_s
|
|
287
|
+
root = opts[:root]
|
|
288
|
+
return false if opts[:pins].include?(name)
|
|
289
|
+
|
|
290
|
+
path = File.join(root, name)
|
|
291
|
+
return false unless owned_directory?(path: path)
|
|
292
|
+
return true if name.match?(PARTIAL_SNAPSHOT)
|
|
293
|
+
return false unless name.match?(GENERATION) || name.match?(LEGACY_SNAPSHOT)
|
|
294
|
+
|
|
295
|
+
state = File.join(path, '.generation.json')
|
|
296
|
+
return true unless File.exist?(state) || File.symlink?(state)
|
|
297
|
+
return false unless File.file?(state) && !File.symlink?(state)
|
|
298
|
+
|
|
299
|
+
record = JSON.parse(File.binread(state))
|
|
300
|
+
record.is_a?(Hash) && record['version'] == 1 && record['state'] == 'complete'
|
|
301
|
+
rescue JSON::ParserError
|
|
302
|
+
false
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private_class_method def self.owned_directory?(opts = {})
|
|
306
|
+
path = opts[:path]
|
|
307
|
+
ancestor = path
|
|
308
|
+
loop do
|
|
309
|
+
return false if File.symlink?(ancestor)
|
|
310
|
+
break if File.dirname(ancestor) == ancestor
|
|
311
|
+
|
|
312
|
+
ancestor = File.dirname(ancestor)
|
|
313
|
+
end
|
|
314
|
+
stat = File.stat(path)
|
|
315
|
+
stat.directory? && stat.uid == Process.uid
|
|
316
|
+
rescue SystemCallError
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
|
|
288
320
|
private_class_method def self.safe_directory(opts = {})
|
|
289
321
|
path = opts[:path]
|
|
290
322
|
ancestor = path
|
data/lib/pwn/version.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'open3'
|
|
4
5
|
require 'tmpdir'
|
|
5
6
|
|
|
6
7
|
RSpec.describe 'PWN::AI::Agent::MissionNamespace compiler-free process ownership' do
|
|
@@ -12,6 +13,16 @@ RSpec.describe 'PWN::AI::Agent::MissionNamespace compiler-free process ownership
|
|
|
12
13
|
PWN::AI::Agent::MissionNamespace.force_exec = previous
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
it 'does not ask newuidmap for a host uid range starting at 0' do
|
|
17
|
+
source = File.read(File.expand_path('../../../../support/mission_namespace.rb', __dir__))
|
|
18
|
+
pid_token = ['#', '{pid}'].join
|
|
19
|
+
expect(source).not_to include('--map-users=0:0:65536')
|
|
20
|
+
expect(source).to include("/proc/#{pid_token}/uid_map")
|
|
21
|
+
expect(source.index('Process.groups = []')).to be < source.index("/proc/#{pid_token}/setgroups")
|
|
22
|
+
expect(source).to include('exit! status')
|
|
23
|
+
expect(source).not_to match(/^\s*exit status\b/)
|
|
24
|
+
end
|
|
25
|
+
|
|
15
26
|
it 'loads through root and direct requires without suppressing sibling autoloads' do
|
|
16
27
|
%w[pwn pwn/ai/agent/mission_namespace].each do |entry|
|
|
17
28
|
code = <<~RUBY
|
|
@@ -49,6 +49,9 @@ describe PWN::Backups do
|
|
|
49
49
|
|
|
50
50
|
it 'pins actual evidence paths but not quoted inventory prose' do
|
|
51
51
|
copies = 7.times.map { described_class.capture(path: @source) }
|
|
52
|
+
generations = copies.map { |path| File.dirname(path, 2) }
|
|
53
|
+
generations.each_with_index { |dir, index| File.utime(Time.at(1_700_000_100 + index), Time.at(1_700_000_100 + index), dir) }
|
|
54
|
+
File.utime(Time.at(1_700_000_000), Time.at(1_700_000_000), generations[1])
|
|
52
55
|
File.write(File.join(@home, '.pwn', 'learning.jsonl'), JSON.generate(evidence_path: copies.first, prose: "old path #{copies[1]}"))
|
|
53
56
|
described_class.capture(path: @source)
|
|
54
57
|
expect(File).to exist(copies.first)
|
|
@@ -119,6 +122,67 @@ describe PWN::Backups do
|
|
|
119
122
|
expect(File.read(result[:moved].fetch(legacy))).to eq('legacy bytes')
|
|
120
123
|
end
|
|
121
124
|
|
|
125
|
+
it 'expires an unreferenced legacy timestamp snapshot and keeps a referenced one' do
|
|
126
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
127
|
+
FileUtils.mkdir_p(root)
|
|
128
|
+
dirs = 7.times.map do |index|
|
|
129
|
+
dir = File.join(root, format('2020010%d_000000', index + 1))
|
|
130
|
+
FileUtils.mkdir_p(dir)
|
|
131
|
+
File.write(File.join(dir, 'learning.jsonl'), "legacy-#{index}")
|
|
132
|
+
File.utime(Time.at(1_600_000_000 + index), Time.at(1_600_000_000 + index), dir)
|
|
133
|
+
dir
|
|
134
|
+
end
|
|
135
|
+
File.write(File.join(@home, '.pwn', 'learning.jsonl'), JSON.generate(evidence_path: "#{dirs.last}/learning.jsonl"))
|
|
136
|
+
described_class.capture(path: @source, label: 'migration')
|
|
137
|
+
expect(File).not_to exist(dirs.first)
|
|
138
|
+
expect(File).to exist(dirs.last)
|
|
139
|
+
expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'expires an unreferenced pre-generation hex snapshot that has no generation record' do
|
|
143
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
144
|
+
FileUtils.mkdir_p(root)
|
|
145
|
+
dirs = 7.times.map do |index|
|
|
146
|
+
hex = (index + 1).to_s(16).rjust(8, '0')
|
|
147
|
+
dir = File.join(root, "2020020#{index + 1}_000000_#{hex}")
|
|
148
|
+
FileUtils.mkdir_p(dir)
|
|
149
|
+
File.utime(Time.at(1_600_000_100 + index), Time.at(1_600_000_100 + index), dir)
|
|
150
|
+
dir
|
|
151
|
+
end
|
|
152
|
+
described_class.capture(path: @source, label: 'migration')
|
|
153
|
+
expect(File).not_to exist(dirs.first)
|
|
154
|
+
expect(File).to exist(dirs.last)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'expires an unpublished partial copy that was never renamed into a restore name' do
|
|
158
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
159
|
+
FileUtils.mkdir_p(root)
|
|
160
|
+
dirs = 7.times.map do |index|
|
|
161
|
+
dir = File.join(root, ".partial-2020040#{index + 1}_000000_abcd1234")
|
|
162
|
+
FileUtils.mkdir_p(dir)
|
|
163
|
+
File.utime(Time.at(1_600_000_200 + index), Time.at(1_600_000_200 + index), dir)
|
|
164
|
+
dir
|
|
165
|
+
end
|
|
166
|
+
described_class.capture(path: @source, label: 'migration')
|
|
167
|
+
expect(File).not_to exist(dirs.first)
|
|
168
|
+
expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'does not expire unknown folders or an interrupted legacy snapshot to free capacity' do
|
|
172
|
+
root = File.join(@home, '.pwn', 'backup')
|
|
173
|
+
unknown = 6.times.map do |index|
|
|
174
|
+
dir = File.join(root, "unknown-#{index}")
|
|
175
|
+
FileUtils.mkdir_p(dir)
|
|
176
|
+
dir
|
|
177
|
+
end
|
|
178
|
+
interrupted = File.join(root, '20200301_000000')
|
|
179
|
+
FileUtils.mkdir_p(interrupted)
|
|
180
|
+
File.write(File.join(interrupted, '.generation.json'), JSON.generate(version: 1, state: 'prepared', pinned: false))
|
|
181
|
+
expect { described_class.capture(path: @source) }.to raise_error(IOError, /capacity/)
|
|
182
|
+
expect(unknown.all? { |dir| File.directory?(dir) }).to be(true)
|
|
183
|
+
expect(File).to exist(interrupted)
|
|
184
|
+
end
|
|
185
|
+
|
|
122
186
|
it 'tightens an owned group-writable backup directory before writing' do
|
|
123
187
|
root = File.join(@home, '.pwn', 'backup')
|
|
124
188
|
FileUtils.mkdir_p(root)
|
|
@@ -381,8 +381,42 @@ describe PWN::Migrate, 'backup housekeeping' do
|
|
|
381
381
|
result = described_class.prune_backups(dry_run: false, keep: 3, expire_history: true)
|
|
382
382
|
expect(result).to include(keep: 7, scope: 'global', deleted: [])
|
|
383
383
|
expect(Dir.children(root).length).to eq(8)
|
|
384
|
-
|
|
385
|
-
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it 'expires oldest unreferenced legacy snapshots so migration can publish one verified generation' do
|
|
387
|
+
root = described_class::BACKUP_ROOT
|
|
388
|
+
dirs = 8.times.map do |i|
|
|
389
|
+
dir = File.join(root, format('2026010%d_000000', i + 1))
|
|
390
|
+
FileUtils.mkdir_p(dir)
|
|
391
|
+
File.write(File.join(dir, 'learning.jsonl'), "unique-#{i}")
|
|
392
|
+
File.utime(Time.at(1_700_000_000 + i), Time.at(1_700_000_000 + i), dir)
|
|
393
|
+
dir
|
|
394
|
+
end
|
|
395
|
+
path = described_class.send(:backup!)
|
|
396
|
+
expect(File).not_to exist(dirs[0])
|
|
397
|
+
expect(File).not_to exist(dirs[1])
|
|
398
|
+
expect(File).to exist(dirs.last)
|
|
399
|
+
expect(JSON.parse(File.binread(File.join(path, '.snapshot-manifest.json')))['complete']).to be(true)
|
|
400
|
+
expect(Dir.children(root).grep(/\A\d/).length).to eq(7)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
it 'admits a schema migration snapshot when seven legacy timestamp folders already fill the store' do
|
|
404
|
+
root = described_class::BACKUP_ROOT
|
|
405
|
+
FileUtils.mkdir_p(root)
|
|
406
|
+
dirs = 7.times.map do |i|
|
|
407
|
+
dir = File.join(root, format('2025010%d_000000', i + 1))
|
|
408
|
+
FileUtils.mkdir_p(dir)
|
|
409
|
+
File.write(File.join(dir, 'learning.jsonl'), "legacy-#{i}")
|
|
410
|
+
File.utime(Time.at(1_700_100_000 + i), Time.at(1_700_100_000 + i), dir)
|
|
411
|
+
dir
|
|
412
|
+
end
|
|
413
|
+
File.write(described_class::SCHEMA_FILE, JSON.generate(schema: 11))
|
|
414
|
+
result = described_class.run(io: io)
|
|
415
|
+
expect(result[:applied_migrations]).to eq([12])
|
|
416
|
+
expect(result[:backup]).to be_a(String)
|
|
417
|
+
expect(File).not_to exist(dirs.first)
|
|
418
|
+
expect(File).to exist(File.join(result[:backup], '.snapshot-manifest.json'))
|
|
419
|
+
expect(Dir.children(root).count { |name| File.directory?(File.join(root, name)) }).to eq(7)
|
|
386
420
|
end
|
|
387
421
|
|
|
388
422
|
it 'publishes a verified scoped snapshot and fails closed on a copy failure' do
|
|
@@ -101,7 +101,15 @@ describe 'Native capability broker daemon' do
|
|
|
101
101
|
Process.setpgrp
|
|
102
102
|
begin
|
|
103
103
|
if account
|
|
104
|
-
|
|
104
|
+
begin
|
|
105
|
+
Process.groups = []
|
|
106
|
+
rescue Errno::EPERM
|
|
107
|
+
# The root suite bootstrap denies setgroups before writing gid_map.
|
|
108
|
+
# That parent already cleared supplementary groups, so an empty set
|
|
109
|
+
# is success. A nonempty set is still a failed drop.
|
|
110
|
+
denied = File.file?('/proc/self/setgroups') && File.read('/proc/self/setgroups').strip == 'deny'
|
|
111
|
+
raise unless denied && Process.groups.empty?
|
|
112
|
+
end
|
|
105
113
|
Process::GID.change_privilege(account.gid)
|
|
106
114
|
Process::UID.change_privilege(account.uid)
|
|
107
115
|
expect(Process.groups).to eq([])
|
|
@@ -1,14 +1,74 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
3
6
|
# Unit fixtures contain Ruby closures/mocks that cannot cross exec. Bootstrap
|
|
4
7
|
# ONLY the test runner into a real user namespace; every mission still creates
|
|
5
8
|
# and reaps its own PID namespace. Exec integration fixtures force the production
|
|
6
9
|
# descriptor path separately. No test flag disables kernel containment.
|
|
7
10
|
# A root runner must map nobody. --map-current-user leaves only uid 0, and
|
|
8
11
|
# chown/setuid to an unmapped account then fails with EINVAL.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Do not ask unshare to map host uids 0-65535. That flag calls newuidmap, which
|
|
13
|
+
# refuses the range unless /etc/subuid grants it. Root still has
|
|
14
|
+
# CAP_SETUID in the parent namespace and writes the map itself.
|
|
15
|
+
module PwnSpecNamespace
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def bootstrap!
|
|
19
|
+
return if PWN::AI::Agent::MissionNamespace.prepared?
|
|
20
|
+
|
|
21
|
+
if Process.uid.zero?
|
|
22
|
+
bootstrap_root!
|
|
23
|
+
else
|
|
24
|
+
exec('unshare', '--user', '--keep-caps', '--map-current-user', RbConfig.ruby, '-I', $LOAD_PATH.join(File::PATH_SEPARATOR), $PROGRAM_NAME, *ARGV)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def bootstrap_root!
|
|
29
|
+
dir = nil
|
|
30
|
+
pid = nil
|
|
31
|
+
# setgroups deny is required before gid_map and then makes Process.groups=
|
|
32
|
+
# return EPERM for every process in the suite. Clear supplementary groups
|
|
33
|
+
# while this parent can still call setgroups. The unshare child inherits
|
|
34
|
+
# the empty set, which is the cleared state the smoke drop expects.
|
|
35
|
+
Process.groups = []
|
|
36
|
+
dir = Dir.mktmpdir('pwn-userns-')
|
|
37
|
+
File.chmod(0o1777, dir)
|
|
38
|
+
ready = File.join(dir, 'ready')
|
|
39
|
+
go = File.join(dir, 'go')
|
|
40
|
+
pid = Process.spawn(
|
|
41
|
+
{ 'PWN_NS_READY' => ready, 'PWN_NS_GO' => go },
|
|
42
|
+
'unshare', '--user', '--keep-caps', '/bin/sh', '-c',
|
|
43
|
+
'touch "$PWN_NS_READY"; while [ ! -f "$PWN_NS_GO" ]; do sleep 0.05; done; exec "$@"',
|
|
44
|
+
'sh', RbConfig.ruby, '-I', $LOAD_PATH.join(File::PATH_SEPARATOR), $PROGRAM_NAME, *ARGV
|
|
45
|
+
)
|
|
46
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
47
|
+
sleep 0.02 until File.exist?(ready) || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
48
|
+
raise 'root user-namespace bootstrap timed out before unshare' unless File.exist?(ready)
|
|
49
|
+
|
|
50
|
+
File.write("/proc/#{pid}/setgroups", 'deny')
|
|
51
|
+
File.write("/proc/#{pid}/gid_map", "0 0 65536\n")
|
|
52
|
+
File.write("/proc/#{pid}/uid_map", "0 0 65536\n")
|
|
53
|
+
File.write(go, "go\n")
|
|
54
|
+
Process.wait(pid)
|
|
55
|
+
status = Process.last_status.exitstatus || 1
|
|
56
|
+
pid = nil
|
|
57
|
+
# Kernel#exit raises SystemExit. RSpec rescues that while loading a spec
|
|
58
|
+
# and reports it as a load failure. exit! ends this parent with the child's
|
|
59
|
+
# status and does not raise. Clean the sync dir first; exit! skips ensure.
|
|
60
|
+
FileUtils.rm_rf(dir) if dir
|
|
61
|
+
dir = nil
|
|
62
|
+
exit! status
|
|
63
|
+
rescue StandardError
|
|
64
|
+
if pid
|
|
65
|
+
Process.kill('KILL', pid)
|
|
66
|
+
Process.wait(pid)
|
|
67
|
+
end
|
|
68
|
+
raise
|
|
69
|
+
ensure
|
|
70
|
+
FileUtils.rm_rf(dir) if dir && File.exist?(dir)
|
|
71
|
+
end
|
|
14
72
|
end
|
|
73
|
+
|
|
74
|
+
PwnSpecNamespace.bootstrap!
|