Module: Shellfie::YamlSafety
- Defined in:
- lib/shellfie/yaml_safety.rb
Constant Summary collapse
- MAX_DEPTH =
100- MAX_NODES =
100_000
Class Method Summary collapse
- .annotate_validation_error(error, documents, provenance: {}) ⇒ Object
- .collect_locations(node, path, locations) ⇒ Object
- .format_path(path) ⇒ Object
- .load_file(path, max_bytes:, label: "Configuration", symbolize_names: true) ⇒ Object
- .location_for_path(content, path) ⇒ Object
- .parse_path(value) ⇒ Object
- .read_file(path, max_bytes:, label: "Configuration") ⇒ Object
- .validate_tree!(value) ⇒ Object
- .validation_location(content, message) ⇒ Object
- .validation_path(message) ⇒ Object
Class Method Details
.annotate_validation_error(error, documents, provenance: {}) ⇒ Object
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# File 'lib/shellfie/yaml_safety.rb', line 13 def annotate_validation_error(error, documents, provenance: {}) if (target = validation_path(error.)) && (origin = provenance[target]) path, local_path = origin content = documents.assoc(path)&.last if content && (location = location_for_path(content, local_path)) return error.class.new("#{path}:#{location[0]}:#{location[1]}: #{error.}") end end documents.each do |path, content| next unless path && content location = validation_location(content, error.) return error.class.new("#{path}:#{location[0]}:#{location[1]}: #{error.}") if location end path = documents.first&.first error.class.new(path ? "#{path}: #{error.}" : error.) end |
.collect_locations(node, path, locations) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/shellfie/yaml_safety.rb', line 100 def collect_locations(node, path, locations) case node when Psych::Nodes::Mapping node.children.each_slice(2) do |key, value| next unless key.respond_to?(:value) child_path = path + [key.value.to_sym] locations[child_path] = [key.start_line + 1, key.start_column + 1] collect_locations(value, child_path, locations) end when Psych::Nodes::Sequence node.children.each_with_index { |child, index| collect_locations(child, path + [index], locations) } end end |
.format_path(path) ⇒ Object
136 137 138 139 140 141 142 143 144 145 |
# File 'lib/shellfie/yaml_safety.rb', line 136 def format_path(path) path.each_with_object(+"") do |part, result| if part.is_a?(Integer) result << "[#{part}]" else result << "." unless result.empty? result << part.to_s end end end |
.load_file(path, max_bytes:, label: "Configuration", symbolize_names: true) ⇒ Object
42 43 44 45 46 47 48 49 |
# File 'lib/shellfie/yaml_safety.rb', line 42 def load_file(path, max_bytes:, label: "Configuration", symbolize_names: true) value = YAML.safe_load( read_file(path, max_bytes: max_bytes, label: label), symbolize_names: symbolize_names, aliases: true ) validate_tree!(value) rescue Psych::Exception => e raise ParseError, "Invalid #{label.downcase} YAML syntax: #{e.}" end |
.location_for_path(content, path) ⇒ Object
92 93 94 95 96 97 98 |
# File 'lib/shellfie/yaml_safety.rb', line 92 def location_for_path(content, path) locations = {} collect_locations(Psych.parse(content).root, [], locations) locations[path] || locations[path[0...-1]] rescue Psych::Exception nil end |
.parse_path(value) ⇒ Object
132 133 134 |
# File 'lib/shellfie/yaml_safety.rb', line 132 def parse_path(value) value.to_s.scan(/[A-Za-z_][A-Za-z0-9_]*|\d+/).map { |part| part.match?(/\A\d+\z/) ? part.to_i : part.to_sym } end |
.read_file(path, max_bytes:, label: "Configuration") ⇒ Object
33 34 35 36 37 38 39 40 |
# File 'lib/shellfie/yaml_safety.rb', line 33 def read_file(path, max_bytes:, label: "Configuration") raise ParseError, "#{label} file not found: #{path}" unless File.file?(path) content = File.open(path, "rb") { |file| file.read(max_bytes + 1) } raise ParseError, "#{label} file is too large: #{path} (max #{max_bytes} bytes)" if content.bytesize > max_bytes content end |
.validate_tree!(value) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/shellfie/yaml_safety.rb', line 51 def validate_tree!(value) active = {} nodes = 0 stack = [[value, 0, false]] until stack.empty? node, depth, leaving = stack.pop next unless node.is_a?(Hash) || node.is_a?(Array) if leaving active.delete(node.object_id) next end raise ParseError, "YAML aliases must not contain cycles" if active[node.object_id] raise ParseError, "YAML nesting is too deep (max #{MAX_DEPTH})" if depth > MAX_DEPTH nodes += 1 raise ParseError, "YAML structure is too large (max #{MAX_NODES} collections)" if nodes > MAX_NODES active[node.object_id] = true stack << [node, depth, true] children = node.is_a?(Hash) ? node.flat_map { |key, nested| [key, nested] } : node children.reverse_each { |child| stack << [child, depth + 1, false] } end value end |
.validation_location(content, message) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/shellfie/yaml_safety.rb', line 78 def validation_location(content, ) locations = {} collect_locations(Psych.parse(content).root, [], locations) target = validation_path() return locations[target] if target && locations[target] locations.sort_by { |path, _location| -path.size }.each do |path, location| return location if .include?(format_path(path)) end nil rescue Psych::Exception nil end |
.validation_path(message) ⇒ Object
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/shellfie/yaml_safety.rb', line 115 def validation_path() if (match = /Unknown (.+?) key\(s\):\s*([^,\s]+)/.match()) prefix = match[1] == "configuration" ? [] : parse_path(match[1]) return prefix + [match[2].to_sym] end explicit = [/\b(?:steps|outputs|frames|lines)\[\d+\](?:\.[a-z_]+)*|\b(?:terminal|render|window|font|animation|cursor|limits)\.[a-z_]+/] return parse_path(explicit) if explicit { "Session config version" => [:version], "mode must" => [:mode], "Invalid theme" => [:theme], "redaction" => [:redact], "requires" => [:requires], "terminal.env" => %i[terminal env], "title must" => [:title], "headless must" => [:headless] }.each { |fragment, path| return path if .include?(fragment) } nil end |