mutaruby 0.1.1 → 0.2.0
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/CHANGELOG.md +23 -0
- data/README.md +31 -6
- data/lib/mutaruby/command.rb +15 -26
- data/lib/mutaruby/generator.rb +26 -2
- data/lib/mutaruby/ignore_file.rb +1 -1
- data/lib/mutaruby/platform.rb +83 -0
- data/lib/mutaruby/runner.rb +2 -1
- data/lib/mutaruby/sandbox.rb +37 -8
- data/lib/mutaruby/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fb14367f23f77c599ae74ba8f36f1d5e0e71c73ab8fa8267378569a3a359655
|
|
4
|
+
data.tar.gz: 036b55cb7754d44886e7056f0d63dda5725a0caf7037dc8eff50d49bf7df73fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 718044deb537125a73dfc0864aeea698594711196ecf12ba16655fac80e6fa23e6c5ef2ef478d360b270da7f3264e7fccc7d8f5bff2bf88e2863cbe61e937d8c
|
|
7
|
+
data.tar.gz: 7fb23d15489c6dae51343866c850e7e4a9ccf049f966e8521fa20fbe92a3eaca1f52bde4014a5387151d1aa36febbc44c92c20708e83fbf22e573dbddbd09f9e
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
All notable changes to this project are documented here. The format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.2.0] - 2026-10-12
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Windows support. The test command runs under `cmd.exe` in a new process group; on timeout the whole process tree
|
|
11
|
+
is stopped with `taskkill /T` (graceful first, then `/F` after the grace period). Sandboxes get short names, writes
|
|
12
|
+
and deletes are retried while a file is locked, and symlinks fall back to copies where Windows does not allow
|
|
13
|
+
them. See "Windows notes" in the README.
|
|
14
|
+
- `Mutaruby::Platform`, which holds everything that differs between POSIX systems and Windows. The POSIX behaviour is
|
|
15
|
+
unchanged.
|
|
16
|
+
- CI on Linux, macOS and Windows (Ruby 3.1 to 3.4), including a fixture run whose infinite-loop mutant must be
|
|
17
|
+
stopped by the timeout.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Files with CRLF line endings aborted the run with a splice self-check error: `parser` reads `\r\n` as `\n`, so its
|
|
22
|
+
offsets did not match the file. Offsets are now mapped back to the file's own characters, and a mutant keeps the
|
|
23
|
+
file's line endings (mixed endings included).
|
|
24
|
+
- Sources are read as bytes and decoded as UTF-8, with no newline conversion on Windows; ignore files are read as
|
|
25
|
+
UTF-8 whatever the default external encoding.
|
|
26
|
+
- Sandbox removal no longer silently leaves files behind when one is locked.
|
|
27
|
+
|
|
6
28
|
## [0.1.1] - 2026-10-12
|
|
7
29
|
|
|
8
30
|
mutaruby now scores 100% on its own code: every mutant is killed by a test or listed, with a checkable reason, in
|
|
@@ -49,5 +71,6 @@ First release.
|
|
|
49
71
|
reason, ambiguity and staleness checks, `--strict-ignore` and `--suggest-ignore`.
|
|
50
72
|
- Human, JSON and JUnit XML reports, a mutation score and `--fail-under` for CI.
|
|
51
73
|
|
|
74
|
+
[0.2.0]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.2.0
|
|
52
75
|
[0.1.1]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.1.1
|
|
53
76
|
[0.1.0]: https://github.com/Atlas-Authorization/mutaruby/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
Free, MIT-licensed mutation testing for Ruby.
|
|
7
7
|
|
|
8
|
-
mutaruby is mutation-tested with itself. In version 0.
|
|
9
|
-
3,
|
|
8
|
+
mutaruby is mutation-tested with itself. In version 0.2.0, all 3,812 mutants of its own code are accounted for:
|
|
9
|
+
3,806 are killed by its tests (521 of them by timeout), and the other 6 are true equivalents, each listed in
|
|
10
10
|
[`.mutaruby-ignore.yml`](.mutaruby-ignore.yml) with a reason you can check. `bundle exec rake mutate` reproduces the
|
|
11
11
|
run; it fails on any surviving mutant or stale ignore entry.
|
|
12
12
|
|
|
@@ -48,6 +48,7 @@ The survivor above tells you that no test checks a total of exactly 100.
|
|
|
48
48
|
- [Output formats](#output-formats)
|
|
49
49
|
- [How it compares with mutant](#how-it-compares-with-mutant)
|
|
50
50
|
- [Design notes](#design-notes)
|
|
51
|
+
- [Windows notes](#windows-notes)
|
|
51
52
|
- [Limitations](#limitations)
|
|
52
53
|
- [Contributing, security, licence](#contributing-security-licence)
|
|
53
54
|
|
|
@@ -84,7 +85,7 @@ end
|
|
|
84
85
|
```
|
|
85
86
|
|
|
86
87
|
mutaruby needs Ruby 3.1 or newer and depends only on the [`parser`](https://github.com/whitequark/parser) gem. It runs
|
|
87
|
-
on Linux and
|
|
88
|
+
on Linux, macOS and Windows (see [Windows notes](#windows-notes)); CI tests all three.
|
|
88
89
|
|
|
89
90
|
## Quickstart
|
|
90
91
|
|
|
@@ -134,7 +135,7 @@ Positional arguments are files or globs relative to the project root. The defaul
|
|
|
134
135
|
|
|
135
136
|
| Option | Meaning |
|
|
136
137
|
|---|---|
|
|
137
|
-
| `-t`, `--test COMMAND` | **Required.** Shell command
|
|
138
|
+
| `-t`, `--test COMMAND` | **Required.** Shell command that runs your tests: run with `/bin/sh -c` on Linux and macOS, with `cmd.exe` on Windows. Exit 0 means the mutant survived, non-zero means killed. |
|
|
138
139
|
| `-C`, `--root DIR` | Project root (default: the current directory). This is the directory copied into each sandbox, and the command runs there. |
|
|
139
140
|
| `-j`, `--jobs N` | Parallel workers (default: the number of CPUs). Each worker has its own copy of the project. |
|
|
140
141
|
| `--timeout SECONDS` | Per-mutant time limit. A mutant that exceeds it (an infinite loop, say) is killed together with every process it started and counted as **killed, by timeout**. The default is `baseline time x factor + 5` seconds. |
|
|
@@ -360,7 +361,7 @@ at any project with one command, mutaruby is for you.
|
|
|
360
361
|
|
|
361
362
|
## Design notes
|
|
362
363
|
|
|
363
|
-
**A fresh process per mutant.** Every run, the baseline and each mutant, is a new `/bin/sh -c COMMAND` with its
|
|
364
|
+
**A fresh process per mutant.** Every run, the baseline and each mutant, is a new shell (`/bin/sh -c COMMAND`; `cmd.exe` on Windows) with its
|
|
364
365
|
own Ruby. Nothing loaded by one run can leak into the next. This matters more than it looks. When mutants are
|
|
365
366
|
evaluated inside one long-lived process, deleting a `require` cannot be detected because the library is already loaded,
|
|
366
367
|
and a deleted method or constant can quietly keep its old definition. Those mutants then "survive" and report test
|
|
@@ -391,9 +392,33 @@ the next one.
|
|
|
391
392
|
|
|
392
393
|
**Site keys instead of line numbers** for the ignore file; see [above](#equivalent-mutants-the-ignore-file).
|
|
393
394
|
|
|
395
|
+
## Windows notes
|
|
396
|
+
|
|
397
|
+
mutaruby works the same way on Windows, with these differences:
|
|
398
|
+
|
|
399
|
+
- **The shell is `cmd.exe`.** Quote with double quotes, not single quotes: `mutaruby -t "bundle exec rspec"` works in
|
|
400
|
+
both shells, but `-t 'ruby -e "exit 1"'` is POSIX-only quoting. In `cmd.exe` the inner quotes have to be escaped
|
|
401
|
+
(or put the command in a `.bat` file, or in a Rake task, and call that). Variables are written `%NAME%`, not
|
|
402
|
+
`$NAME`.
|
|
403
|
+
- **Stopping a run.** Each run starts in a new process group. On timeout its whole process tree is stopped with
|
|
404
|
+
`taskkill /T` (first without `/F`, then, after the same two-second grace period, with `/F`). Unlike on POSIX
|
|
405
|
+
systems, processes that a run leaves behind after it exits normally are not stopped: Windows finds a tree through
|
|
406
|
+
the parent process, which is gone by then, and its id may already belong to an unrelated process. Make test
|
|
407
|
+
helpers that start servers stop them.
|
|
408
|
+
- **Sandboxes** are named `mr…` (shorter than `mutaruby-…`) under `%TEMP%`, because classic Windows paths are
|
|
409
|
+
limited to 260 characters. For a deeply nested project, enable long paths or point `TMP`/`TEMP` at a short
|
|
410
|
+
directory such as `C:\t`.
|
|
411
|
+
- **Locked files.** A process that is still exiting, or a virus scanner, can hold a file open for a moment. Writes
|
|
412
|
+
into the sandbox and its removal are retried (10 times, 0.1 s apart) before mutaruby gives up.
|
|
413
|
+
- **Symlinks** in the project are copied as symlinks when Windows allows it (Developer Mode or the symlink
|
|
414
|
+
privilege), and otherwise replaced by a copy of what they point to.
|
|
415
|
+
- **Line endings.** Files with CRLF (or mixed) line endings are mutated in place without changing their line endings.
|
|
416
|
+
Sources are read as UTF-8 regardless of the console code page.
|
|
417
|
+
|
|
394
418
|
## Limitations
|
|
395
419
|
|
|
396
|
-
-
|
|
420
|
+
- On Windows, processes that a test run leaves running after it exits normally are not stopped (see
|
|
421
|
+
[Windows notes](#windows-notes)).
|
|
397
422
|
- The test command is run as is for every mutant; mutaruby does not work out which tests cover which code.
|
|
398
423
|
- The parser grammar is chosen to match the running Ruby, up to the newest grammar the installed `parser` gem knows.
|
|
399
424
|
Files that `parser` cannot read (for example syntax newer than it supports) are skipped with a warning.
|
data/lib/mutaruby/command.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'tempfile'
|
|
4
|
+
require_relative 'platform'
|
|
4
5
|
|
|
5
6
|
module Mutaruby
|
|
6
7
|
# Runs the user's test command once, in a fresh process (a new shell and everything it starts), inside a sandbox.
|
|
@@ -16,18 +17,19 @@ module Mutaruby
|
|
|
16
17
|
KILL_GRACE = 2
|
|
17
18
|
POLL = 0.05
|
|
18
19
|
|
|
19
|
-
def initialize(command, root:, kill_grace: KILL_GRACE)
|
|
20
|
+
def initialize(command, root:, kill_grace: KILL_GRACE, platform: Platform.current)
|
|
20
21
|
@command = command
|
|
22
|
+
@platform = platform
|
|
21
23
|
@kill_grace = kill_grace
|
|
22
24
|
@root = File.expand_path(root)
|
|
23
|
-
@live =
|
|
25
|
+
@live = {} # pid => the thread reaping it
|
|
24
26
|
@lock = Mutex.new
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
# Kills every run still in progress (used when mutaruby itself is interrupted: the runs live in their own process
|
|
28
30
|
# groups, so a Ctrl-C in the terminal does not reach them).
|
|
29
31
|
def kill_all
|
|
30
|
-
@lock.synchronize { @live.each { |pid| kill_group(pid) } }
|
|
32
|
+
@lock.synchronize { @live.each { |pid, waiter| kill_group(pid, waiter) } }
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# dir: sandbox project dir. timeout: seconds (nil = none). capture: keep an output tail (for the baseline report).
|
|
@@ -46,13 +48,13 @@ module Mutaruby
|
|
|
46
48
|
|
|
47
49
|
def execute(dir, timeout, env, out)
|
|
48
50
|
started = monotonic
|
|
49
|
-
pid = Process.spawn(child_env(dir).merge(env),
|
|
50
|
-
chdir: dir,
|
|
51
|
-
@lock.synchronize { @live << pid }
|
|
51
|
+
pid = Process.spawn(child_env(dir).merge(env), *@platform.spawn_args(@command),
|
|
52
|
+
chdir: dir, in: File::NULL, out: out, err: out, **@platform.spawn_options)
|
|
52
53
|
waiter = Process.detach(pid)
|
|
54
|
+
@lock.synchronize { @live[pid] = waiter }
|
|
53
55
|
timed_out = waiter.join(timeout).nil?
|
|
54
|
-
# A timed-out run, and anything a finished run left behind
|
|
55
|
-
kill_group(pid)
|
|
56
|
+
# A timed-out run, and (where the platform allows) anything a finished run left behind, must not outlive it.
|
|
57
|
+
kill_group(pid, waiter) if timed_out || @platform.stop_leftovers?
|
|
56
58
|
st = waiter.value
|
|
57
59
|
@lock.synchronize { @live.delete(pid) }
|
|
58
60
|
status = if timed_out then :timeout
|
|
@@ -78,25 +80,12 @@ module Mutaruby
|
|
|
78
80
|
env
|
|
79
81
|
end
|
|
80
82
|
|
|
81
|
-
#
|
|
82
|
-
def kill_group(pid)
|
|
83
|
-
|
|
83
|
+
# Stop the whole tree gracefully, give it up to kill_grace seconds, then force it.
|
|
84
|
+
def kill_group(pid, waiter)
|
|
85
|
+
@platform.stop_tree(pid, graceful: true)
|
|
84
86
|
deadline = monotonic + @kill_grace
|
|
85
|
-
sleep POLL while monotonic < deadline && alive?(pid)
|
|
86
|
-
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def signal_group(sig, pid)
|
|
90
|
-
Process.kill(sig, -pid)
|
|
91
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
92
|
-
# the group is already gone
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def alive?(pid)
|
|
96
|
-
Process.kill(0, -pid)
|
|
97
|
-
true
|
|
98
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
99
|
-
false
|
|
87
|
+
sleep POLL while monotonic < deadline && @platform.alive?(pid, waiter)
|
|
88
|
+
@platform.stop_tree(pid, graceful: false)
|
|
100
89
|
end
|
|
101
90
|
|
|
102
91
|
def tail(bytes)
|
data/lib/mutaruby/generator.rb
CHANGED
|
@@ -97,7 +97,7 @@ module Mutaruby
|
|
|
97
97
|
@errors << "#{@path}: cannot parse (not valid #{@src.encoding}); skipped"
|
|
98
98
|
return []
|
|
99
99
|
end
|
|
100
|
-
ast = Parsing.parse(@path,
|
|
100
|
+
ast = Parsing.parse(@path, normalize_newlines)
|
|
101
101
|
@out = []
|
|
102
102
|
@seen = {}
|
|
103
103
|
@scope = [] # namespace names ("Foo", "Bar")
|
|
@@ -109,6 +109,28 @@ module Mutaruby
|
|
|
109
109
|
[]
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# `parser` reads every "\r\n" as "\n", so its offsets count one character per CRLF line break. The source is
|
|
113
|
+
# parsed in that form; @crlf records where (in parser offsets) a "\n" stands for "\r\n" in the file, so every
|
|
114
|
+
# splice is made at the file's own character offsets and the file keeps its line endings.
|
|
115
|
+
def normalize_newlines
|
|
116
|
+
@crlf = {}
|
|
117
|
+
norm = @src.gsub("\r\n") do
|
|
118
|
+
@crlf[$~.begin(0) - @crlf.size] = true
|
|
119
|
+
"\n"
|
|
120
|
+
end
|
|
121
|
+
@newline = @crlf.empty? ? "\n" : "\r\n"
|
|
122
|
+
norm
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def original_offset(pos)
|
|
126
|
+
pos + @crlf.count { |q, _| q < pos }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A slice of the parsed source as it reads in the file.
|
|
130
|
+
def with_crlf(slice, start)
|
|
131
|
+
slice.each_char.with_index.map { |c, i| @crlf[start + i] ? "\r\n" : c }.join
|
|
132
|
+
end
|
|
133
|
+
|
|
112
134
|
# lineno => 1-based index of that line among the identical (stripped) lines of the file.
|
|
113
135
|
def occurrence_index(lines)
|
|
114
136
|
seen = Hash.new(0)
|
|
@@ -130,7 +152,9 @@ module Mutaruby
|
|
|
130
152
|
line = @lines[lineno - 1]
|
|
131
153
|
return if line.include?(DISABLE_MARK)
|
|
132
154
|
|
|
133
|
-
|
|
155
|
+
b = range.begin_pos
|
|
156
|
+
text = Splicer.splice(@src, original_offset(b), original_offset(range.end_pos), with_crlf(orig, b),
|
|
157
|
+
repl.gsub("\n", @newline))
|
|
134
158
|
return if @seen[text]
|
|
135
159
|
|
|
136
160
|
@seen[text] = true
|
data/lib/mutaruby/ignore_file.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Mutaruby
|
|
|
38
38
|
attr_reader :entries, :path
|
|
39
39
|
|
|
40
40
|
def self.load(path)
|
|
41
|
-
new(YAML.safe_load(File.read(path)), path: path)
|
|
41
|
+
new(YAML.safe_load(File.read(path, encoding: 'UTF-8')), path: path)
|
|
42
42
|
rescue Psych::SyntaxError => e
|
|
43
43
|
raise Invalid, "#{path}: not valid YAML (#{e})"
|
|
44
44
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
module Mutaruby
|
|
6
|
+
# Everything that differs between POSIX systems and Windows: how a test command is started, how its whole process
|
|
7
|
+
# tree is stopped, and how sandboxes are named.
|
|
8
|
+
module Platform
|
|
9
|
+
# Linux, macOS and the BSDs: the command runs under /bin/sh as the leader of its own process group, and the group
|
|
10
|
+
# is signalled as a whole.
|
|
11
|
+
class Posix
|
|
12
|
+
def name = 'posix'
|
|
13
|
+
|
|
14
|
+
def spawn_args(command) = ['/bin/sh', '-c', command]
|
|
15
|
+
|
|
16
|
+
def spawn_options = { pgroup: true }
|
|
17
|
+
|
|
18
|
+
def temp_prefix = 'mutaruby-'
|
|
19
|
+
|
|
20
|
+
# After a run ends, whatever is left of its process group is stopped too.
|
|
21
|
+
def stop_leftovers? = true
|
|
22
|
+
|
|
23
|
+
# graceful: SIGTERM, otherwise SIGKILL, to the process group led by pid.
|
|
24
|
+
def stop_tree(pid, graceful:)
|
|
25
|
+
Process.kill(graceful ? 'TERM' : 'KILL', -pid)
|
|
26
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
27
|
+
# the group is already gone
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# waiter: the thread reaping the run's leader (Process.detach); unused here, the group decides.
|
|
31
|
+
def alive?(pid, _waiter)
|
|
32
|
+
Process.kill(0, -pid)
|
|
33
|
+
true
|
|
34
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Windows: the command string runs under cmd.exe (Process.spawn with a single string does that) in a new process
|
|
40
|
+
# group, and the tree below the command's process is stopped with taskkill /T (without /F first, then with /F).
|
|
41
|
+
class Windows
|
|
42
|
+
# system: how taskkill is run (replaceable in tests).
|
|
43
|
+
def initialize(system: ->(*argv) { Kernel.system(*argv, out: File::NULL, err: File::NULL) })
|
|
44
|
+
@system = system
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def name = 'windows'
|
|
48
|
+
|
|
49
|
+
def spawn_args(command) = [command]
|
|
50
|
+
|
|
51
|
+
def spawn_options = { new_pgroup: true }
|
|
52
|
+
|
|
53
|
+
# Short: Windows paths are limited to 260 characters unless long paths are enabled.
|
|
54
|
+
def temp_prefix = 'mr'
|
|
55
|
+
|
|
56
|
+
# Not after a normal exit: taskkill /T finds the tree through the (now exited) parent process, and Windows may
|
|
57
|
+
# already have reused its id for an unrelated process.
|
|
58
|
+
def stop_leftovers? = false
|
|
59
|
+
|
|
60
|
+
def stop_tree(pid, graceful:)
|
|
61
|
+
@system.call('taskkill', '/T', *(graceful ? [] : ['/F']), '/PID', pid.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# While the thread reaping the command's process (Process.detach) runs, that process has not exited. (Signal 0
|
|
65
|
+
# is not a reliable liveness test on Windows: it can succeed for a process that has already exited.)
|
|
66
|
+
def alive?(_pid, waiter)
|
|
67
|
+
waiter.alive?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.windows?(host_os = RbConfig::CONFIG['host_os'])
|
|
72
|
+
host_os.match?(/mswin|mingw|cygwin/)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.for(host_os)
|
|
76
|
+
windows?(host_os) ? Windows.new : Posix.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.current
|
|
80
|
+
@current ||= self.for(RbConfig::CONFIG['host_os'])
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/mutaruby/runner.rb
CHANGED
|
@@ -48,7 +48,8 @@ module Mutaruby
|
|
|
48
48
|
def plan
|
|
49
49
|
sel = selection
|
|
50
50
|
files = sel.files
|
|
51
|
-
|
|
51
|
+
# Bytes as they are (no newline conversion on Windows), read as UTF-8 whatever the default encoding.
|
|
52
|
+
sources = files.to_h { |f| [f, File.binread(File.join(@root, f)).force_encoding(Encoding::UTF_8)] }
|
|
52
53
|
warnings = []
|
|
53
54
|
all = files.flat_map do |f|
|
|
54
55
|
g = Generator.new(sources[f], path: File.join(@root, f), rel: f, operators: @o.operators)
|
data/lib/mutaruby/sandbox.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'find'
|
|
5
5
|
require 'tmpdir'
|
|
6
|
+
require_relative 'platform'
|
|
6
7
|
|
|
7
8
|
module Mutaruby
|
|
8
9
|
# A private copy of the project. Every test-command run happens inside one, so the user's files are never written:
|
|
@@ -12,16 +13,35 @@ module Mutaruby
|
|
|
12
13
|
# is written back before the next mutant.
|
|
13
14
|
class Sandbox
|
|
14
15
|
DEFAULT_EXCLUDES = %w[.git coverage].freeze
|
|
15
|
-
|
|
16
|
+
# Writes and deletes are retried this many times, RETRY_DELAY seconds apart, while the file is locked (on Windows a
|
|
17
|
+
# process that is still exiting, or a virus scanner, can hold it for a moment).
|
|
18
|
+
RETRIES = 10
|
|
19
|
+
RETRY_DELAY = 0.1
|
|
20
|
+
LOCKED = [Errno::EACCES, Errno::EBUSY, Errno::EPERM].freeze
|
|
21
|
+
|
|
22
|
+
# Runs the block, retrying while it raises one of the LOCKED errors.
|
|
23
|
+
def self.retrying(tries: RETRIES, delay: RETRY_DELAY)
|
|
24
|
+
attempt = 1
|
|
25
|
+
begin
|
|
26
|
+
yield
|
|
27
|
+
rescue *LOCKED
|
|
28
|
+
raise if attempt >= tries
|
|
29
|
+
|
|
30
|
+
attempt += 1
|
|
31
|
+
sleep delay
|
|
32
|
+
retry
|
|
33
|
+
end
|
|
34
|
+
end
|
|
16
35
|
|
|
17
36
|
attr_reader :dir
|
|
18
37
|
|
|
19
|
-
def initialize(root, excludes: [])
|
|
38
|
+
def initialize(root, excludes: [], platform: Platform.current)
|
|
20
39
|
# The real path: Find does not descend into a symlink, so a root reached through one (/tmp on macOS) would
|
|
21
40
|
# otherwise be copied as an empty directory.
|
|
22
41
|
@root = File.realpath(root)
|
|
23
42
|
@excludes = DEFAULT_EXCLUDES + excludes
|
|
24
|
-
@
|
|
43
|
+
@prefix = platform.temp_prefix
|
|
44
|
+
@base = Dir.mktmpdir(@prefix)
|
|
25
45
|
@tmp_parent = File.realpath(File.dirname(@base))
|
|
26
46
|
@dir = File.join(@base, File.basename(@root))
|
|
27
47
|
copy_tree
|
|
@@ -35,15 +55,16 @@ module Mutaruby
|
|
|
35
55
|
def with_file(rel, text, original)
|
|
36
56
|
target = path(rel)
|
|
37
57
|
begin
|
|
38
|
-
File.binwrite(target, text)
|
|
58
|
+
Sandbox.retrying { File.binwrite(target, text) }
|
|
39
59
|
yield
|
|
40
60
|
ensure
|
|
41
|
-
File.binwrite(target, original)
|
|
61
|
+
Sandbox.retrying { File.binwrite(target, original) }
|
|
42
62
|
end
|
|
43
63
|
end
|
|
44
64
|
|
|
65
|
+
# Removes the sandbox. Unlike rm_rf, this does not silently leave a locked file behind: it retries, then raises.
|
|
45
66
|
def cleanup
|
|
46
|
-
FileUtils.
|
|
67
|
+
Sandbox.retrying { FileUtils.rm_r(@base) if File.exist?(@base) }
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
private
|
|
@@ -58,10 +79,18 @@ module Mutaruby
|
|
|
58
79
|
# A sandbox directory (this one or another worker's). When the temporary directory lies inside the project (for
|
|
59
80
|
# example TMPDIR=./tmp), copying it would copy the sandbox into itself. Paths found under the real root are real.
|
|
60
81
|
def sandbox?(path)
|
|
61
|
-
File.directory?(path) && File.basename(path).start_with?(
|
|
82
|
+
File.directory?(path) && File.basename(path).start_with?(@prefix) &&
|
|
62
83
|
File.dirname(path) == @tmp_parent
|
|
63
84
|
end
|
|
64
85
|
|
|
86
|
+
# A symlink is copied as a symlink. Where that is not allowed (Windows without the symlink privilege), what it
|
|
87
|
+
# points to is copied instead; a dangling link is skipped.
|
|
88
|
+
def link(src, dest)
|
|
89
|
+
File.symlink(File.readlink(src), dest)
|
|
90
|
+
rescue NotImplementedError, Errno::EPERM, Errno::EACCES
|
|
91
|
+
FileUtils.cp_r(src, dest) if File.exist?(src) # cp_r follows a symlink given as its source
|
|
92
|
+
end
|
|
93
|
+
|
|
65
94
|
def copy_tree
|
|
66
95
|
FileUtils.mkdir_p(@dir)
|
|
67
96
|
Find.find(@root) do |src|
|
|
@@ -72,7 +101,7 @@ module Mutaruby
|
|
|
72
101
|
dest = File.join(@dir, rel)
|
|
73
102
|
st = File.lstat(src)
|
|
74
103
|
if st.symlink?
|
|
75
|
-
|
|
104
|
+
link(src, dest)
|
|
76
105
|
elsif st.directory?
|
|
77
106
|
FileUtils.mkdir_p(dest)
|
|
78
107
|
elsif st.file? # not sockets, FIFOs or devices: copying a FIFO would block
|
data/lib/mutaruby/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mutaruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Atlas Authorization
|
|
@@ -51,6 +51,7 @@ files:
|
|
|
51
51
|
- lib/mutaruby/ignore_file.rb
|
|
52
52
|
- lib/mutaruby/mutation.rb
|
|
53
53
|
- lib/mutaruby/parsing.rb
|
|
54
|
+
- lib/mutaruby/platform.rb
|
|
54
55
|
- lib/mutaruby/reporters.rb
|
|
55
56
|
- lib/mutaruby/runner.rb
|
|
56
57
|
- lib/mutaruby/sandbox.rb
|