releasehx 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/README.adoc +2915 -0
- data/bin/releasehx +7 -0
- data/bin/rhx +7 -0
- data/bin/rhx-mcp +7 -0
- data/bin/sourcerer +32 -0
- data/build/docs/CNAME +1 -0
- data/build/docs/Gemfile.lock +95 -0
- data/build/docs/_config.yml +36 -0
- data/build/docs/config-reference.adoc +4104 -0
- data/build/docs/config-reference.json +1546 -0
- data/build/docs/index.adoc +2915 -0
- data/build/docs/landing.adoc +21 -0
- data/build/docs/manpage.adoc +68 -0
- data/build/docs/releasehx.1 +281 -0
- data/build/docs/releasehx_readme.html +367 -0
- data/build/docs/sample-config.adoc +9 -0
- data/build/docs/sample-config.yml +251 -0
- data/build/docs/schemagraphy_readme.html +0 -0
- data/build/docs/sourcerer_readme.html +46 -0
- data/build/snippets/helpscreen.txt +29 -0
- data/lib/docopslab/mcp/asset_packager.rb +30 -0
- data/lib/docopslab/mcp/manifest.rb +67 -0
- data/lib/docopslab/mcp/resource_pack.rb +46 -0
- data/lib/docopslab/mcp/server.rb +92 -0
- data/lib/docopslab/mcp.rb +6 -0
- data/lib/releasehx/cli.rb +937 -0
- data/lib/releasehx/configuration.rb +215 -0
- data/lib/releasehx/generated.rb +17 -0
- data/lib/releasehx/helpers.rb +58 -0
- data/lib/releasehx/mcp/asset_packager.rb +21 -0
- data/lib/releasehx/mcp/assets/agent-config-guide.md +178 -0
- data/lib/releasehx/mcp/assets/config-def.yml +1426 -0
- data/lib/releasehx/mcp/assets/config-reference.adoc +4104 -0
- data/lib/releasehx/mcp/assets/config-reference.json +1546 -0
- data/lib/releasehx/mcp/assets/sample-config.yml +251 -0
- data/lib/releasehx/mcp/manifest.rb +18 -0
- data/lib/releasehx/mcp/resource_pack.rb +26 -0
- data/lib/releasehx/mcp/server.rb +57 -0
- data/lib/releasehx/mcp.rb +7 -0
- data/lib/releasehx/ops/check_ops.rb +136 -0
- data/lib/releasehx/ops/draft_ops.rb +173 -0
- data/lib/releasehx/ops/enrich_ops.rb +221 -0
- data/lib/releasehx/ops/template_ops.rb +61 -0
- data/lib/releasehx/ops/write_ops.rb +124 -0
- data/lib/releasehx/rest/clients/github.yml +46 -0
- data/lib/releasehx/rest/clients/gitlab.yml +31 -0
- data/lib/releasehx/rest/clients/jira.yml +31 -0
- data/lib/releasehx/rest/yaml_client.rb +418 -0
- data/lib/releasehx/rhyml/adapter.rb +740 -0
- data/lib/releasehx/rhyml/change.rb +167 -0
- data/lib/releasehx/rhyml/liquid.rb +13 -0
- data/lib/releasehx/rhyml/loaders.rb +37 -0
- data/lib/releasehx/rhyml/mappings/github.yaml +60 -0
- data/lib/releasehx/rhyml/mappings/gitlab.yaml +73 -0
- data/lib/releasehx/rhyml/mappings/jira.yaml +29 -0
- data/lib/releasehx/rhyml/mappings/verb_past_tenses.yml +98 -0
- data/lib/releasehx/rhyml/release.rb +144 -0
- data/lib/releasehx/rhyml.rb +15 -0
- data/lib/releasehx/sgyml/helpers.rb +45 -0
- data/lib/releasehx/transforms/adf_to_markdown.rb +307 -0
- data/lib/releasehx/version.rb +7 -0
- data/lib/releasehx.rb +69 -0
- data/lib/schemagraphy/attribute_resolver.rb +48 -0
- data/lib/schemagraphy/cfgyml/definition.rb +90 -0
- data/lib/schemagraphy/cfgyml/doc_builder.rb +52 -0
- data/lib/schemagraphy/cfgyml/path_reference.rb +24 -0
- data/lib/schemagraphy/data_query/json_pointer.rb +42 -0
- data/lib/schemagraphy/loader.rb +59 -0
- data/lib/schemagraphy/regexp_utils.rb +215 -0
- data/lib/schemagraphy/safe_expression.rb +189 -0
- data/lib/schemagraphy/schema_utils.rb +124 -0
- data/lib/schemagraphy/tag_utils.rb +32 -0
- data/lib/schemagraphy/templating.rb +104 -0
- data/lib/schemagraphy.rb +17 -0
- data/lib/sourcerer/builder.rb +120 -0
- data/lib/sourcerer/jekyll/bootstrapper.rb +78 -0
- data/lib/sourcerer/jekyll/liquid/file_system.rb +74 -0
- data/lib/sourcerer/jekyll/liquid/filters.rb +215 -0
- data/lib/sourcerer/jekyll/liquid/tags.rb +44 -0
- data/lib/sourcerer/jekyll/monkeypatches.rb +73 -0
- data/lib/sourcerer/jekyll.rb +26 -0
- data/lib/sourcerer/plaintext_converter.rb +75 -0
- data/lib/sourcerer/templating.rb +190 -0
- data/lib/sourcerer.rb +322 -0
- data/specs/data/api-client-schema.yaml +160 -0
- data/specs/data/config-def.yml +1426 -0
- data/specs/data/mcp-manifest.yml +50 -0
- data/specs/data/rhyml-mapping-schema.yaml +410 -0
- data/specs/data/rhyml-schema.yaml +152 -0
- metadata +376 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'psych'
|
|
5
|
+
require_relative 'attribute_resolver'
|
|
6
|
+
|
|
7
|
+
module SchemaGraphy
|
|
8
|
+
# The Loader class provides methods for loading YAML files while preserving
|
|
9
|
+
# custom tags and resolving attribute references.
|
|
10
|
+
class Loader
|
|
11
|
+
# Load a YAML file and resolve AsciiDoc attribute references like `\{attribute_name}`.
|
|
12
|
+
#
|
|
13
|
+
# @param path [String] The path to the YAML file.
|
|
14
|
+
# @param attrs [Hash] The AsciiDoc attributes to use for resolution.
|
|
15
|
+
# @return [Hash] The loaded YAML data with attributes resolved.
|
|
16
|
+
def self.load_yaml_with_attributes path, attrs = {}
|
|
17
|
+
raw_data = load_yaml_with_tags(path)
|
|
18
|
+
AttributeResolver.resolve_attributes!(raw_data, attrs)
|
|
19
|
+
raw_data
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Load a YAML file, preserving any custom tags (e.g., `!foo`).
|
|
23
|
+
# Custom tags are attached to the data structure.
|
|
24
|
+
#
|
|
25
|
+
# @param path [String] The path to the YAML file.
|
|
26
|
+
# @return [Hash] The loaded YAML data with custom tags attached.
|
|
27
|
+
def self.load_yaml_with_tags path
|
|
28
|
+
return {} if File.empty?(path)
|
|
29
|
+
|
|
30
|
+
data = Psych.load_file(path, aliases: true, permitted_classes: [Date, Time])
|
|
31
|
+
ast = Psych.parse_file(path)
|
|
32
|
+
attach_tags(ast.root, data)
|
|
33
|
+
data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Recursively attach YAML tags to the loaded data structure for template processing.
|
|
37
|
+
#
|
|
38
|
+
# @param node [Psych::Nodes::Node] The current AST node.
|
|
39
|
+
# @param data [Object] The data corresponding to the current node.
|
|
40
|
+
# @api private
|
|
41
|
+
def self.attach_tags node, data
|
|
42
|
+
return unless node.is_a?(Psych::Nodes::Mapping)
|
|
43
|
+
|
|
44
|
+
node.children.each_slice(2) do |key_node, val_node|
|
|
45
|
+
key = key_node.value
|
|
46
|
+
|
|
47
|
+
if val_node.respond_to?(:tag) && val_node.tag && data[key].is_a?(String)
|
|
48
|
+
normalized_tag = val_node.tag.sub(/^!+/, '').sub(/^.*:/, '')
|
|
49
|
+
data[key] = {
|
|
50
|
+
'value' => data[key],
|
|
51
|
+
'__tag__' => normalized_tag
|
|
52
|
+
}
|
|
53
|
+
elsif data[key].is_a?(Hash)
|
|
54
|
+
attach_tags(val_node, data[key])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'to_regexp'
|
|
4
|
+
|
|
5
|
+
module SchemaGraphy
|
|
6
|
+
# A utility module for robustly parsing and using regular expressions.
|
|
7
|
+
# It handles various formats, including literals and plain strings,
|
|
8
|
+
# and provides helpers for extracting captured content.
|
|
9
|
+
module RegexpUtils
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Parse a regex pattern string using the `to_regexp` gem for robust parsing.
|
|
13
|
+
# Handles `/pattern/flags`, `%r{pattern}flags`, and plain text formats.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# parse_pattern("/^hello.*$/im")
|
|
17
|
+
# # => { pattern: "^hello.*$", flags: "im", regexp: /^hello.*$/im, options: 6 }
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# parse_pattern("hello world")
|
|
21
|
+
# # => { pattern: "hello world", flags: "", regexp: /hello world/, options: 0 }
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# parse_pattern("hello world", "i")
|
|
25
|
+
# # => { pattern: "hello world", flags: "i", regexp: /hello world/i, options: 1 }
|
|
26
|
+
#
|
|
27
|
+
# @param input [String] The input string, e.g., "/pattern/flags" or "plain pattern".
|
|
28
|
+
# @param default_flags [String] Default flags to apply if none are specified (default: "").
|
|
29
|
+
# @return [Hash, nil] A hash with `:pattern`, `:flags`, `:regexp`, and `:options`, or `nil`.
|
|
30
|
+
def parse_pattern input, default_flags = ''
|
|
31
|
+
return nil if input.nil? || input.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
input_str = input.to_s.strip
|
|
34
|
+
|
|
35
|
+
# Remove surrounding quotes that might come from YAML parsing
|
|
36
|
+
clean_input = input_str.gsub(/^["']|["']$/, '')
|
|
37
|
+
|
|
38
|
+
# Heuristic to detect if it's a Regexp literal
|
|
39
|
+
is_literal = (clean_input.start_with?('/') && clean_input.rindex('/').positive?) || clean_input.start_with?('%r{')
|
|
40
|
+
|
|
41
|
+
if is_literal
|
|
42
|
+
# Try to parse as regex literal using to_regexp
|
|
43
|
+
begin
|
|
44
|
+
regexp_obj = clean_input.to_regexp(detect: true)
|
|
45
|
+
|
|
46
|
+
# Extract pattern and flags from the compiled regexp
|
|
47
|
+
pattern_str = regexp_obj.source
|
|
48
|
+
flags_str = extract_flags_from_regexp(regexp_obj)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
pattern: pattern_str,
|
|
52
|
+
flags: flags_str,
|
|
53
|
+
regexp: regexp_obj,
|
|
54
|
+
options: regexp_obj.options
|
|
55
|
+
}
|
|
56
|
+
rescue RegexpError => e
|
|
57
|
+
# Malformed literal is an error
|
|
58
|
+
raise RegexpError, "Invalid regex literal '#{input}': #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
# Treat as plain pattern string with default flags
|
|
62
|
+
flags_str = default_flags.to_s
|
|
63
|
+
options = flags_to_options(flags_str)
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
regexp_obj = Regexp.new(clean_input, options)
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
pattern: clean_input,
|
|
70
|
+
flags: flags_str,
|
|
71
|
+
regexp: regexp_obj,
|
|
72
|
+
options: options
|
|
73
|
+
}
|
|
74
|
+
rescue RegexpError => e
|
|
75
|
+
raise RegexpError, "Invalid regex pattern '#{input}': #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @note Not yet implemented.
|
|
81
|
+
# Future enhancement to parse structured pattern definitions from a Hash.
|
|
82
|
+
# @param pattern_hash [Hash] A hash with 'pattern' and 'flags' keys.
|
|
83
|
+
# @raise [NotImplementedError] Always raises this error.
|
|
84
|
+
def parse_structured_pattern pattern_hash
|
|
85
|
+
# TODO: Implement structured pattern parsing
|
|
86
|
+
# pattern_hash should have 'pattern' and 'flags' keys
|
|
87
|
+
# flags can be string or array
|
|
88
|
+
raise NotImplementedError, 'Structured pattern parsing not yet implemented'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @note Not yet implemented.
|
|
92
|
+
# Future enhancement to parse custom YAML tags for regular expressions.
|
|
93
|
+
# @param tagged_input [String] The input string with a YAML tag.
|
|
94
|
+
# @param tag_type [Symbol] The type of tag, e.g., `:literal` or `:pattern`.
|
|
95
|
+
# @raise [NotImplementedError] Always raises this error.
|
|
96
|
+
def parse_tagged_pattern tagged_input, tag_type
|
|
97
|
+
# TODO: Implement custom YAML tag parsing
|
|
98
|
+
# tag_type would be :literal or :pattern
|
|
99
|
+
raise NotImplementedError, 'Tagged pattern parsing not yet implemented'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Convert a flags string (ex: "im") to a Regexp options integer.
|
|
103
|
+
#
|
|
104
|
+
# @param flags [String] String containing regex flags.
|
|
105
|
+
# @return [Integer] Regexp options integer.
|
|
106
|
+
def flags_to_options flags
|
|
107
|
+
options = 0
|
|
108
|
+
flags = flags.to_s
|
|
109
|
+
|
|
110
|
+
options |= Regexp::IGNORECASE if flags.include?('i')
|
|
111
|
+
options |= Regexp::MULTILINE if flags.include?('m')
|
|
112
|
+
options |= Regexp::EXTENDED if flags.include?('x')
|
|
113
|
+
|
|
114
|
+
# NOTE: 'g' (global) and 'o' (once) are not standard Ruby flags
|
|
115
|
+
# encoding flags ('n', 'e', 's', 'u') are handled by to_regexp
|
|
116
|
+
|
|
117
|
+
options
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Extract a flags string from a compiled Regexp object.
|
|
121
|
+
#
|
|
122
|
+
# @param regexp [Regexp] A compiled regexp object.
|
|
123
|
+
# @return [String] String representation of the flags (e.g., "im").
|
|
124
|
+
def extract_flags_from_regexp regexp
|
|
125
|
+
flags = ''
|
|
126
|
+
flags += 'i' if regexp.options.anybits?(Regexp::IGNORECASE)
|
|
127
|
+
flags += 'm' if regexp.options.anybits?(Regexp::MULTILINE)
|
|
128
|
+
flags += 'x' if regexp.options.anybits?(Regexp::EXTENDED)
|
|
129
|
+
flags
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Create a Regexp object from a pattern string and explicit flags.
|
|
133
|
+
#
|
|
134
|
+
# @param pattern [String] The regex pattern (without delimiters).
|
|
135
|
+
# @param flags [String] The flags string (ex: "im").
|
|
136
|
+
# @return [Regexp] The compiled Regexp object.
|
|
137
|
+
def create_regexp pattern, flags = ''
|
|
138
|
+
options = flags_to_options(flags)
|
|
139
|
+
Regexp.new(pattern, options)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Extract content using named or positional capture groups.
|
|
143
|
+
#
|
|
144
|
+
# @param text [String] The text to match against.
|
|
145
|
+
# @param pattern_info [Hash] The hash result from `parse_pattern`.
|
|
146
|
+
# @param capture_name [String] The name of the capture group to extract (optional).
|
|
147
|
+
# @return [String, nil] The extracted text, or `nil` if no match is found.
|
|
148
|
+
def extract_capture text, pattern_info, capture_name = nil
|
|
149
|
+
return nil unless text && pattern_info
|
|
150
|
+
|
|
151
|
+
regexp = pattern_info[:regexp]
|
|
152
|
+
match = text.match(regexp)
|
|
153
|
+
|
|
154
|
+
return nil unless match
|
|
155
|
+
|
|
156
|
+
if capture_name && match.names.include?(capture_name.to_s)
|
|
157
|
+
# Extract named capture group
|
|
158
|
+
match[capture_name.to_s]
|
|
159
|
+
elsif match.captures.any?
|
|
160
|
+
# Extract first capture group
|
|
161
|
+
match[1]
|
|
162
|
+
else
|
|
163
|
+
# Return the entire match
|
|
164
|
+
match[0]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Extract all named capture groups as a hash or positional captures as an array.
|
|
169
|
+
#
|
|
170
|
+
# @param text [String] The text to match against.
|
|
171
|
+
# @param pattern_info [Hash] The hash result from `parse_pattern`.
|
|
172
|
+
# @return [Hash, Array, nil] A hash of named captures, an array of positional captures, or `nil`.
|
|
173
|
+
def extract_all_captures text, pattern_info
|
|
174
|
+
return nil unless text && pattern_info
|
|
175
|
+
|
|
176
|
+
regexp = pattern_info[:regexp]
|
|
177
|
+
match = text.match(regexp)
|
|
178
|
+
|
|
179
|
+
return nil unless match
|
|
180
|
+
|
|
181
|
+
if match.names.any?
|
|
182
|
+
# Return hash of named captures
|
|
183
|
+
match.names.each_with_object({}) do |name, captures|
|
|
184
|
+
captures[name] = match[name]
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
# Return array of positional captures
|
|
188
|
+
match.captures
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# A convenience method that combines parsing and a single extraction.
|
|
193
|
+
#
|
|
194
|
+
# @param text [String] The text to match against.
|
|
195
|
+
# @param pattern_input [String] The pattern string (with or without /flags/).
|
|
196
|
+
# @param capture_name [String] Name of the capture group to extract (optional).
|
|
197
|
+
# @param default_flags [String] Default flags if the pattern has no flags.
|
|
198
|
+
# @return [String, nil] The extracted text, or `nil` if no match is found.
|
|
199
|
+
def parse_and_extract text, pattern_input, capture_name = nil, default_flags = ''
|
|
200
|
+
pattern_info = parse_pattern(pattern_input, default_flags)
|
|
201
|
+
extract_capture(text, pattern_info, capture_name)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# A convenience method that combines parsing and extraction of all captures.
|
|
205
|
+
#
|
|
206
|
+
# @param text [String] The text to match against.
|
|
207
|
+
# @param pattern_input [String] The pattern string (with or without /flags/).
|
|
208
|
+
# @param default_flags [String] Default flags if the pattern has no flags.
|
|
209
|
+
# @return [Hash, Array, nil] All captured content, or `nil` if no match is found.
|
|
210
|
+
def parse_and_extract_all text, pattern_input, default_flags = ''
|
|
211
|
+
pattern_info = parse_pattern(pattern_input, default_flags)
|
|
212
|
+
extract_all_captures(text, pattern_info)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module SchemaGraphy
|
|
7
|
+
# Provides a simple, deny-by-exception sandbox for mapping expressions.
|
|
8
|
+
# It validates code by walking the Abstract Syntax Tree (AST) and blocking
|
|
9
|
+
# known dangerous operations, rather than attempting to allowlist safe ones.
|
|
10
|
+
class AstGate
|
|
11
|
+
# A list of dangerous bareword methods that are blocked.
|
|
12
|
+
BLOCKED_BAREWORDS = %w[
|
|
13
|
+
eval instance_eval class_eval module_eval binding
|
|
14
|
+
require require_relative load autoload
|
|
15
|
+
system exec spawn fork backtick `
|
|
16
|
+
open ObjectSpace GC Thread Process at_exit
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# A list of AST node types that are explicitly disallowed.
|
|
20
|
+
DISALLOWED_NODES = %i[
|
|
21
|
+
# Definitions and meta-programming
|
|
22
|
+
def_node class_node module_node define_node alias_node undef_node
|
|
23
|
+
# Globals and constants paths
|
|
24
|
+
global_variable_read_node constant_path_node
|
|
25
|
+
# Shell and backticks
|
|
26
|
+
x_string_node interpolated_x_string_node
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# A list of constants that are considered dangerous and are blocked.
|
|
30
|
+
DANGEROUS_CONSTANTS = %w[
|
|
31
|
+
Kernel Object Module Class File FileUtils IO Dir Process Open3 PTY Thread
|
|
32
|
+
SystemSignal Signal Gem Net HTTP TCPSocket UDPSocket Socket ObjectSpace GC
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Validates the given code by parsing it and walking the AST.
|
|
36
|
+
#
|
|
37
|
+
# @param code [String] The Ruby code to validate.
|
|
38
|
+
# @param context_keys [Array<Symbol>] A list of keys available in the execution context.
|
|
39
|
+
# @raise [SyntaxError] if the code has syntax errors.
|
|
40
|
+
# @raise [SecurityError] if the code contains disallowed operations.
|
|
41
|
+
def self.validate! code, context_keys: []
|
|
42
|
+
result = Prism.parse(code)
|
|
43
|
+
raise SyntaxError, result.errors.map(&:message).join(', ') if result.errors.any?
|
|
44
|
+
|
|
45
|
+
walk(result.value, context_keys: context_keys)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @api private
|
|
49
|
+
# Recursively walks the AST, checking for disallowed nodes and operations.
|
|
50
|
+
#
|
|
51
|
+
# @param node [Prism::Node] The current AST node.
|
|
52
|
+
# @param context_keys [Array<Symbol>] A list of keys available in the execution context.
|
|
53
|
+
# @raise [SecurityError] if a disallowed operation is found.
|
|
54
|
+
def self.walk node, context_keys: []
|
|
55
|
+
return unless node.is_a?(Prism::Node)
|
|
56
|
+
|
|
57
|
+
type = node.type
|
|
58
|
+
raise SecurityError, "node not allowed: #{type}" if DISALLOWED_NODES.include?(type)
|
|
59
|
+
|
|
60
|
+
case node
|
|
61
|
+
when Prism::CallNode
|
|
62
|
+
# Block dangerous barewords (system, eval, etc.)
|
|
63
|
+
if node.receiver.nil? && BLOCKED_BAREWORDS.include?(node.name.to_s)
|
|
64
|
+
raise SecurityError, "method not allowed: #{node.name}"
|
|
65
|
+
end
|
|
66
|
+
# Block dangerous constants and constant paths
|
|
67
|
+
if node.receiver.is_a?(Prism::ConstantReadNode) && DANGEROUS_CONSTANTS.include?(node.receiver.name.to_s)
|
|
68
|
+
raise SecurityError, "unsafe constant: #{node.receiver.name}"
|
|
69
|
+
end
|
|
70
|
+
raise SecurityError, 'unsafe constant path' if node.receiver.is_a?(Prism::ConstantPathNode)
|
|
71
|
+
|
|
72
|
+
when Prism::ConstantReadNode
|
|
73
|
+
# Allow only core Ruby constants defined in SafeTransform
|
|
74
|
+
const_name = node.name.to_s
|
|
75
|
+
unless SafeTransform::CORE_CONSTANTS.key?(const_name.to_sym)
|
|
76
|
+
raise SecurityError, "constant not allowed: #{const_name}"
|
|
77
|
+
end
|
|
78
|
+
when Prism::ConstantPathNode, Prism::GlobalVariableReadNode
|
|
79
|
+
raise SecurityError, 'constant paths and global variables are not allowed'
|
|
80
|
+
when Prism::DefNode, Prism::ClassNode, Prism::ModuleNode
|
|
81
|
+
raise SecurityError, 'method, class, and module definitions are not allowed'
|
|
82
|
+
when Prism::BackReferenceReadNode, Prism::XStringNode, Prism::InterpolatedXStringNode
|
|
83
|
+
raise SecurityError, 'shell commands and backticks are not allowed'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
node.child_nodes.each { |child| walk(child, context_keys: context_keys) if child }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Provides a sandboxed environment for executing Ruby code.
|
|
91
|
+
# Inherits from `BasicObject` for a minimal namespace and uses `instance_eval`
|
|
92
|
+
# to run code within its own context. All code is validated by {AstGate} before execution.
|
|
93
|
+
class SafeTransform < BasicObject
|
|
94
|
+
# A minimal set of core Ruby constants exposed to the sandboxed environment.
|
|
95
|
+
CORE_CONSTANTS = {
|
|
96
|
+
Array: ::Array,
|
|
97
|
+
Hash: ::Hash,
|
|
98
|
+
String: ::String,
|
|
99
|
+
Integer: ::Integer,
|
|
100
|
+
Float: ::Float,
|
|
101
|
+
TrueClass: ::TrueClass,
|
|
102
|
+
FalseClass: ::FalseClass,
|
|
103
|
+
NilClass: ::NilClass,
|
|
104
|
+
Symbol: ::Symbol,
|
|
105
|
+
Numeric: ::Numeric,
|
|
106
|
+
Regexp: ::Regexp
|
|
107
|
+
}.freeze
|
|
108
|
+
|
|
109
|
+
CORE_CONSTANTS.each do |name, ref|
|
|
110
|
+
const_set(name, ref) unless const_defined?(name, false)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param context [Hash] A hash of data to be made available in the sandbox.
|
|
114
|
+
def initialize context = {}
|
|
115
|
+
@context = context
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Executes the given code within the sandboxed environment.
|
|
119
|
+
#
|
|
120
|
+
# @param code [String] The Ruby code to execute.
|
|
121
|
+
# @return [Object] The result of the executed code.
|
|
122
|
+
# @raise [Timeout::Error] if the execution time exceeds the limit.
|
|
123
|
+
# @raise [SecurityError] if the code contains disallowed operations.
|
|
124
|
+
def transform code
|
|
125
|
+
::Timeout.timeout(0.25) do
|
|
126
|
+
AstGate.validate!(code, context_keys: @context.keys)
|
|
127
|
+
instance_eval(code)
|
|
128
|
+
end
|
|
129
|
+
rescue ::Timeout::Error
|
|
130
|
+
::Kernel.raise ::StandardError, 'transform timed out'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Adds a key-value pair to the execution context.
|
|
134
|
+
#
|
|
135
|
+
# @param key [String, Symbol] The key to add.
|
|
136
|
+
# @param value [Object] The value to associate with the key.
|
|
137
|
+
def add_context key, value
|
|
138
|
+
@context[key.to_s] = value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Safely traverses a nested object using a dot-separated path.
|
|
142
|
+
#
|
|
143
|
+
# @param obj [Object] The object to traverse.
|
|
144
|
+
# @param path [String] The dot-separated path (e.g., "a.b.c").
|
|
145
|
+
# @return [Object, nil] The value at the specified path, or `nil`.
|
|
146
|
+
def dig_path obj, path
|
|
147
|
+
keys = path.to_s.split('.')
|
|
148
|
+
keys.reduce(obj) { |memo, key| memo.respond_to?(:[]) ? memo[key] : nil }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def to_s
|
|
152
|
+
'#<SchemaGraphy::SafeTransform>'
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# Handles access to variables in the context.
|
|
158
|
+
def method_missing(name, *args, &block)
|
|
159
|
+
key = name.to_s
|
|
160
|
+
if @context.key?(key) && args.empty? && block.nil?
|
|
161
|
+
@context[key]
|
|
162
|
+
else
|
|
163
|
+
::Kernel.raise ::NoMethodError, "undefined method `#{name}` for #{self}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def respond_to_missing? name, include_private = false
|
|
168
|
+
@context.key?(name.to_s) || super
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Disable methods that could be used to break out of the sandbox.
|
|
172
|
+
|
|
173
|
+
def instance_exec(*_args)
|
|
174
|
+
::Kernel.raise ::NoMethodError, 'disabled'
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def method(*_args)
|
|
178
|
+
::Kernel.raise ::NoMethodError, 'disabled'
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def singleton_class(*_args)
|
|
182
|
+
::Kernel.raise ::NoMethodError, 'disabled'
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def define_singleton_method(*_args)
|
|
186
|
+
::Kernel.raise ::NoMethodError, 'disabled'
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaGraphy
|
|
4
|
+
# A utility module for introspecting schema definitions.
|
|
5
|
+
# Provides methods for retrieving metadata, default values, and type information
|
|
6
|
+
# from a schema hash using a dot-separated path syntax.
|
|
7
|
+
module SchemaUtils
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Retrieve a nested property definition from a schema using a dot-separated path.
|
|
11
|
+
#
|
|
12
|
+
# @example Schema Structure
|
|
13
|
+
# schema = {
|
|
14
|
+
# "$schema": {
|
|
15
|
+
# "properties": {
|
|
16
|
+
# "property1": {
|
|
17
|
+
# "properties": {
|
|
18
|
+
# "subproperty1": {
|
|
19
|
+
# "default": "value1",
|
|
20
|
+
# "type": "String"
|
|
21
|
+
# }
|
|
22
|
+
# }
|
|
23
|
+
# }
|
|
24
|
+
# }
|
|
25
|
+
# }
|
|
26
|
+
# }
|
|
27
|
+
# crawl_properties(schema, "property1.subproperty1")
|
|
28
|
+
# # => { "default" => "value1", "type" => "String" }
|
|
29
|
+
#
|
|
30
|
+
# @param schema [Hash] The schema hash to crawl.
|
|
31
|
+
# @param path [String] The dot-separated path to the property.
|
|
32
|
+
# @return [Hash, nil] The property definition hash, or `nil` if not found.
|
|
33
|
+
def crawl_properties schema, path
|
|
34
|
+
path_components = path.split('.')
|
|
35
|
+
current = schema['$schema'] || schema
|
|
36
|
+
|
|
37
|
+
path_components.each do |component|
|
|
38
|
+
return nil unless current.is_a?(Hash)
|
|
39
|
+
return nil unless current['properties']&.key?(component)
|
|
40
|
+
|
|
41
|
+
current = current['properties'][component]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
current
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get the default value for a property from the schema.
|
|
48
|
+
#
|
|
49
|
+
# @param schema [Hash] The schema hash.
|
|
50
|
+
# @param path [String] The dot-separated path to the property.
|
|
51
|
+
# @return [Object, nil] The default value, or `nil` if not defined.
|
|
52
|
+
def default_for schema, path
|
|
53
|
+
property = crawl_properties(schema, path)
|
|
54
|
+
return nil unless property.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
property['default'] || property['dflt']
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get the type for a property from the schema.
|
|
60
|
+
#
|
|
61
|
+
# @param schema [Hash] The schema hash.
|
|
62
|
+
# @param path [String] The dot-separated path to the property.
|
|
63
|
+
# @return [String, nil] The property type, or `nil` if not defined.
|
|
64
|
+
def type_for schema, path
|
|
65
|
+
property = crawl_properties(schema, path)
|
|
66
|
+
return nil unless property.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
property['type']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get the templating configuration for a property from the schema.
|
|
72
|
+
#
|
|
73
|
+
# @param schema [Hash] The schema hash.
|
|
74
|
+
# @param path [String] The dot-separated path to the property.
|
|
75
|
+
# @return [Hash] The templating configuration hash.
|
|
76
|
+
def templating_config_for schema, path
|
|
77
|
+
property = crawl_properties(schema, path)
|
|
78
|
+
return {} unless property.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
return property['templating'] if property['templating']
|
|
81
|
+
|
|
82
|
+
if property['type'].to_s.downcase == 'liquid'
|
|
83
|
+
{ 'default' => 'liquid', 'delay' => true }
|
|
84
|
+
elsif property['type'].to_s.downcase == 'erb'
|
|
85
|
+
{ 'default' => 'erb', 'delay' => true }
|
|
86
|
+
else
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if a property is a templated field.
|
|
92
|
+
#
|
|
93
|
+
# @param schema [Hash] The schema hash.
|
|
94
|
+
# @param path [String] The dot-separated path to the property.
|
|
95
|
+
# @return [Boolean] `true` if the field has templating configured, `false` otherwise.
|
|
96
|
+
def templated_field? schema, path
|
|
97
|
+
property = crawl_properties(schema, path)
|
|
98
|
+
return false unless property.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
property.key?('templating') && property['templating'].is_a?(Hash)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Crawl the schema to find the metadata for a given path.
|
|
104
|
+
#
|
|
105
|
+
# @param schema [Hash] The schema hash.
|
|
106
|
+
# @param path [String, nil] The dot-separated path.
|
|
107
|
+
# @return [Hash] The metadata hash.
|
|
108
|
+
def self.crawl_meta schema, path = nil
|
|
109
|
+
parts = path ? path.split('.') : []
|
|
110
|
+
node = schema['$schema'] || schema
|
|
111
|
+
meta = {}
|
|
112
|
+
|
|
113
|
+
parts.each do |part|
|
|
114
|
+
node = node['properties'][part] if node['properties']&.key?(part)
|
|
115
|
+
break unless node.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
# Only update meta if this level has it
|
|
118
|
+
meta = node if node['templating']
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
meta['$meta'] || meta['sgyml'] || meta['templating'] || {}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SchemaGraphy
|
|
4
|
+
# A utility module for working with the custom tag data structure.
|
|
5
|
+
# The structure is a hash with 'value' and '__tag__' keys.
|
|
6
|
+
module TagUtils
|
|
7
|
+
# Extracts the original value from a tagged data structure.
|
|
8
|
+
#
|
|
9
|
+
# @param value [Object] The tagged value (a Hash) or any other value.
|
|
10
|
+
# @return [Object] The original value, or the value itself if not tagged.
|
|
11
|
+
def self.detag value
|
|
12
|
+
value.is_a?(Hash) && value.key?('value') ? value['value'] : value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Retrieves the tag from a tagged data structure.
|
|
16
|
+
#
|
|
17
|
+
# @param value [Object] The tagged value (a Hash) or any other value.
|
|
18
|
+
# @return [String, nil] The tag string, or `nil` if not tagged.
|
|
19
|
+
def self.tag_of value
|
|
20
|
+
value.is_a?(Hash) ? value['__tag__'] : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Checks if a value has a specific tag.
|
|
24
|
+
#
|
|
25
|
+
# @param value [Object] The tagged value to check.
|
|
26
|
+
# @param tag [String, Symbol] The tag to check for.
|
|
27
|
+
# @return [Boolean] `true` if the value has the specified tag, `false` otherwise.
|
|
28
|
+
def self.tag? value, tag
|
|
29
|
+
tag_of(value)&.to_s == tag.to_s
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|