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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Brittle
6
+ module Doctor
7
+ module_function
8
+
9
+ def check(output: $stdout)
10
+ output.puts "ruby: #{RUBY_VERSION}"
11
+ output.puts "kernel: #{Etc.uname.fetch(:release)}"
12
+ output.puts "arch: #{Etc.uname.fetch(:machine)}"
13
+
14
+ unless RUBY_PLATFORM.include?("linux")
15
+ output.puts "seccomp: unsupported (Linux only)"
16
+ return false
17
+ end
18
+
19
+ require "seccomp/notify"
20
+ features = Seccomp::Notify.features
21
+ output.puts "features: #{features.map { |name, enabled| "#{name}=#{enabled}" }.join(" ")}"
22
+ features[:user_notif] && features[:continue]
23
+ rescue LoadError, StandardError => error
24
+ output.puts "seccomp: unavailable (#{error.message})"
25
+ false
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brittle
4
+ class Injection
5
+ attr_reader :syscall, :errno, :return_value, :matcher, :port
6
+
7
+ def initialize(syscall, errno: nil, return_value: nil, at: nil, every: nil, probability: nil, seed: nil,
8
+ when_fd_path: nil, when_path: nil, port: nil)
9
+ raise ArgumentError, "choose exactly one of errno or return_value" if errno.nil? == return_value.nil?
10
+ unless errno.nil? || errno.is_a?(Integer) || errno.is_a?(Class) && errno < SystemCallError
11
+ raise ArgumentError, "errno must be an Errno class or integer"
12
+ end
13
+ raise ArgumentError, "return_value must be an integer" unless return_value.nil? || return_value.is_a?(Integer)
14
+ raise ArgumentError, "port must be between 1 and 65535" unless port.nil? || port.is_a?(Integer) && port.between?(1, 65_535)
15
+
16
+ @syscall = syscall.to_sym
17
+ @errno = errno
18
+ @return_value = return_value
19
+ @matcher = Matcher.new(at:, every:, probability:, seed:)
20
+ @when_fd_path = pattern(when_fd_path)
21
+ @when_path = pattern(when_path)
22
+ @port = port
23
+ end
24
+
25
+ def candidate?(request, path: nil)
26
+ return false unless request.syscall == @syscall
27
+ path ||= request.read_string(request.args[1]) if @when_path && %i[openat newfstatat].include?(request.syscall)
28
+ return false if @when_path && !@when_path.match?(path.to_s)
29
+ return false if @when_fd_path && !@when_fd_path.match?(fd_path(request))
30
+ return false if @port && request.read_sockaddr(request.args[1], request.args[2]).ip_port != @port
31
+
32
+ true
33
+ rescue StandardError
34
+ false
35
+ end
36
+
37
+ def inject!(request)
38
+ @errno ? request.error!(@errno) : request.allow!(@return_value)
39
+ end
40
+
41
+ def returned
42
+ @errno ? errno_name : @return_value
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ syscall: @syscall,
48
+ errno: errno_name,
49
+ return_value: @return_value,
50
+ at: matcher.at.empty? ? nil : (matcher.at.length == 1 ? matcher.at.first : matcher.at),
51
+ every: matcher.every,
52
+ probability: matcher.probability,
53
+ seed: matcher.seed,
54
+ scope: {fd_path: pattern_source(@when_fd_path), path: pattern_source(@when_path), port: @port}.compact,
55
+ realism: realism
56
+ }.compact
57
+ end
58
+
59
+ private
60
+
61
+ def fd_path(request)
62
+ File.readlink("/proc/#{request.tid}/fd/#{request.args[0]}")
63
+ end
64
+
65
+ def pattern(value)
66
+ value && (value.is_a?(Regexp) ? value : Regexp.new(value.to_s))
67
+ end
68
+
69
+ def pattern_source(value) = value&.source
70
+
71
+ def errno_name
72
+ @errno.is_a?(Class) ? @errno.name.delete_prefix("Errno::") : @errno
73
+ end
74
+
75
+ def realism
76
+ return {realistic: false, note: "EINTR is injected without delivering a signal"} if errno_name == "EINTR"
77
+ if @return_value
78
+ return {realistic: false, note: "the emulated return value does not perform the partial syscall side effect"}
79
+ end
80
+
81
+ {realistic: true}
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "fileutils"
5
+ require "json"
6
+ require "rbconfig"
7
+
8
+ module Brittle
9
+ class Journal
10
+ SCHEMA_VERSION = 1
11
+
12
+ attr_reader :data
13
+
14
+ def initialize(scenario:, harness:, injections:, status:, stdout:, stderr:, timed_out:, arm_state:, artifacts:, command:)
15
+ @scenario = scenario.to_h
16
+ @injections = injections
17
+ @data = {
18
+ schema_version: SCHEMA_VERSION,
19
+ scenario: @scenario,
20
+ environment: {ruby: RUBY_VERSION, kernel: Etc.uname.fetch(:release), arch: Etc.uname.fetch(:machine)},
21
+ harness: harness,
22
+ injections: injections,
23
+ result: result(status, stdout, stderr, timed_out, arm_state, artifacts),
24
+ reproduce: command
25
+ }.compact
26
+ end
27
+
28
+ def write(path)
29
+ FileUtils.mkdir_p(File.dirname(path))
30
+ File.write(path, JSON.pretty_generate(@data) << "\n")
31
+ path
32
+ end
33
+
34
+ private
35
+
36
+ def result(status, stdout, stderr, timed_out, arm_state, artifacts)
37
+ expected = stdout.lines.filter_map do |line|
38
+ match = line.match(/\AOK:([^:\n]+):(\d+)(?::([0-9a-f]{64}))?\s*\z/)
39
+ [match[1], {size: Integer(match[2]), sha256: match[3]}] if match
40
+ end.to_h
41
+ artifacts.each do |name, details|
42
+ next unless expected.key?(name)
43
+
44
+ details[:expected_size] = expected[name][:size]
45
+ details[:expected_sha256] = expected[name][:sha256] if expected[name][:sha256]
46
+ details[:truncated] = details[:size] < expected[name][:size]
47
+ details[:mismatched] = details[:size] != expected[name][:size] ||
48
+ expected[name][:sha256] && details[:sha256] != expected[name][:sha256]
49
+ end
50
+ (expected.keys - artifacts.keys).each { |name| artifacts[name] = {missing: true, expected_size: expected[name][:size]} }
51
+
52
+ {
53
+ exit_status: status&.exitstatus,
54
+ signal: status&.termsig,
55
+ stdout: stdout,
56
+ stderr: stderr,
57
+ verdict: verdict(status, stdout, timed_out, arm_state, artifacts),
58
+ fd_leak: arm_state.fd_leak?,
59
+ fd_counts: {
60
+ before: arm_state.fd_before,
61
+ after: arm_state.fd_after,
62
+ delta: arm_state.fd_before && arm_state.fd_after ? arm_state.fd_after - arm_state.fd_before : nil
63
+ }.compact,
64
+ artifacts: artifacts
65
+ }.compact
66
+ end
67
+
68
+ def verdict(status, stdout, timed_out, arm_state, artifacts)
69
+ return "hang" if timed_out
70
+ return "leak" if arm_state.fd_leak?
71
+ return "swallowed" if artifacts.values.any? { |artifact| artifact[:missing] } || stdout.start_with?("SWALLOWED:")
72
+ return "corrupt" if artifacts.values.any? { |artifact| artifact[:mismatched] } || stdout.start_with?("CORRUPT:")
73
+ return "crash" unless status&.success?
74
+ return "expected" if stdout.start_with?("EXPECTED:")
75
+
76
+ error = stdout[/^ERR:((?:\w+::)*\w+):/, 1]
77
+ return expected_error?(error) ? "expected" : "crash" if error
78
+ "expected"
79
+ end
80
+
81
+ def expected_error?(error)
82
+ return false unless error
83
+
84
+ errno = error.delete_prefix("Errno::")
85
+ @injections.any? { |injection| injection[:returned].to_s == errno }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brittle
4
+ ARM_PATH = "/tmp/.brittle-arm"
5
+ DISARM_PATH = "/tmp/.brittle-disarm"
6
+ MARKER_SYSCALL = :newfstatat
7
+
8
+ def self.arm! = File.exist?(ARM_PATH)
9
+ def self.disarm! = File.exist?(DISARM_PATH)
10
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brittle
4
+ class Matcher
5
+ attr_reader :at, :count, :every, :probability, :seed
6
+
7
+ def initialize(at: nil, every: nil, probability: nil, seed: nil)
8
+ selectors = [at, every, probability].compact
9
+ raise ArgumentError, "choose only one of at, every, or probability" if selectors.length > 1
10
+
11
+ @at = Array(at || (selectors.empty? ? 1 : nil))
12
+ raise ArgumentError, "at must not be empty" if at && @at.empty?
13
+ validate_positive_integers!(@at, "at") unless @at.empty?
14
+ validate_positive_integers!([every], "every") if every
15
+ unless probability.nil? || probability.is_a?(Numeric) && probability.between?(0, 1)
16
+ raise ArgumentError, "probability must be between 0 and 1"
17
+ end
18
+ raise ArgumentError, "seed is required with probability" if probability && seed.nil?
19
+
20
+ @every = every
21
+ @probability = probability
22
+ @seed = seed
23
+ @random = Random.new(seed) if probability
24
+ @count = 0
25
+ end
26
+
27
+ def match?
28
+ @count += 1
29
+ return @at.include?(@count) unless @at.empty?
30
+ return (@count % @every).zero? if @every
31
+
32
+ @random.rand < @probability
33
+ end
34
+
35
+ private
36
+
37
+ def validate_positive_integers!(values, name)
38
+ return if values.all? { |value| value.is_a?(Integer) && value.positive? }
39
+
40
+ raise ArgumentError, "#{name} must contain positive integers"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "rbconfig"
6
+ require "tmpdir"
7
+
8
+ module Brittle
9
+ class Runner
10
+ def initialize(harness, scenario, timeout: 10, command: nil)
11
+ raise ArgumentError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.positive?
12
+
13
+ @harness = File.expand_path(harness)
14
+ @scenario = scenario
15
+ @timeout = timeout
16
+ @command = command
17
+ end
18
+
19
+ def run
20
+ require "seccomp/notify"
21
+ raise Error, "seccomp user notification with CONTINUE is unavailable" unless Seccomp::Notify.features[:continue]
22
+ raise ArgumentError, "harness not found: #{@harness}" unless File.file?(@harness)
23
+
24
+ Dir.mktmpdir("brittle-") { |sandbox| supervise(sandbox) }
25
+ end
26
+
27
+ private
28
+
29
+ def supervise(sandbox)
30
+ stdout_reader, stdout_writer = IO.pipe
31
+ stderr_reader, stderr_writer = IO.pipe
32
+ syscalls = @scenario.notify_syscalls
33
+ policy = Seccomp::Notify::Policy.new { notify(*syscalls) }
34
+ supervisor = Seccomp::Notify.spawn(policy) do
35
+ stdout_reader.close
36
+ stderr_reader.close
37
+ STDOUT.reopen(stdout_writer)
38
+ STDERR.reopen(stderr_writer)
39
+ stdout_writer.close
40
+ stderr_writer.close
41
+ ENV["BRITTLE_SANDBOX"] = sandbox
42
+ ports = @scenario.injections.filter_map(&:port).uniq
43
+ ENV["BRITTLE_PORT"] = ports.first.to_s if ports.one?
44
+ exec(RbConfig.ruby, "-I", File.expand_path("..", __dir__), @harness)
45
+ end
46
+ stdout_writer.close
47
+ stderr_writer.close
48
+ stdout_thread = Thread.new { stdout_reader.read }
49
+ stderr_thread = Thread.new { stderr_reader.read }
50
+
51
+ arm_state = ArmState.new
52
+ injections = []
53
+ errors = []
54
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ handler = request_handler(arm_state, injections, started_at)
56
+ syscalls.each { |syscall| supervisor.on(syscall, &handler) }
57
+ supervisor.on_error { |error, _request| errors << error.full_message(highlight: false, order: :top) }
58
+
59
+ timed_out = false
60
+ watchdog = Thread.new do
61
+ sleep(@timeout)
62
+ timed_out = true
63
+ Process.kill("KILL", supervisor.target_pid)
64
+ rescue Errno::ESRCH
65
+ nil
66
+ end
67
+ begin
68
+ status = supervisor.run
69
+ ensure
70
+ watchdog.kill
71
+ watchdog.join
72
+ end
73
+ stderr = stderr_thread.value
74
+ stderr = "#{stderr}#{errors.join("\n")}" unless errors.empty?
75
+ Journal.new(
76
+ scenario: @scenario,
77
+ harness: @harness,
78
+ injections:,
79
+ status:,
80
+ stdout: stdout_thread.value,
81
+ stderr:,
82
+ timed_out:,
83
+ arm_state:,
84
+ artifacts: collect_artifacts(sandbox),
85
+ command: @command
86
+ )
87
+ ensure
88
+ [stdout_reader, stdout_writer, stderr_reader, stderr_writer].compact.each { |io| io.close unless io.closed? }
89
+ end
90
+
91
+ def request_handler(arm_state, injections, started_at)
92
+ lambda do |request|
93
+ path = request.read_string(request.args[1]) if request.syscall == MARKER_SYSCALL
94
+ transition = arm_state.transition(path, fd_count: fd_count(request.tid))
95
+ if transition || !arm_state.armed?
96
+ request.continue!(unsafe: true)
97
+ next
98
+ end
99
+
100
+ injection = @scenario.match(request, path:)
101
+ unless injection
102
+ request.continue!(unsafe: true)
103
+ next
104
+ end
105
+
106
+ injections << {
107
+ seq: injection.matcher.count,
108
+ syscall: request.syscall,
109
+ tid: request.tid,
110
+ returned: injection.returned,
111
+ at_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
112
+ }
113
+ injection.inject!(request)
114
+ end
115
+ end
116
+
117
+ def fd_count(tid)
118
+ Dir.children("/proc/#{tid}/fd").length
119
+ rescue SystemCallError
120
+ nil
121
+ end
122
+
123
+ def collect_artifacts(sandbox)
124
+ Dir.glob("#{sandbox}/**/*", File::FNM_DOTMATCH).filter_map do |path|
125
+ next unless File.file?(path)
126
+
127
+ name = path.delete_prefix("#{sandbox}/")
128
+ [name, {size: File.size(path), sha256: Digest::SHA256.file(path).hexdigest}]
129
+ end.to_h
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brittle
4
+ class Scenario
5
+ attr_reader :injections
6
+
7
+ def initialize(&block)
8
+ @injections = []
9
+ instance_eval(&block) if block
10
+ end
11
+
12
+ def inject(syscall, **options)
13
+ @injections << Injection.new(syscall, **options)
14
+ self
15
+ end
16
+
17
+ def notify_syscalls
18
+ (@injections.map(&:syscall) << MARKER_SYSCALL).uniq
19
+ end
20
+
21
+ def match(request, path: nil)
22
+ @injections.filter_map { |injection| injection if injection.candidate?(request, path:) && injection.matcher.match? }.first
23
+ end
24
+
25
+ def to_h
26
+ @injections.length == 1 ? @injections.first.to_h : {injections: @injections.map(&:to_h)}
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Brittle
4
+ VERSION = "0.1.0"
5
+ end
data/lib/brittle.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "brittle/version"
4
+ require_relative "brittle/marker"
5
+ require_relative "brittle/matcher"
6
+ require_relative "brittle/injection"
7
+ require_relative "brittle/scenario"
8
+ require_relative "brittle/arm_state"
9
+ require_relative "brittle/journal"
10
+ require_relative "brittle/runner"
11
+
12
+ module Brittle
13
+ class Error < StandardError; end
14
+
15
+ def self.scenario(&block)
16
+ Scenario.new(&block)
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ range=${AT_RANGE:-1..20}
5
+ file_harnesses=(file_write tempfile logger csv_write gzip_writer)
6
+
7
+ for harness in "${file_harnesses[@]}"; do
8
+ for errno in ENOSPC EDQUOT; do
9
+ bundle exec exe/brittle sweep "harness/$harness.rb" --syscall write --errno "$errno" --fd-path /brittle- --at "$range"
10
+ done
11
+ for errno in EMFILE ENOENT; do
12
+ bundle exec exe/brittle sweep "harness/$harness.rb" --syscall openat --errno "$errno" --path /brittle- --at "$range"
13
+ done
14
+ done
15
+
16
+ bundle exec exe/brittle sweep harness/gzip_writer.rb --syscall read --errno EIO --at "$range"
17
+ for errno in ECONNREFUSED ETIMEDOUT EINTR; do
18
+ bundle exec exe/brittle sweep harness/net_http.rb --syscall connect --errno "$errno" --at "$range"
19
+ done
20
+ bundle exec exe/brittle sweep harness/syswrite_loop.rb --syscall write --return-value 10 --at "$range"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "seccomp/notify"
4
+
5
+ iterations = Integer(ENV.fetch("ITERATIONS", "10000"))
6
+ measure = lambda do |&block|
7
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+ block.call
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
10
+ end
11
+
12
+ baseline = measure.call do
13
+ File.open("/dev/null", "w") { |file| iterations.times { file.syswrite("x") } }
14
+ end
15
+
16
+ policy = Seccomp::Notify::Policy.new { notify :write }
17
+ notified = measure.call do
18
+ supervisor = Seccomp::Notify.spawn(policy) do
19
+ File.open("/dev/null", "w") { |file| iterations.times { file.syswrite("x") } }
20
+ end
21
+ supervisor.on(:write) { |request| request.continue! }
22
+ supervisor.run
23
+ end
24
+
25
+ puts "iterations=#{iterations}"
26
+ puts "baseline_ms=#{(baseline * 1000).round(2)}"
27
+ puts "notified_ms=#{(notified * 1000).round(2)}"
28
+ puts "overhead_us_per_write=#{((notified - baseline) * 1_000_000 / iterations).round(2)}"
data/spike/enospc.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "seccomp/notify"
4
+ require "tmpdir"
5
+
6
+ def inject(response)
7
+ reader, writer = IO.pipe
8
+ policy = Seccomp::Notify::Policy.new { notify :write }
9
+ Dir.mktmpdir("brittle-spike-") do |directory|
10
+ path = File.join(directory, "out.txt")
11
+ supervisor = Seccomp::Notify.spawn(policy) do
12
+ reader.close
13
+ result = begin
14
+ written = File.write(path, "x" * 100)
15
+ "OK:reported=#{written}:actual=#{File.size(path)}"
16
+ rescue => error
17
+ "ERR:#{error.class}:#{error.message}"
18
+ end
19
+ writer.write(result)
20
+ writer.close
21
+ end
22
+ writer.close
23
+ count = 0
24
+ supervisor.on(:write) do |request|
25
+ count += 1
26
+ count == 1 ? response.call(request) : request.continue!
27
+ end
28
+ supervisor.run
29
+ reader.read
30
+ end
31
+ ensure
32
+ reader&.close
33
+ end
34
+
35
+ puts inject(->(request) { request.error!(Errno::ENOSPC) })
36
+ puts inject(->(request) { request.allow!(10) })
data/verify/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # Real-condition verification
2
+
3
+ Fault injection finds candidates; these checks reproduce them without seccomp before they are
4
+ reported upstream.
5
+
6
+ ```bash
7
+ docker run --rm --tmpfs /full:rw,size=64k \
8
+ -v "$PWD:/app" -w /app brittle-dev \
9
+ bundle exec ruby verify/logger_enospc.rb /full
10
+ ```
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ directory = ARGV.fetch(0) { abort "usage: ruby verify/logger_enospc.rb FULL_FILESYSTEM" }
6
+ log_path = File.join(directory, "app.log")
7
+ fill_path = File.join(directory, "fill")
8
+
9
+ begin
10
+ File.open(fill_path, "wb") { |file| loop { file.syswrite("x" * 4096) } }
11
+ abort "failed to fill filesystem"
12
+ rescue Errno::ENOSPC
13
+ attempts = 5
14
+ before = Dir.children("/proc/self/fd").length
15
+ failures = attempts.times.count do
16
+ Logger.new(log_path)
17
+ false
18
+ rescue Errno::ENOSPC
19
+ File.unlink(log_path) if File.exist?(log_path)
20
+ true
21
+ end
22
+ after = Dir.children("/proc/self/fd").length
23
+
24
+ puts "logger=#{Logger::VERSION} failures=#{failures} fd_before=#{before} fd_after=#{after} delta=#{after - before}"
25
+ abort "expected one leaked fd per failed initialization" unless failures == attempts && after - before == attempts
26
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: brittle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: seccomp-notify
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.3'
40
+ description: Brittle injects errno responses into selected Linux syscalls using seccomp
41
+ user notifications.
42
+ email:
43
+ - t.yudai92@gmail.com
44
+ executables:
45
+ - brittle
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".dockerignore"
50
+ - Dockerfile.dev
51
+ - LICENSE.txt
52
+ - NOTES.md
53
+ - README.md
54
+ - Rakefile
55
+ - exe/brittle
56
+ - findings/logger-enospc-fd-leak.md
57
+ - harness/csv_write.rb
58
+ - harness/file_write.rb
59
+ - harness/gzip_writer.rb
60
+ - harness/logger.rb
61
+ - harness/net_http.rb
62
+ - harness/syswrite_loop.rb
63
+ - harness/tempfile.rb
64
+ - lib/brittle.rb
65
+ - lib/brittle/arm_state.rb
66
+ - lib/brittle/cli.rb
67
+ - lib/brittle/doctor.rb
68
+ - lib/brittle/injection.rb
69
+ - lib/brittle/journal.rb
70
+ - lib/brittle/marker.rb
71
+ - lib/brittle/matcher.rb
72
+ - lib/brittle/runner.rb
73
+ - lib/brittle/scenario.rb
74
+ - lib/brittle/version.rb
75
+ - script/sweep_catalog
76
+ - spike/benchmark.rb
77
+ - spike/enospc.rb
78
+ - verify/README.md
79
+ - verify/logger_enospc.rb
80
+ homepage: https://github.com/ydah/brittle
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ allowed_push_host: https://rubygems.org
85
+ source_code_uri: https://github.com/ydah/brittle
86
+ rubygems_mfa_required: 'true'
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 3.2.0
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 4.0.19
102
+ specification_version: 4
103
+ summary: Deterministic syscall fault injection for Ruby programs
104
+ test_files: []