hecks 3.6.0 → 3.9.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/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
- data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/cache_dir.rb +10 -0
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
- data/lib/hecks/deploy/oidc.json +10 -0
- data/lib/hecks/doors/usage_cache.rb +19 -9
- data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
- data/lib/hecks/fuzzing/failure_triage.rb +80 -0
- data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
- data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
- data/lib/hecks/fuzzing/form_census.rb +28 -52
- data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
- data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
- data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
- data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
- data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
- data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
- data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
- data/lib/hecks/fuzzing/mutation/report.rb +78 -0
- data/lib/hecks/fuzzing/mutation/run.rb +99 -0
- data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
- data/lib/hecks/fuzzing/mutation/site.rb +21 -0
- data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
- data/lib/hecks/fuzzing/mutation.rb +12 -0
- data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
- data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
- data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
- data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
- data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
- data/lib/hecks/fuzzing/properties.rb +7 -0
- data/lib/hecks/fuzzing/replay/observation.rb +1 -1
- data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
- data/lib/hecks/fuzzing/replay/session.rb +22 -6
- data/lib/hecks/fuzzing/replay.rb +1 -0
- data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
- data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
- data/lib/hecks/fuzzing/shrinker.rb +3 -0
- data/lib/hecks/fuzzing.rb +3 -0
- data/lib/hecks/gate/stages.yml +12 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
- data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
- data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +37 -0
- data/lib/hecks/hecks/hecks.hecksagon +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
- data/lib/hecks/projections/deploy/box/settings.rb +18 -0
- data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
- data/lib/hecks/projections/deploy/box.rb +14 -2
- data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
- data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
- data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
- data/lib/hecks/projections/deploy/vercel.rb +134 -0
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/projections/site/site_host/templates.rb +12 -4
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
- data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/rust_build/native_build.rb +29 -1
- data/lib/hecks/rust_build/wasm.rb +16 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
- data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
- data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
- data/lib/hecks/tools/deploy_recipe.rb +20 -10
- data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
- data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
- data/lib/hecks/tools/fuzz_sweep.rb +26 -9
- data/lib/hecks/tools/lanes/files.rb +2 -4
- data/lib/hecks/tools/lanes/live.rb +3 -4
- data/lib/hecks/tools/lanes/ruleset.rb +23 -1
- data/lib/hecks/tools/lanes.rb +12 -1
- data/lib/hecks/tools/mutation_run.rb +73 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +3 -1
- data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
- data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
- data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
- data/qa/settings.yml +1 -0
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +6 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +28 -5
- data/rust/host/src/auth.rs +11 -1
- data/rust/host/src/boundary_fuzz/api.rs +260 -0
- data/rust/host/src/boundary_fuzz/auth.rs +119 -0
- data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
- data/rust/host/src/boundary_fuzz/ir.rs +79 -0
- data/rust/host/src/boundary_fuzz/server.rs +227 -0
- data/rust/host/src/boundary_fuzz/web.rs +125 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/fuzz_support.rs +165 -0
- data/rust/host/src/ir.rs +4 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +39 -0
- data/rust/host/src/main.rs +10 -44
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +44 -0
- data/rust/host/src/server.rs +6 -1
- data/rust/host/src/web/commerce.rs +113 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +299 -0
- data/rust/host/src/web.rs +55 -49
- data/rust/parser/fuzz/.gitignore +4 -0
- data/rust/parser/fuzz/Cargo.lock +89 -0
- data/rust/parser/fuzz/Cargo.toml +27 -0
- data/rust/parser/fuzz/README.md +19 -0
- data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
- data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
- data/rust/parser/src/parse/command.rs +14 -2
- data/rust/parser/src/parse/mod.rs +25 -0
- data/rust/parser/src/ruby_value.rs +95 -6
- data/rust/src/kernel/json.rs +34 -3
- metadata +58 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module FormCensus
|
|
4
|
+
# The reference-hop forms: a path crossing references, and a reference re-declared under
|
|
5
|
+
# another type. Each reads one aggregate's exported hash.
|
|
6
|
+
module ReferenceForms
|
|
7
|
+
# A given path crossing two references has at least this many segments.
|
|
8
|
+
TWO_HOP_GIVEN_PATH_LENGTH = 3
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def two_hop_given?(aggregate)
|
|
13
|
+
FormCensus.commands(aggregate).any? do |verb|
|
|
14
|
+
(verb["givens"] || []).any? { |given| deep_lookup?(given["ast"]) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A `where` whose field crosses two `/` hops, as in `member/sponsor/standing`.
|
|
19
|
+
def multi_hop_where?(aggregate)
|
|
20
|
+
FormCensus.queries(aggregate).any? do |query|
|
|
21
|
+
(query["wheres"] || []).any? { |where| where["field"].to_s.count("/") >= 2 }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A command attribute reusing the name of a reference attribute under a non-reference type.
|
|
26
|
+
def revalued_reference?(aggregate)
|
|
27
|
+
references = FormCensus.attributes(aggregate).select { |held| FormCensus.reference?(held) }
|
|
28
|
+
.to_set { |held| held["name"].to_s }
|
|
29
|
+
FormCensus.commands(aggregate).any? do |verb|
|
|
30
|
+
(verb["attributes"] || []).any? { |held| references.include?(held["name"].to_s) && !FormCensus.reference?(held) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def deep_lookup?(node)
|
|
35
|
+
case node
|
|
36
|
+
when Hash
|
|
37
|
+
return true if node["op"] == "lookup" && Array(node["path"]).size >= TWO_HOP_GIVEN_PATH_LENGTH
|
|
38
|
+
|
|
39
|
+
node.each_value.any? { |child| deep_lookup?(child) }
|
|
40
|
+
when Array
|
|
41
|
+
node.any? { |child| deep_lookup?(child) }
|
|
42
|
+
else
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "../corpus"
|
|
3
|
+
require_relative "form_census/chapter_forms"
|
|
4
|
+
require_relative "form_census/reference_forms"
|
|
3
5
|
|
|
4
6
|
module Hecks
|
|
5
7
|
module Fuzzing
|
|
@@ -9,9 +11,6 @@ module Hecks
|
|
|
9
11
|
# they cannot drift. The unit is one aggregate: forms on one head meet at dispatch, forms in one
|
|
10
12
|
# chapter do not.
|
|
11
13
|
module FormCensus
|
|
12
|
-
# A given path crossing two references has at least this many segments.
|
|
13
|
-
TWO_HOP_GIVEN_PATH_LENGTH = 3
|
|
14
|
-
|
|
15
14
|
# Ports and adapters a census boots with, relative to the repository root.
|
|
16
15
|
CENSUS_SUPPORT = [
|
|
17
16
|
"lib/hecks/ports/persistence.port", "lib/hecks/ports/extraction.port",
|
|
@@ -19,25 +18,28 @@ module Hecks
|
|
|
19
18
|
].freeze
|
|
20
19
|
|
|
21
20
|
FORMS = {
|
|
22
|
-
"composite_id"
|
|
23
|
-
"has_entity"
|
|
24
|
-
"two_entities"
|
|
25
|
-
"composite_piece"
|
|
26
|
-
"multi_emit"
|
|
27
|
-
"lifecycle"
|
|
28
|
-
"piece_lifecycle"
|
|
29
|
-
"has_query"
|
|
30
|
-
"list_attr"
|
|
31
|
-
"reference_attr"
|
|
32
|
-
"closed_set"
|
|
33
|
-
"has_default"
|
|
34
|
-
"has_optional"
|
|
21
|
+
"composite_id" => ->(a) { (a["identified_by"] || []).size >= 2 },
|
|
22
|
+
"has_entity" => ->(a) { entities(a).any? },
|
|
23
|
+
"two_entities" => ->(a) { entities(a).size >= 2 },
|
|
24
|
+
"composite_piece" => ->(a) { entities(a).any? { |piece| (piece["identified_by"] || []).size >= 2 } },
|
|
25
|
+
"multi_emit" => ->(a) { commands(a).any? { |verb| (verb["emits"] || []).size >= 2 } },
|
|
26
|
+
"lifecycle" => ->(a) { !a["lifecycle"].nil? },
|
|
27
|
+
"piece_lifecycle" => ->(a) { entities(a).any? { |piece| !piece["lifecycle"].nil? } },
|
|
28
|
+
"has_query" => ->(a) { queries(a).any? },
|
|
29
|
+
"list_attr" => ->(a) { attributes(a).any? { |held| held["list"] } },
|
|
30
|
+
"reference_attr" => ->(a) { attributes(a).any? { |held| reference?(held) } },
|
|
31
|
+
"closed_set" => ->(a) { (a["value_objects"] || []).any? { |shape| shape["closed_set"] } },
|
|
32
|
+
"has_default" => ->(a) { attributes(a).any? { |held| !held["default"].nil? } },
|
|
33
|
+
"has_optional" => ->(a) { optional_command_attribute?(a) },
|
|
35
34
|
# Reference-hop forms, then `corrects` and `role`; entity commands count too.
|
|
36
|
-
"corrects"
|
|
37
|
-
"role_gated"
|
|
38
|
-
"two_hop_given"
|
|
39
|
-
"multi_hop_where"
|
|
40
|
-
"revalued_reference"
|
|
35
|
+
"corrects" => ->(a) { every_command(a).any? { |verb| corrects?(verb) } },
|
|
36
|
+
"role_gated" => ->(a) { every_command(a).any? { |verb| !verb["role"].to_s.empty? } },
|
|
37
|
+
"two_hop_given" => ->(a) { ReferenceForms.two_hop_given?(a) },
|
|
38
|
+
"multi_hop_where" => ->(a) { ReferenceForms.multi_hop_where?(a) },
|
|
39
|
+
"revalued_reference" => ->(a) { ReferenceForms.revalued_reference?(a) },
|
|
40
|
+
# Chapter-level constructs, attributed to the aggregate they name (see `ChapterForms`).
|
|
41
|
+
"cross_domain_policy" => ->(a) { (a["across_policies"] || []).any? },
|
|
42
|
+
"multi_head_read_model" => ->(a) { (a["multi_head_read_models"] || []).any? }
|
|
41
43
|
}.freeze
|
|
42
44
|
|
|
43
45
|
module_function
|
|
@@ -51,42 +53,16 @@ module Hecks
|
|
|
51
53
|
|
|
52
54
|
def corrects?(verb) = (verb["mutations"] || []).any? { |change| change["op"].to_s == "corrects" }
|
|
53
55
|
|
|
56
|
+
def optional_command_attribute?(aggregate)
|
|
57
|
+
commands(aggregate).any? { |verb| (verb["attributes"] || []).any? { |held| held["optional"] } }
|
|
58
|
+
end
|
|
59
|
+
|
|
54
60
|
def attributes(aggregate) = aggregate["attributes"] || []
|
|
55
61
|
|
|
56
62
|
def queries(aggregate) = aggregate["queries"] || []
|
|
57
63
|
|
|
58
64
|
def reference?(attribute) = attribute["type"].to_s.start_with?("Reference<")
|
|
59
65
|
|
|
60
|
-
def two_hop_given?(aggregate)
|
|
61
|
-
commands(aggregate).any? { |verb| (verb["givens"] || []).any? { |given| deep_lookup?(given["ast"]) } }
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# A `where` whose field crosses two `/` hops, as in `member/sponsor/standing`.
|
|
65
|
-
def multi_hop_where?(aggregate)
|
|
66
|
-
queries(aggregate).any? { |query| (query["wheres"] || []).any? { |where| where["field"].to_s.count("/") >= 2 } }
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# A command attribute reusing the name of a reference attribute under a non-reference type.
|
|
70
|
-
def revalued_reference?(aggregate)
|
|
71
|
-
references = attributes(aggregate).select { |held| reference?(held) }.to_set { |held| held["name"].to_s }
|
|
72
|
-
commands(aggregate).any? do |verb|
|
|
73
|
-
(verb["attributes"] || []).any? { |held| references.include?(held["name"].to_s) && !reference?(held) }
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def deep_lookup?(node)
|
|
78
|
-
case node
|
|
79
|
-
when Hash
|
|
80
|
-
return true if node["op"] == "lookup" && Array(node["path"]).size >= TWO_HOP_GIVEN_PATH_LENGTH
|
|
81
|
-
|
|
82
|
-
node.each_value.any? { |child| deep_lookup?(child) }
|
|
83
|
-
when Array
|
|
84
|
-
node.any? { |child| deep_lookup?(child) }
|
|
85
|
-
else
|
|
86
|
-
false
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
66
|
def properties(aggregate)
|
|
91
67
|
FORMS.transform_values { |form| form.call(aggregate) }
|
|
92
68
|
end
|
|
@@ -107,7 +83,7 @@ module Hecks
|
|
|
107
83
|
|
|
108
84
|
def aggregates_in(chapter_ir)
|
|
109
85
|
(chapter_ir["aggregates"] || []).map do |aggregate|
|
|
110
|
-
["#{chapter_ir["name"]}::#{aggregate["name"]}", properties(aggregate)]
|
|
86
|
+
["#{chapter_ir["name"]}::#{aggregate["name"]}", properties(ChapterForms.onto(aggregate, chapter_ir))]
|
|
111
87
|
end
|
|
112
88
|
end
|
|
113
89
|
|
|
@@ -3,12 +3,12 @@ module Hecks
|
|
|
3
3
|
module IsolatedBoot
|
|
4
4
|
# Rebinds a copied domain to a scratch Postgres schema and prepares that schema.
|
|
5
5
|
module PostgresScratch
|
|
6
|
-
# Rewrites every `.hecksagon` in the copy to bind through Postgres, against
|
|
7
|
-
#
|
|
6
|
+
# Rewrites every `.hecksagon` in the copy to bind through Postgres, against this process's
|
|
7
|
+
# scratch schema unless the caller names its own via `scratch:`.
|
|
8
8
|
def rebind_to_postgres!(copy, database: nil, schema: nil)
|
|
9
9
|
require "pg"
|
|
10
10
|
database ||= FUZZ_POSTGRES_DATABASE
|
|
11
|
-
schema ||=
|
|
11
|
+
schema ||= scratch_schema(database)
|
|
12
12
|
rewrite_bindings!(copy, "Postgres")
|
|
13
13
|
strip_translations!(copy)
|
|
14
14
|
ensure_fuzz_schema!(database, schema)
|
|
@@ -107,15 +107,30 @@ module Hecks
|
|
|
107
107
|
reset_schema!(database, schema, create: false)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Several pool children may reach this at once: one wins the CREATE, and the rest see the
|
|
111
|
+
# database exist, or wait out the moment the template database is busy and look again.
|
|
110
112
|
def create_database_if_missing(database)
|
|
111
113
|
admin = PG.connect(dbname: "postgres")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
3.times do
|
|
115
|
+
break if database_exists?(admin, database)
|
|
116
|
+
|
|
117
|
+
create_database(admin, database)
|
|
118
|
+
end
|
|
116
119
|
admin.close
|
|
117
120
|
end
|
|
118
121
|
|
|
122
|
+
def database_exists?(admin, database)
|
|
123
|
+
admin.exec_params("SELECT 1 FROM pg_database WHERE datname = $1", [database]).ntuples.positive?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def create_database(admin, database)
|
|
127
|
+
admin.exec(%(CREATE DATABASE "#{database}"))
|
|
128
|
+
rescue PG::DuplicateDatabase, PG::UniqueViolation
|
|
129
|
+
nil
|
|
130
|
+
rescue PG::ObjectInUse
|
|
131
|
+
sleep(0.2)
|
|
132
|
+
end
|
|
133
|
+
|
|
119
134
|
# Drops `schema` from `database`, and creates it again when `create` is true.
|
|
120
135
|
def reset_schema!(database, schema, create:)
|
|
121
136
|
db = PG.connect(dbname: database)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module IsolatedBoot
|
|
4
|
+
# Which Postgres scratch schema a fuzz boot uses: one per pool child, so children can fuzz
|
|
5
|
+
# one database at once, or the shared default for any other process.
|
|
6
|
+
module ScratchSchema
|
|
7
|
+
# The schema this process boots into. A pool child names its own in `FUZZ_SCHEMA_ENV`, so
|
|
8
|
+
# several children can fuzz one database at once; it is dropped when the child exits.
|
|
9
|
+
# Any other process shares `FUZZ_POSTGRES_SCHEMA`, which is safe only one boot at a time.
|
|
10
|
+
def scratch_schema(database)
|
|
11
|
+
named = ENV.fetch(FUZZ_SCHEMA_ENV, nil)
|
|
12
|
+
return FUZZ_POSTGRES_SCHEMA unless named
|
|
13
|
+
|
|
14
|
+
@fuzz_schema_cleanups ||= {}
|
|
15
|
+
@fuzz_schema_cleanups[named] ||= at_exit { drop_schema_quietly(database, named) }
|
|
16
|
+
named
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def drop_schema_quietly(database, schema)
|
|
20
|
+
reset_schema!(database, schema, create: false)
|
|
21
|
+
rescue PG::Error
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "../runtime/adapter_lookup"
|
|
|
6
6
|
require_relative "isolated_boot/copying"
|
|
7
7
|
require_relative "isolated_boot/worlds"
|
|
8
8
|
require_relative "isolated_boot/postgres_scratch"
|
|
9
|
+
require_relative "isolated_boot/scratch_schema"
|
|
9
10
|
|
|
10
11
|
module Hecks
|
|
11
12
|
module Fuzzing
|
|
@@ -14,14 +15,17 @@ module Hecks
|
|
|
14
15
|
module IsolatedBoot
|
|
15
16
|
# Postgres has no zero-config default (Postgres.connect_for refuses without a
|
|
16
17
|
# `database` setting), so every domain name gets a fresh `.world` written for it.
|
|
17
|
-
# One
|
|
18
|
-
#
|
|
18
|
+
# One schema per process, dropped and recreated per boot, not a fresh name per call —
|
|
19
|
+
# a process runs one ephemeral boot at a time. The default is shared by every process that
|
|
20
|
+
# does not name its own schema in `FUZZ_SCHEMA_ENV`, as a sweep's pool children do.
|
|
19
21
|
FUZZ_POSTGRES_DATABASE = "hecks_fuzz".freeze
|
|
20
22
|
FUZZ_POSTGRES_SCHEMA = "hecks_fuzz".freeze
|
|
23
|
+
FUZZ_SCHEMA_ENV = "HECKS_FUZZ_SCHEMA".freeze
|
|
21
24
|
|
|
22
25
|
extend Copying
|
|
23
26
|
extend Worlds
|
|
24
27
|
extend PostgresScratch
|
|
28
|
+
extend ScratchSchema
|
|
25
29
|
|
|
26
30
|
module_function
|
|
27
31
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require_relative "../../cli/run"
|
|
2
|
+
require_relative "../../behaviors"
|
|
3
|
+
require_relative "observation"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Fuzzing
|
|
7
|
+
module Mutation
|
|
8
|
+
# What is replayed against a domain and its mutants: the generated sequences, the corpus
|
|
9
|
+
# script and the domain's own `.behaviors` tests.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] sequences
|
|
12
|
+
# @return [Array<Array<Hash>>] generated step lists, valid for the unmutated domain
|
|
13
|
+
# @!attribute [r] corpus
|
|
14
|
+
# @return [Hash, nil] the parsed corpus script
|
|
15
|
+
Plan = Struct.new(:sequences, :corpus)
|
|
16
|
+
|
|
17
|
+
# The checks a mutant is held to, each answering what it saw.
|
|
18
|
+
module Checks
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# What the unmutated domain does under the plan, as values a mutant's run is compared to.
|
|
22
|
+
#
|
|
23
|
+
# @param domain [String] the domain directory
|
|
24
|
+
# @param plan [Plan] what to replay
|
|
25
|
+
# @return [Hash] `:sequences` an observation per sequence, `:corpus` one for the script and
|
|
26
|
+
# `:failing_behaviors` the `.behaviors` tests that already fail
|
|
27
|
+
def baseline(domain, plan)
|
|
28
|
+
{ sequences: plan.sequences.map { |steps| Observation.of(Replay.call(domain, steps)) },
|
|
29
|
+
corpus: plan.corpus && Observation.of(corpus_report(domain, plan.corpus)),
|
|
30
|
+
failing_behaviors: failing_behaviors(domain) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] why the copy cannot boot, nil when it does
|
|
34
|
+
def boot_failure(copy)
|
|
35
|
+
Replay.call(copy, [])
|
|
36
|
+
nil
|
|
37
|
+
rescue StandardError, ScriptError => e
|
|
38
|
+
"#{e.class}: #{e.message.lines.first.to_s.strip}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] the first property a history breaks, as `property: <name>`
|
|
42
|
+
def property_failure(history)
|
|
43
|
+
broken = Properties.check(history).find { |_, result| result != true }
|
|
44
|
+
broken && "property: #{broken.first}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The report `hecks run` would print for the script.
|
|
48
|
+
def corpus_report(domain, corpus)
|
|
49
|
+
IsolatedBoot.call(domain) do |copy|
|
|
50
|
+
CLI::Run.execute(Hecks.boot(copy, environment: nil), corpus.fetch("steps"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String, nil] the first expectation of the script the report does not meet
|
|
55
|
+
def unmet_expectation(corpus, report)
|
|
56
|
+
unmet = CLI::RunExpectations.unmet(corpus["expectations"] || {}, report).first
|
|
57
|
+
unmet && unmet.lines.first.strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The `.behaviors` tests of the domain that do not pass, by description.
|
|
61
|
+
#
|
|
62
|
+
# @param domain [String] the domain directory
|
|
63
|
+
# @return [Array<String>] empty when every test passes or the domain has none
|
|
64
|
+
def failing_behaviors(domain)
|
|
65
|
+
Behaviors.run_all(domain).files.flat_map { |file| failures_in(file) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def failures_in(file)
|
|
69
|
+
return ["#{file.path}: #{file.parse_error}"] if file.parse_error
|
|
70
|
+
|
|
71
|
+
file.runs.reject { |run| run.status == :pass }.map(&:description)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Mutation
|
|
4
|
+
# Each finder looks at one line of a `SourceMap` and answers the changes that apply there, as
|
|
5
|
+
# `{ operator:, removed:, replacement: }` hashes; `Operators` turns them into `Site`s.
|
|
6
|
+
module Finders
|
|
7
|
+
# The shape of a change: a line (or block) taken out, or a line written differently.
|
|
8
|
+
module Change
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def removal(operator, length = 1) = { operator: operator, removed: length, replacement: [] }
|
|
12
|
+
|
|
13
|
+
def rewrite(operator, line) = { operator: operator, removed: 1, replacement: [line] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Rules a command or value object enforces: `given`, `invariant`, comparisons, query bounds,
|
|
17
|
+
# an attribute's `pattern:` and `one_of:`.
|
|
18
|
+
module Rules
|
|
19
|
+
RULE = /\A\s*(given|invariant)\b.*\{.*\}\s*\z/
|
|
20
|
+
COMPARISONS = { " >= " => " > ", " > " => " >= ", " <= " => " < ", " < " => " <= ",
|
|
21
|
+
" == " => " != ", " != " => " == " }.freeze
|
|
22
|
+
PATTERN = /,\s*pattern:\s*'[^']*'/
|
|
23
|
+
CLOSED_SET = /,\s*one_of:\s*\[[^\]]*\]/
|
|
24
|
+
BOUND = /\A\s*where\(.*\b(lt|gt):/
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def at(map, index)
|
|
29
|
+
line = map.lines[index]
|
|
30
|
+
rule(line) + attribute(line) + query_bound(line)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def rule(line)
|
|
34
|
+
return [] unless line.match?(RULE)
|
|
35
|
+
|
|
36
|
+
flipped = flip_comparison(line)
|
|
37
|
+
[Change.removal(:"drop_#{line[RULE, 1]}"), (Change.rewrite(:flip_comparison, flipped) if flipped)].compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The first comparison inside the line's braces, moved one step.
|
|
41
|
+
def flip_comparison(line)
|
|
42
|
+
body = line[/\{.*\}/]
|
|
43
|
+
operator = COMPARISONS.keys.find { |candidate| body.include?(candidate) }
|
|
44
|
+
operator && line.sub(body, body.sub(operator, COMPARISONS.fetch(operator)))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def attribute(line)
|
|
48
|
+
return [] unless line.match?(/\A\s*attribute\b/)
|
|
49
|
+
|
|
50
|
+
[[:drop_pattern, PATTERN], [:drop_one_of, CLOSED_SET]].filter_map do |operator, shape|
|
|
51
|
+
(found = line[shape]) && Change.rewrite(operator, line.sub(found, ""))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def query_bound(line)
|
|
56
|
+
return [] unless line.match?(BOUND)
|
|
57
|
+
|
|
58
|
+
[Change.rewrite(:flip_query_bound, line.sub(/\b(lt|gt):/) { Regexp.last_match(1) == "lt" ? "gt:" : "lt:" })]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The shape of the domain: `from:` guards, `sets`, lifecycle targets, emitted events.
|
|
63
|
+
module Structure
|
|
64
|
+
GUARD = /,\s*from:\s*"[^"]*"/
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
def at(map, index)
|
|
69
|
+
line = map.lines[index]
|
|
70
|
+
scope = map.scope(index)
|
|
71
|
+
sets(line, scope) + command_guard(line) + transition(map, line, scope) + emits(map, line)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sets(line, scope)
|
|
75
|
+
line.match?(/\A\s*sets\s+:\w+\s*\z/) && scope == "command" ? [Change.removal(:drop_sets)] : []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def command_guard(line)
|
|
79
|
+
guard = line[GUARD]
|
|
80
|
+
guard && line.match?(/\A\s*command\b.*\bdo\s*\z/) ? [Change.rewrite(:drop_from_guard, line.sub(guard, ""))] : []
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def transition(map, line, scope)
|
|
84
|
+
match = line.match(SourceMap::TRANSITION)
|
|
85
|
+
return [] unless match && scope == "lifecycle"
|
|
86
|
+
|
|
87
|
+
[retarget(map, line, match[3]), guard(line, match[4])].compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def retarget(map, line, target)
|
|
91
|
+
other = map.states.find { |state| state != target }
|
|
92
|
+
other && Change.rewrite(:retarget_transition, line.sub("=> \"#{target}\"", "=> \"#{other}\""))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def guard(line, rest)
|
|
96
|
+
found = rest[GUARD]
|
|
97
|
+
found && Change.rewrite(:drop_from_guard, line.sub(found, ""))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def emits(map, line)
|
|
101
|
+
match = line.match(SourceMap::EMITS)
|
|
102
|
+
other = match && map.emitted.find { |name| name != match[2] }
|
|
103
|
+
other ? [Change.rewrite(:swap_emits, "#{match[1]}emits #{other}\n")] : []
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Reactions: whole policies, and what a process manager does on each of its transitions.
|
|
108
|
+
module Reactions
|
|
109
|
+
module_function
|
|
110
|
+
|
|
111
|
+
def at(map, index)
|
|
112
|
+
line = map.lines[index]
|
|
113
|
+
scope = map.scope(index)
|
|
114
|
+
policy(map, index, line) + saga_transition(map, index, line, scope) + saga_dispatch(line, scope)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def policy(map, index, line)
|
|
118
|
+
length = line.match?(/\A\s*policy\s+"/) && map.block_length(index)
|
|
119
|
+
length ? [Change.removal(:drop_policy, length)] : []
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def saga_transition(map, index, line, scope)
|
|
123
|
+
length = scope == "process_manager" && line.match?(/\A\s*transition\b.*\bdo\s*\z/) && map.block_length(index)
|
|
124
|
+
length ? [Change.removal(:drop_saga_transition, length)] : []
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def saga_dispatch(line, scope)
|
|
128
|
+
scope == "transition" && line.match?(/\A\s*dispatch\s+\S/) ? [Change.removal(:drop_saga_dispatch)] : []
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
ALL = [Rules, Structure, Reactions].freeze
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require_relative "checks"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Fuzzing
|
|
5
|
+
module Mutation
|
|
6
|
+
# How one mutant fared.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] site
|
|
9
|
+
# @return [Site] what was changed
|
|
10
|
+
# @!attribute [r] status
|
|
11
|
+
# @return [Symbol] `:killed`, `:survived`, `:unreached` or `:invalid`
|
|
12
|
+
# @!attribute [r] by
|
|
13
|
+
# @return [String, nil] what killed it (`property: <name>`, `crash: <class>`, `corpus: ...`,
|
|
14
|
+
# `behavior: ...`), or why it is invalid
|
|
15
|
+
Outcome = Struct.new(:site, :status, :by, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# Replays one mutant and says which check, if any, noticed.
|
|
18
|
+
#
|
|
19
|
+
# The trials run in order and stop at the first that fails. Each answers whether it killed the
|
|
20
|
+
# mutant and whether the mutant behaved differently from the original under it; a mutant that
|
|
21
|
+
# differed everywhere it was looked at and was never killed survived.
|
|
22
|
+
class Judge
|
|
23
|
+
TRIALS = %i[sequence_trial corpus_trial behavior_trial].freeze
|
|
24
|
+
|
|
25
|
+
# @param site [Site] what was changed
|
|
26
|
+
# @param copy [String] the mutated copy of the domain
|
|
27
|
+
# @param plan [Plan] what to replay
|
|
28
|
+
# @param baseline [Hash] what `Checks.baseline` answered for the unmutated domain
|
|
29
|
+
# @return [Outcome] the verdict
|
|
30
|
+
def self.call(site, copy, plan, baseline) = new(site, copy, plan, baseline).call
|
|
31
|
+
|
|
32
|
+
def initialize(site, copy, plan, baseline)
|
|
33
|
+
@site = site
|
|
34
|
+
@copy = copy
|
|
35
|
+
@plan = plan
|
|
36
|
+
@baseline = baseline
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Outcome] invalid when it does not boot, killed by the first failing trial,
|
|
40
|
+
# otherwise survived or unreached by whether anything differed
|
|
41
|
+
def call
|
|
42
|
+
reason = Checks.boot_failure(@copy)
|
|
43
|
+
return outcome(:invalid, reason) if reason
|
|
44
|
+
|
|
45
|
+
run_trials
|
|
46
|
+
rescue StandardError, ScriptError => e
|
|
47
|
+
outcome(:killed, "crash: #{e.class}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def run_trials
|
|
53
|
+
differed = false
|
|
54
|
+
TRIALS.each do |trial|
|
|
55
|
+
killer, changed = send(trial)
|
|
56
|
+
return outcome(:killed, killer) if killer
|
|
57
|
+
|
|
58
|
+
differed ||= changed
|
|
59
|
+
end
|
|
60
|
+
outcome(differed ? :survived : :unreached)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def outcome(status, by = nil) = Outcome.new(site: @site, status: status, by: by)
|
|
64
|
+
|
|
65
|
+
def sequence_trial
|
|
66
|
+
differed = false
|
|
67
|
+
@plan.sequences.each_with_index do |steps, index|
|
|
68
|
+
history = Replay.call(@copy, steps)
|
|
69
|
+
broken = Checks.property_failure(history)
|
|
70
|
+
return [broken, true] if broken
|
|
71
|
+
|
|
72
|
+
differed ||= Observation.of(history) != @baseline[:sequences][index]
|
|
73
|
+
end
|
|
74
|
+
[nil, differed]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def corpus_trial
|
|
78
|
+
return [nil, false] unless @plan.corpus
|
|
79
|
+
|
|
80
|
+
report = Checks.corpus_report(@copy, @plan.corpus)
|
|
81
|
+
unmet = Checks.unmet_expectation(@plan.corpus, report)
|
|
82
|
+
return ["corpus: #{unmet}", true] if unmet
|
|
83
|
+
|
|
84
|
+
[nil, Observation.of(report) != @baseline[:corpus]]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def behavior_trial
|
|
88
|
+
failing = (Checks.failing_behaviors(@copy) - @baseline[:failing_behaviors]).first
|
|
89
|
+
[failing && "behavior: #{failing}", !failing.nil?]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Fuzzing
|
|
5
|
+
module Mutation
|
|
6
|
+
# A history or report reduced to plain values, so two runs compare equal when they did the
|
|
7
|
+
# same thing and differ when they did not.
|
|
8
|
+
module Observation
|
|
9
|
+
PARTS = %i[instances events refusals reactions sagas queries].freeze
|
|
10
|
+
PLAIN = [String, Numeric, true, false, nil].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param history [Hash] a replay's history, or a `hecks run` report
|
|
15
|
+
# @return [String] canonical JSON of its observable parts
|
|
16
|
+
def of(history)
|
|
17
|
+
JSON.generate(plain(history.slice(*PARTS)))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Object] the value as hashes, arrays, strings, numbers and booleans only
|
|
21
|
+
def plain(value)
|
|
22
|
+
case value
|
|
23
|
+
when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key.to_s, plain(item)] }
|
|
24
|
+
when Array then value.map { |item| plain(item) }
|
|
25
|
+
when *PLAIN then value
|
|
26
|
+
else opaque(value)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# An object that is not plain data: its own hash form when it has one, else its text with
|
|
31
|
+
# the address that would differ between runs taken out.
|
|
32
|
+
def opaque(value)
|
|
33
|
+
value.respond_to?(:to_h) ? plain(value.to_h) : value.to_s.gsub(/0x\h+/, "0x")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|