hecks 3.0.2 → 3.0.4
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/exe/hecks +6 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -0
- data/lib/hecks/bluebook/command.rb +7 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
- data/lib/hecks/bluebook/expression/projection.json +21 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
- data/lib/hecks/cli/project_cli.rb +6 -2
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
- data/lib/hecks/doors/cli_runner.rb +57 -12
- data/lib/hecks/doors/launcher_options.rb +23 -0
- data/lib/hecks/embryonaut_bluebook.rb +10 -0
- data/lib/hecks/fuzzing/properties.rb +1 -1
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
- data/lib/hecks/gate/stages.yml +51 -0
- data/lib/hecks/grammar/evolve.rb +22 -1
- data/lib/hecks/grammar/expression.bluebook +6 -1
- data/lib/hecks/grammar/expression_operators.json +135 -0
- data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/codebase.behaviors +12 -0
- data/lib/hecks/hecks/codebase.bluebook +194 -0
- data/lib/hecks/hecks/custodian.bluebook +2 -0
- data/lib/hecks/hecks/hecks.hecksagon +14 -0
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/language/bluebook/command.bluebook +26 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/clock.rb +3 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +6 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +29 -0
- data/lib/hecks/runtime/invocation.rb +5 -1
- data/lib/hecks/runtime/loader.rb +16 -13
- data/lib/hecks/runtime.rb +11 -0
- data/lib/hecks/three_zero/forms.yml +1 -1
- data/lib/hecks/tools/ci_gate_decision.rb +109 -0
- data/lib/hecks/tools/ci_gates.rb +124 -0
- data/lib/hecks/tools/gate.rb +117 -0
- data/lib/hecks/tools.rb +3 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +12 -2
- data/rust/build/src/resolve.rs +25 -0
- data/rust/codegen/src/naming.rs +13 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/dispatch.rs +207 -1
- data/rust/host/src/main.rs +1 -0
- data/rust/host/src/needs.rs +243 -0
- data/rust/host/src/resend.rs +52 -10
- data/rust/host/src/server.rs +7 -3
- data/rust/host/src/web.rs +38 -5
- data/rust/parser/src/canonical.rs +137 -2
- data/rust/parser/src/emit.rs +25 -19
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +44 -0
- data/rust/project/naming.rb +4 -2
- data/rust/project_rust_pipeline.rb +4 -0
- data/rust/src/kernel/cli.rs +3 -0
- data/rust/src/kernel/mod.rs +1 -0
- data/rust/src/kernel/needs.rs +194 -0
- data/rust/src/kernel/orchestrate.rs +84 -0
- data/rust/src/kernel/repository.rs +43 -2
- metadata +9 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../tools"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module Tools
|
|
9
|
+
# Runs a stage's checks, which `lib/hecks/gate/stages.yml` holds as data, all at once and
|
|
10
|
+
# reports every one, so a red check does not hide the others.
|
|
11
|
+
#
|
|
12
|
+
# hecks gate pre_push # every check of the stage
|
|
13
|
+
# hecks gate pre_push only=rubocop # the named checks
|
|
14
|
+
# hecks gate --list # the stages and their checks
|
|
15
|
+
#
|
|
16
|
+
# A green stage prints which checks passed; a red one prints each failing check's whole output
|
|
17
|
+
# and what its failure means, and answers status 1.
|
|
18
|
+
module Gate
|
|
19
|
+
STAGES_FILE = File.expand_path("../gate/stages.yml", __dir__)
|
|
20
|
+
USAGE = "usage: hecks gate <stage> [only=a,b] | hecks gate --list"
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# @param argv [Array<String>] a stage name and optionally `only=a,b`, or `--list`
|
|
25
|
+
# @param root [String] the checkout the checks run in
|
|
26
|
+
# @return [Integer] 0 when every check passed, 1 when one failed, 2 on bad usage
|
|
27
|
+
def main(argv, root: Tools::ROOT, **)
|
|
28
|
+
stages = YAML.load_file(STAGES_FILE)
|
|
29
|
+
return list(stages) if argv == ["--list"]
|
|
30
|
+
|
|
31
|
+
name = argv.find { |arg| !arg.include?("=") && !arg.start_with?("--") }
|
|
32
|
+
only = argv.filter_map { |arg| arg.delete_prefix("only=").split(",") if arg.start_with?("only=") }.flatten
|
|
33
|
+
stage = stages[name]
|
|
34
|
+
return usage(stages, name) unless stage
|
|
35
|
+
|
|
36
|
+
checks = select(stage.fetch("checks"), only)
|
|
37
|
+
return unknown(stage, only) if checks.nil?
|
|
38
|
+
|
|
39
|
+
report(name, checks, run_all(stage, checks, root))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param checks [Array<Hash>] a stage's checks
|
|
43
|
+
# @param only [Array<String>] the check ids asked for; every check when empty
|
|
44
|
+
# @return [Array<Hash>, nil] the checks to run, nil when an id names no check
|
|
45
|
+
def select(checks, only)
|
|
46
|
+
return checks if only.empty?
|
|
47
|
+
return nil unless (only - checks.map { |check| check["id"] }).empty?
|
|
48
|
+
|
|
49
|
+
checks.select { |check| only.include?(check["id"]) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Starts every check at once and waits for all of them.
|
|
53
|
+
#
|
|
54
|
+
# @param stage [Hash] the stage: its `env` and `checks`
|
|
55
|
+
# @param checks [Array<Hash>] the checks to run
|
|
56
|
+
# @param root [String] the directory they run in
|
|
57
|
+
# @return [Hash{String => Array}] each check's id, and its output and whether it passed
|
|
58
|
+
def run_all(stage, checks, root)
|
|
59
|
+
env = stage.fetch("env", {}).reject { |key, _| ENV.key?(key) }
|
|
60
|
+
threads = checks.map do |check|
|
|
61
|
+
Thread.new { [check["id"], run_one(check, env, root)] }
|
|
62
|
+
end
|
|
63
|
+
threads.to_h(&:value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Array(String, Boolean)] what the check printed, and whether it exited 0
|
|
67
|
+
def run_one(check, env, root)
|
|
68
|
+
argv = check.fetch("run").map { |word| word.to_s.sub("{workers}", workers.to_s) }
|
|
69
|
+
output, status = Open3.capture2e(env, *argv, chdir: root)
|
|
70
|
+
[output, status.success?]
|
|
71
|
+
rescue SystemCallError => e
|
|
72
|
+
["could not start #{argv.first}: #{e.message}", false]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Integer] half the machine's cores, at least one; the parallel suite at full width
|
|
76
|
+
# starves the fuzzing run beside it
|
|
77
|
+
def workers
|
|
78
|
+
require "etc"
|
|
79
|
+
[Etc.nprocessors / 2, 1].max
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def report(name, checks, results)
|
|
83
|
+
failed = checks.reject { |check| results.fetch(check["id"]).last }
|
|
84
|
+
failed.each do |check|
|
|
85
|
+
output, = results.fetch(check["id"])
|
|
86
|
+
puts "\n[gate #{name}] #{check['title']}\n\n#{output}"
|
|
87
|
+
puts "\n[gate #{name}] BLOCKED: #{check['blocked']}\n"
|
|
88
|
+
end
|
|
89
|
+
if failed.empty?
|
|
90
|
+
puts "[gate #{name}] green: #{checks.map { |check| check['id'] }.join(', ')}"
|
|
91
|
+
return 0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
puts "[gate #{name}] red: #{failed.map { |check| check['id'] }.join(', ')}"
|
|
95
|
+
1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def list(stages)
|
|
99
|
+
stages.each do |name, stage|
|
|
100
|
+
puts "#{name}: #{stage.fetch('checks').map { |check| check['id'] }.join(', ')}"
|
|
101
|
+
end
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def unknown(stage, only)
|
|
106
|
+
known = stage.fetch("checks").map { |check| check["id"] }
|
|
107
|
+
warn "no such check: #{(only - known).join(', ')} (checks: #{known.join(', ')})"
|
|
108
|
+
2
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def usage(stages, name)
|
|
112
|
+
warn(name ? "no such stage: #{name} (stages: #{stages.keys.join(', ')})" : USAGE)
|
|
113
|
+
2
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/hecks/tools.rb
CHANGED
|
@@ -19,7 +19,10 @@ module Hecks
|
|
|
19
19
|
"standardize_comments" => ["tools/comment_style", "CommentStyle"],
|
|
20
20
|
"standardize_comments_rust" => ["tools/rust_comment_style", "RustCommentStyle"],
|
|
21
21
|
"regen_codegen_domains" => ["tools/regeneration_run", "RegenerationRun"],
|
|
22
|
+
"gate" => ["tools/gate", "Gate"],
|
|
22
23
|
"argument_gate_matrix" => ["tools/argument_gate_matrix", "ArgumentGateMatrix"],
|
|
24
|
+
"project_ci_gates" => ["tools/ci_gates", "CiGates"],
|
|
25
|
+
"decide_ci_gate" => ["tools/ci_gate_decision", "CiGateDecision"],
|
|
23
26
|
"project_deploy" => ["tools/deploy_recipe", "DeployRecipe"],
|
|
24
27
|
"lint_deploy_recipes" => ["tools/deploy_recipe_lint", "DeployRecipeLint"],
|
|
25
28
|
"project_oidc" => ["tools/oidc_manifests", "OidcManifests"],
|
data/lib/hecks/version.rb
CHANGED
data/lib/hecks/vocabulary.rb
CHANGED
|
@@ -49,6 +49,10 @@ module Hecks
|
|
|
49
49
|
{"name"=>"build-dependencies"}.freeze,
|
|
50
50
|
{"name"=>"workspace"}.freeze
|
|
51
51
|
].freeze,
|
|
52
|
+
"CiGate" => [
|
|
53
|
+
{"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"skip", "label"=>"does this change touch lib/hecks/runtime/**?"}.freeze,
|
|
54
|
+
{"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "pattern"=>"^(docs/|editors/|release/|deploy/|\\.claude/|\\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests|project)/|rust/project\\.rb$|rust/project_rust_pipeline\\.rb$|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"before_sha", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
|
|
55
|
+
].freeze,
|
|
52
56
|
"Comparison" => [
|
|
53
57
|
{"symbol"=>">=", "compares_less_than"=>"true", "compares_equal"=>"false", "negated"=>"true"}.freeze,
|
|
54
58
|
{"symbol"=>"<=", "compares_less_than"=>"true", "compares_equal"=>"true", "negated"=>"false"}.freeze,
|
|
@@ -123,7 +127,8 @@ module Hecks
|
|
|
123
127
|
].freeze,
|
|
124
128
|
"NormalisationStrategy" => [
|
|
125
129
|
{"name"=>"collapse_whitespace"}.freeze,
|
|
126
|
-
{"name"=>"replace"}.freeze
|
|
130
|
+
{"name"=>"replace"}.freeze,
|
|
131
|
+
{"name"=>"scale_call"}.freeze
|
|
127
132
|
].freeze,
|
|
128
133
|
"Primitive" => [
|
|
129
134
|
{"name"=>"String"}.freeze,
|
data/lib/hecks.rb
CHANGED
|
@@ -27,8 +27,7 @@ require_relative "hecks/projector"
|
|
|
27
27
|
# every target registers itself as it loads, so this require is the
|
|
28
28
|
# installation of them.
|
|
29
29
|
require_relative "hecks/projections"
|
|
30
|
-
# After `Projector
|
|
31
|
-
# `Ports::Clock` (fills a staleness rule's `now` at the door) both exist.
|
|
30
|
+
# After `Projector`, which dispatches against the `:cli` projection.
|
|
32
31
|
require_relative "hecks/doors/cli_door"
|
|
33
32
|
require_relative "hecks/doors/cli_runner"
|
|
34
33
|
require_relative "hecks/storehouse"
|
|
@@ -76,6 +75,17 @@ module Hecks
|
|
|
76
75
|
Runtime.describe(path, shared: shared, environment: environment)
|
|
77
76
|
end
|
|
78
77
|
|
|
78
|
+
# Finishes a boot from declarations `describe` already loaded, so the domain's files are read
|
|
79
|
+
# once. See `Runtime::Loader.boot_described`.
|
|
80
|
+
#
|
|
81
|
+
# @param described [Runtime::Loader::Described] what `describe` answered
|
|
82
|
+
# @param install_doors [Boolean] install the `Widget::Item.Add`-style facade
|
|
83
|
+
# @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
|
|
84
|
+
# @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] dispatcher bound to the domain
|
|
85
|
+
def boot_described(described, install_doors: true, install_facade: nil)
|
|
86
|
+
Runtime.boot_described(described, install_doors: install_doors, install_facade: install_facade)
|
|
87
|
+
end
|
|
88
|
+
|
|
79
89
|
# Boots a domain from an explicit list of files (a `.bluebook`, its
|
|
80
90
|
# `.hecksagon`, optionally a `.world`) instead of a whole directory —
|
|
81
91
|
# see `Runtime::Loader.boot_files` for why this exists beside `boot`.
|
data/rust/build/src/resolve.rs
CHANGED
|
@@ -94,7 +94,20 @@ fn resolve_json(parser_bin: &Path, chapter_name: &str, hecksagon_path: &Path, ke
|
|
|
94
94
|
Ok(names.iter().filter_map(Json::as_str).map(str::to_string).collect())
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/// `/\A[a-z][a-z0-9_]*\z/` — the shape `hecks vendor` accepts for a package directory name.
|
|
98
|
+
/// The name is joined into a path and, through its chapter, into a directory the build deletes,
|
|
99
|
+
/// so `../x` or an absolute path must never reach either.
|
|
100
|
+
pub fn valid_package_name(name: &str) -> bool {
|
|
101
|
+
let mut chars = name.chars();
|
|
102
|
+
matches!(chars.next(), Some(c) if c.is_ascii_lowercase()) && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
pub fn vendored_bluebook_files(domain: &Path, pkg_name: &str) -> Result<Vec<PathBuf>, String> {
|
|
106
|
+
if !valid_package_name(pkg_name) {
|
|
107
|
+
return Err(format!(
|
|
108
|
+
"uses_embryonaut_bluebook {pkg_name:?} is not a package name — it must match [a-z][a-z0-9_]* (the name `hecks vendor` accepts)"
|
|
109
|
+
));
|
|
110
|
+
}
|
|
98
111
|
let dir = domain.join("vendor").join("embryonaut_bluebooks").join(pkg_name).join("bluebook");
|
|
99
112
|
if !dir.is_dir() {
|
|
100
113
|
return Err(format!(
|
|
@@ -109,6 +122,18 @@ pub fn vendored_bluebook_files(domain: &Path, pkg_name: &str) -> Result<Vec<Path
|
|
|
109
122
|
mod tests {
|
|
110
123
|
use super::*;
|
|
111
124
|
|
|
125
|
+
#[test]
|
|
126
|
+
fn a_package_name_that_is_a_path_is_refused_before_any_directory_is_touched() {
|
|
127
|
+
for name in ["../x", "a/b", "/etc", "", "X", "1st", "a-b", "a b", ".."] {
|
|
128
|
+
assert!(!valid_package_name(name), "{name:?}");
|
|
129
|
+
let refusal = vendored_bluebook_files(Path::new("/nonexistent"), name).unwrap_err();
|
|
130
|
+
assert!(refusal.contains("is not a package name"), "{refusal}");
|
|
131
|
+
}
|
|
132
|
+
for name in ["payments", "console_settings", "a1"] {
|
|
133
|
+
assert!(valid_package_name(name), "{name:?}");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
#[test]
|
|
113
138
|
fn pascal_cases_snake_case_stems() {
|
|
114
139
|
assert_eq!(pascal("governance"), "Governance");
|
data/rust/codegen/src/naming.rs
CHANGED
|
@@ -125,13 +125,15 @@ fn plain_identifier(name: &str) -> bool {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
/// Every declared `name` in the IR, in document order. The `fields` maps under `mutations` are
|
|
128
|
-
/// skipped: their keys are attribute names and their values are source text, not declarations.
|
|
128
|
+
/// skipped: their keys are attribute names and their values are source text, not declarations. So is
|
|
129
|
+
/// `translations`: an era edge names data paths (`attendee.first_name`, a backfill into a nested
|
|
130
|
+
/// value object), which the host applies to stored rows and which are never written into Rust.
|
|
129
131
|
fn declared_names<'a>(ir: &'a Json, out: &mut Vec<&'a str>) {
|
|
130
132
|
match ir {
|
|
131
133
|
Json::Object(pairs) => {
|
|
132
134
|
for (key, value) in pairs {
|
|
133
135
|
match (key.as_str(), value) {
|
|
134
|
-
("fields", _) => {}
|
|
136
|
+
("fields" | "translations", _) => {}
|
|
135
137
|
("name", Json::String(name)) => out.push(name),
|
|
136
138
|
_ => declared_names(value, out),
|
|
137
139
|
}
|
|
@@ -335,6 +337,15 @@ mod tests {
|
|
|
335
337
|
assert_eq!(unsafe_name_refusal("shop", &ir), None);
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
#[test]
|
|
341
|
+
fn ignores_data_paths_named_by_a_translation_edge() {
|
|
342
|
+
let ir = Json::parse(
|
|
343
|
+
r#"{"name":"Shop","aggregates":[{"name":"Pizza"}],"translations":[{"aggregates":[{"name":"Pizza","backfills":[{"name":"attendee.first_name"}]}]}]}"#,
|
|
344
|
+
)
|
|
345
|
+
.unwrap();
|
|
346
|
+
assert_eq!(unsafe_name_refusal("shop", &ir), None);
|
|
347
|
+
}
|
|
348
|
+
|
|
338
349
|
#[test]
|
|
339
350
|
fn refuses_an_aggregate_name_that_is_not_a_plain_identifier() {
|
|
340
351
|
assert!(reserved_name_refusal("shop", "shop", &["My-App"]).is_some());
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.0.
|
|
1
|
+
3.0.4
|
data/rust/host/src/dispatch.rs
CHANGED
|
@@ -14,6 +14,61 @@ pub struct Outcome {
|
|
|
14
14
|
pub accepted: bool,
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/// Who a command is dispatched for: the role they state and, when the host knows who they are, the
|
|
18
|
+
/// actor Governance holds assignments for. Both absent is an unidentified caller.
|
|
19
|
+
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
20
|
+
pub struct Caller<'a> {
|
|
21
|
+
pub role: Option<&'a str>,
|
|
22
|
+
pub actor_id: Option<&'a str>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// The role an unidentified caller is dispatched under when roles are enforced: no command declares
|
|
26
|
+
/// it, so a command that declares any role refuses it.
|
|
27
|
+
pub const ANONYMOUS_ROLE: &str = "Anonymous";
|
|
28
|
+
|
|
29
|
+
/// What the host does about the role a command declares (`HECKS_ROLE_ENFORCEMENT`).
|
|
30
|
+
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
31
|
+
pub enum Enforcement {
|
|
32
|
+
/// A caller with no role is unchecked, as before.
|
|
33
|
+
Off,
|
|
34
|
+
/// An unidentified or unassigned caller is let through and logged as `would_refuse_role`.
|
|
35
|
+
Shadow,
|
|
36
|
+
/// An unidentified or unassigned caller is refused.
|
|
37
|
+
Enforce,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl Enforcement {
|
|
41
|
+
/// Reads `off` (the default), `shadow` or `enforce`; anything else is `Off`.
|
|
42
|
+
pub fn parse(value: Option<&str>) -> Self {
|
|
43
|
+
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
|
|
44
|
+
Some("shadow") => Self::Shadow,
|
|
45
|
+
Some("enforce") => Self::Enforce,
|
|
46
|
+
_ => Self::Off,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
pub fn from_env() -> Self {
|
|
51
|
+
static MODE: std::sync::OnceLock<Enforcement> = std::sync::OnceLock::new();
|
|
52
|
+
*MODE.get_or_init(|| Self::parse(std::env::var("HECKS_ROLE_ENFORCEMENT").ok().as_deref()))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The caller the kernel is asked to check: an unidentified caller becomes the anonymous role
|
|
56
|
+
/// unless enforcement is off.
|
|
57
|
+
pub fn effective<'a>(self, caller: Caller<'a>) -> Caller<'a> {
|
|
58
|
+
if self != Self::Off && caller.role.is_none() && caller.actor_id.is_none() {
|
|
59
|
+
return Caller { role: Some(ANONYMOUS_ROLE), actor_id: None };
|
|
60
|
+
}
|
|
61
|
+
caller
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Whether the kernel refused the command for the caller's role.
|
|
66
|
+
fn refused_for_role(result: &serde_json::Value) -> bool {
|
|
67
|
+
result.get("refusals").and_then(|r| r.as_array()).is_some_and(|all| {
|
|
68
|
+
all.iter().any(|r| r.get("kind").and_then(|k| k.as_str()) == Some("Unauthorized"))
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
17
72
|
// Distinguishes a module refusal from a kernel failure to run at all.
|
|
18
73
|
#[derive(Debug, PartialEq)]
|
|
19
74
|
enum KernelAnswer {
|
|
@@ -80,6 +135,21 @@ pub async fn handle_routed(
|
|
|
80
135
|
handle(client, wasm_path, verb, routed_invocation(to, facts)?, role, config, invoker).await
|
|
81
136
|
}
|
|
82
137
|
|
|
138
|
+
/// `handle_routed` for a caller the host can name.
|
|
139
|
+
#[allow(clippy::too_many_arguments)]
|
|
140
|
+
pub async fn handle_routed_as(
|
|
141
|
+
client: &Mutex<Client>,
|
|
142
|
+
wasm_path: &Path,
|
|
143
|
+
verb: &str,
|
|
144
|
+
to: serde_json::Value,
|
|
145
|
+
facts: serde_json::Value,
|
|
146
|
+
caller: Caller<'_>,
|
|
147
|
+
config: &LineageConfig,
|
|
148
|
+
invoker: &dyn LambdaInvoker,
|
|
149
|
+
) -> anyhow::Result<Outcome> {
|
|
150
|
+
handle_as(client, wasm_path, verb, routed_invocation(to, facts)?, caller, config, invoker).await
|
|
151
|
+
}
|
|
152
|
+
|
|
83
153
|
// Facts-only counterpart to `handle_routed`.
|
|
84
154
|
#[allow(clippy::too_many_arguments)]
|
|
85
155
|
pub async fn handle_facts(
|
|
@@ -94,6 +164,20 @@ pub async fn handle_facts(
|
|
|
94
164
|
handle(client, wasm_path, verb, facts_invocation(facts)?, role, config, invoker).await
|
|
95
165
|
}
|
|
96
166
|
|
|
167
|
+
/// `handle_facts` for a caller the host can name.
|
|
168
|
+
#[allow(clippy::too_many_arguments)]
|
|
169
|
+
pub async fn handle_facts_as(
|
|
170
|
+
client: &Mutex<Client>,
|
|
171
|
+
wasm_path: &Path,
|
|
172
|
+
verb: &str,
|
|
173
|
+
facts: serde_json::Value,
|
|
174
|
+
caller: Caller<'_>,
|
|
175
|
+
config: &LineageConfig,
|
|
176
|
+
invoker: &dyn LambdaInvoker,
|
|
177
|
+
) -> anyhow::Result<Outcome> {
|
|
178
|
+
handle_as(client, wasm_path, verb, facts_invocation(facts)?, caller, config, invoker).await
|
|
179
|
+
}
|
|
180
|
+
|
|
97
181
|
// A Mutex, not a bare Arc<Client>: `handle` needs `Client::transaction`,
|
|
98
182
|
// which takes `&mut Client`. Locking also means concurrent callers on
|
|
99
183
|
// this connection can't interleave statements.
|
|
@@ -114,6 +198,57 @@ pub async fn handle(
|
|
|
114
198
|
config: &LineageConfig,
|
|
115
199
|
invoker: &dyn LambdaInvoker,
|
|
116
200
|
) -> anyhow::Result<Outcome> {
|
|
201
|
+
handle_as(client, wasm_path, verb, args, Caller { role, actor_id: None }, config, invoker).await
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Dispatches for a caller, applying `HECKS_ROLE_ENFORCEMENT`: unidentified callers are refused (or,
|
|
205
|
+
/// in shadow, logged as would-be refusals and let through).
|
|
206
|
+
pub async fn handle_as(
|
|
207
|
+
client: &Mutex<Client>,
|
|
208
|
+
wasm_path: &Path,
|
|
209
|
+
verb: &str,
|
|
210
|
+
args: serde_json::Value,
|
|
211
|
+
caller: Caller<'_>,
|
|
212
|
+
config: &LineageConfig,
|
|
213
|
+
invoker: &dyn LambdaInvoker,
|
|
214
|
+
) -> anyhow::Result<Outcome> {
|
|
215
|
+
handle_with(Enforcement::from_env(), client, wasm_path, verb, args, caller, config, invoker).await
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[allow(clippy::too_many_arguments)]
|
|
219
|
+
async fn handle_with(
|
|
220
|
+
mode: Enforcement,
|
|
221
|
+
client: &Mutex<Client>,
|
|
222
|
+
wasm_path: &Path,
|
|
223
|
+
verb: &str,
|
|
224
|
+
args: serde_json::Value,
|
|
225
|
+
caller: Caller<'_>,
|
|
226
|
+
config: &LineageConfig,
|
|
227
|
+
invoker: &dyn LambdaInvoker,
|
|
228
|
+
) -> anyhow::Result<Outcome> {
|
|
229
|
+
let checked = mode.effective(caller);
|
|
230
|
+
let outcome = run(client, wasm_path, verb, args.clone(), checked, config, invoker).await?;
|
|
231
|
+
if mode == Enforcement::Shadow && !outcome.accepted && refused_for_role(&outcome.result) {
|
|
232
|
+
crate::log::error("would_refuse_role", serde_json::json!({
|
|
233
|
+
"verb": verb, "role": checked.role, "actor_id": checked.actor_id,
|
|
234
|
+
"refusals": outcome.result.get("refusals"),
|
|
235
|
+
}));
|
|
236
|
+
return run(client, wasm_path, verb, args, Caller::default(), config, invoker).await;
|
|
237
|
+
}
|
|
238
|
+
Ok(outcome)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[allow(clippy::too_many_arguments)]
|
|
242
|
+
async fn run(
|
|
243
|
+
client: &Mutex<Client>,
|
|
244
|
+
wasm_path: &Path,
|
|
245
|
+
verb: &str,
|
|
246
|
+
args: serde_json::Value,
|
|
247
|
+
caller: Caller<'_>,
|
|
248
|
+
config: &LineageConfig,
|
|
249
|
+
invoker: &dyn LambdaInvoker,
|
|
250
|
+
) -> anyhow::Result<Outcome> {
|
|
251
|
+
let role = caller.role;
|
|
117
252
|
let mut guard = client.lock().await;
|
|
118
253
|
let txn = guard.transaction().await?;
|
|
119
254
|
|
|
@@ -147,10 +282,19 @@ pub async fn handle(
|
|
|
147
282
|
// carries whatever role it was dispatched with. Built as a mutable
|
|
148
283
|
// step, not inlined into the `json!` literal, so a roleless call
|
|
149
284
|
// still omits the `"role"` key entirely rather than sending null.
|
|
285
|
+
// A fact the command `needs` is answered here, before the kernel's gates read the arguments and
|
|
286
|
+
// before the step is journaled, so a replay re-dispatches the recorded answer (ADR 0081).
|
|
287
|
+
let mut args = args;
|
|
288
|
+
if let Some(domain_ir) = crate::ir::ir() {
|
|
289
|
+
crate::needs::fill_needs(domain_ir, verb, &mut args, &crate::needs::ProcessClock);
|
|
290
|
+
}
|
|
150
291
|
let mut step = serde_json::json!({ "verb": verb, "args": args.clone() });
|
|
151
292
|
if let Some(role) = role {
|
|
152
293
|
step["role"] = serde_json::Value::String(role.to_string());
|
|
153
294
|
}
|
|
295
|
+
if let Some(actor_id) = caller.actor_id {
|
|
296
|
+
step["actor_id"] = serde_json::Value::String(actor_id.to_string());
|
|
297
|
+
}
|
|
154
298
|
// Stamped only on this live step; replayed history already carries
|
|
155
299
|
// its own real timestamp, and re-stamping today's time onto a
|
|
156
300
|
// replay would be wrong, not just redundant.
|
|
@@ -179,7 +323,15 @@ pub async fn handle(
|
|
|
179
323
|
}))
|
|
180
324
|
.collect::<Vec<_>>());
|
|
181
325
|
|
|
182
|
-
let input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed })
|
|
326
|
+
let mut input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed });
|
|
327
|
+
// Lets the kernel answer a reaction's command that needs a fact the same way (ADR 0081).
|
|
328
|
+
if let Some(domain_ir) = crate::ir::ir() {
|
|
329
|
+
let table = crate::needs::table(domain_ir);
|
|
330
|
+
if table.as_object().is_some_and(|t| !t.is_empty()) {
|
|
331
|
+
input["needs"] = table;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let input = input.to_string();
|
|
183
335
|
// `wasm_runner::run` is sync, and wasmtime-wasi's sync bridge spins up
|
|
184
336
|
// its own tokio runtime internally — fatal on a thread already
|
|
185
337
|
// driving one. `spawn_blocking` moves it off this async runtime.
|
|
@@ -1169,6 +1321,60 @@ pub(crate) mod tests {
|
|
|
1169
1321
|
assert_eq!(steps.len(), 1, "only the correctly-authorized registration should be persisted");
|
|
1170
1322
|
}
|
|
1171
1323
|
|
|
1324
|
+
#[test]
|
|
1325
|
+
fn enforcement_reads_off_shadow_and_enforce_and_treats_anything_else_as_off() {
|
|
1326
|
+
assert_eq!(Enforcement::parse(None), Enforcement::Off);
|
|
1327
|
+
assert_eq!(Enforcement::parse(Some("")), Enforcement::Off);
|
|
1328
|
+
assert_eq!(Enforcement::parse(Some("nonsense")), Enforcement::Off);
|
|
1329
|
+
assert_eq!(Enforcement::parse(Some("Shadow")), Enforcement::Shadow);
|
|
1330
|
+
assert_eq!(Enforcement::parse(Some(" enforce ")), Enforcement::Enforce);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
#[test]
|
|
1334
|
+
fn only_an_unidentified_caller_becomes_anonymous_and_only_when_roles_are_checked() {
|
|
1335
|
+
let nobody = Caller::default();
|
|
1336
|
+
assert_eq!(Enforcement::Off.effective(nobody), nobody);
|
|
1337
|
+
for mode in [Enforcement::Shadow, Enforcement::Enforce] {
|
|
1338
|
+
assert_eq!(mode.effective(nobody).role, Some(ANONYMOUS_ROLE));
|
|
1339
|
+
let stated = Caller { role: Some("Teller"), actor_id: None };
|
|
1340
|
+
assert_eq!(mode.effective(stated), stated, "a stated role is kept");
|
|
1341
|
+
let identified = Caller { role: None, actor_id: Some("u1") };
|
|
1342
|
+
assert_eq!(mode.effective(identified), identified, "an identified caller is kept");
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// The unidentified caller is the case the host never refused: no role at all.
|
|
1347
|
+
#[tokio::test]
|
|
1348
|
+
async fn an_unidentified_caller_is_refused_when_enforced_and_let_through_when_off_or_shadowed() {
|
|
1349
|
+
let client = scratch_db("rust_host_dispatch_test_enforce").await;
|
|
1350
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1351
|
+
let config = test_config("Banking", 1);
|
|
1352
|
+
let go = |mode: Enforcement, id: &'static str| {
|
|
1353
|
+
let client = &client;
|
|
1354
|
+
let config = &config;
|
|
1355
|
+
async move {
|
|
1356
|
+
handle_with(mode, client, &wasm_path(), "Banking::Customer.Register", register(id), Caller::default(), config, &lambda_client::NeverInvoker)
|
|
1357
|
+
.await
|
|
1358
|
+
.unwrap()
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
let off = go(Enforcement::Off, "CUST-0021").await;
|
|
1363
|
+
assert!(off.accepted, "off keeps the old behaviour: {:?}", off.result);
|
|
1364
|
+
|
|
1365
|
+
let enforced = go(Enforcement::Enforce, "CUST-0022").await;
|
|
1366
|
+
assert!(!enforced.accepted, "an unidentified caller must be refused: {:?}", enforced.result);
|
|
1367
|
+
assert!(refused_for_role(&enforced.result), "{:?}", enforced.result);
|
|
1368
|
+
let error = enforced.result["refusals"][0]["error"].as_str().unwrap();
|
|
1369
|
+
assert!(error.contains("Branch clerk") && error.contains(ANONYMOUS_ROLE), "{error}");
|
|
1370
|
+
|
|
1371
|
+
let shadowed = go(Enforcement::Shadow, "CUST-0023").await;
|
|
1372
|
+
assert!(shadowed.accepted, "shadow lets the command through: {:?}", shadowed.result);
|
|
1373
|
+
|
|
1374
|
+
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1375
|
+
assert_eq!(steps.len(), 2, "the enforced refusal was not persisted; off and shadow were");
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1172
1378
|
#[tokio::test]
|
|
1173
1379
|
async fn a_mid_transaction_failure_rolls_back_any_saga_state_already_written() {
|
|
1174
1380
|
// Deliberately doesn't provision "Transfer": its mutation fails
|