rails-mmd 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 +10 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/SECURITY.md +40 -0
- data/docs/active-record-association-support.md +37 -0
- data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
- data/docs/usage.md +126 -0
- data/exe/rails-mmd +6 -0
- data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
- data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
- data/lib/rails_mmd/attribute_types.rb +29 -0
- data/lib/rails_mmd/canonical_json.rb +19 -0
- data/lib/rails_mmd/cli.rb +86 -0
- data/lib/rails_mmd/config.rb +249 -0
- data/lib/rails_mmd/constant_resolver.rb +33 -0
- data/lib/rails_mmd/diagnostic_factory.rb +122 -0
- data/lib/rails_mmd/domain_resolver.rb +97 -0
- data/lib/rails_mmd/exit_policy.rb +27 -0
- data/lib/rails_mmd/generate_command.rb +116 -0
- data/lib/rails_mmd/generation_pipeline.rb +109 -0
- data/lib/rails_mmd/ir_builder.rb +122 -0
- data/lib/rails_mmd/mermaid_serializer.rb +168 -0
- data/lib/rails_mmd/metadata_shapes.rb +55 -0
- data/lib/rails_mmd/model_inventory.rb +144 -0
- data/lib/rails_mmd/ordering.rb +25 -0
- data/lib/rails_mmd/output_directory.rb +192 -0
- data/lib/rails_mmd/publisher.rb +166 -0
- data/lib/rails_mmd/rails_loader.rb +96 -0
- data/lib/rails_mmd/redactor.rb +143 -0
- data/lib/rails_mmd/relationship_builder.rb +982 -0
- data/lib/rails_mmd/render_plan_builder.rb +192 -0
- data/lib/rails_mmd/safe_token_assigner.rb +130 -0
- data/lib/rails_mmd/schema_probe.rb +376 -0
- data/lib/rails_mmd/schema_validator.rb +136 -0
- data/lib/rails_mmd/subject_ids.rb +17 -0
- data/lib/rails_mmd/tooling_tasks.rb +47 -0
- data/lib/rails_mmd/verification_runner.rb +143 -0
- data/lib/rails_mmd/version.rb +5 -0
- data/lib/rails_mmd.rb +15 -0
- data/schemas/config.schema.json +54 -0
- data/schemas/diagnostics.schema.json +418 -0
- data/schemas/ir.schema.json +84 -0
- data/schemas/render_plan.schema.json +136 -0
- metadata +135 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module RailsMmd
|
|
8
|
+
# Validates and resolves project-root-relative output directories.
|
|
9
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
10
|
+
class OutputDirectory
|
|
11
|
+
Resolution = Struct.new(:path, :relative_path, :reason, keyword_init: true) do
|
|
12
|
+
def valid?
|
|
13
|
+
reason.nil?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
DRIVE_OR_UNC_PATTERN = /\A(?:[A-Za-z]:|\\\\)/
|
|
17
|
+
SAFE_RELATIVE_PATH_PATTERN = %r{\A[A-Za-z0-9._/-]+\z}
|
|
18
|
+
MANAGED_PATH_PATTERN = /
|
|
19
|
+
\A(?:
|
|
20
|
+
global\.diagnostics\.json |
|
|
21
|
+
[a-z][a-z0-9_]*\.(?:diagnostics\.json|(?:er|class)\.mmd|(?:er|class)\.render_plan\.json)
|
|
22
|
+
)\z
|
|
23
|
+
/x
|
|
24
|
+
|
|
25
|
+
def initialize(project_root:)
|
|
26
|
+
@project_root = Pathname(project_root).expand_path
|
|
27
|
+
@project_root_real = @project_root.realpath
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resolve(value)
|
|
31
|
+
text = value.to_s
|
|
32
|
+
reason = lexical_rejection_reason(text)
|
|
33
|
+
return invalid_resolution(reason) if reason
|
|
34
|
+
|
|
35
|
+
relative_path = normalize(text)
|
|
36
|
+
return invalid_resolution('normalizes to project root') if relative_path == '.'
|
|
37
|
+
return invalid_resolution('escapes project root') if relative_path.start_with?('../') || relative_path == '..'
|
|
38
|
+
|
|
39
|
+
absolute_path = project_root.join(relative_path)
|
|
40
|
+
return invalid_resolution('symlink escapes project root') unless inside_project_root?(absolute_path)
|
|
41
|
+
|
|
42
|
+
Resolution.new(path: absolute_path, relative_path: relative_path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def publish!(resolution, selected_domain_ids:)
|
|
46
|
+
raise ArgumentError, 'output directory is unresolved' unless resolution&.valid? && resolution.path
|
|
47
|
+
|
|
48
|
+
output_path = resolution.path
|
|
49
|
+
raise ArgumentError, 'output path escapes project root' unless inside_project_root?(output_path)
|
|
50
|
+
|
|
51
|
+
output_path.mkpath
|
|
52
|
+
ensure_inside_project_root!(output_path)
|
|
53
|
+
plan = yield
|
|
54
|
+
ensure_managed_plan!(plan, selected_domain_ids)
|
|
55
|
+
write_plan(output_path, plan, selected_domain_ids)
|
|
56
|
+
plan.keys.sort
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
attr_reader :project_root, :project_root_real
|
|
62
|
+
|
|
63
|
+
def lexical_rejection_reason(text)
|
|
64
|
+
return 'is empty' if text.empty?
|
|
65
|
+
return 'contains NUL' if text.include?("\u0000")
|
|
66
|
+
return 'contains backslash' if text.include?('\\')
|
|
67
|
+
return 'uses drive or UNC form' if text.match?(DRIVE_OR_UNC_PATTERN)
|
|
68
|
+
return 'is absolute' if Pathname(text).absolute?
|
|
69
|
+
|
|
70
|
+
'contains unsupported characters' unless text.match?(SAFE_RELATIVE_PATH_PATTERN)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize(text)
|
|
74
|
+
Pathname(text).cleanpath.to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inside_project_root?(absolute_path)
|
|
78
|
+
existing = closest_existing_path(absolute_path)
|
|
79
|
+
relative = existing.realpath.relative_path_from(project_root_real).to_s
|
|
80
|
+
relative != '..' && !relative.start_with?('../')
|
|
81
|
+
rescue Errno::ENOENT
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def closest_existing_path(path)
|
|
86
|
+
current = Pathname(path)
|
|
87
|
+
current = current.parent until current.exist?
|
|
88
|
+
current
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write_plan(output_path, plan, selected_domain_ids)
|
|
92
|
+
ensure_safe_replacement_set!(output_path, plan, selected_domain_ids)
|
|
93
|
+
Dir.mktmpdir('.rails-mmd-', output_path.to_s) do |staging_directory|
|
|
94
|
+
staging_path = Pathname(staging_directory)
|
|
95
|
+
plan.each { |relative_path, content| staging_path.join(relative_path).write(content) }
|
|
96
|
+
ensure_inside_project_root!(output_path)
|
|
97
|
+
ensure_safe_replacement_set!(output_path, plan, selected_domain_ids)
|
|
98
|
+
replace_plan_files(output_path, staging_path, plan, selected_domain_ids)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def replace_plan_files(output_path, tmp_path, plan, selected_domain_ids)
|
|
103
|
+
backup_path = Pathname(Dir.mktmpdir('.rails-mmd-backup-', output_path.to_s))
|
|
104
|
+
published_paths = []
|
|
105
|
+
backup_existing_files(output_path, backup_path, selected_domain_ids)
|
|
106
|
+
move_plan_files(output_path, tmp_path, plan, published_paths)
|
|
107
|
+
replacement_complete = true
|
|
108
|
+
rescue StandardError
|
|
109
|
+
rollback_replacement(output_path, backup_path, published_paths) if backup_path
|
|
110
|
+
raise
|
|
111
|
+
ensure
|
|
112
|
+
FileUtils.remove_entry(backup_path) if backup_path && (replacement_complete || backup_path.children.empty?)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def backup_existing_files(output_path, backup_path, selected_domain_ids)
|
|
116
|
+
scoped_managed_paths(output_path, selected_domain_ids).each do |path|
|
|
117
|
+
ensure_safe_target!(output_path, path.basename.to_s)
|
|
118
|
+
FileUtils.mv(path.to_s, backup_path.join(path.basename).to_s)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def move_plan_files(output_path, tmp_path, plan, published_paths)
|
|
123
|
+
plan.each_key do |relative_path|
|
|
124
|
+
replace_file(output_path, tmp_path, relative_path)
|
|
125
|
+
published_paths << relative_path
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def rollback_replacement(output_path, backup_path, published_paths)
|
|
130
|
+
published_paths.each do |relative_path|
|
|
131
|
+
target = output_path.join(relative_path)
|
|
132
|
+
target.delete if target.file? && !target.symlink?
|
|
133
|
+
end
|
|
134
|
+
backup_path.children.each do |path|
|
|
135
|
+
ensure_safe_target!(output_path, path.basename.to_s)
|
|
136
|
+
FileUtils.mv(path.to_s, output_path.join(path.basename).to_s)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def ensure_managed_plan!(plan, selected_domain_ids)
|
|
141
|
+
plan.each_key do |relative_path|
|
|
142
|
+
unless relative_path.is_a?(String) && relative_path.match?(MANAGED_PATH_PATTERN) &&
|
|
143
|
+
scoped_managed_path?(relative_path, selected_domain_ids)
|
|
144
|
+
raise ArgumentError, 'output plan path is unmanaged'
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ensure_safe_replacement_set!(output_path, plan, selected_domain_ids)
|
|
150
|
+
scoped_managed_paths(output_path, selected_domain_ids).each do |path|
|
|
151
|
+
ensure_safe_target!(output_path, path.basename.to_s)
|
|
152
|
+
end
|
|
153
|
+
plan.each_key { |relative_path| ensure_safe_target!(output_path, relative_path) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def scoped_managed_paths(output_path, selected_domain_ids)
|
|
157
|
+
output_path.children.select do |path|
|
|
158
|
+
path.basename.to_s.match?(MANAGED_PATH_PATTERN) &&
|
|
159
|
+
scoped_managed_path?(path.basename.to_s, selected_domain_ids)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def scoped_managed_path?(name, selected_domain_ids)
|
|
164
|
+
return true if name == 'global.diagnostics.json'
|
|
165
|
+
|
|
166
|
+
selected_domain_ids.any? { |domain_id| name.start_with?("#{domain_id}.") }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def replace_file(output_path, tmp_path, relative_path)
|
|
170
|
+
ensure_inside_project_root!(output_path)
|
|
171
|
+
ensure_safe_target!(output_path, relative_path)
|
|
172
|
+
FileUtils.mv(tmp_path.join(relative_path).to_s, output_path.join(relative_path).to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def ensure_safe_target!(output_path, relative_path)
|
|
176
|
+
target = output_path.join(relative_path)
|
|
177
|
+
raise ArgumentError, 'output target is symlink' if target.symlink?
|
|
178
|
+
raise ArgumentError, 'output target is not a regular file' if target.exist? && !target.file?
|
|
179
|
+
|
|
180
|
+
ensure_inside_project_root!(target.parent)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def ensure_inside_project_root!(path)
|
|
184
|
+
relative = path.realpath.relative_path_from(project_root_real).to_s
|
|
185
|
+
raise ArgumentError, 'output path escapes project root' if relative == '..' || relative.start_with?('../')
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def invalid_resolution(reason)
|
|
189
|
+
Resolution.new(reason: reason)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rails_mmd/diagnostic_factory'
|
|
5
|
+
require 'rails_mmd/output_directory'
|
|
6
|
+
require 'rails_mmd/schema_validator'
|
|
7
|
+
|
|
8
|
+
module RailsMmd
|
|
9
|
+
# Atomically publishes diagnostics, render plans, and Mermaid artifacts.
|
|
10
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
11
|
+
class Publisher
|
|
12
|
+
Result = Struct.new(:success, :diagnostics, :written_paths, :stderr, keyword_init: true) do
|
|
13
|
+
def success?
|
|
14
|
+
success
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
PRE_OUTPUT_FATAL_CODES = %w[
|
|
19
|
+
CONFIG_NOT_FOUND
|
|
20
|
+
CONFIG_SCHEMA_INVALID
|
|
21
|
+
CONFIG_DOMAIN_NOT_FOUND
|
|
22
|
+
OUTPUT_DIRECTORY_INVALID
|
|
23
|
+
].freeze
|
|
24
|
+
ARTIFACT_KINDS = %w[er class].freeze
|
|
25
|
+
def initialize(project_root:, schema_validator: SchemaValidator.new, diagnostics_factory: DiagnosticFactory.new)
|
|
26
|
+
@output_directory = OutputDirectory.new(project_root: project_root)
|
|
27
|
+
@schema_validator = schema_validator
|
|
28
|
+
@diagnostics_factory = diagnostics_factory
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pre_output_stderr(diagnostics)
|
|
32
|
+
document = schema_validator.validate_pre_output!(diagnostics)
|
|
33
|
+
"#{JSON.pretty_generate(document)}\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def publish(output_dir:, selected_domain_ids:, diagnostics:, artifacts:)
|
|
37
|
+
return pre_output_fatal_result(diagnostics) if pre_output_fatal?(diagnostics)
|
|
38
|
+
|
|
39
|
+
output_resolution = output_directory.resolve(output_dir)
|
|
40
|
+
return invalid_output_result(output_resolution.reason) unless output_resolution.valid?
|
|
41
|
+
|
|
42
|
+
publish_inside_output(output_resolution, selected_domain_ids, diagnostics, artifacts)
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Result.new(success: false, diagnostics: [output_write_failed(e)], written_paths: [], stderr: nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :diagnostics_factory, :output_directory, :schema_validator
|
|
50
|
+
|
|
51
|
+
def publish_inside_output(output_resolution, selected_domain_ids, diagnostics, artifacts)
|
|
52
|
+
written_paths = output_directory.publish!(output_resolution, selected_domain_ids: selected_domain_ids) do
|
|
53
|
+
diagnostics = scoped_diagnostics(diagnostics, selected_domain_ids)
|
|
54
|
+
artifacts = scoped_artifacts(artifacts, selected_domain_ids)
|
|
55
|
+
diagnostic_documents = validate_payloads!(diagnostics, artifacts)
|
|
56
|
+
blocked_domains = blocked_domains(selected_domain_ids, diagnostics)
|
|
57
|
+
publish_plan(selected_domain_ids, diagnostic_documents, artifacts, blocked_domains)
|
|
58
|
+
end
|
|
59
|
+
Result.new(success: true, diagnostics: diagnostics, written_paths: written_paths, stderr: nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_payloads!(diagnostics, artifacts)
|
|
63
|
+
schema_validator.validate_publication!(
|
|
64
|
+
diagnostics: diagnostics,
|
|
65
|
+
artifacts: artifacts
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def scoped_diagnostics(diagnostics, selected_domain_ids)
|
|
70
|
+
diagnostics.select do |diagnostic|
|
|
71
|
+
diagnostic.fetch('scope') == 'invocation' || selected_domain_ids.include?(diagnostic_domain_id(diagnostic))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def scoped_artifacts(artifacts, selected_domain_ids)
|
|
76
|
+
artifacts.slice(*selected_domain_ids)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def publish_plan(selected_domain_ids, diagnostic_documents, artifacts, blocked_domains)
|
|
80
|
+
plan = diagnostics_plan(diagnostic_documents)
|
|
81
|
+
selected_domain_ids.each do |domain_id|
|
|
82
|
+
next if blocked_domains.include?(domain_id)
|
|
83
|
+
|
|
84
|
+
plan.merge!(domain_publish_plan(domain_id, artifacts.fetch(domain_id, {})))
|
|
85
|
+
end
|
|
86
|
+
plan
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def domain_publish_plan(domain_id, artifacts)
|
|
90
|
+
ARTIFACT_KINDS.each_with_object({}) do |kind, plan|
|
|
91
|
+
artifact = artifacts[kind]
|
|
92
|
+
next unless artifact
|
|
93
|
+
|
|
94
|
+
plan["#{domain_id}.#{kind}.mmd"] = artifact.fetch(:mermaid)
|
|
95
|
+
plan["#{domain_id}.#{kind}.render_plan.json"] = json_document(artifact.fetch(:render_plan))
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def diagnostics_plan(diagnostic_documents)
|
|
100
|
+
diagnostic_documents.each_with_object({}) do |document, plan|
|
|
101
|
+
name = if document.fetch('scope') == 'global'
|
|
102
|
+
'global.diagnostics.json'
|
|
103
|
+
else
|
|
104
|
+
"#{document.fetch('domain_id')}.diagnostics.json"
|
|
105
|
+
end
|
|
106
|
+
plan[name] = json_document(document)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def diagnostic_domain_id(diagnostic)
|
|
111
|
+
diagnostic.fetch('metadata', {}).fetch('domain_id')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def blocked_domains(selected_domain_ids, diagnostics)
|
|
115
|
+
return selected_domain_ids if invocation_blocking?(diagnostics)
|
|
116
|
+
|
|
117
|
+
diagnostics.each_with_object([]) do |diagnostic, blocked|
|
|
118
|
+
next unless blocking?(diagnostic)
|
|
119
|
+
next if diagnostic.fetch('scope') == 'invocation'
|
|
120
|
+
|
|
121
|
+
blocked << diagnostic_domain_id(diagnostic)
|
|
122
|
+
end.uniq
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def invocation_blocking?(diagnostics)
|
|
126
|
+
diagnostics.any? { |diagnostic| diagnostic.fetch('scope') == 'invocation' && blocking?(diagnostic) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def blocking?(diagnostic)
|
|
130
|
+
%w[fatal error].include?(diagnostic.fetch('severity'))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def json_document(payload)
|
|
134
|
+
"#{JSON.pretty_generate(payload)}\n"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def invalid_output_result(reason)
|
|
138
|
+
diagnostic = diagnostics_factory.build(
|
|
139
|
+
code: 'OUTPUT_DIRECTORY_INVALID',
|
|
140
|
+
message: 'output directory invalid',
|
|
141
|
+
metadata: { field_path: '$.output.directory', reason: reason.to_s }
|
|
142
|
+
)
|
|
143
|
+
Result.new(success: false, diagnostics: [diagnostic], written_paths: [], stderr: pre_output_stderr([diagnostic]))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def pre_output_fatal?(diagnostics)
|
|
147
|
+
diagnostics.any? { |diagnostic| PRE_OUTPUT_FATAL_CODES.include?(diagnostic.fetch('code')) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def pre_output_fatal_result(diagnostics)
|
|
151
|
+
Result.new(success: false, diagnostics: diagnostics, written_paths: [], stderr: pre_output_stderr(diagnostics))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def output_write_failed(exception)
|
|
155
|
+
diagnostics_factory.build(
|
|
156
|
+
code: 'OUTPUT_WRITE_FAILED',
|
|
157
|
+
message: 'output write failed',
|
|
158
|
+
metadata: { operation: 'write', path: 'output' },
|
|
159
|
+
remediation: {
|
|
160
|
+
summary: exception.class.name,
|
|
161
|
+
steps: ['Check the output directory for retained .rails-mmd-backup-* directories before retrying.']
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails_mmd/diagnostic_factory'
|
|
4
|
+
|
|
5
|
+
module RailsMmd
|
|
6
|
+
# Boots a host Rails app and eager-loads it through explicit, testable paths.
|
|
7
|
+
# rubocop:disable-next Metrics/MethodLength
|
|
8
|
+
class RailsLoader
|
|
9
|
+
Result = Struct.new(:application, :diagnostics, :exit_code, keyword_init: true) do
|
|
10
|
+
def success?
|
|
11
|
+
diagnostics.empty?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
EXIT_CONTRACT_ERROR = 2
|
|
16
|
+
|
|
17
|
+
def initialize(project_root:, kernel: Kernel, rails_provider: -> { Object.const_get(:Rails) },
|
|
18
|
+
diagnostics: DiagnosticFactory.new, bundler: (Bundler if defined?(Bundler)))
|
|
19
|
+
@project_root = Pathname(project_root).expand_path
|
|
20
|
+
@kernel = kernel
|
|
21
|
+
@rails_provider = rails_provider
|
|
22
|
+
@diagnostic_factory = diagnostics
|
|
23
|
+
@bundler = bundler
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def boot
|
|
27
|
+
validate_bundle_context!
|
|
28
|
+
kernel.load(environment_path.to_s)
|
|
29
|
+
application = rails_provider.call.application
|
|
30
|
+
eager_load(application)
|
|
31
|
+
Result.new(application: application, diagnostics: [], exit_code: 0)
|
|
32
|
+
rescue EagerLoadError => e
|
|
33
|
+
failure('RAILS_EAGER_LOAD_FAILED', e.cause || e)
|
|
34
|
+
rescue LoadError, SyntaxError, StandardError => e
|
|
35
|
+
failure('RAILS_LOAD_FAILED', e)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
class EagerLoadError < StandardError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class BundleContextError < StandardError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :bundler, :diagnostic_factory, :kernel, :project_root, :rails_provider
|
|
47
|
+
|
|
48
|
+
def environment_path
|
|
49
|
+
project_root.join('config/environment.rb')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_bundle_context!
|
|
53
|
+
app_gemfile = project_root.join('Gemfile')
|
|
54
|
+
return unless app_gemfile.file?
|
|
55
|
+
|
|
56
|
+
current_gemfile = current_bundle_gemfile
|
|
57
|
+
return if current_gemfile &&
|
|
58
|
+
File.basename(current_gemfile) == app_gemfile.basename.to_s &&
|
|
59
|
+
File.identical?(File.dirname(current_gemfile), project_root)
|
|
60
|
+
|
|
61
|
+
raise BundleContextError,
|
|
62
|
+
'rails-mmd must be run inside the target Rails application bundle'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def current_bundle_gemfile
|
|
66
|
+
return unless bundler.respond_to?(:default_gemfile)
|
|
67
|
+
|
|
68
|
+
bundler.default_gemfile
|
|
69
|
+
rescue LoadError, StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def eager_load(application)
|
|
74
|
+
raise EagerLoadError, 'Rails application does not expose eager_load!' unless application.respond_to?(:eager_load!)
|
|
75
|
+
|
|
76
|
+
application.eager_load! if application.respond_to?(:eager_load!)
|
|
77
|
+
rescue LoadError, SyntaxError, StandardError => e
|
|
78
|
+
raise EagerLoadError, e.message
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def failure(code, exception)
|
|
82
|
+
Result.new(
|
|
83
|
+
application: nil,
|
|
84
|
+
diagnostics: [
|
|
85
|
+
diagnostic_factory.build(
|
|
86
|
+
code: code,
|
|
87
|
+
message: exception.message,
|
|
88
|
+
subject_id: 'rails',
|
|
89
|
+
metadata: diagnostic_factory.exception_metadata(exception)
|
|
90
|
+
)
|
|
91
|
+
],
|
|
92
|
+
exit_code: EXIT_CONTRACT_ERROR
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module RailsMmd
|
|
7
|
+
# Sanitizes free-form text before it can leave the process.
|
|
8
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
9
|
+
class Redactor
|
|
10
|
+
SECRET_KEY_PATTERN = /
|
|
11
|
+
(?:password|passwd|secret|api[_-]?key|credential|database_url|(?:access|auth|refresh)[_-]?token|\btoken\b)
|
|
12
|
+
/ix
|
|
13
|
+
# Free text has no reliable value delimiter, so keep only the context before an assignment.
|
|
14
|
+
SECRET_ASSIGNMENT_PATTERN = /
|
|
15
|
+
["']?[A-Za-z0-9_-]*#{SECRET_KEY_PATTERN}[A-Za-z0-9_-]*["']?\s*(?:=>|=|:)[\s\S]*
|
|
16
|
+
/ix
|
|
17
|
+
URL_CREDENTIAL_PATTERN = %r{([a-z][a-z0-9+.-]*://)[^/\s]+@}i
|
|
18
|
+
DRIVE_OR_UNC_PATTERN = %r{\b(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/][^\\/\s]+)}
|
|
19
|
+
MERMAID_SECRET_KEY = /
|
|
20
|
+
(?:
|
|
21
|
+
password|passwd|secret|credential|token|pid|
|
|
22
|
+
api\s*[_-]?\s*key|
|
|
23
|
+
database\s*[_-]?\s*url|
|
|
24
|
+
(?:access|auth|refresh)\s*[_-]?\s*token|
|
|
25
|
+
generated\s*[_-]?\s*at|
|
|
26
|
+
process\s*[_-]?\s*id|
|
|
27
|
+
random\s*[_-]?\s*seed|
|
|
28
|
+
raw\s*[_-]?\s*exception\s*[_-]?\s*backtrace
|
|
29
|
+
)
|
|
30
|
+
/ix
|
|
31
|
+
MERMAID_FORBIDDEN_ASSIGNMENT = /
|
|
32
|
+
["']?[A-Za-z0-9_-]*#{MERMAID_SECRET_KEY}[A-Za-z0-9_-]*["']?\s*(?:=>|:|=|\s+)[\s\S]*
|
|
33
|
+
/ix
|
|
34
|
+
REDACTED_KEY_ASSIGNMENT = /
|
|
35
|
+
["']?[A-Za-z0-9_-]*\[REDACTED_KEY\][A-Za-z0-9_-]*["']?\s*(?:=>|:|=|\s+)[\s\S]*
|
|
36
|
+
/x
|
|
37
|
+
URL_PATTERN = %r{\b[a-z][a-z0-9+.-]*://[^\s]+}i
|
|
38
|
+
URL_PLACEHOLDER_PREFIX = "\uE000RAILSMMDURL"
|
|
39
|
+
REDACTED_URL = '[REDACTED_URL]'
|
|
40
|
+
MERMAID_CONTROL_TEXT = /[\r\n\t[:cntrl:]]+/
|
|
41
|
+
CONTROL_SPLIT_ABSOLUTE_PATH =
|
|
42
|
+
/(?<![A-Za-z0-9_])\/[^[:cntrl:]\s)\]}>;,:!?]+(?:[\r\n\t[:cntrl:]][^[:cntrl:]\s)\]}>;,:!?]*)*/
|
|
43
|
+
STRUCTURED_PATTERNS = {
|
|
44
|
+
ruby_constant: /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/,
|
|
45
|
+
ruby_constant_label: /\A[A-Z][A-Za-z0-9_]*\z/,
|
|
46
|
+
snake_identifier: /\A[a-z][a-z0-9_]*\z/
|
|
47
|
+
}.freeze
|
|
48
|
+
STRUCTURED_FALLBACK = 'X'
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def sanitize_mermaid_free_text(value, sanitizer:)
|
|
52
|
+
urls = []
|
|
53
|
+
text = protect_urls(value.to_s.delete("\u0000"), urls)
|
|
54
|
+
text = text.gsub(CONTROL_SPLIT_ABSOLUTE_PATH, '[REDACTED_PATH]')
|
|
55
|
+
text = text.gsub(MERMAID_CONTROL_TEXT, ' ')
|
|
56
|
+
text = text.gsub(MERMAID_FORBIDDEN_ASSIGNMENT, '[REDACTED]')
|
|
57
|
+
text = sanitizer.sanitize(text).gsub(REDACTED_KEY_ASSIGNMENT, '[REDACTED]')
|
|
58
|
+
restore_urls(text, urls).gsub(/\s+/, ' ').strip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sanitize_mermaid_structured(value, grammar:)
|
|
62
|
+
text = value.to_s.delete("\u0000").gsub(MERMAID_CONTROL_TEXT, ' ').gsub(/\s+/, ' ').strip
|
|
63
|
+
return STRUCTURED_FALLBACK unless STRUCTURED_PATTERNS.fetch(grammar).match?(text)
|
|
64
|
+
|
|
65
|
+
text
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def protect_urls(text, urls)
|
|
71
|
+
text.gsub(URL_PATTERN) do |_url|
|
|
72
|
+
urls << REDACTED_URL
|
|
73
|
+
"#{URL_PLACEHOLDER_PREFIX}#{urls.length - 1}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def restore_urls(text, urls)
|
|
78
|
+
urls.each_with_index.reduce(text) do |output, (url, index)|
|
|
79
|
+
output.gsub("#{URL_PLACEHOLDER_PREFIX}#{index}", url)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize(project_root: Dir.pwd, env: ENV.to_h)
|
|
85
|
+
@project_root = Pathname(project_root).expand_path
|
|
86
|
+
@env_values = env.values.compact.map(&:to_s).select { |value| value.length >= 4 }.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def sanitize(value)
|
|
90
|
+
text = value.to_s.dup
|
|
91
|
+
text = text.delete("\u0000")
|
|
92
|
+
text = text.gsub(URL_CREDENTIAL_PATTERN, '\\1[REDACTED]@')
|
|
93
|
+
text = text.gsub(SECRET_ASSIGNMENT_PATTERN, '[REDACTED]')
|
|
94
|
+
text = text.gsub(DRIVE_OR_UNC_PATTERN, '[REDACTED_PATH]')
|
|
95
|
+
text = redact_absolute_paths(text)
|
|
96
|
+
text = redact_env_values(text)
|
|
97
|
+
text.gsub(SECRET_KEY_PATTERN, '[REDACTED_KEY]')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def sanitize_object(value)
|
|
101
|
+
case value
|
|
102
|
+
when String
|
|
103
|
+
sanitize(value)
|
|
104
|
+
when Array
|
|
105
|
+
value.map { |item| sanitize_object(item) }
|
|
106
|
+
when Hash
|
|
107
|
+
sanitize_hash(value)
|
|
108
|
+
else
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
attr_reader :project_root, :env_values
|
|
116
|
+
|
|
117
|
+
def sanitize_hash(value)
|
|
118
|
+
value.to_h do |key, item|
|
|
119
|
+
sanitized_value = SECRET_KEY_PATTERN.match?(key.to_s) ? '[REDACTED]' : sanitize_object(item)
|
|
120
|
+
[sanitize(key), sanitized_value]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def redact_env_values(text)
|
|
125
|
+
env_values.reduce(text) { |output, value| output.gsub(value, '[REDACTED]') }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def redact_absolute_paths(text)
|
|
129
|
+
text.gsub(%r{(?:/[\w.-]+){2,}}) do |path|
|
|
130
|
+
display_path(path)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def display_path(path)
|
|
135
|
+
pathname = Pathname(path)
|
|
136
|
+
expanded = pathname.expand_path
|
|
137
|
+
relative = expanded.relative_path_from(project_root)
|
|
138
|
+
return relative.to_s unless relative.to_s.start_with?('..')
|
|
139
|
+
|
|
140
|
+
'[REDACTED_PATH]'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|