brittle 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7b2628f2dc42bcd5afbb6586e92515cdbeee446aaa60fbbb4594321602068021
4
+ data.tar.gz: fbf4abf31d9875ddd1265ce49e3be162d7670d103ed9c06390fbf689a2355959
5
+ SHA512:
6
+ metadata.gz: 4e638214991f9572b5c270a4261de60e631720e76b331177c2d1415fe077cd8cbba82b82fd7025b8157b3eb260891916c196faf0a0b1a96e8901c4eddcb15fff
7
+ data.tar.gz: 5d162c0bcc823769ac498e59ace9d1d7c510184ff7ae425778e47ccd5a1bfca9cee9c75f5a1087eafee69632f1da9814f917fc2c184296142955c92c664a919c
data/.dockerignore ADDED
@@ -0,0 +1,7 @@
1
+ .git
2
+ .idea
3
+ .bundle
4
+ Gemfile.lock
5
+ pkg
6
+ results
7
+ *.gem
data/Dockerfile.dev ADDED
@@ -0,0 +1,11 @@
1
+ FROM ruby:4.0
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends strace \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+ COPY . .
9
+ RUN bundle install
10
+
11
+ CMD ["bash"]
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/NOTES.md ADDED
@@ -0,0 +1,42 @@
1
+ # Brittle experiment notes
2
+
3
+ Measurements below were taken on 2026-09-09 in the development container: Ruby 4.0.6,
4
+ Linux 6.8.0-117-generic, and aarch64.
5
+
6
+ | Check | Result |
7
+ | --- | --- |
8
+ | `error!` argument | `Errno::ENOSPC` is accepted and Ruby receives `Errno::ENOSPC`. |
9
+ | `allow!(10)` | Ruby reports the full 100-byte `File.write` as successful, but the file contains 90 bytes. The emulated first 10 bytes are not written. |
10
+ | marker syscall | `File.exist?` emits `newfstatat(AT_FDCWD, path, ...)` for both marker paths. |
11
+ | target output | Output while armed is also a `write`; every harness disarms before printing its result. |
12
+ | notify overhead | 10,000 `/dev/null` writes took 2.32 ms normally and 316.89 ms when continued through notifications: about 31.46 us per write. |
13
+ | kernel requirement | Pass-through notifications require `continue!`, so Brittle requires Linux 5.5 or newer despite errno responses being available since 5.0. |
14
+
15
+ `allow!(n)` is useful for observing callers' response to a short return, but it is not a
16
+ faithful partial-write emulator because the reported prefix is dropped. Such scenarios are marked
17
+ `realistic: false` and must not be reported as an upstream data-corruption bug without reproduction
18
+ under a real short write.
19
+
20
+ ## Finding: Logger leaks a descriptor when its header write fails
21
+
22
+ `brittle sweep harness/logger.rb --syscall write --errno ENOSPC --at 1..8` found a one-fd
23
+ increase at `at=1`. The generated journal recorded `fd_before=7`, `fd_after=8`, and `verdict=leak`.
24
+
25
+ The behavior reproduces without seccomp on a full 64 KiB tmpfs. Five failed `Logger.new` calls
26
+ increase the descriptor count from 6 to 11. `Logger::LogDevice#create_logfile` opens and locks the
27
+ new file, then lets a header-write exception escape without closing the local `logdev`.
28
+
29
+ See [`findings/logger-enospc-fd-leak.md`](findings/logger-enospc-fd-leak.md) for the upstream report
30
+ draft and [`verify/logger_enospc.rb`](verify/logger_enospc.rb) for the real-condition check.
31
+
32
+ ## Catalog sweep
33
+
34
+ The full `script/sweep_catalog` run completed all 500 deterministic cases (`at=1..20`):
35
+
36
+ - Logger header writes leaked one fd for both `ENOSPC` and `EDQUOT` at occurrence 1.
37
+ - Logger swallowed the same errors for the log-message write at occurrence 2, leaving the expected
38
+ message absent; the harness classified this as `corrupt`.
39
+ - `Net::HTTP` translated `connect` `ETIMEDOUT` into `Net::OpenTimeout`, which its harness accepts.
40
+ - Synthetic short `write` corrupted output and signal-less `EINTR` led to `ENOTCONN`; both scenarios
41
+ are marked unrealistic and were excluded from upstream findings.
42
+ - All remaining scoped cases were classified as expected.
data/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # Brittle
2
+
3
+ Brittle deterministically injects errno responses and synthetic return values into syscalls made
4
+ by Ruby programs. It uses Linux seccomp user notifications through
5
+ [`seccomp-notify`](https://github.com/ydah/seccomp-notify).
6
+
7
+ > [!WARNING]
8
+ > Brittle deliberately breaks its target process. Use it only in isolated development and test
9
+ > environments, never in production.
10
+
11
+ ## Requirements
12
+
13
+ - Linux 5.5 or newer
14
+ - x86_64 or aarch64
15
+ - Ruby 3.2 or newer
16
+ - A container or host that permits seccomp user notifications
17
+
18
+ Linux 5.0 can return synthetic errno values, but Brittle also needs `continue!` to pass unmatched
19
+ syscalls through, which requires Linux 5.5.
20
+
21
+ ## Installation
22
+
23
+ Install the gem after its first release:
24
+
25
+ ```bash
26
+ gem install brittle
27
+ ```
28
+
29
+ For development, build the included container once and run commands through `bin/dev`:
30
+
31
+ ```bash
32
+ docker build -f Dockerfile.dev -t brittle-dev .
33
+ bin/dev bundle exec exe/brittle doctor
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ Harnesses call `Brittle.arm!` immediately before the operation under test and always call
39
+ `Brittle.disarm!` afterward. Startup, requires, and result output therefore pass through without
40
+ fault injection.
41
+
42
+ ```ruby
43
+ require "brittle/marker"
44
+
45
+ result = nil
46
+ Brittle.arm!
47
+ begin
48
+ File.write(ENV.fetch("BRITTLE_SANDBOX") + "/out.txt", "hello")
49
+ result = "OK:out.txt:5"
50
+ rescue => error
51
+ result = "ERR:#{error.class}:#{error.message}"
52
+ ensure
53
+ Brittle.disarm!
54
+ end
55
+ puts result
56
+ ```
57
+
58
+ Inject `ENOSPC` into the first armed `write`:
59
+
60
+ ```bash
61
+ brittle run harness/file_write.rb --inject write --errno ENOSPC --at 1
62
+ ```
63
+
64
+ Search every occurrence in a deterministic range:
65
+
66
+ ```bash
67
+ brittle sweep harness/logger.rb \
68
+ --syscall write --errno ENOSPC --fd-path /brittle- --at 1..20
69
+ ```
70
+
71
+ Other selectors and scopes are available:
72
+
73
+ ```bash
74
+ # Multiple exact occurrences
75
+ brittle run harness/file_write.rb --inject write --errno EDQUOT --at 3,7,11
76
+
77
+ # Every fifth matching call
78
+ brittle run harness/file_write.rb --inject write --errno ENOSPC --every 5
79
+
80
+ # Seeded probability
81
+ brittle run harness/file_write.rb \
82
+ --inject write --errno ENOSPC --probability 0.1 --seed 42
83
+
84
+ # Path and destination scopes
85
+ brittle run harness/file_write.rb --inject openat --errno EMFILE --path /brittle-
86
+ brittle run harness/net_http.rb --inject connect --errno ECONNREFUSED --port 8080
87
+
88
+ # Synthetic successful return without executing the syscall
89
+ brittle run harness/syswrite_loop.rb --inject write --return-value 10 --at 1
90
+ ```
91
+
92
+ Every run writes a JSON journal under `results/`. Journals include the scenario, environment,
93
+ injection events, stdout/stderr, artifact sizes and SHA-256 hashes, fd counts, and verdict. They can
94
+ be summarized or converted back into a command:
95
+
96
+ ```bash
97
+ brittle report results/
98
+ brittle reproduce results/file_write-write-ENOSPC-1-....json
99
+ ```
100
+
101
+ Verdicts are `expected`, `crash`, `swallowed`, `corrupt`, `leak`, or `hang`. A harness may print
102
+ `EXPECTED:`, `CORRUPT:`, or `SWALLOWED:` when it has a target-specific oracle that cannot be
103
+ inferred from the injected errno alone.
104
+
105
+ ## Scenario DSL
106
+
107
+ The same matching rules are available to Ruby callers:
108
+
109
+ ```ruby
110
+ scenario = Brittle.scenario do
111
+ inject :write, errno: Errno::ENOSPC, at: 3, when_fd_path: /brittle-/
112
+ inject :connect, errno: Errno::ECONNREFUSED, port: 8080, every: 1
113
+ end
114
+ ```
115
+
116
+ ## Reality checks
117
+
118
+ Fault injection identifies candidates; it does not prove that a result is an upstream bug. Re-run
119
+ every finding under the real failure condition before reporting it. The checks under `verify/`
120
+ demonstrate this workflow.
121
+
122
+ - `allow!(n)` reports success without performing the syscall. For `write`, the reported prefix is
123
+ not written, so this is not a faithful partial-write emulator. Such journals say
124
+ `realistic: false`.
125
+ - An injected `EINTR` has no accompanying signal. Brittle marks that combination as unrealistic.
126
+ - Pointer-based path and port scopes are for test targeting, not security decisions; target memory
127
+ may change before a continued syscall executes.
128
+
129
+ See [NOTES.md](NOTES.md) for measurements and the first real-condition finding.
130
+
131
+ ## Development
132
+
133
+ ```bash
134
+ bundle install
135
+ bundle exec rake
136
+
137
+ # Linux integration tests
138
+ docker build -f Dockerfile.dev -t brittle-dev .
139
+ bin/dev bundle exec rake
140
+
141
+ # Short catalog check; omit AT_RANGE for the full 1..20 sweep
142
+ AT_RANGE=1..2 bin/dev script/sweep_catalog
143
+ ```
144
+
145
+ ## License
146
+
147
+ Brittle is available under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/brittle ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "brittle/cli"
5
+
6
+ exit Brittle::CLI.start
@@ -0,0 +1,51 @@
1
+ # `Logger.new` leaks a file descriptor when writing the log header fails
2
+
3
+ ## Environment
4
+
5
+ - logger 1.7.0
6
+ - Ruby 4.0.6
7
+ - Linux 6.8.0-117-generic, aarch64
8
+
9
+ ## Reproduction
10
+
11
+ This repository includes a check that fills a small tmpfs, calls `Logger.new` five times, and
12
+ counts `/proc/self/fd` before and after. It does not use seccomp or fault injection.
13
+
14
+ ```bash
15
+ docker run --rm --tmpfs /full:rw,size=64k \
16
+ -v "$PWD:/app" -w /app brittle-dev \
17
+ bundle exec ruby verify/logger_enospc.rb /full
18
+ ```
19
+
20
+ Actual output:
21
+
22
+ ```text
23
+ logger=1.7.0 failures=5 fd_before=6 fd_after=11 delta=5
24
+ ```
25
+
26
+ Expected: each descriptor opened by a failed initialization is closed.
27
+
28
+ Actual: each failed initialization leaves one descriptor open until garbage collection.
29
+
30
+ ## Cause
31
+
32
+ `Logger::LogDevice#create_logfile` stores the newly opened file only in its local `logdev`. If
33
+ `add_log_header(logdev)` raises `Errno::ENOSPC`, initialization exits before the file reaches
34
+ `@dev`, so callers cannot close it.
35
+
36
+ ## Proposed fix
37
+
38
+ Close the local file for every exception other than the existing `EEXIST` retry:
39
+
40
+ ```diff
41
+ rescue Errno::EEXIST
42
+ # file is created by another process
43
+ open_logfile(filename)
44
+ + rescue Exception
45
+ + logdev&.close
46
+ + raise
47
+ end
48
+ ```
49
+
50
+ The corresponding regression test should make `add_log_header` raise, assert that the exception
51
+ is preserved, and assert that the opened file is closed.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "csv"
5
+ require "digest"
6
+
7
+ rows = [["name", "value"], ["brittle", "1"], ["ruby", "2"]]
8
+ payload = rows.map { |row| CSV.generate_line(row) }.join
9
+ path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "data.csv")
10
+ Brittle.arm!
11
+ result = begin
12
+ CSV.open(path, "w") { |csv| rows.each { |row| csv << row } }
13
+ "OK:data.csv:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
14
+ rescue => error
15
+ "ERR:#{error.class}:#{error.message}"
16
+ ensure
17
+ Brittle.disarm!
18
+ end
19
+ puts result
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "digest"
5
+
6
+ payload = "file-write\n" * 4096
7
+ path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "file-write.txt")
8
+ Brittle.arm!
9
+ result = begin
10
+ File.write(path, payload)
11
+ "OK:file-write.txt:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
12
+ rescue => error
13
+ "ERR:#{error.class}:#{error.message}"
14
+ ensure
15
+ Brittle.disarm!
16
+ end
17
+ puts result
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "zlib"
5
+
6
+ payload = "gzip\n" * 4096
7
+ path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "data.gz")
8
+ Brittle.arm!
9
+ result = begin
10
+ Zlib::GzipWriter.open(path) { |gzip| gzip.write(payload) }
11
+ raise "gzip content mismatch" unless Zlib::GzipReader.open(path, &:read) == payload
12
+
13
+ "OK:gzip"
14
+ rescue => error
15
+ "ERR:#{error.class}:#{error.message}"
16
+ ensure
17
+ Brittle.disarm!
18
+ end
19
+ puts result
data/harness/logger.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "logger"
5
+
6
+ logger = nil
7
+ path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "app.log")
8
+ message = "hello from brittle"
9
+ Brittle.arm!
10
+ result = begin
11
+ logger = Logger.new(path)
12
+ logger.info(message)
13
+ logger.close
14
+ File.read(path).include?(message) ? "OK:logger" : "CORRUPT:app.log:missing message"
15
+ rescue => error
16
+ "ERR:#{error.class}:#{error.message}"
17
+ ensure
18
+ logger&.close
19
+ Brittle.disarm!
20
+ end
21
+ puts result
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "net/http"
5
+ require "socket"
6
+
7
+ server = TCPServer.new("127.0.0.1", Integer(ENV.fetch("BRITTLE_PORT", "0")))
8
+ port = server.local_address.ip_port
9
+ thread = Thread.new do
10
+ socket = server.accept
11
+ loop do
12
+ line = socket.gets
13
+ break if line.nil? || line == "\r\n"
14
+ end
15
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK")
16
+ socket.close
17
+ rescue IOError, Errno::EBADF
18
+ nil
19
+ end
20
+
21
+ Brittle.arm!
22
+ result = begin
23
+ body = Net::HTTP.get(URI("http://127.0.0.1:#{port}/"))
24
+ raise "HTTP content mismatch" unless body == "OK"
25
+
26
+ "OK:http"
27
+ rescue Net::OpenTimeout => error
28
+ "EXPECTED:#{error.class}:#{error.message}"
29
+ rescue => error
30
+ "ERR:#{error.class}:#{error.message}"
31
+ ensure
32
+ Brittle.disarm!
33
+ end
34
+ server.close
35
+ thread.join(1)
36
+ puts result
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "digest"
5
+
6
+ payload = "syswrite\n" * 4096
7
+ path = File.join(ENV.fetch("BRITTLE_SANDBOX"), "syswrite.txt")
8
+ file = File.open(path, "w")
9
+ Brittle.arm!
10
+ result = begin
11
+ remaining = payload
12
+ remaining = remaining.byteslice(file.syswrite(remaining)..) until remaining.empty?
13
+ file.close
14
+ "OK:syswrite.txt:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
15
+ rescue => error
16
+ "ERR:#{error.class}:#{error.message}"
17
+ ensure
18
+ file.close unless file.closed?
19
+ Brittle.disarm!
20
+ end
21
+ puts result
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brittle/marker"
4
+ require "digest"
5
+ require "tempfile"
6
+
7
+ payload = "tempfile\n" * 4096
8
+ file = nil
9
+ Brittle.arm!
10
+ result = begin
11
+ file = Tempfile.create("brittle", ENV.fetch("BRITTLE_SANDBOX"))
12
+ file.write(payload)
13
+ file.flush
14
+ file.close
15
+ "OK:#{File.basename(file.path)}:#{payload.bytesize}:#{Digest::SHA256.hexdigest(payload)}"
16
+ rescue => error
17
+ "ERR:#{error.class}:#{error.message}"
18
+ ensure
19
+ file&.close
20
+ Brittle.disarm!
21
+ end
22
+ puts result
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brittle
4
+ class ArmState
5
+ attr_reader :fd_before, :fd_after
6
+
7
+ def initialize
8
+ @armed = false
9
+ end
10
+
11
+ def armed? = @armed
12
+
13
+ def transition(path, fd_count: nil)
14
+ case path
15
+ when ARM_PATH
16
+ @armed = true
17
+ @fd_before = fd_count
18
+ :armed
19
+ when DISARM_PATH
20
+ @armed = false
21
+ @fd_after = fd_count
22
+ :disarmed
23
+ end
24
+ end
25
+
26
+ def fd_leak? = @fd_before && @fd_after ? @fd_after > @fd_before : false
27
+ end
28
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require "shellwords"
6
+ require_relative "../brittle"
7
+ require_relative "doctor"
8
+
9
+ module Brittle
10
+ module CLI
11
+ module_function
12
+
13
+ def start(argv = ARGV, output: $stdout, error: $stderr)
14
+ case argv.shift
15
+ when "doctor"
16
+ Doctor.check(output:) ? 0 : 1
17
+ when "run"
18
+ run(argv, output:)
19
+ when "sweep"
20
+ sweep(argv, output:)
21
+ when "report"
22
+ report(argv, output:)
23
+ when "reproduce"
24
+ reproduce(argv, output:)
25
+ else
26
+ error.puts usage
27
+ 1
28
+ end
29
+ rescue Error, JSON::ParserError, OptionParser::ParseError, SystemCallError, ArgumentError => exception
30
+ error.puts "brittle: #{exception.message}"
31
+ 1
32
+ end
33
+
34
+ def run(argv, output:)
35
+ command = ["brittle", "run", *argv]
36
+ options, harness = parse_run_options(argv)
37
+ journal = Runner.new(harness, scenario(options), timeout: options[:timeout], command:).run
38
+ path = journal.write(options[:journal] || journal_path(harness, options))
39
+ result = journal.data.fetch(:result)
40
+ output.write(result.fetch(:stdout))
41
+ output.puts "verdict: #{result.fetch(:verdict)}"
42
+ output.puts "journal: #{path}"
43
+ 0
44
+ end
45
+
46
+ def sweep(argv, output:)
47
+ options, harness = parse_run_options(argv, sweep: true)
48
+ options.fetch(:at).each do |at|
49
+ run_options = options.merge(at:, journal: nil)
50
+ command = reproduce_command(harness, run_options)
51
+ journal = Runner.new(harness, scenario(run_options), timeout: options[:timeout], command:).run
52
+ journal.write(journal_path(harness, run_options))
53
+ result = journal.data.fetch(:result)
54
+ detail = result.fetch(:stdout).lines.grep(/\A(?:OK|ERR|EXPECTED|CORRUPT|SWALLOWED):/).last&.strip
55
+ output.puts ["at=#{at}", result.fetch(:verdict), detail].compact.join(" ")
56
+ end
57
+ 0
58
+ end
59
+
60
+ def report(argv, output:)
61
+ directory = argv.shift or raise ArgumentError, "usage: brittle report RESULTS_DIR"
62
+ raise ArgumentError, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
63
+
64
+ paths = Dir.glob(File.join(directory, "*.json")).sort
65
+ raise ArgumentError, "no journals found in #{directory}" if paths.empty?
66
+
67
+ paths.each do |path|
68
+ journal = JSON.parse(File.read(path))
69
+ result = journal.fetch("result")
70
+ output.puts [File.basename(path), result.fetch("verdict"), "exit=#{result["exit_status"]}"].join(" ")
71
+ end
72
+ 0
73
+ end
74
+
75
+ def reproduce(argv, output:)
76
+ path = argv.shift or raise ArgumentError, "usage: brittle reproduce JOURNAL"
77
+ raise ArgumentError, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
78
+
79
+ command = JSON.parse(File.read(path)).fetch("reproduce")
80
+ output.puts Shellwords.join(command)
81
+ 0
82
+ end
83
+
84
+ def parse_run_options(argv, sweep: false)
85
+ options = {timeout: 10}
86
+ parser = OptionParser.new do |opts|
87
+ opts.on("--inject SYSCALL", "--syscall SYSCALL") { |value| options[:syscall] = value.to_sym }
88
+ opts.on("--errno NAME") { |value| options[:errno] = errno(value) }
89
+ opts.on("--return-value INTEGER", Integer) { |value| options[:return_value] = value }
90
+ opts.on("--at SELECTOR") { |value| options[:at] = sweep ? occurrence_range(value) : occurrences(value) }
91
+ opts.on("--every INTEGER", Integer) { |value| options[:every] = value }
92
+ opts.on("--probability NUMBER", Float) { |value| options[:probability] = value }
93
+ opts.on("--seed INTEGER", Integer) { |value| options[:seed] = value }
94
+ opts.on("--fd-path PATTERN") { |value| options[:when_fd_path] = value }
95
+ opts.on("--path PATTERN") { |value| options[:when_path] = value }
96
+ opts.on("--port PORT", Integer) { |value| options[:port] = value }
97
+ opts.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
98
+ opts.on("--journal PATH") { |value| options[:journal] = value } unless sweep
99
+ end
100
+ parser.parse!(argv)
101
+ harness = argv.shift or raise OptionParser::MissingArgument, "HARNESS"
102
+ raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
103
+ raise OptionParser::MissingArgument, "--inject/--syscall" unless options[:syscall]
104
+ if options[:errno].nil? == options[:return_value].nil?
105
+ raise OptionParser::InvalidArgument, "choose exactly one of --errno or --return-value"
106
+ end
107
+ if sweep
108
+ raise OptionParser::MissingArgument, "--at FIRST..LAST" unless options[:at].is_a?(Range)
109
+ raise OptionParser::InvalidArgument, "sweep only supports --at" if options[:every] || options[:probability]
110
+ else
111
+ options[:at] ||= 1 unless options[:every] || options[:probability]
112
+ end
113
+
114
+ [options, harness]
115
+ end
116
+
117
+ def scenario(options)
118
+ Scenario.new.inject(options.fetch(:syscall), **options.slice(
119
+ :errno, :return_value, :at, :every, :probability, :seed, :when_fd_path, :when_path, :port
120
+ ))
121
+ end
122
+
123
+ def errno(value)
124
+ Errno.const_get(value.delete_prefix("Errno::"), false)
125
+ rescue NameError
126
+ raise OptionParser::InvalidArgument, "unknown errno: #{value}"
127
+ end
128
+
129
+ def occurrences(value)
130
+ value.split(",").map { |item| Integer(item, 10) }
131
+ rescue ArgumentError
132
+ raise OptionParser::InvalidArgument, "--at must be a positive integer or comma-separated list"
133
+ end
134
+
135
+ def occurrence_range(value)
136
+ match = value.match(/\A(\d+)\.\.(\d+)\z/)
137
+ raise OptionParser::InvalidArgument, "--at must be FIRST..LAST" unless match
138
+
139
+ first, last = match.captures.map { |item| Integer(item, 10) }
140
+ raise OptionParser::InvalidArgument, "--at range must be ascending and positive" unless first.positive? && first <= last
141
+
142
+ first..last
143
+ end
144
+
145
+ def journal_path(harness, options)
146
+ action = options[:errno]&.name&.delete_prefix("Errno::") || "return-#{options[:return_value]}"
147
+ at = Array(options[:at]).join("-")
148
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S-%6N")
149
+ File.join("results", "#{File.basename(harness, ".rb")}-#{options.fetch(:syscall)}-#{action}-#{at}-#{timestamp}.json")
150
+ end
151
+
152
+ def reproduce_command(harness, options)
153
+ command = ["brittle", "run", harness, "--inject", options.fetch(:syscall).to_s]
154
+ command.concat(options[:errno] ? ["--errno", options[:errno].name.delete_prefix("Errno::")] : ["--return-value", options[:return_value].to_s])
155
+ command.concat(["--at", options.fetch(:at).to_s])
156
+ command.concat(["--fd-path", options[:when_fd_path]]) if options[:when_fd_path]
157
+ command.concat(["--path", options[:when_path]]) if options[:when_path]
158
+ command.concat(["--port", options[:port].to_s]) if options[:port]
159
+ command.concat(["--timeout", options[:timeout].to_s]) if options[:timeout] != 10
160
+ command
161
+ end
162
+
163
+ def usage
164
+ <<~USAGE
165
+ usage:
166
+ brittle doctor
167
+ brittle run HARNESS --inject SYSCALL --errno NAME --at N
168
+ brittle sweep HARNESS --syscall SYSCALL --errno NAME --at FIRST..LAST
169
+ brittle report RESULTS_DIR
170
+ brittle reproduce JOURNAL
171
+ USAGE
172
+ end
173
+ end
174
+ end