Files

1455 lines
41 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
module YAML
class << self
alias_method :load, :unsafe_load if YAML.respond_to? :unsafe_load
alias_method :original_dump, :dump
alias_method :original_load_file, :load_file
end
def self.LOG(info)
puts Time.new.strftime("%Y-%m-%d %H:%M:%S") + " [Info] " + "#{info}"
end
def self.LOG_ERROR(info)
puts Time.new.strftime("%Y-%m-%d %H:%M:%S") + " [Error] " + "#{info}"
end
def self.LOG_WARN(info)
puts Time.new.strftime("%Y-%m-%d %H:%M:%S") + " [Warning] " + "#{info}"
end
def self.LOG_TIP(info)
puts Time.new.strftime("%Y-%m-%d %H:%M:%S") + " [Tip] " + "#{info}"
end
def self.load_file(filename, *args, **kwargs)
secret = nil
if kwargs.key?(:secret)
secret = kwargs.delete(:secret)
end
header = File.binread(filename, 512).to_s
if header.include?("BEGIN AGE ENCRYPTED FILE")
yaml_content = File.read(filename, mode: "r:bom|utf-8")
if secret && secret.to_s.strip != ""
decrypted = decrypt_content_with_secret(secret.to_s, yaml_content)
if decrypted && !decrypted.empty? && !decrypted.include?("BEGIN AGE ENCRYPTED FILE")
return fix_and_load(decrypted, *args, **kwargs)
else
raise "Decrypted content empty or still encrypted: [#{filename}]"
end
end
keys = find_age_keys_for_filename(filename)
last_error = nil
(keys[:secrets] || []).each do |sec|
begin
decrypted = decrypt_content_with_secret(sec, yaml_content)
if decrypted && !decrypted.empty? && !decrypted.include?("BEGIN AGE ENCRYPTED FILE")
return fix_and_load(decrypted, *args, **kwargs)
end
rescue => e
last_error = e.message
end
end
detail = last_error ? "#{last_error}" : ""
raise "Encrypted file: decryption failed for [#{filename}]: [#{detail}]"
end
base64, short_id, protocol_param = File.open(filename, "r:bom|utf-8") do |io|
scan_for_fixes(io)
end
if base64 || protocol_param
fix_and_load(File.read(filename, mode: "r:bom|utf-8"), *args, **kwargs)
elsif short_id
fixed = File.open(filename, "r:bom|utf-8") { |io| fix_short_id_text(io) }
begin
load(fixed, *args, **kwargs)
rescue => e
raise "fix short-id values type failed: #{e.message}"
end
else
result = File.open(filename, "r:bom|utf-8") do |io|
load(io, *args, **kwargs)
end
if result.nil? || result == false
result = fix_and_load(File.read(filename, mode: "r:bom|utf-8"), *args, **kwargs)
end
result
end
end
# The pipeline loads the same large file repeatedly, cache it by mtime/size.
def self.load_file_cached(filename, cache_file, *args, **kwargs)
if File.exist?(cache_file)
begin
entry = Marshal.load(File.binread(cache_file))
if entry.is_a?(Array) && entry.length == 3 &&
entry[0] == File.mtime(filename).to_i && entry[1] == File.size(filename)
return entry[2]
end
rescue ::Exception
# ignore broken cache, parse the file again
end
end
value = load_file(filename, *args, **kwargs)
cache_write(filename, value, cache_file)
value
end
def self.cache_write(filename, value, cache_file)
begin
File.open(cache_file, 'wb') do |file|
Marshal.dump([File.mtime(filename).to_i, File.size(filename), value], file)
end
rescue ::Exception
File.delete(cache_file) rescue nil
end
end
def self.dump(obj, io = nil, **options)
if obj.nil? || obj == false
target = ""
if io.is_a?(String)
target = " [#{io}]"
elsif io && io.respond_to?(:path)
target = " [#{io.path}]"
elsif options.key?(:filename)
target = " [#{options[:filename]}]"
end
raise "YAML.dump: refusing to write nil/false config content#{target} (previous load may have failed)"
end
if io.is_a?(String)
dump_to_path(obj, io, **options)
else
dump_to_io(obj, io, **options)
end
end
def self.dump_to_io(obj, io = nil, **options)
public_key = nil
fname = nil
if options.key?(:public)
public_key = options.delete(:public)
end
if (!public_key || public_key.to_s.strip == "")
if options.key?(:filename)
fname = options.delete(:filename)
elsif io && io.respond_to?(:path)
fname = io.path
elsif io && io.respond_to?(:to_path)
fname = io.to_path
end
if fname && fname.to_s.strip != ""
keys = find_age_keys_for_filename(fname)
public_key = keys[:publics].first if keys[:publics] && !keys[:publics].empty?
end
end
needs_fix = contains_short_id?(obj)
if public_key && public_key.to_s.strip != ""
yaml_content = original_dump(obj, **options)
processed = needs_fix ? fix_short_id_quotes(yaml_content) : yaml_content
begin
encrypted = encrypt_content_with_public(public_key.to_s, processed)
if encrypted && !encrypted.empty?
if io.nil?
return encrypted
elsif io.respond_to?(:write)
io.write(encrypted)
return io
else
return encrypted
end
end
rescue => e
if io.respond_to?(:write)
io.write(processed)
end
raise
end
end
stream = options.empty? && StreamDump.supported?(obj)
if io.respond_to?(:write)
if stream
StreamDump.new(io).dump(obj, short_id: needs_fix)
elsif needs_fix
fix_short_id_text(original_dump(obj, **options), io)
else
original_dump(obj, io, **options)
end
io
elsif stream
stream_dump_to_string(obj, needs_fix)
else
dump_string(obj, options, needs_fix)
end
end
def self.stream_dump_to_string(obj, needs_fix)
sink = StringSink.new
StreamDump.new(sink).dump(obj, short_id: needs_fix)
sink.string
end
def self.dump_string(obj, options, needs_fix)
yaml_content = original_dump(obj, **options)
needs_fix ? fix_short_id_quotes(yaml_content) : yaml_content
end
def self.dump_to_path(obj, path, **options)
real = File.symlink?(path) ? File.realpath(path) : path
dir = File.dirname(real)
tmp = File.join(dir, ".#{File.basename(real)}.tmp#{Process.pid}.#{rand(1000)}")
mode = File.exist?(real) ? File.stat(real).mode & 07777 : nil
begin
File.open(tmp, 'w') { |f| dump_to_io(obj, f, **options.merge(filename: real)) }
File.chmod(mode, tmp) if mode
File.rename(tmp, real)
rescue ::Exception
begin
File.unlink(tmp) if File.exist?(tmp)
rescue
end
raise
end
path
end
def self.popen_stream(cmd, input, chunk_size: 64 * 1024)
output = String.new
IO.popen(cmd, 'r+', err: [:child, :out]) do |io|
io.binmode
writer_error = nil
writer = Thread.new do
begin
input.bytesize.times do |i|
chunk = input.byteslice(i * chunk_size, chunk_size)
break if chunk.nil?
io.write(chunk)
end
io.close_write
rescue Errno::EPIPE
# child exited before reading all input, not an error
rescue => e
writer_error = e
end
end
while chunk = io.read(chunk_size)
output << chunk
end
writer.join
raise writer_error if writer_error
end
[output, $?]
end
def self.decode64(input)
first_line = input.each_line.find { |l| !l.strip.empty? } || ""
return input if !first_line.strip.match?(/\A[A-Za-z0-9+\/=]+\z/)
out, status = popen_stream(["base64", "-d"], input)
status.success? ? out : input
rescue Errno::ENOENT
input
end
AGE_KEYS_UCI_CONFIG = "/etc/config/openclash"
AGE_KEYS_SECTION = "config_age_secret".freeze
# The file itself says whether a lookup is needed: the common case has no age key and must
# not pay for a fork and a uci parse.
def self.age_keys_enabled?
File.read(AGE_KEYS_UCI_CONFIG).include?(AGE_KEYS_SECTION)
rescue StandardError
true
end
# One "uci show" dump resolves every name and both key types
def self.age_keys
@age_keys ||= begin
keys = {}
if age_keys_enabled?
cmd = ["/bin/sh", "-c", ". /usr/share/openclash/uci.sh; uci_age_keys_dump"]
IO.popen(cmd, "r") do |io|
io.each_line do |line|
tag, name, value = line.strip.split("\t", 3)
next if name.nil? || value.nil? || value.strip == ""
keys[name] ||= { publics: [], secrets: [] }
(tag == "P" ? keys[name][:publics] : keys[name][:secrets]) << value.strip
end
end
end
keys
rescue Errno::ENOENT
{}
end
end
def self.find_age_keys_for_filename(filename)
keys = age_keys
publics = []
secrets = []
[File.basename(filename), File.basename(filename, File.extname(filename))].uniq.each do |n|
entry = keys[n]
next if entry.nil?
publics.concat(entry[:publics])
secrets.concat(entry[:secrets])
end
{ publics: publics, secrets: secrets }
end
def self.decrypt_content_with_secret(secret, content)
# Clear injection-detection env vars to prevent OIX GuardStartup from
# killing the child process (e.g. LD_PRELOAD set by opkg upgrade)
cmd = ['/etc/openclash/core/clash_meta', 'age', 'decrypt', secret, '-', '-']
old_ld_preload = ENV.delete('LD_PRELOAD')
old_ld_audit = ENV.delete('LD_AUDIT')
old_dyld = ENV.delete('DYLD_INSERT_LIBRARIES')
begin
out, status = popen_stream(cmd, content)
status.success? ? out : raise("age decrypt failed: #{out.to_s.strip}")
rescue => e
raise "age decrypt failed: #{e.message.strip}"
ensure
ENV['LD_PRELOAD'] = old_ld_preload if old_ld_preload
ENV['LD_AUDIT'] = old_ld_audit if old_ld_audit
ENV['DYLD_INSERT_LIBRARIES'] = old_dyld if old_dyld
end
end
def self.encrypt_content_with_public(public, content)
cmd = ['/etc/openclash/core/clash_meta', 'age', 'encrypt', public, '-', '-']
old_ld_preload = ENV.delete('LD_PRELOAD')
old_ld_audit = ENV.delete('LD_AUDIT')
old_dyld = ENV.delete('DYLD_INSERT_LIBRARIES')
begin
out, status = popen_stream(cmd, content)
status.success? ? out : raise("age encrypt failed: #{out.to_s.strip}")
rescue => e
raise "age encrypt failed: #{e.message.strip}"
ensure
ENV['LD_PRELOAD'] = old_ld_preload if old_ld_preload
ENV['LD_AUDIT'] = old_ld_audit if old_ld_audit
ENV['DYLD_INSERT_LIBRARIES'] = old_dyld if old_dyld
end
end
private
# fix_short_id_quotes:
# Purpose: ensure YAML `short-id` values are emitted with the intended
# representation when dumping or re-writing YAML files.
# Behavior:
# - Non-null, non-empty scalar `short-id` values (and items inside
# `short-id` sequences) are written as double-quoted strings.
# - Empty strings are preserved as empty quoted strings ("" remains "").
# - Null values are preserved and may be emitted explicitly (e.g. `! "").
# Lastly, use gsub to clean up any `! ""` tags
# Examples:
# Input: short-id: 00000000 -> Output: short-id: "00000000"
# Input: short-id: "" -> Output: short-id: ""
# Input: short-id: "abc123" -> Output: short-id: "abc123"
# Input: short-id: "1600e237" -> Output: short-id: "1600e237"
# Input: short-id: null -> Output: short-id: ""
def self.fix_and_load(yaml_content, *args, **kwargs)
yaml_content = decode64(yaml_content)
# Fix bare protocol-param values that break YAML parsing (e.g. "1.2.3.4:8080#test")
yaml_content.gsub!(/^(\s*protocol-param:\s+)([^\s"'][^"\n\r]*[#:][^"\n\r]*)$/, '\1"\2"')
return load(yaml_content, *args, **kwargs) unless yaml_content.include?('short-id:')
begin
load(fix_short_id_text(yaml_content), *args, **kwargs)
rescue => e
raise "fix short-id values type failed: #{e.message}"
end
end
def self.quote_short_id_scalar(value)
v = value.strip
return value if v.empty?
return '""' if v =~ /\A(?:~|null|NULL|Null)\z/
if v.start_with?("'")
if (m = v.match(/\A'((?:[^']|'')*)'(\s+#.*)?\z/))
inner = m[1].gsub("''", "'")
return "\"#{inner.gsub(/["\\]/) { |c| "\\#{c}" }}\"#{m[2]}"
end
return value
end
return value if v.start_with?('"')
return value if v =~ /[:{}\[\],|>]/
if (m = v.match(/\A(\S+)(\s+#.*)?\z/))
"\"#{m[1].gsub('"', '\\"')}\"#{m[2]}"
else
"\"#{v.gsub('"', '\\"')}\""
end
end
def self.fix_short_id_text(yaml_content, output = nil)
out = output || String.new
in_seq = false
seq_indent = -1
in_block = false
block_indent = -1
pending = nil
yaml_content.each_line do |line|
if in_block
if line.strip.empty? || (line =~ /^(\s*)/ && Regexp.last_match(1).length > block_indent)
out << line
next
else
in_block = false
end
end
if pending
if (m = line.match(/^(\s*)-(\s*)(.*?)(\r?\n)?\z/)) && m[1].length >= seq_indent
out << pending
in_seq = true
else
out << pending.sub(/^(\s*)short-id:.*?(\r?\n)?\z/, '\1short-id: ""\2')
in_seq = false
end
pending = nil
end
if (m = line.match(/^(\s*)short-id:(\s*)(.*?)(\r?\n)?\z/))
indent = m[1]
rest = m[3].to_s.strip
if rest.empty? || rest.start_with?('#')
in_seq = true
seq_indent = indent.length
pending = line
else
in_seq = false
out << indent + "short-id:" + m[2] + quote_short_id_scalar(rest) + m[4].to_s
end
elsif (m = line.match(/^(\s*)[^:\s][^:]*:\s*[|>](\s*.*)?$/))
in_block = true
in_seq = false
block_indent = m[1].length
out << line
elsif line.strip.empty?
out << line
elsif in_seq && (m = line.match(/^(\s*)-(\s*)(.*?)(\r?\n)?\z/)) && m[1].length >= seq_indent
out << m[1] + "-" + m[2] + quote_short_id_scalar(m[3]) + m[4].to_s
else
in_seq = false
out << line
end
end
if pending
out << pending.sub(/^(\s*)short-id:.*?(\r?\n)?\z/, '\1short-id: ""\2')
end
out
end
def self.fix_short_id_quotes(yaml_content)
begin
fix_short_id_text(yaml_content)
rescue => e
raise "fix short-id values type failed: #{e.message}"
end
end
def self.scan_for_fixes(io)
base64 = false
short_id = false
protocol_param = false
first_nonempty_seen = false
buffer = String.new
chunk_size = 64 * 1024
while (chunk = io.read(chunk_size))
buffer << chunk
unless first_nonempty_seen
if (idx = buffer.index("\n"))
stripped = buffer[0...idx].strip
if !stripped.empty?
first_nonempty_seen = true
base64 = stripped.match?(/\A[A-Za-z0-9+\/=]+\z/)
end
end
end
short_id ||= buffer.include?('short-id:')
protocol_param ||= buffer.include?('protocol-param:')
if first_nonempty_seen && buffer.bytesize > chunk_size + 4096
buffer = buffer[-4096, 4096]
end
break if base64 || short_id || protocol_param
end
unless first_nonempty_seen
stripped = buffer.strip
base64 = !stripped.empty? && stripped.match?(/\A[A-Za-z0-9+\/=]+\z/)
end
[base64, short_id, protocol_param]
end
def self.contains_short_id?(obj, depth = 0)
return false if depth > 64
case obj
when Hash
return true if obj.key?('short-id') || obj.key?(:"short-id")
obj.each_value { |v| return true if contains_short_id?(v, depth + 1) }
false
when Array
obj.any? { |v| contains_short_id?(v, depth + 1) }
else
false
end
end
# StreamDump writes the data tree directly into libyaml, Psych.dump builds a full AST first
# and nearly doubles the peak memory. supported? skips trees with anchors or tags,
# and the scalar style rules below reproduce Psych::Visitors::YAMLTree#visit_String.
class StringSink
attr_reader :string
def initialize
@string = String.new
end
def write(data)
@string << data
data.to_s.bytesize
end
end
class StreamDump
ANY = Psych::Nodes::Scalar::ANY
PLAIN = Psych::Nodes::Scalar::PLAIN
SINGLE_QUOTED = Psych::Nodes::Scalar::SINGLE_QUOTED
DOUBLE_QUOTED = Psych::Nodes::Scalar::DOUBLE_QUOTED
LITERAL = Psych::Nodes::Scalar::LITERAL
MAP_BLOCK = Psych::Nodes::Mapping::BLOCK
SEQ_BLOCK = Psych::Nodes::Sequence::BLOCK
NULL_TAG = 'tag:yaml.org,2002:null'
STR_TAG = 'tag:yaml.org,2002:str'
def self.available?
defined?(Psych::Emitter) && defined?(Psych::ScalarScanner) && defined?(Psych::ClassLoader)
end
def self.supported?(obj)
return false unless available?
seen = {}.compare_by_identity
stack = [obj]
until stack.empty?
item = stack.pop
case item
when Hash
return false unless item.class == ::Hash
return false if seen.key?(item)
seen[item] = true
item.each do |key, value|
stack << key
stack << value
end
when Array
return false unless item.class == ::Array
return false if seen.key?(item)
seen[item] = true
stack.concat(item)
when String
return false unless item.class == ::String
return false if item.instance_variables.any?
return false if item.encoding == Encoding::ASCII_8BIT && !item.ascii_only?
when Integer, Float, TrueClass, FalseClass, NilClass
# dumped through to_s, exactly like Psych does
else
return false
end
end
true
end
def initialize(io)
@io = io
@scanner = Psych::ScalarScanner.new(Psych::ClassLoader.new)
@short_id = false
end
def dump(obj, short_id: false)
@short_id = short_id
emitter = Psych::Emitter.new(@io)
@emitter = emitter
emitter.start_stream(Psych::Parser::UTF8)
emitter.start_document([], [], false)
emit(obj)
emitter.end_document(true)
emitter.end_stream
@emitter = nil
@io
end
private
def emit(obj)
case obj
when Hash
@emitter.start_mapping(nil, nil, true, MAP_BLOCK)
obj.each do |key, value|
emit(key)
emit_value(value, key)
end
@emitter.end_mapping
when Array
@emitter.start_sequence(nil, nil, true, SEQ_BLOCK)
obj.each { |value| emit(value) }
@emitter.end_sequence
when String
emit_string(obj)
when Integer, TrueClass, FalseClass
@emitter.scalar(obj.to_s, nil, nil, true, false, ANY)
when Float
emit_float(obj)
when NilClass
@emitter.scalar('', nil, NULL_TAG, true, false, ANY)
end
end
def emit_value(value, key)
if @short_id && (key == 'short-id' || key == :'short-id')
emit_short_id(value)
else
emit(value)
end
end
def emit_float(value)
if value.nan?
@emitter.scalar('.nan', nil, nil, true, false, ANY)
elsif value.infinite?
@emitter.scalar(value.infinite? > 0 ? '.inf' : '-.inf', nil, nil, true, false, ANY)
else
@emitter.scalar(value.to_s, nil, nil, true, false, ANY)
end
end
# short-id values are always double quoted: same result as
# fix_short_id_text, without building the intermediate document string
def emit_short_id(value)
if value.is_a?(Array)
@emitter.start_sequence(nil, nil, true, SEQ_BLOCK)
value.each { |item| emit_quoted(item) }
@emitter.end_sequence
else
emit_quoted(value)
end
end
def emit_quoted(value)
case value
when String, Integer, Float
@emitter.scalar(value.to_s, nil, nil, false, true, DOUBLE_QUOTED)
when nil
@emitter.scalar('', nil, nil, false, true, DOUBLE_QUOTED)
else
emit(value)
end
end
def emit_string(value)
plain = true
quote = true
style = PLAIN
tag = nil
if value.match?(/\n(?!\Z)/)
style = LITERAL
elsif value == '<<'
style = SINGLE_QUOTED
tag = STR_TAG
plain = false
quote = false
elsif value == 'y' || value == 'Y' || value == 'n' || value == 'N'
style = DOUBLE_QUOTED
elsif value.match?(/^[^[:word:]][^"]*$/)
style = DOUBLE_QUOTED
elsif !(String === @scanner.tokenize(value)) || /\A0[0-7]*[89]/.match?(value)
style = SINGLE_QUOTED
end
@emitter.scalar(value, nil, tag, plain, quote, style)
end
end
# Inline replaces the "one Thread per field" pattern: a finished Thread keeps its stack
# until it is joined, which costs hundreds of MB for thousands of entries.
class Inline
def initialize(*args, &block)
@error = nil
begin
block.call(*args)
rescue ::Exception => e
@error = e
end
end
def join
raise @error if @error
self
end
def value
raise @error if @error
self
end
def alive?
false
end
def status
@error ? nil : false
end
def kill
self
end
alias_method :terminate, :kill
end
def self.overwrite(base, override)
return override if base.nil?
return base if override.nil?
current_key = nil
current_operation = nil
begin
case override
when Hash
result = base.is_a?(Hash) ? base.dup : {}
override.each do |key, value|
current_key = key
processed_key, operation = parse_key(key)
current_operation = operation
applied = apply_operation(result[processed_key], value, operation)
if applied.equal?(DELETED_SENTINEL)
result.delete(processed_key)
else
result[processed_key] = applied
end
end
result
else
override
end
rescue => e
raise "key: [#{current_key}] - operation: [#{current_operation}], error: [#{e.message}]"
end
end
private
def self.parse_key(key)
key_str = key.to_s
# +<key>
if key_str.start_with?('+<') && key_str.include?('>')
close_idx = key_str.index('>')
inner_key = key_str[2...close_idx]
return inner_key, :prepend_array
end
# <key>suffix
if key_str.start_with?('<') && key_str.include?('>')
close_idx = key_str.index('>')
inner_key = key_str[1...close_idx]
suffix = key_str[(close_idx + 1)..-1]
return inner_key, determine_operation(suffix)
end
# 前缀 +key
if key_str.start_with?('+')
return key_str[1..-1], :prepend_array
end
# 尾部(支持 +, !, *, -)
if key_str =~ /^(.*?)([+!*\-])$/
return Regexp.last_match(1), determine_operation(Regexp.last_match(2))
end
[key_str, :merge]
end
def self.determine_operation(suffix)
case suffix
when '+'
:append_array
when '-'
:delete
when '!'
:force_overwrite
when '*'
:batch_update
else
:merge
end
end
def self.match_value(target, condition)
return false if target.nil? || condition.nil?
begin
if condition.is_a?(String) && condition.start_with?('/') && condition.end_with?('/')
pattern = condition[1...-1]
regexp = Regexp.new(pattern)
if target.is_a?(Array)
target.any? { |item| item.to_s =~ regexp }
else
target.to_s =~ regexp
end
elsif condition.is_a?(Array) && target.is_a?(Array)
condition.all? { |c| target.include?(c) }
else
target == condition
end
rescue => e
raise "[match value] target: [#{target}] - condition: [#{condition}], error: [#{e.message}]"
end
end
def self.deep_dup(obj)
case obj
when Array
obj.map { |x| deep_dup(x) }
when Hash
obj.transform_values { |v| deep_dup(v) }
else
obj.dup rescue obj
end
end
def self.merge_hash(base, value, prepend: false)
if prepend
result = {}
value.each do |k, v|
if base.key?(k)
result[k] = apply_operation(base[k], v, :merge)
else
result[k] = deep_dup(v)
end
end
base.each do |k, v|
result[k] = deep_dup(v) unless result.key?(k)
end
result
else
result = deep_dup(base)
value.each do |k, v|
if result.key?(k)
result[k] = apply_operation(result[k], v, :merge)
else
result[k] = deep_dup(v)
end
end
result
end
end
def self.delete_from_hash(base, value)
result = deep_dup(base)
case value
when Array
value.each { |k| result.delete(k) }
when Hash
value.each do |k, v|
if v.nil? || v == true
result.delete(k)
elsif result[k].is_a?(Hash) && v.is_a?(Hash)
nested = apply_operation(result[k], v, :delete)
if nested.equal?(DELETED_SENTINEL)
result.delete(k)
else
result[k] = nested
end
else
result.delete(k)
end
end
else
result.delete(value)
end
result
end
DELETED_SENTINEL = Object.new.freeze
def self.apply_operation(base, value, operation)
case operation
when :delete
if base.is_a?(Array) && value.is_a?(Array)
base - value
elsif base.is_a?(Array) && !value.nil?
base - [value]
elsif base.is_a?(Hash)
delete_from_hash(base, value)
else
DELETED_SENTINEL
end
when :force_overwrite
deep_dup(value)
when :prepend_array
if base.is_a?(Array) && value.is_a?(Array)
(deep_dup(value) + base).uniq
elsif base.is_a?(Hash) && value.is_a?(Hash)
merge_hash(base, value, prepend: true)
else
deep_dup(value)
end
when :append_array
if base.is_a?(Array) && value.is_a?(Array)
base_dup = base.dup
deep_dup(value).each { |v| base_dup.delete(v) }
base_dup + deep_dup(value)
elsif base.is_a?(Hash) && value.is_a?(Hash)
merge_hash(base, value, prepend: false)
else
deep_dup(value)
end
when :batch_update
batch_update_items(base, value)
when :merge
if base.is_a?(Hash) && value.is_a?(Hash)
overwrite(base, value)
elsif value.nil?
base
else
deep_dup(value)
end
else
deep_dup(value)
end
end
def self.apply_set_fields(item, set_values)
keys_to_delete = []
set_values.each do |k, v|
processed_key, operation = parse_key(k)
result = apply_operation(item[processed_key], v, operation)
if result.equal?(DELETED_SENTINEL)
keys_to_delete << processed_key
else
item[processed_key] = result
end
end
keys_to_delete.each { |k| item.delete(k) }
end
def self.match_item(item, where_conditions, key = nil)
where_conditions.all? do |k, v|
if k == 'key' && !key.nil?
match_value(key, v)
elsif item.is_a?(Hash)
match_value(item[k] || item[k.to_s], v)
elsif item.is_a?(String) && k == 'value'
match_value(item, v)
else
false
end
end
end
def self.batch_update_items(collection, update_spec)
return collection unless update_spec.is_a?(Hash)
begin
where_conditions = update_spec['where'] || {}
set_values = update_spec['set'] || {}
if collection.is_a?(Array)
result = collection.dup
delete_indices = []
result.each_with_index do |item, index|
match = match_item(item, where_conditions)
if match
if item.is_a?(Hash)
apply_set_fields(item, set_values)
elsif item.is_a?(String) && set_values.key?('value')
new_value = set_values['value']
if new_value.nil?
delete_indices << index
else
result[index] = deep_dup(new_value)
end
end
end
end
delete_indices.reverse_each { |i| result.delete_at(i) }
result
elsif collection.is_a?(Hash)
if where_conditions.any? { |k, _| k != 'key' } &&
match_item(collection, where_conditions)
result = collection.dup
apply_set_fields(result, set_values)
result
else
result = collection.dup
keys_to_delete = []
result.each do |key, value|
next unless value.is_a?(Hash)
match = match_item(value, where_conditions, key)
if match
if set_values.key?('key-') || (set_values.key?('key') && set_values['key'].nil?)
keys_to_delete << key
else
apply_set_fields(value, set_values)
end
end
end
keys_to_delete.each { |k| result.delete(k) }
result
end
elsif collection.nil?
nil
else
collection
end
rescue => e
raise "[batch update] update_spec: [#{update_spec}], error: [#{e.message}]"
end
end
# The [Overwrite] modules and the custom overwrite script are both executed by this section,
# they record their helper calls and let one process own one Value and one dump; the argument
# counts below must stay in sync with ruby_record() in ruby.sh
OVERWRITE_HELPERS = {
'ruby_arr_add_file' => 5,
'ruby_arr_edit' => 6,
'ruby_arr_head_add_file' => 4,
'ruby_arr_insert' => 4,
'ruby_arr_insert_arr' => 4,
'ruby_arr_insert_hash' => 4,
'ruby_cover' => 4,
'ruby_delete' => 3,
'ruby_edit' => 3,
'ruby_map_edit' => 5,
'ruby_merge' => 4,
'ruby_merge_hash' => 3,
'ruby_uniq' => 2
}.freeze
OVERWRITE_REQUIRED = {
'ruby_arr_add_file' => [1, 2, 3, 4, 5],
'ruby_arr_edit' => [1, 2],
'ruby_arr_head_add_file' => [1, 2, 3, 4],
'ruby_arr_insert' => [1, 2, 3, 4],
'ruby_arr_insert_arr' => [1, 2, 3, 4],
'ruby_arr_insert_hash' => [1, 2, 3, 4],
'ruby_cover' => [1, 2],
'ruby_delete' => [1, 3],
'ruby_edit' => [1, 2],
'ruby_map_edit' => [1, 2, 3, 4],
'ruby_merge' => [1, 2, 3],
'ruby_merge_hash' => [1, 2, 3],
'ruby_uniq' => [1, 2]
}.freeze
# The code arguments are checked against these lists before eval: node types give the allowed
# syntax, the method, operator and constant lists the allowed callable surface, everything
# not listed is rejected; the inspected arguments never take part in the check
OVERWRITE_NODES = %i[
AND ARGS ATTRASGN BEGIN BLOCK BLOCK_PASS BREAK CALL CASE CASE3 CONST DASGN DOT2 DOT3
DREGX DSTR DVAR EVSTR FALSE FCALL HASH IF IN ITER LAMBDA LASGN LIST LIT LVAR MASGN
NEXT NIL NOT OP_ASGN1 OP_ASGN2 OP_ASGN_AND OP_ASGN_OR OPCALL OR RESBODY RESCUE SCOPE
STR SYM TRUE UNLESS WHEN ZLIST
].freeze
OVERWRITE_METHODS = %w(
[] []= LOG LOG_ERROR LOG_TIP LOG_WARN abs all? any? call capitalize ceil chars chomp
chomp! chop clone compact compact! concat count delete delete_at delete_if detect dig
downcase drop drop_while dup each each_char each_key each_line each_pair each_slice
each_value each_with_index empty? end_with? fetch filter filter! find find_index first
flat_map flatten flatten! floor frozen? group_by gsub gsub! has_key? has_value?
include? index insert inspect instance_of? is_a? itself join keep_if key? keys kind_of?
lambda last length lstrip map map! match match? max max_by member? merge merge! min
min_by nil? none? partition pop proc push reject reject! replace reverse reverse!
rindex round rstrip select select! shift size slice slice! sort sort! sort_by sort_by!
split start_with? store strip sub sub! sum swapcase take take_while tally tap then to_a
to_f to_h to_i to_s to_sym transform_keys transform_keys! transform_values
transform_values! uniq uniq! unshift upcase update values values_at yield_self
).freeze
OVERWRITE_OPERATORS = %w[! != !~ % & * ** + - / < << <= <=> == =~ > >= >> ^ | ~].freeze
OVERWRITE_CONSTANTS = %w[Array FalseClass Float Hash Integer NilClass Numeric String Symbol TrueClass Value YAML].freeze
# Derived lookups keep the node walk cheap, the tables above stay readable
OVERWRITE_NODE_LOOKUP = OVERWRITE_NODES.to_h { |type| [type, true] }.freeze
OVERWRITE_CALL_LOOKUP = (OVERWRITE_METHODS + OVERWRITE_OPERATORS).to_h { |name| [name.to_sym, true] }.freeze
OVERWRITE_CONST_LOOKUP = OVERWRITE_CONSTANTS.to_h { |name| [name.to_sym, true] }.freeze
# Argument numbers follow the helper call order of ruby.sh, 1 is the config file; the check
# only covers the arguments interpolated as ruby source
OVERWRITE_CODE = {
'ruby_arr_add_file' => [2, 3, 5],
'ruby_arr_edit' => [2, 3, 5],
'ruby_arr_head_add_file' => [2, 4],
'ruby_arr_insert' => [2, 3],
'ruby_arr_insert_arr' => [2, 3, 4],
'ruby_arr_insert_hash' => [2, 3, 4],
'ruby_cover' => [2],
'ruby_delete' => [2],
'ruby_edit' => [2, 3],
'ruby_map_edit' => [2, 4],
'ruby_merge' => [2, 4],
'ruby_merge_hash' => [2, 3],
'ruby_uniq' => [2]
}.freeze
# Manifest records: 'M <module>' opens a module block, 'Y <file>' is its [YAML] override block
# and every 'L <line>' is one [Overwrite] line of it
def self.overwrite_run(config_file, manifest_file)
return if config_file.nil? || config_file.empty? || !File.exist?(manifest_file)
# module lines expand $CONFIG_FILE from the environment, keep the legacy contract
ENV['CONFIG_FILE'] = config_file
value = load_file(config_file)
blocks = []
File.foreach(manifest_file) do |raw|
line = raw.chomp
if line.start_with?('M ')
blocks << [line[2..-1], nil, []]
elsif blocks.any?
if line.start_with?('Y ')
blocks.last[1] = line[2..-1]
elsif line.start_with?('L ')
blocks.last[2] << line[2..-1]
end
end
end
blocks.each do |name, yaml_file, lines|
value = overwrite_apply_yaml(value, yaml_file) if yaml_file
lines.each { |line| overwrite_apply_line(value, name, line) }
end
dump(value, config_file)
rescue ::Exception => e
LOG_ERROR("Set Custom Overwrite Script Failed,【#{e.message}】")
end
# Calls recorded by ruby_record() in ruby.sh: NUL separated fields and a fixed argument count
# per helper, the records may target several config files
def self.overwrite_run_custom(calls_file)
return if calls_file.nil? || calls_file.empty? || !File.exist?(calls_file)
fields = File.read(calls_file).split("\0", -1)
values = {}
position = 0
while position < fields.length
fn = fields[position]
arity = OVERWRITE_HELPERS[fn]
if fn.nil? || fn.empty?
position += 1
next
elsif arity.nil?
LOG_WARN("skip unsafe Overwrite command【Ruby Script => #{fn}】")
position += 1
next
end
args = fields[position + 1, arity] || []
args += [''] * (arity - args.length)
position += arity + 1
if overwrite_unsafe?(fn, args)
LOG_WARN("skip unsafe Overwrite command【Ruby Script => #{fn}(#{args.map(&:inspect).join(', ')})】")
next
end
file = args[0]
next if file.nil? || file.empty? || !File.exist?(file)
values[file] = load_file(file) unless values.key?(file)
begin
overwrite_apply(values[file], fn, args)
rescue ::Exception => e
LOG_ERROR("Set Custom Overwrite Script Failed,【#{e.message}】")
end
end
values.each { |file, value| dump(value, file) }
rescue ::Exception => e
LOG_ERROR("Set Custom Overwrite Script Failed,【#{e.message}】")
end
def self.overwrite_apply_yaml(value, yaml_file)
return value if yaml_file.nil? || yaml_file.empty? || !File.exist?(yaml_file)
begin
yaml_data = load(File.read(yaml_file))
if yaml_data.is_a?(Hash)
value = overwrite(value, yaml_data)
else
LOG_WARN('Invalid YAML Override format, skipped...')
end
rescue ::Exception => e
LOG_ERROR("Parse YAML Override failed:【#{e.message}】")
end
value
end
def self.overwrite_apply_line(value, name, line)
parsed = overwrite_parse_line(line)
if parsed.nil?
LOG_WARN("skip invalid Overwrite command【Ruby Script => #{name}: #{line}】")
return
end
fn, args = parsed
if overwrite_unsafe?(fn, args)
LOG_WARN("skip invalid Overwrite command【Ruby Script => #{name}: #{line}】")
return
end
LOG_TIP("Load Overwrite Script【Ruby Script => #{line}】")
overwrite_apply(value, fn, args)
rescue ::Exception => e
LOG_ERROR("Set Custom Overwrite Script Failed,【#{e.message}】")
end
def self.overwrite_unsafe?(fn, args)
OVERWRITE_CODE[fn].any? do |index|
text = args[index - 1]
!text.nil? && !text.empty? && overwrite_code_unsafe?(text)
end
end
# The hash body of ruby_merge_hash is only valid inside braces, so a => fragment needs the
# braced source checked too, an unparsable argument is unsafe
def self.overwrite_code_unsafe?(text)
tree = overwrite_parse_source(text)
if tree.nil?
tree = overwrite_parse_source("{#{text}}")
return true if tree.nil?
elsif text.include?('=>')
braced = overwrite_parse_source("{#{text}}")
return true if braced && overwrite_ast_unsafe?(braced)
end
overwrite_ast_unsafe?(tree)
end
def self.overwrite_parse_source(source)
RubyVM::AbstractSyntaxTree.parse(source)
rescue ::Exception
nil
end
# An explicit stack keeps the walk free of per-node recursion and block calls
def self.overwrite_ast_unsafe?(node)
stack = [node]
until stack.empty?
current = stack.pop
next unless current.is_a?(RubyVM::AbstractSyntaxTree::Node)
return true unless OVERWRITE_NODE_LOOKUP.key?(current.type)
case current.type
when :CALL, :FCALL, :VCALL, :ATTRASGN
return true unless overwrite_method_allowed?(overwrite_call_name(current))
when :OPCALL
return true unless overwrite_method_allowed?(current.children[1])
when :CONST
names = overwrite_leaf_names(current)
return true unless names.length == 1 && OVERWRITE_CONST_LOOKUP.key?(names[0].to_sym)
when :BLOCK_PASS
names = overwrite_leaf_names(current)
return true if names.empty? || names.any? { |name| !overwrite_method_allowed?(name) }
end
stack.concat(current.children)
end
false
end
def self.overwrite_method_allowed?(name)
return OVERWRITE_CALL_LOOKUP.key?(name) if name.is_a?(Symbol)
OVERWRITE_CALL_LOOKUP.key?(name.to_s.to_sym)
end
def self.overwrite_call_name(node)
(node.type == :CALL || node.type == :ATTRASGN) ? node.children[1] : node.children[0]
end
# Symbols and strings of a node subtree, the called name of &:<name> block passes and the
# name of constants are carried this way
def self.overwrite_leaf_names(node, names = [])
node.children.each do |child|
if child.is_a?(Symbol) || child.is_a?(String)
names << child
elsif child.is_a?(RubyVM::AbstractSyntaxTree::Node)
overwrite_leaf_names(child, names)
end
end
names
end
# Only a single call to an allowed helper with quoted arguments is accepted; in double quoted
# arguments $NAME and ${NAME} expand from the environment and every other character stays
# literal, in single quoted arguments nothing is expanded
def self.overwrite_parse_line(line)
length = line.length
position = 0
position += 1 while position < length && (line[position] == ' ' || line[position] == "\t")
head = position
position += 1 while position < length && line[position] != ' ' && line[position] != "\t"
fn = line[head...position]
return nil unless OVERWRITE_HELPERS.key?(fn)
args = []
while position < length
position += 1 while position < length && (line[position] == ' ' || line[position] == "\t")
break if position >= length
quote = line[position]
return nil unless quote == '"' || quote == "'"
position += 1
closing = line.index(quote, position)
return nil if closing.nil?
arg = line[position...closing]
args << (quote == '"' && arg.include?('$') ? overwrite_expand_env(arg) : arg)
position = closing + 1
end
return nil if args.empty?
[fn, args]
end
def self.overwrite_expand_env(text)
text.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/) do
ENV[Regexp.last_match(1) || Regexp.last_match(2)].to_s
end
end
def self.overwrite_apply(value, fn, args)
arity = OVERWRITE_HELPERS[fn]
args = args[0, arity] || []
args += [''] * (arity - args.length)
return if OVERWRITE_REQUIRED[fn].any? { |index| args[index - 1].empty? }
statement = overwrite_statement(fn, args)
return if statement.nil?
overwrite_eval(value, statement)
end
def self.overwrite_statement(fn, args)
path = args[1]
a3 = args[2]
a4 = args[3]
a5 = args[4]
a6 = args[5]
case fn
when 'ruby_edit'
"Value#{path} = #{a3}"
when 'ruby_cover'
"if File.exist?(#{a3.inspect}) then value_1 = YAML.load_file(#{a3.inspect}); if not #{a4.inspect}.empty? then Value#{path} = value_1[#{a4.inspect}] else Value#{path} = value_1 end else if not #{a4.inspect}.empty? then Value.delete(#{a4.inspect}); end; end"
when 'ruby_merge'
"if File.exist?(#{a3.inspect}) then value_1 = YAML.load_file(#{a3.inspect}); if not Value#{path} then Value#{path} = {}; end; if value_1#{a4} && value_1#{a4}.is_a?(Hash) then Value#{path}.merge!(value_1#{a4}) end end"
when 'ruby_uniq'
"if Value#{path} then Value#{path} = Value#{path}.uniq; end"
when 'ruby_merge_hash'
"if not Value#{path} then Value#{path} = {}; end; Value#{path}.merge!({#{a3}})"
when 'ruby_arr_add_file'
"if File.exist?(#{a4.inspect}) then value_1 = YAML.load_file(#{a4.inspect}); if not Value#{path} or Value#{path}.nil? then Value#{path} = []; end; if value_1#{a5} && value_1#{a5}.is_a?(Array) then idx = [#{a3}.to_i, 0].max; idx = [idx, Value#{path}.length].min; value_1#{a5}.reverse.each{|x| Value#{path}.insert(idx,x); idx += 1}; Value#{path} = Value#{path}.uniq end end"
when 'ruby_arr_head_add_file'
"if File.exist?(#{a3.inspect}) then value_1 = YAML.load_file(#{a3.inspect}); if not Value#{path} or Value#{path}.nil? then Value#{path} = []; end; if value_1#{a4} && value_1#{a4}.is_a?(Array) then Value#{path} = (value_1#{a4} + Value#{path}).uniq else Value#{path} = Value#{path}.uniq end end"
when 'ruby_arr_insert'
"if not Value#{path} or Value#{path}.nil? then Value#{path} = []; end; idx = [#{a3}.to_i, 0].max; idx = [idx, Value#{path}.length].min; Value#{path} = Value#{path}.insert(idx, #{a4.inspect}).uniq"
when 'ruby_arr_insert_hash'
"if not Value#{path} or Value#{path}.nil? then Value#{path} = []; end; idx = [#{a3}.to_i, 0].max; idx = [idx, Value#{path}.length].min; Value#{path} = Value#{path}.insert(idx, #{a4}).uniq"
when 'ruby_arr_insert_arr'
"if not Value#{path} or Value#{path}.nil? then Value#{path} = []; end; if (#{a4}).is_a?(Array) then idx = [#{a3}.to_i, 0].max; idx = [idx, Value#{path}.length].min; (#{a4}).reverse.each{|x| Value#{path} = Value#{path}.insert(idx,x); idx += 1}; Value#{path} = Value#{path}.uniq end"
when 'ruby_delete'
if path.empty?
"Value.delete(#{a3.inspect})"
else
"if Value#{path} then if Value#{path}.is_a?(Hash) then Value#{path}.delete(#{a3.inspect}) elsif Value#{path}.is_a?(Array) then Value#{path}.delete(#{a3.inspect}) end end"
end
when 'ruby_map_edit'
"if Value#{path} && Value#{path}.is_a?(Hash) then if Value#{path}[#{a3.inspect}] && Value#{path}[#{a3.inspect}].is_a?(Hash) then Value#{path}[#{a3.inspect}]#{a4} = #{a5.inspect} end end"
when 'ruby_arr_edit'
if !a3.empty? && !a4.empty? && !a5.empty? && !a6.empty?
"if Value#{path} && Value#{path}.is_a?(Array) then Value#{path}.map!{|x| if x.is_a?(Hash) && x#{a3} == #{a4.inspect} then x#{a5} = #{a6.inspect} end; x}; Value#{path}.uniq! end"
elsif a3.empty? && !a4.empty? && a5.empty? && !a6.empty?
"if Value#{path} && Value#{path}.is_a?(Array) then Value#{path}.map!{|x| if x == #{a4.inspect} then #{a6.inspect} else x end}; Value#{path}.uniq! end"
end
end
end
def self.overwrite_eval(value, statement)
remove_const(:Value) if const_defined?(:Value, false)
const_set(:Value, value)
eval(statement, binding, '(overwrite)', 1)
end
# Value readers for scripts, the path keeps the ruby source form the old helpers accepted,
# e.g. "['dns']['fake-ip-range']" or ".select { |k, _| k != 'proxies' }.to_yaml"
def self.overwrite_read(file, path)
value = load_file(file)
result = overwrite_eval(value, "Value#{path}")
puts result if result
rescue
nil
end
def self.overwrite_read_hash(code, path)
overwrite_eval(nil, "v = #{code}; if v#{path} then puts v#{path} end")
rescue
nil
end
def self.overwrite_read_hash_arr(file, path, item_path)
overwrite_eval(nil, "v = YAML.load_file(#{file.inspect}); if v#{path} then v#{path}.each do |i| if i#{item_path} then puts i#{item_path} end; end; end")
rescue
nil
end
end