bullematic 0.1.0 → 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 +18 -1
- data/README.md +23 -11
- data/exe/bullematic +7 -0
- data/lib/bullematic/ast/finder.rb +190 -35
- data/lib/bullematic/ast/parser.rb +1 -17
- data/lib/bullematic/ast/rewriter.rb +90 -28
- data/lib/bullematic/cli.rb +185 -0
- data/lib/bullematic/configuration.rb +15 -4
- data/lib/bullematic/detection.rb +110 -13
- data/lib/bullematic/evidence_store.rb +78 -0
- data/lib/bullematic/fixer.rb +258 -31
- data/lib/bullematic/integrations/minitest.rb +20 -6
- data/lib/bullematic/integrations/rails.rb +23 -9
- data/lib/bullematic/integrations/rspec.rb +19 -7
- data/lib/bullematic/logger.rb +24 -12
- data/lib/bullematic/notifier.rb +131 -36
- data/lib/bullematic/version.rb +1 -1
- data/lib/bullematic.rb +12 -4
- data/sig/generated/bullematic/ast/finder.rbs +70 -11
- data/sig/generated/bullematic/ast/parser.rbs +0 -6
- data/sig/generated/bullematic/ast/rewriter.rbs +28 -11
- data/sig/generated/bullematic/cli.rbs +56 -0
- data/sig/generated/bullematic/configuration.rbs +16 -20
- data/sig/generated/bullematic/detection.rbs +77 -7
- data/sig/generated/bullematic/evidence_store.rbs +35 -0
- data/sig/generated/bullematic/fixer.rbs +59 -3
- data/sig/generated/bullematic/integrations/rails.rbs +7 -0
- data/sig/generated/bullematic/logger.rbs +5 -0
- data/sig/generated/bullematic/notifier.rbs +17 -13
- data/sig/generated/bullematic.rbs +5 -0
- data/sig/stubs/rails.rbs +60 -0
- metadata +161 -8
- data/Rakefile +0 -26
- data/Steepfile +0 -15
- data/assets/logo-header.svg +0 -28
- data/rbs_collection.lock.yaml +0 -24
- data/rbs_collection.yaml +0 -14
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Bullematic
|
|
8
|
+
class CLI
|
|
9
|
+
class << self
|
|
10
|
+
# @rbs argv: Array[String]
|
|
11
|
+
# @rbs return: Integer
|
|
12
|
+
def run(argv)
|
|
13
|
+
command = argv.shift
|
|
14
|
+
case command
|
|
15
|
+
when "record" then record(command_args(argv))
|
|
16
|
+
when "plan" then apply(dry_run: true)
|
|
17
|
+
when "apply" then apply(dry_run: false)
|
|
18
|
+
when "verify" then verify(command_args(argv))
|
|
19
|
+
when "fix" then fix(argv)
|
|
20
|
+
when "doctor" then doctor
|
|
21
|
+
else
|
|
22
|
+
warn "Usage: bullematic record -- COMMAND | plan | apply | verify [-- COMMAND] | fix [--verify] -- COMMAND | doctor"
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
rescue Error, SystemCallError, JSON::ParserError => error
|
|
26
|
+
warn "bullematic: #{error.message}"
|
|
27
|
+
1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# @rbs argv: Array[String]
|
|
33
|
+
# @rbs return: Array[String]
|
|
34
|
+
def command_args(argv)
|
|
35
|
+
argv = argv.dup
|
|
36
|
+
argv.shift if argv.first == "--"
|
|
37
|
+
argv
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @rbs command: Array[String]
|
|
41
|
+
# @rbs return: Integer
|
|
42
|
+
def record(command)
|
|
43
|
+
raise Error, "record requires a command" if command.empty?
|
|
44
|
+
|
|
45
|
+
evidence_path = EvidenceStore.path
|
|
46
|
+
EvidenceStore.clear(evidence_path)
|
|
47
|
+
write_command(evidence_path, command)
|
|
48
|
+
run_command(command, evidence_path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs dry_run: bool
|
|
52
|
+
# @rbs return: Integer
|
|
53
|
+
def apply(dry_run:)
|
|
54
|
+
load_application
|
|
55
|
+
configure(dry_run)
|
|
56
|
+
detections = EvidenceStore.read.select { |detection| detection.type == :n_plus_one }
|
|
57
|
+
raise Error, "no recorded N+1 evidence in #{EvidenceStore.path}" if detections.empty?
|
|
58
|
+
|
|
59
|
+
record_file = ENV.delete(EvidenceStore::ENV_KEY)
|
|
60
|
+
begin
|
|
61
|
+
detections.each { |detection| Fixer.queue(detection) }
|
|
62
|
+
ensure
|
|
63
|
+
ENV[EvidenceStore::ENV_KEY] = record_file if record_file
|
|
64
|
+
end
|
|
65
|
+
stats = Fixer.apply_fixes
|
|
66
|
+
operation = dry_run ? :planned : :fixed
|
|
67
|
+
raise Error, "#{stats[:errors]} error(s) while applying fixes" if stats[:errors].positive?
|
|
68
|
+
raise Error, "no safe fixes were #{operation}" if stats[operation].zero?
|
|
69
|
+
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @rbs argv: Array[String]
|
|
74
|
+
# @rbs return: Integer
|
|
75
|
+
def fix(argv)
|
|
76
|
+
separator = argv.index("--") || argv.length
|
|
77
|
+
verify_index = argv.first(separator).index("--verify")
|
|
78
|
+
verify_after = argv.delete_at(verify_index) if verify_index
|
|
79
|
+
command = command_args(argv)
|
|
80
|
+
status = record(command)
|
|
81
|
+
return status unless status.zero?
|
|
82
|
+
|
|
83
|
+
status = apply(dry_run: false)
|
|
84
|
+
return status unless status.zero? && verify_after
|
|
85
|
+
|
|
86
|
+
verify(command)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @rbs command: Array[String]
|
|
90
|
+
# @rbs return: Integer
|
|
91
|
+
def verify(command)
|
|
92
|
+
load_application
|
|
93
|
+
configure(true)
|
|
94
|
+
original = EvidenceStore.read.select { |detection| detection.type == :n_plus_one }
|
|
95
|
+
raise Error, "no recorded N+1 evidence in #{EvidenceStore.path}" if original.empty?
|
|
96
|
+
|
|
97
|
+
command = read_command(EvidenceStore.path) if command.empty?
|
|
98
|
+
raise Error, "verify requires a recorded command" if command.empty?
|
|
99
|
+
|
|
100
|
+
Tempfile.create(["bullematic-verify", ".jsonl"]) do |file|
|
|
101
|
+
file.close
|
|
102
|
+
status = run_command(command, file.path)
|
|
103
|
+
return status unless status.zero?
|
|
104
|
+
|
|
105
|
+
remaining = EvidenceStore.read(file.path).count { |detection| detection.type == :n_plus_one }
|
|
106
|
+
raise Error, "#{remaining} N+1 warning(s) remain" unless remaining.zero?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
puts "Bullematic verification passed"
|
|
110
|
+
0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: () -> Integer
|
|
114
|
+
def doctor
|
|
115
|
+
require "bullet"
|
|
116
|
+
require "prism"
|
|
117
|
+
raise Error, "unsupported Bullet notification API" unless Notifier.compatible?
|
|
118
|
+
|
|
119
|
+
bullet_spec = Gem.loaded_specs.fetch("bullet") #: untyped
|
|
120
|
+
prism_spec = Gem.loaded_specs.fetch("prism") #: untyped
|
|
121
|
+
bullet_version = bullet_spec.version
|
|
122
|
+
prism_version = prism_spec.version
|
|
123
|
+
puts "Bullematic #{VERSION}; Bullet #{bullet_version}; Prism #{prism_version}: compatible"
|
|
124
|
+
0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @rbs command: Array[String]
|
|
128
|
+
# @rbs evidence_path: String
|
|
129
|
+
# @rbs return: Integer
|
|
130
|
+
def run_command(command, evidence_path)
|
|
131
|
+
environment = { "BULLEMATIC" => "1", EvidenceStore::ENV_KEY => evidence_path }
|
|
132
|
+
system(environment, *command) ? 0 : ($?&.exitstatus || 1)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: () -> void
|
|
136
|
+
def load_application
|
|
137
|
+
environment = File.expand_path("config/environment.rb")
|
|
138
|
+
require environment if File.file?(environment)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @rbs dry_run: bool
|
|
142
|
+
# @rbs return: void
|
|
143
|
+
def configure(dry_run)
|
|
144
|
+
Bullematic.configure do |config|
|
|
145
|
+
config.auto_fix = true
|
|
146
|
+
config.dry_run = dry_run
|
|
147
|
+
config.backup = true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @rbs evidence_path: String
|
|
152
|
+
# @rbs command: Array[String]
|
|
153
|
+
# @rbs return: void
|
|
154
|
+
def write_command(evidence_path, command)
|
|
155
|
+
path = "#{evidence_path}.command.json"
|
|
156
|
+
raise Error, "symlink command files are unsupported" if File.symlink?(path)
|
|
157
|
+
|
|
158
|
+
open_command(path, File::WRONLY | File::CREAT | File::TRUNC) do |file|
|
|
159
|
+
file.chmod(0o600)
|
|
160
|
+
file.write(JSON.generate(command))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @rbs evidence_path: String
|
|
165
|
+
# @rbs return: Array[String]
|
|
166
|
+
def read_command(evidence_path)
|
|
167
|
+
path = "#{evidence_path}.command.json"
|
|
168
|
+
raise Error, "symlink command files are unsupported" if File.symlink?(path)
|
|
169
|
+
return [] unless File.file?(path)
|
|
170
|
+
|
|
171
|
+
value = open_command(path, File::RDONLY) { |file| JSON.parse(file.read) }
|
|
172
|
+
value.is_a?(Array) && value.all?(String) ? value : []
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
#: (String, Integer) { (File) -> untyped } -> untyped
|
|
176
|
+
def open_command(path, flags, &block)
|
|
177
|
+
flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
|
|
178
|
+
File.open(path, flags, 0o600, &block)
|
|
179
|
+
rescue Errno::ELOOP
|
|
180
|
+
raise Error, "symlink command files are unsupported"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -5,14 +5,16 @@ require "logger"
|
|
|
5
5
|
|
|
6
6
|
module Bullematic
|
|
7
7
|
class Configuration
|
|
8
|
+
VALID_FIX_STRATEGIES = %i[includes preload eager_load].freeze #: Array[Symbol]
|
|
9
|
+
|
|
8
10
|
# @rbs skip: to avoid empty array warning
|
|
9
11
|
DEFAULTS = { #: Hash[Symbol, untyped]
|
|
10
12
|
enabled: true,
|
|
11
|
-
auto_fix:
|
|
13
|
+
auto_fix: false,
|
|
12
14
|
target_paths: %w[app/controllers app/models app/services],
|
|
13
15
|
skip_paths: [],
|
|
14
|
-
dry_run:
|
|
15
|
-
backup:
|
|
16
|
+
dry_run: true,
|
|
17
|
+
backup: true,
|
|
16
18
|
fix_strategy: :includes,
|
|
17
19
|
logger: nil,
|
|
18
20
|
debug: false
|
|
@@ -39,7 +41,8 @@ module Bullematic
|
|
|
39
41
|
# attr_accessor debug: bool
|
|
40
42
|
# attr_writer logger: Logger?
|
|
41
43
|
attr_accessor :enabled, :auto_fix, :target_paths, :skip_paths,
|
|
42
|
-
:dry_run, :backup, :
|
|
44
|
+
:dry_run, :backup, :debug
|
|
45
|
+
attr_reader :fix_strategy
|
|
43
46
|
attr_writer :logger
|
|
44
47
|
|
|
45
48
|
#: () -> void
|
|
@@ -52,6 +55,14 @@ module Bullematic
|
|
|
52
55
|
@logger ||= default_logger
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
# @rbs strategy: Symbol
|
|
59
|
+
# @rbs return: Symbol
|
|
60
|
+
def fix_strategy=(strategy)
|
|
61
|
+
raise ArgumentError, "invalid fix strategy: #{strategy.inspect}" unless VALID_FIX_STRATEGIES.include?(strategy)
|
|
62
|
+
|
|
63
|
+
@fix_strategy = strategy
|
|
64
|
+
end
|
|
65
|
+
|
|
55
66
|
private
|
|
56
67
|
|
|
57
68
|
#: () -> Logger
|
data/lib/bullematic/detection.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# rbs_inline: enabled
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
4
7
|
module Bullematic
|
|
5
8
|
class Detection
|
|
6
9
|
# @rbs @type: Symbol
|
|
@@ -10,6 +13,8 @@ module Bullematic
|
|
|
10
13
|
# @rbs @source_file: String?
|
|
11
14
|
# @rbs @line_number: Integer?
|
|
12
15
|
# @rbs @method_name: String?
|
|
16
|
+
# @rbs @context_id: untyped
|
|
17
|
+
# @rbs @source_digest: String?
|
|
13
18
|
|
|
14
19
|
# @rbs!
|
|
15
20
|
# attr_reader type: Symbol
|
|
@@ -19,20 +24,60 @@ module Bullematic
|
|
|
19
24
|
# attr_reader source_file: String?
|
|
20
25
|
# attr_reader line_number: Integer?
|
|
21
26
|
# attr_reader method_name: String?
|
|
27
|
+
# attr_reader context_id: untyped
|
|
28
|
+
# attr_reader source_digest: String?
|
|
22
29
|
attr_reader :type, :base_class, :associations, :call_stack,
|
|
23
|
-
:source_file, :line_number, :method_name
|
|
30
|
+
:source_file, :line_number, :method_name, :context_id, :source_digest
|
|
24
31
|
|
|
25
32
|
# @rbs type: Symbol
|
|
26
33
|
# @rbs base_class: untyped
|
|
27
34
|
# @rbs associations: untyped
|
|
28
35
|
# @rbs call_stack: Array[String]
|
|
36
|
+
# @rbs context_id: untyped
|
|
37
|
+
# @rbs source_digest: String?
|
|
29
38
|
# @rbs return: void
|
|
30
|
-
def initialize(type:, base_class:, associations:, call_stack:)
|
|
39
|
+
def initialize(type:, base_class:, associations:, call_stack:, context_id: nil, source_digest: nil)
|
|
31
40
|
@type = type
|
|
32
41
|
@base_class = base_class
|
|
33
42
|
@associations = normalize_associations(associations)
|
|
34
43
|
@call_stack = call_stack
|
|
44
|
+
@context_id = context_id
|
|
35
45
|
parse_source_location
|
|
46
|
+
@source_digest = source_digest || current_source_digest
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @rbs data: Hash[String, untyped]
|
|
50
|
+
# @rbs return: Detection
|
|
51
|
+
def self.from_h(data)
|
|
52
|
+
type = data.fetch("type").to_s.to_sym
|
|
53
|
+
associations = data.fetch("associations")
|
|
54
|
+
call_stack = data.fetch("call_stack")
|
|
55
|
+
raise ArgumentError, "invalid evidence type" unless %i[n_plus_one unused_eager_loading].include?(type)
|
|
56
|
+
raise ArgumentError, "invalid evidence associations" unless associations.is_a?(Array)
|
|
57
|
+
raise ArgumentError, "invalid evidence call stack" unless call_stack.is_a?(Array)
|
|
58
|
+
source_digest = data["source_digest"]
|
|
59
|
+
raise ArgumentError, "invalid evidence source digest" unless source_digest.is_a?(String) && source_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
60
|
+
|
|
61
|
+
new(
|
|
62
|
+
type: type,
|
|
63
|
+
base_class: data.fetch("base_class"),
|
|
64
|
+
associations: associations,
|
|
65
|
+
call_stack: call_stack,
|
|
66
|
+
context_id: data["context_id"],
|
|
67
|
+
source_digest: source_digest
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @rbs return: Hash[String, untyped]
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
"type" => type.to_s,
|
|
75
|
+
"base_class" => model_class_name,
|
|
76
|
+
"associations" => associations.map(&:to_s),
|
|
77
|
+
"call_stack" => call_stack.map(&:to_s),
|
|
78
|
+
"context_id" => context_id,
|
|
79
|
+
"source_digest" => source_digest
|
|
80
|
+
}
|
|
36
81
|
end
|
|
37
82
|
|
|
38
83
|
#: () -> String
|
|
@@ -44,16 +89,25 @@ module Bullematic
|
|
|
44
89
|
def fixable?
|
|
45
90
|
!source_file.nil? &&
|
|
46
91
|
!source_file.empty? &&
|
|
92
|
+
!associations.empty? &&
|
|
47
93
|
target_path? &&
|
|
48
94
|
!skip_path?
|
|
49
95
|
end
|
|
50
96
|
|
|
97
|
+
#: () -> Array[untyped]
|
|
98
|
+
def fingerprint
|
|
99
|
+
[type, context_id, source_file, line_number, model_class_name, associations.sort]
|
|
100
|
+
end
|
|
101
|
+
|
|
51
102
|
private
|
|
52
103
|
|
|
53
104
|
# @rbs assocs: untyped
|
|
54
105
|
# @rbs return: Array[Symbol]
|
|
55
106
|
def normalize_associations(assocs)
|
|
56
|
-
Array(assocs)
|
|
107
|
+
values = Array(assocs) #: Array[untyped]
|
|
108
|
+
values.filter_map do |association|
|
|
109
|
+
association.to_sym if association.is_a?(String) || association.is_a?(Symbol)
|
|
110
|
+
end.uniq
|
|
57
111
|
end
|
|
58
112
|
|
|
59
113
|
#: () -> void
|
|
@@ -63,19 +117,19 @@ module Bullematic
|
|
|
63
117
|
|
|
64
118
|
target_paths = config.target_paths
|
|
65
119
|
|
|
66
|
-
@call_stack.each do |
|
|
67
|
-
|
|
120
|
+
@call_stack.each do |raw_frame|
|
|
121
|
+
frame = raw_frame.to_s
|
|
68
122
|
|
|
69
|
-
match = frame.match(/\A(.+):(\d+)(?::in `(.+)')
|
|
123
|
+
match = frame.match(/\A(.+):(\d+)(?::in [`'](.+)')?\z/)
|
|
70
124
|
next unless match
|
|
71
125
|
|
|
72
126
|
filepath = match[1]
|
|
73
127
|
line = match[2].to_i
|
|
74
128
|
method = match[3]
|
|
75
129
|
|
|
76
|
-
next unless filepath &&
|
|
130
|
+
next unless filepath && path_in?(filepath, target_paths)
|
|
77
131
|
|
|
78
|
-
@source_file = filepath
|
|
132
|
+
@source_file = absolute_path(filepath).to_s
|
|
79
133
|
@line_number = line
|
|
80
134
|
@method_name = method
|
|
81
135
|
break
|
|
@@ -89,9 +143,7 @@ module Bullematic
|
|
|
89
143
|
config = Bullematic.configuration
|
|
90
144
|
return false unless config
|
|
91
145
|
|
|
92
|
-
config.target_paths
|
|
93
|
-
source_file&.include?(path)
|
|
94
|
-
end
|
|
146
|
+
path_in?(source_file, config.target_paths)
|
|
95
147
|
end
|
|
96
148
|
|
|
97
149
|
#: () -> bool
|
|
@@ -101,9 +153,54 @@ module Bullematic
|
|
|
101
153
|
config = Bullematic.configuration
|
|
102
154
|
return false unless config
|
|
103
155
|
|
|
104
|
-
config.skip_paths
|
|
105
|
-
|
|
156
|
+
path_in?(source_file, config.skip_paths)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @rbs filepath: String
|
|
160
|
+
# @rbs paths: Array[String]
|
|
161
|
+
# @rbs return: bool
|
|
162
|
+
def path_in?(filepath, paths)
|
|
163
|
+
source = expand_path(filepath)
|
|
164
|
+
|
|
165
|
+
paths.any? do |path|
|
|
166
|
+
next false unless path.is_a?(String) && !path.empty?
|
|
167
|
+
|
|
168
|
+
target = expand_path(path)
|
|
169
|
+
source == target || source.to_s.start_with?("#{target}#{File::SEPARATOR}")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @rbs path: String
|
|
174
|
+
# @rbs return: Pathname
|
|
175
|
+
def expand_path(path)
|
|
176
|
+
expanded = absolute_path(path)
|
|
177
|
+
return expanded unless expanded.exist?
|
|
178
|
+
|
|
179
|
+
begin
|
|
180
|
+
expanded.realpath
|
|
181
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
182
|
+
expanded
|
|
106
183
|
end
|
|
107
184
|
end
|
|
185
|
+
|
|
186
|
+
# @rbs path: String
|
|
187
|
+
# @rbs return: Pathname
|
|
188
|
+
def absolute_path(path)
|
|
189
|
+
pathname = Pathname.new(path)
|
|
190
|
+
return pathname if path.match?(%r{\A[A-Za-z]:[\\/]})
|
|
191
|
+
return pathname.expand_path if pathname.absolute?
|
|
192
|
+
|
|
193
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root : Dir.pwd
|
|
194
|
+
Pathname.new(root).join(pathname).expand_path
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @rbs return: String?
|
|
198
|
+
def current_source_digest
|
|
199
|
+
return unless source_file && File.file?(source_file)
|
|
200
|
+
|
|
201
|
+
Digest::SHA256.hexdigest(File.binread(source_file))
|
|
202
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
108
205
|
end
|
|
109
206
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Bullematic
|
|
8
|
+
class EvidenceStore
|
|
9
|
+
ENV_KEY = "BULLEMATIC_RECORD_FILE"
|
|
10
|
+
DEFAULT_PATH = ".bullematic/evidence.jsonl"
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
#: () -> bool
|
|
14
|
+
def recording?
|
|
15
|
+
!ENV[ENV_KEY].to_s.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: () -> String
|
|
19
|
+
def path
|
|
20
|
+
File.expand_path(ENV.fetch(ENV_KEY, DEFAULT_PATH))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs detection: Detection
|
|
24
|
+
# @rbs filepath: String
|
|
25
|
+
# @rbs return: void
|
|
26
|
+
def append(detection, filepath = path)
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(filepath))
|
|
28
|
+
reject_symlink!(filepath)
|
|
29
|
+
open_evidence(filepath, File::WRONLY | File::CREAT | File::APPEND) do |file|
|
|
30
|
+
file.flock(File::LOCK_EX)
|
|
31
|
+
file.puts(JSON.generate(detection.to_h))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @rbs filepath: String
|
|
36
|
+
# @rbs return: void
|
|
37
|
+
def clear(filepath = path)
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(filepath))
|
|
39
|
+
reject_symlink!(filepath)
|
|
40
|
+
open_evidence(filepath, File::WRONLY | File::CREAT | File::TRUNC) {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs filepath: String
|
|
44
|
+
# @rbs return: Array[Detection]
|
|
45
|
+
def read(filepath = path)
|
|
46
|
+
reject_symlink!(filepath)
|
|
47
|
+
return [] unless File.file?(filepath)
|
|
48
|
+
|
|
49
|
+
detections = open_evidence(filepath, File::RDONLY) do |file|
|
|
50
|
+
file.each_line.filter_map do |line|
|
|
51
|
+
next if line.strip.empty?
|
|
52
|
+
|
|
53
|
+
Detection.from_h(JSON.parse(line))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
detections.uniq(&:fingerprint)
|
|
57
|
+
rescue JSON::ParserError, KeyError, ArgumentError, TypeError => error
|
|
58
|
+
raise Error, "invalid evidence file #{filepath}: #{error.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# @rbs filepath: String
|
|
64
|
+
# @rbs return: void
|
|
65
|
+
def reject_symlink!(filepath)
|
|
66
|
+
raise Error, "symlink evidence files are unsupported" if File.symlink?(filepath)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: (String, Integer) { (File) -> untyped } -> untyped
|
|
70
|
+
def open_evidence(filepath, flags, &block)
|
|
71
|
+
flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
|
|
72
|
+
File.open(filepath, flags, 0o600, &block)
|
|
73
|
+
rescue Errno::ELOOP
|
|
74
|
+
raise Error, "symlink evidence files are unsupported"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|