pinspec 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 +7 -0
- data/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
class << self
|
|
6
|
+
attr_writer :exit_code
|
|
7
|
+
|
|
8
|
+
def exit_code
|
|
9
|
+
@exit_code || 1
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def exit_code
|
|
14
|
+
self.class.exit_code
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class UnparsableSource < Error
|
|
19
|
+
self.exit_code = 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class TargetNotFound < Error
|
|
23
|
+
self.exit_code = 2
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class AmbiguousTarget < Error
|
|
27
|
+
self.exit_code = 3
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class BlockRequired < Error
|
|
31
|
+
self.exit_code = 4
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class UnresolvableSetup < Error
|
|
35
|
+
self.exit_code = 5
|
|
36
|
+
|
|
37
|
+
REASONS = %i[
|
|
38
|
+
association_cycle
|
|
39
|
+
attachment
|
|
40
|
+
opaque_constructor
|
|
41
|
+
unknown_column_type
|
|
42
|
+
apartment
|
|
43
|
+
unresolvable_parameter
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :reason
|
|
47
|
+
|
|
48
|
+
def initialize(reason, message = nil)
|
|
49
|
+
unless REASONS.include?(reason)
|
|
50
|
+
raise ArgumentError, "unknown UnresolvableSetup reason: #{reason.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@reason = reason
|
|
54
|
+
super(message || reason.to_s.tr("_", " "))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class SchemaFormatUnsupported < Error
|
|
59
|
+
self.exit_code = 6
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class ProbeFailure < Error
|
|
63
|
+
self.exit_code = 7
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class NothingStableToPin < Error
|
|
67
|
+
self.exit_code = 8
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class VerifyFailed < Error
|
|
71
|
+
self.exit_code = 9
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class UnsupportedRailsVersion < Error
|
|
75
|
+
self.exit_code = 10
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class EnvironmentRefused < Error
|
|
79
|
+
self.exit_code = 11
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class PinspecInternalError < Error
|
|
83
|
+
self.exit_code = 12
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
module Inputs
|
|
5
|
+
module Boundary
|
|
6
|
+
BY_TYPE = {
|
|
7
|
+
"Integer" => [0, 1, -1],
|
|
8
|
+
"Float" => [0.0, 1.0, -1.0],
|
|
9
|
+
"Boolean" => [true, false],
|
|
10
|
+
"String" => ["", "pinspec"],
|
|
11
|
+
"Symbol" => nil,
|
|
12
|
+
"Array" => [[]],
|
|
13
|
+
"Hash" => [{}],
|
|
14
|
+
"Proc" => nil,
|
|
15
|
+
"Date" => [PINSPEC_EPOCH[0, 10]],
|
|
16
|
+
"Time" => [PINSPEC_EPOCH]
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
DECIMAL_HINTS = %w[BigDecimal Decimal].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def values_for(param, column: nil, default_first: true)
|
|
23
|
+
declared = default_value(param)
|
|
24
|
+
edges = edges_for(param, column)
|
|
25
|
+
|
|
26
|
+
ordered = default_first ? [declared, *edges] : [*edges, declared]
|
|
27
|
+
|
|
28
|
+
present(ordered).map { |value| encode(value, param) }.uniq
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def base_value(param, column: nil)
|
|
32
|
+
declared = default_value(param)
|
|
33
|
+
return Tags.encode(declared) unless declared == :__none__
|
|
34
|
+
|
|
35
|
+
encode(Array(edges_for(param, column)).first, param)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def omittable?(param)
|
|
39
|
+
param.optional?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def encode(value, param)
|
|
45
|
+
return Tags.decimal(value) if decimal?(param) && !value.nil?
|
|
46
|
+
|
|
47
|
+
Tags.encode(value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def decimal?(param)
|
|
51
|
+
DECIMAL_HINTS.include?(param.type_hint)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def present(values)
|
|
55
|
+
values.reject { |value| value == :__none__ }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_value(param)
|
|
59
|
+
return :__none__ if param.default_source.nil?
|
|
60
|
+
|
|
61
|
+
literal_from_source(param.default_source)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def literal_from_source(source)
|
|
65
|
+
text = source.to_s.strip
|
|
66
|
+
|
|
67
|
+
case text
|
|
68
|
+
when "nil" then nil
|
|
69
|
+
when "true" then true
|
|
70
|
+
when "false" then false
|
|
71
|
+
when /\A:([A-Za-z_]\w*)\z/ then Regexp.last_match(1).to_sym
|
|
72
|
+
when /\A-?\d+\z/ then text.to_i
|
|
73
|
+
when /\A-?\d+\.\d+\z/ then text.to_f
|
|
74
|
+
when /\A"([^"]*)"\z/, /\A'([^']*)'\z/ then Regexp.last_match(1)
|
|
75
|
+
when "[]" then []
|
|
76
|
+
when "{}" then {}
|
|
77
|
+
else :__none__
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def edges_for(param, column = nil)
|
|
82
|
+
hint = param.type_hint
|
|
83
|
+
|
|
84
|
+
return decimal_edges if DECIMAL_HINTS.include?(hint)
|
|
85
|
+
return BY_TYPE.fetch(hint) if BY_TYPE.key?(hint)
|
|
86
|
+
|
|
87
|
+
from_column = column && edges_for_column_type(column.type)
|
|
88
|
+
return from_column if from_column
|
|
89
|
+
|
|
90
|
+
[nil]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def edges_for_column_type(type)
|
|
94
|
+
case type
|
|
95
|
+
when :string, :text, :citext then BY_TYPE.fetch("String")
|
|
96
|
+
when :integer, :bigint, :smallint then BY_TYPE.fetch("Integer")
|
|
97
|
+
when :float then BY_TYPE.fetch("Float")
|
|
98
|
+
when :decimal, :numeric, :money then decimal_edges
|
|
99
|
+
when :boolean then BY_TYPE.fetch("Boolean")
|
|
100
|
+
when :date then BY_TYPE.fetch("Date")
|
|
101
|
+
when :datetime, :timestamp, :timestamptz then BY_TYPE.fetch("Time")
|
|
102
|
+
when :json, :jsonb, :hstore then BY_TYPE.fetch("Hash")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def decimal_edges
|
|
107
|
+
%w[0.0 1.0 -1.0]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinspec
|
|
4
|
+
module Inputs
|
|
5
|
+
class Corpus
|
|
6
|
+
DEFAULT_MAX_CASES = 12
|
|
7
|
+
|
|
8
|
+
OMIT = :__pinspec_omit__
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def build(target:, plan:, schema: nil, max_cases: DEFAULT_MAX_CASES, imports: [])
|
|
12
|
+
new(target: target, plan: plan, schema: schema, max_cases: max_cases, imports: imports).build
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(target:, plan:, schema: nil, max_cases: DEFAULT_MAX_CASES, imports: [])
|
|
17
|
+
@target = target
|
|
18
|
+
@plan = plan
|
|
19
|
+
@schema = schema
|
|
20
|
+
@max_cases = max_cases
|
|
21
|
+
@imports = imports
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build
|
|
25
|
+
cases = [base_case]
|
|
26
|
+
cases.concat(boundary_cases(@max_cases - cases.size))
|
|
27
|
+
|
|
28
|
+
InputCorpus.new(cases: dedup(cases), setup_plan: @plan)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def base_case
|
|
34
|
+
build_case("c001", :defaults) { |param| base_value_for(param) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def boundary_cases(budget)
|
|
38
|
+
return [] if budget <= 0
|
|
39
|
+
|
|
40
|
+
variations = round_robin(ctor_variations, method_variations).first(budget)
|
|
41
|
+
|
|
42
|
+
variations.each_with_index.map do |(param, value), index|
|
|
43
|
+
build_case(format("c%03d", index + 2), :boundary) do |candidate|
|
|
44
|
+
candidate.equal?(param) ? value : base_value_for(candidate)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def round_robin(first, second)
|
|
50
|
+
interleaved = []
|
|
51
|
+
[first.size, second.size].max.times do |index|
|
|
52
|
+
interleaved << first[index] if first[index]
|
|
53
|
+
interleaved << second[index] if second[index]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
interleaved
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ctor_variations
|
|
60
|
+
variations_for(@target.initializer_params)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def method_variations
|
|
64
|
+
variations_for(@target.params)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def variations_for(params)
|
|
68
|
+
params.flat_map do |param|
|
|
69
|
+
next [] if bound?(param)
|
|
70
|
+
next [] if %i[rest keyrest].include?(param.kind)
|
|
71
|
+
|
|
72
|
+
base = base_value_for(param)
|
|
73
|
+
values = Boundary.values_for(param, column: column_for(param))
|
|
74
|
+
.reject { |value| value == base }
|
|
75
|
+
|
|
76
|
+
values += [OMIT] if Boundary.omittable?(param)
|
|
77
|
+
|
|
78
|
+
values.map { |value| [param, value] }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_case(id, origin)
|
|
83
|
+
ctor_args, ctor_kwargs = split_params(@target.initializer_params) { |param| yield(param) }
|
|
84
|
+
args, kwargs = split_params(@target.params) { |param| yield(param) }
|
|
85
|
+
|
|
86
|
+
InputCase.new(
|
|
87
|
+
id: id,
|
|
88
|
+
ctor_args: ctor_args,
|
|
89
|
+
ctor_kwargs: ctor_kwargs,
|
|
90
|
+
args: args,
|
|
91
|
+
kwargs: kwargs,
|
|
92
|
+
origin: origin
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def split_params(params)
|
|
97
|
+
positional = []
|
|
98
|
+
keyword = {}
|
|
99
|
+
|
|
100
|
+
params.each do |param|
|
|
101
|
+
value = yield(param)
|
|
102
|
+
next if value == OMIT
|
|
103
|
+
|
|
104
|
+
if %i[keyreq key].include?(param.kind)
|
|
105
|
+
keyword[param.name.to_s] = value
|
|
106
|
+
elsif %i[req opt].include?(param.kind)
|
|
107
|
+
positional << value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[positional, keyword]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def base_value_for(param)
|
|
115
|
+
ref = @plan.binding_for(param.name)
|
|
116
|
+
return Tags.ref(ref) if ref
|
|
117
|
+
|
|
118
|
+
Boundary.base_value(param, column: column_for(param))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def column_for(param)
|
|
122
|
+
return nil if @schema.nil?
|
|
123
|
+
|
|
124
|
+
matches = @schema.tables.filter_map { |table| table.column(param.name) }
|
|
125
|
+
return nil if matches.empty?
|
|
126
|
+
|
|
127
|
+
types = matches.map(&:type).uniq
|
|
128
|
+
types.size == 1 ? matches.first : nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def bound?(param)
|
|
132
|
+
!@plan.binding_for(param.name).nil?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def dedup(cases)
|
|
136
|
+
seen = {}
|
|
137
|
+
|
|
138
|
+
cases.each_with_object([]) do |input_case, kept|
|
|
139
|
+
signature = input_case.signature
|
|
140
|
+
next if seen[signature]
|
|
141
|
+
|
|
142
|
+
seen[signature] = true
|
|
143
|
+
kept << input_case
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Inputs
|
|
7
|
+
class Hydrator
|
|
8
|
+
MAX_DEPTH = 3
|
|
9
|
+
MAX_ROWS_PER_CLUSTER = 20
|
|
10
|
+
|
|
11
|
+
VOLATILE = %w[created_at updated_at].freeze
|
|
12
|
+
DB_FUNCTION_DEFAULT = /\A(?:->|gen_random_uuid|now|uuid_generate_v4|CURRENT_)/i.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(schema:, redactor:, factories:, max_depth: MAX_DEPTH,
|
|
15
|
+
max_rows: MAX_ROWS_PER_CLUSTER)
|
|
16
|
+
raise ArgumentError, "factories: is required - without it an imported row " \
|
|
17
|
+
"gets a camelized table name, which is the wrong constant " \
|
|
18
|
+
"on any app whose tables sit behind an engine prefix" if factories.nil?
|
|
19
|
+
|
|
20
|
+
@schema = schema
|
|
21
|
+
@redactor = redactor
|
|
22
|
+
@resolver = Setup::DependencyResolver.new(schema, factories)
|
|
23
|
+
@max_depth = max_depth
|
|
24
|
+
@max_rows = max_rows
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hydrate(table, rows, all_rows, target_source: "")
|
|
28
|
+
@notes = []
|
|
29
|
+
clusters = []
|
|
30
|
+
ordinal = 0
|
|
31
|
+
|
|
32
|
+
@refs = {}
|
|
33
|
+
@counters = Hash.new(0)
|
|
34
|
+
@budget = @max_rows
|
|
35
|
+
|
|
36
|
+
Array(rows).each do |row|
|
|
37
|
+
ordinal += 1
|
|
38
|
+
clusters.concat(build_cluster(table, row, all_rows, ordinal, target_source))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[clusters, @notes]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_cluster(table, row, all_rows, ordinal, target_source)
|
|
47
|
+
emitted = []
|
|
48
|
+
|
|
49
|
+
walk = lambda do |current_table, current_row, depth|
|
|
50
|
+
return nil if current_row.nil?
|
|
51
|
+
return nil if depth > @max_depth
|
|
52
|
+
return nil if @budget <= 0
|
|
53
|
+
|
|
54
|
+
key = row_key(current_table, current_row)
|
|
55
|
+
return @refs[key] if @refs.key?(key)
|
|
56
|
+
|
|
57
|
+
@counters[current_table] += 1
|
|
58
|
+
ref = "imported_#{singular(current_table)}_#{@counters[current_table]}"
|
|
59
|
+
@refs[key] = ref
|
|
60
|
+
@budget -= 1
|
|
61
|
+
|
|
62
|
+
assoc_refs = {}
|
|
63
|
+
|
|
64
|
+
all_parents(current_table).each do |column, parent_table|
|
|
65
|
+
parent_id = current_row[column] || current_row[column.to_sym]
|
|
66
|
+
parent_row = find_row(all_rows, parent_table, parent_id)
|
|
67
|
+
|
|
68
|
+
if parent_row.nil?
|
|
69
|
+
assoc_refs[column] = handle_missing_parent(current_table, column, parent_table)
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
parent_ref = walk.call(parent_table, parent_row, depth + 1)
|
|
74
|
+
assoc_refs[column] = parent_ref.nil? ? nil : Tags.ref(parent_ref)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
emitted << build_import(current_table, current_row, ref, assoc_refs, ordinal, target_source)
|
|
78
|
+
ref
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
walk.call(table, row, 0)
|
|
82
|
+
emitted
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handle_missing_parent(table, column, parent_table)
|
|
86
|
+
nullable = column_of(table, column)&.nullable?
|
|
87
|
+
|
|
88
|
+
if nullable
|
|
89
|
+
note(:cross_cluster_nulled,
|
|
90
|
+
"#{table}.#{column} pointed outside the sampled rows; it is nullable, " \
|
|
91
|
+
"so the import leaves it null")
|
|
92
|
+
return Tags.encode(nil)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
note(:row_discarded,
|
|
96
|
+
"#{table}.#{column} requires a #{parent_table} that was not sampled, so " \
|
|
97
|
+
"that row cannot be recreated and was dropped")
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_import(table, row, ref, assoc_refs, ordinal, target_source)
|
|
102
|
+
attrs, redacted = @redactor.redact(importable_attrs(table, row), ordinal: ordinal, table: table)
|
|
103
|
+
|
|
104
|
+
tagged = attrs.transform_values { |value| Tags.encode(value) }
|
|
105
|
+
tagged.merge!(assoc_refs.compact)
|
|
106
|
+
|
|
107
|
+
flags = []
|
|
108
|
+
reads = @redactor.reads_in(target_source, redacted)
|
|
109
|
+
flags << :redaction_read unless reads.empty?
|
|
110
|
+
|
|
111
|
+
reads.each do |read|
|
|
112
|
+
note(:redaction_read,
|
|
113
|
+
"the target reads #{read[:attribute]} at line #{read[:line]}, and that " \
|
|
114
|
+
"value was rewritten - the pin would freeze behaviour the app never has")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
ImportCluster.new(
|
|
118
|
+
model: model_for(table),
|
|
119
|
+
table: table,
|
|
120
|
+
name: ref,
|
|
121
|
+
attrs: tagged,
|
|
122
|
+
source: provenance(table, row),
|
|
123
|
+
redacted: redacted,
|
|
124
|
+
flags: flags
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def importable_attrs(table, row)
|
|
129
|
+
schema_table = @schema.table(table)
|
|
130
|
+
pk = Array(schema_table&.primary_key)
|
|
131
|
+
fk_columns = all_parents(table).map(&:first)
|
|
132
|
+
|
|
133
|
+
row.each_with_object({}) do |(column, value), out|
|
|
134
|
+
name = column.to_s
|
|
135
|
+
next if pk.include?(name)
|
|
136
|
+
next if VOLATILE.include?(name)
|
|
137
|
+
next if fk_columns.include?(name)
|
|
138
|
+
next if db_function_default?(schema_table, name)
|
|
139
|
+
|
|
140
|
+
out[name] = value
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def db_function_default?(schema_table, column_name)
|
|
145
|
+
default = schema_table&.column(column_name)&.default
|
|
146
|
+
return false if default.nil?
|
|
147
|
+
|
|
148
|
+
default.to_s.match?(DB_FUNCTION_DEFAULT)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def provenance(table, row)
|
|
152
|
+
identifier = row["id"] || row[:id] || row.values.first
|
|
153
|
+
|
|
154
|
+
"sample:#{table}:#{Digest::SHA1.hexdigest("#{table}:#{identifier}")[0, 8]}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def all_parents(table)
|
|
158
|
+
schema_table = @schema.table(table)
|
|
159
|
+
return [] if schema_table.nil?
|
|
160
|
+
|
|
161
|
+
schema_table.columns.filter_map do |column|
|
|
162
|
+
fk = @schema.fk_for(table, column.name)
|
|
163
|
+
fk ? [column.name, fk.to_table] : nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def find_row(all_rows, table, id)
|
|
168
|
+
return nil if id.nil?
|
|
169
|
+
|
|
170
|
+
Array(all_rows[table] || all_rows[table.to_sym]).find do |row|
|
|
171
|
+
(row["id"] || row[:id]).to_s == id.to_s
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def column_of(table, column_name)
|
|
176
|
+
@schema.table(table)&.column(column_name)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def row_key(table, row)
|
|
180
|
+
"#{table}:#{row['id'] || row[:id] || row.hash}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def model_for(table)
|
|
184
|
+
@resolver.model_for(table)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def singular(table)
|
|
188
|
+
Analyzer::Inflector.singular_candidates(table).first
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def note(kind, detail)
|
|
192
|
+
@notes << { kind: kind, detail: detail }
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Inputs
|
|
7
|
+
class Redactor
|
|
8
|
+
BUILT_IN = %w[
|
|
9
|
+
email phone phone_number mobile
|
|
10
|
+
ssn social_security_number
|
|
11
|
+
dob date_of_birth birthdate
|
|
12
|
+
first_name last_name full_name middle_name maiden_name
|
|
13
|
+
address address1 address2 address_line1 address_line2 street city postcode zip zipcode
|
|
14
|
+
ip_address remote_ip
|
|
15
|
+
token api_key access_token refresh_token secret password_digest encrypted_password
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(attributes: [], enabled: true)
|
|
19
|
+
@extra = Array(attributes).map(&:to_s)
|
|
20
|
+
@enabled = enabled
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enabled?
|
|
24
|
+
@enabled
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
PERSON_TABLES = %w[
|
|
28
|
+
customers users people contacts employees members clients patients
|
|
29
|
+
subscribers accounts admins authors owners guests students staff
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
PERSON_COLUMNS = %w[name display_name nickname username handle].freeze
|
|
33
|
+
|
|
34
|
+
def redacts?(column_name, table: nil)
|
|
35
|
+
return false unless @enabled
|
|
36
|
+
|
|
37
|
+
name = column_name.to_s
|
|
38
|
+
return true if BUILT_IN.include?(name) || @extra.include?(name)
|
|
39
|
+
|
|
40
|
+
PERSON_COLUMNS.include?(name) && PERSON_TABLES.include?(table.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def redact(attrs, ordinal:, table: nil)
|
|
44
|
+
redacted = []
|
|
45
|
+
|
|
46
|
+
rewritten = attrs.each_with_object({}) do |(column, value), out|
|
|
47
|
+
if redacts?(column, table: table) && value.is_a?(String) && !value.empty?
|
|
48
|
+
out[column] = rewrite(column.to_s, value, ordinal)
|
|
49
|
+
redacted << column.to_s
|
|
50
|
+
else
|
|
51
|
+
out[column] = value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
[rewritten, redacted]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reads_in(source, columns)
|
|
59
|
+
return [] unless @enabled
|
|
60
|
+
|
|
61
|
+
Array(columns).uniq.filter_map do |column|
|
|
62
|
+
line = read_line(source, column.to_s)
|
|
63
|
+
next unless line
|
|
64
|
+
|
|
65
|
+
{ attribute: column.to_s, line: line }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def read_line(source, column)
|
|
72
|
+
pattern = /(?:\.#{Regexp.escape(column)}\b|:#{Regexp.escape(column)}\b|["']#{Regexp.escape(column)}["'])/
|
|
73
|
+
|
|
74
|
+
source.to_s.lines.each_with_index do |text, index|
|
|
75
|
+
return index + 1 if text.match?(pattern)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rewrite(column, value, ordinal)
|
|
82
|
+
case column
|
|
83
|
+
when /email/ then rewrite_email(value, ordinal)
|
|
84
|
+
when /phone|mobile/ then rewrite_digits(value, ordinal)
|
|
85
|
+
when /ssn|social_security/ then rewrite_digits(value, ordinal)
|
|
86
|
+
when /dob|birth/ then rewrite_date(value)
|
|
87
|
+
when /token|key|secret|password/ then rewrite_hex(value, ordinal)
|
|
88
|
+
when /ip_address|remote_ip/ then rewrite_ip(value, ordinal)
|
|
89
|
+
else rewrite_filler(value, ordinal)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rewrite_email(value, ordinal)
|
|
94
|
+
local, _, domain = value.rpartition("@")
|
|
95
|
+
return rewrite_filler(value, ordinal) if domain.empty? || local.empty?
|
|
96
|
+
|
|
97
|
+
"#{same_length_token(local, ordinal)}@#{domain}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rewrite_digits(value, ordinal)
|
|
101
|
+
counter = ordinal.abs
|
|
102
|
+
digits = format("%0#{value.count('0-9')}d", counter)
|
|
103
|
+
index = -1
|
|
104
|
+
|
|
105
|
+
value.gsub(/\d/) do
|
|
106
|
+
index += 1
|
|
107
|
+
digits[index] || "0"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rewrite_date(value)
|
|
112
|
+
value.match?(/\A\d{4}-\d{2}-\d{2}/) ? "1970-01-01#{value[10..]}" : value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def rewrite_hex(value, ordinal)
|
|
116
|
+
digest = Digest::SHA256.hexdigest("pinspec:#{ordinal}")
|
|
117
|
+
(digest * ((value.length / digest.length) + 1))[0, value.length]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def rewrite_ip(value, ordinal)
|
|
121
|
+
return rewrite_digits(value, ordinal) if value.include?(":")
|
|
122
|
+
|
|
123
|
+
"192.0.2.#{(ordinal.abs % 254) + 1}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def rewrite_filler(value, ordinal)
|
|
127
|
+
same_length_token(value, ordinal)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def same_length_token(value, ordinal)
|
|
131
|
+
seed = "p#{ordinal}"
|
|
132
|
+
return seed[0, value.length] if seed.length >= value.length
|
|
133
|
+
|
|
134
|
+
seed + ("x" * (value.length - seed.length))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|