dotlyte 0.1.1
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.md +30 -0
- data/lib/dotlyte/coercion.rb +55 -0
- data/lib/dotlyte/config.rb +209 -0
- data/lib/dotlyte/encryption.rb +120 -0
- data/lib/dotlyte/errors.rb +56 -0
- data/lib/dotlyte/interpolation.rb +169 -0
- data/lib/dotlyte/loader.rb +425 -0
- data/lib/dotlyte/masking.rb +79 -0
- data/lib/dotlyte/merger.rb +21 -0
- data/lib/dotlyte/validator.rb +231 -0
- data/lib/dotlyte/version.rb +5 -0
- data/lib/dotlyte/watcher.rb +152 -0
- data/lib/dotlyte.rb +57 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 526dd14fe18f6f8939c2d146e85b8338c9dabd19e8b8ddce7099828397d990b7
|
|
4
|
+
data.tar.gz: b174bd56fd0936e55398d51f36c833c472a38a9b684fbfa1753bbe09449b2a84
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 022f372764d0763326c4d8492b7155ba1021e766e4e969d8de29f7ca813df55476661137ef0132120295866a77c9f77ded9ec191321c5f16212d114a720ff8e2
|
|
7
|
+
data.tar.gz: f00100fb8ed6b0faf536d759d7b3e3357c0687c2333225c28e9341a453f34b36bfeca6bf04e4dec3de7985909ae2921e4b1376212f5d6a8b0137d86375bd2a13
|
data/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# dotlyte — Ruby
|
|
2
|
+
|
|
3
|
+
The universal `.env` and configuration library for Ruby.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install dotlyte
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or in your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "dotlyte"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "dotlyte"
|
|
21
|
+
|
|
22
|
+
config = Dotlyte.load
|
|
23
|
+
config.port # automatically Integer
|
|
24
|
+
config.debug # automatically Boolean
|
|
25
|
+
config.database.host # dot-notation via method_missing
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
[MIT](../../LICENSE)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotlyte
|
|
4
|
+
# Type coercion engine.
|
|
5
|
+
module Coercion
|
|
6
|
+
NULL_VALUES = %w[null none nil].freeze
|
|
7
|
+
TRUE_VALUES = %w[true yes 1 on].freeze
|
|
8
|
+
FALSE_VALUES = %w[false no 0 off].freeze
|
|
9
|
+
|
|
10
|
+
# Auto-convert a string value to the correct Ruby type.
|
|
11
|
+
#
|
|
12
|
+
# @param value [Object] the value to coerce
|
|
13
|
+
# @return [Object] the coerced value
|
|
14
|
+
def self.coerce(value)
|
|
15
|
+
return value unless value.is_a?(String)
|
|
16
|
+
|
|
17
|
+
stripped = value.strip
|
|
18
|
+
lower = stripped.downcase
|
|
19
|
+
|
|
20
|
+
# Null
|
|
21
|
+
return nil if stripped.empty? || NULL_VALUES.include?(lower)
|
|
22
|
+
|
|
23
|
+
# Boolean
|
|
24
|
+
return true if TRUE_VALUES.include?(lower)
|
|
25
|
+
return false if FALSE_VALUES.include?(lower)
|
|
26
|
+
|
|
27
|
+
# Integer
|
|
28
|
+
return Integer(stripped) if stripped.match?(/\A-?\d+\z/)
|
|
29
|
+
|
|
30
|
+
# Float
|
|
31
|
+
if stripped.include?(".") && stripped.match?(/\A-?\d+\.\d+\z/)
|
|
32
|
+
return Float(stripped)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# List (comma-separated)
|
|
36
|
+
if stripped.include?(",")
|
|
37
|
+
return stripped.split(",").map { |item| coerce(item.strip) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# String
|
|
41
|
+
stripped
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Recursively coerce all string values in a hash.
|
|
45
|
+
def self.coerce_hash(data)
|
|
46
|
+
data.each_with_object({}) do |(key, value), result|
|
|
47
|
+
result[key] = case value
|
|
48
|
+
when Hash then coerce_hash(value)
|
|
49
|
+
when String then coerce(value)
|
|
50
|
+
else value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Dotlyte
|
|
7
|
+
# Immutable configuration object with dot-notation access via method_missing.
|
|
8
|
+
class Config
|
|
9
|
+
# @param data [Hash] the configuration data
|
|
10
|
+
# @param schema [Hash<String, SchemaRule>, nil] optional schema for validation
|
|
11
|
+
# @param sensitive_keys [Set<String>] keys to redact in output
|
|
12
|
+
def initialize(data, schema: nil, sensitive_keys: Set.new)
|
|
13
|
+
@data = data.freeze
|
|
14
|
+
@schema = schema
|
|
15
|
+
@sensitive_keys = sensitive_keys
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Safe access with dot-notation and optional default.
|
|
19
|
+
#
|
|
20
|
+
# @param key [String] dot-notation key (e.g., "database.host")
|
|
21
|
+
# @param default_value [Object] fallback if key is missing
|
|
22
|
+
# @return [Object] the config value or default
|
|
23
|
+
def get(key, default_value = nil)
|
|
24
|
+
parts = key.to_s.split(".")
|
|
25
|
+
val = @data
|
|
26
|
+
|
|
27
|
+
parts.each do |part|
|
|
28
|
+
if val.is_a?(Hash)
|
|
29
|
+
val = val.key?(part) ? val[part] : val[part.to_sym]
|
|
30
|
+
return default_value if val.nil?
|
|
31
|
+
else
|
|
32
|
+
return default_value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
val
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Required access — raises MissingKeyError if missing.
|
|
40
|
+
#
|
|
41
|
+
# @param key [String] the config key
|
|
42
|
+
# @return [Object] the config value
|
|
43
|
+
# @raise [MissingKeyError] if the key is missing
|
|
44
|
+
def require(key)
|
|
45
|
+
val = get(key)
|
|
46
|
+
if val.nil?
|
|
47
|
+
raise MissingKeyError.new(
|
|
48
|
+
"Required config key '#{key}' is missing. " \
|
|
49
|
+
"Set it in your .env file or as an environment variable.",
|
|
50
|
+
key: key
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
val
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Require multiple keys at once.
|
|
57
|
+
#
|
|
58
|
+
# @param keys [Array<String>] the config keys
|
|
59
|
+
# @return [Array<Object>] the values
|
|
60
|
+
# @raise [MissingKeyError] if any key is missing
|
|
61
|
+
def require_keys(*keys)
|
|
62
|
+
keys.flatten.map { |k| self.require(k) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if a key exists.
|
|
66
|
+
def has?(key)
|
|
67
|
+
!get(key).nil?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get a scoped sub-config (returns a new Config for a nested hash).
|
|
71
|
+
#
|
|
72
|
+
# @param prefix [String] dot-notation prefix
|
|
73
|
+
# @return [Config] scoped config
|
|
74
|
+
def scope(prefix)
|
|
75
|
+
sub = get(prefix)
|
|
76
|
+
raise Error.new("No config section found for '#{prefix}'", key: prefix) unless sub.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
scoped_sensitive = Set.new
|
|
79
|
+
@sensitive_keys.each do |sk|
|
|
80
|
+
scoped_sensitive.add(sk.delete_prefix("#{prefix}.")) if sk.start_with?("#{prefix}.")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Config.new(sub, schema: nil, sensitive_keys: scoped_sensitive)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# All top-level keys.
|
|
87
|
+
def keys
|
|
88
|
+
@data.keys
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# All keys flattened via dot-notation.
|
|
92
|
+
def to_flat_keys
|
|
93
|
+
flat_keys(@data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Flatten the config to a single-level hash.
|
|
97
|
+
def to_flat_hash
|
|
98
|
+
flatten(@data)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Convert to a plain Hash (deep copy).
|
|
102
|
+
def to_h
|
|
103
|
+
deep_dup(@data)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Return a redacted hash with sensitive values masked.
|
|
107
|
+
def to_h_redacted
|
|
108
|
+
Masking.redact(deep_dup(@data), @sensitive_keys)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Serialize to JSON.
|
|
112
|
+
def to_json(*_args)
|
|
113
|
+
JSON.generate(to_h)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Write config to a file (JSON or YAML).
|
|
117
|
+
def write_to(path)
|
|
118
|
+
ext = File.extname(path).downcase
|
|
119
|
+
content = case ext
|
|
120
|
+
when ".json"
|
|
121
|
+
JSON.pretty_generate(to_h)
|
|
122
|
+
when ".yaml", ".yml"
|
|
123
|
+
require "yaml"
|
|
124
|
+
YAML.dump(to_h)
|
|
125
|
+
else
|
|
126
|
+
raise Error, "Unsupported output format: #{ext}"
|
|
127
|
+
end
|
|
128
|
+
File.write(path, content)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Validate against schema. Returns array of violations.
|
|
132
|
+
def validate(schema = nil)
|
|
133
|
+
s = schema || @schema
|
|
134
|
+
return [] unless s
|
|
135
|
+
|
|
136
|
+
Validator.validate(deep_dup(@data), s)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Validate and raise on failure.
|
|
140
|
+
def assert_valid!(schema = nil)
|
|
141
|
+
s = schema || @schema
|
|
142
|
+
return unless s
|
|
143
|
+
|
|
144
|
+
Validator.assert_valid!(deep_dup(@data), s)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Support dot-notation via method_missing.
|
|
148
|
+
def method_missing(name, *args)
|
|
149
|
+
key = name.to_s
|
|
150
|
+
if @data.key?(key)
|
|
151
|
+
val = @data[key]
|
|
152
|
+
val.is_a?(Hash) ? Config.new(val, sensitive_keys: scoped_sensitive_keys(key)) : val
|
|
153
|
+
else
|
|
154
|
+
super
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def respond_to_missing?(name, include_private = false)
|
|
159
|
+
@data.key?(name.to_s) || super
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def inspect
|
|
163
|
+
"Config(#{to_h_redacted})"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
alias_method :to_s, :inspect
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def deep_dup(hash)
|
|
171
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
172
|
+
out[k] = v.is_a?(Hash) ? deep_dup(v) : v
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def flat_keys(hash, prefix = "", out = [])
|
|
177
|
+
hash.each do |key, value|
|
|
178
|
+
full = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
179
|
+
if value.is_a?(Hash)
|
|
180
|
+
flat_keys(value, full, out)
|
|
181
|
+
else
|
|
182
|
+
out << full
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
out
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def flatten(hash, prefix = "", out = {})
|
|
189
|
+
hash.each do |key, value|
|
|
190
|
+
full = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
191
|
+
if value.is_a?(Hash)
|
|
192
|
+
flatten(value, full, out)
|
|
193
|
+
else
|
|
194
|
+
out[full] = value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
out
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def scoped_sensitive_keys(key)
|
|
201
|
+
scoped = Set.new
|
|
202
|
+
prefix = "#{key}."
|
|
203
|
+
@sensitive_keys.each do |sk|
|
|
204
|
+
scoped.add(sk.delete_prefix(prefix)) if sk.start_with?(prefix)
|
|
205
|
+
end
|
|
206
|
+
scoped
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Dotlyte
|
|
8
|
+
# AES-256-GCM encryption/decryption for DOTLYTE v2 (SOPS-style).
|
|
9
|
+
#
|
|
10
|
+
# Format: ENC[aes-256-gcm,iv:<base64>,data:<base64>,tag:<base64>]
|
|
11
|
+
module Encryption
|
|
12
|
+
PREFIX = "ENC["
|
|
13
|
+
GCM_TAG_BYTES = 16
|
|
14
|
+
IV_BYTES = 12
|
|
15
|
+
KEY_BYTES = 32
|
|
16
|
+
|
|
17
|
+
# Check whether a string is an encrypted SOPS-style value.
|
|
18
|
+
def self.encrypted?(value)
|
|
19
|
+
value.is_a?(String) && value.start_with?(PREFIX) && value.end_with?("]")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Generate a random 32-byte key as hex.
|
|
23
|
+
def self.generate_key
|
|
24
|
+
SecureRandom.hex(KEY_BYTES)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Encrypt a plaintext string with a hex-encoded 256-bit key.
|
|
28
|
+
#
|
|
29
|
+
# @param plaintext [String]
|
|
30
|
+
# @param key_hex [String] 64-char hex string
|
|
31
|
+
# @return [String] SOPS-style encrypted value
|
|
32
|
+
def self.encrypt_value(plaintext, key_hex)
|
|
33
|
+
key_bytes = [key_hex].pack("H*")
|
|
34
|
+
raise Error, "Key must be 32 bytes, got #{key_bytes.bytesize}" unless key_bytes.bytesize == KEY_BYTES
|
|
35
|
+
|
|
36
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
37
|
+
cipher.encrypt
|
|
38
|
+
cipher.key = key_bytes
|
|
39
|
+
iv = cipher.random_iv
|
|
40
|
+
cipher.auth_data = ""
|
|
41
|
+
|
|
42
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
43
|
+
tag = cipher.auth_tag(GCM_TAG_BYTES)
|
|
44
|
+
|
|
45
|
+
iv_b64 = Base64.strict_encode64(iv)
|
|
46
|
+
data_b64 = Base64.strict_encode64(ciphertext)
|
|
47
|
+
tag_b64 = Base64.strict_encode64(tag)
|
|
48
|
+
|
|
49
|
+
"ENC[aes-256-gcm,iv:#{iv_b64},data:#{data_b64},tag:#{tag_b64}]"
|
|
50
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
51
|
+
raise Error, "Encryption failed: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Decrypt a SOPS-style encrypted value.
|
|
55
|
+
#
|
|
56
|
+
# @param encrypted [String]
|
|
57
|
+
# @param key_hex [String] 64-char hex string
|
|
58
|
+
# @return [String] plaintext
|
|
59
|
+
def self.decrypt_value(encrypted, key_hex)
|
|
60
|
+
key_bytes = [key_hex].pack("H*")
|
|
61
|
+
raise Error, "Key must be 32 bytes, got #{key_bytes.bytesize}" unless key_bytes.bytesize == KEY_BYTES
|
|
62
|
+
|
|
63
|
+
inner = encrypted
|
|
64
|
+
inner = inner[4..-2] if inner.start_with?("ENC[") && inner.end_with?("]")
|
|
65
|
+
|
|
66
|
+
parts = {}
|
|
67
|
+
inner.split(",").each do |part|
|
|
68
|
+
part = part.strip
|
|
69
|
+
if part.start_with?("iv:")
|
|
70
|
+
parts[:iv] = part[3..]
|
|
71
|
+
elsif part.start_with?("data:")
|
|
72
|
+
parts[:data] = part[5..]
|
|
73
|
+
elsif part.start_with?("tag:")
|
|
74
|
+
parts[:tag] = part[4..]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
iv = Base64.strict_decode64(parts[:iv])
|
|
79
|
+
data = Base64.strict_decode64(parts[:data])
|
|
80
|
+
tag = Base64.strict_decode64(parts[:tag])
|
|
81
|
+
|
|
82
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
83
|
+
cipher.decrypt
|
|
84
|
+
cipher.key = key_bytes
|
|
85
|
+
cipher.iv = iv
|
|
86
|
+
cipher.auth_tag = tag
|
|
87
|
+
cipher.auth_data = ""
|
|
88
|
+
|
|
89
|
+
cipher.update(data) + cipher.final
|
|
90
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
91
|
+
raise DecryptionError, "Decryption failed: #{e.message}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Decrypt all ENC[...] values in a hash.
|
|
95
|
+
def self.decrypt_hash(data, key_hex)
|
|
96
|
+
data.each do |k, v|
|
|
97
|
+
data[k] = decrypt_value(v, key_hex) if encrypted?(v)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Resolve encryption key from environment / key file.
|
|
102
|
+
def self.resolve_encryption_key(env_name = nil)
|
|
103
|
+
if env_name && !env_name.empty?
|
|
104
|
+
val = ENV["DOTLYTE_KEY_#{env_name.upcase}"]
|
|
105
|
+
return val if val && !val.empty?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
val = ENV["DOTLYTE_KEY"]
|
|
109
|
+
return val if val && !val.empty?
|
|
110
|
+
|
|
111
|
+
key_file = ".dotlyte-keys"
|
|
112
|
+
if File.exist?(key_file)
|
|
113
|
+
line = File.readlines(key_file, chomp: true).first&.strip
|
|
114
|
+
return line if line && !line.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotlyte
|
|
4
|
+
# Base error class for all DOTLYTE errors.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# @return [String, nil] the config key that caused the error
|
|
7
|
+
attr_reader :key
|
|
8
|
+
|
|
9
|
+
def initialize(message, key: nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@key = key
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when require() encounters a missing key.
|
|
16
|
+
class MissingKeyError < Error
|
|
17
|
+
# @return [Array<String>] the source files that were checked
|
|
18
|
+
attr_reader :sources_checked
|
|
19
|
+
|
|
20
|
+
def initialize(message, key: nil, sources_checked: [])
|
|
21
|
+
super(message, key: key)
|
|
22
|
+
@sources_checked = sources_checked
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Raised when a config file has invalid syntax.
|
|
27
|
+
class ParseError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised when a config file cannot be read or found.
|
|
30
|
+
class FileError < Error
|
|
31
|
+
# @return [String, nil] the file path
|
|
32
|
+
attr_reader :file_path
|
|
33
|
+
|
|
34
|
+
def initialize(message, file_path: nil, key: nil)
|
|
35
|
+
super(message, key: key)
|
|
36
|
+
@file_path = file_path
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Raised when schema validation fails.
|
|
41
|
+
class ValidationError < Error
|
|
42
|
+
# @return [Array<SchemaViolation>] the violations found
|
|
43
|
+
attr_reader :violations
|
|
44
|
+
|
|
45
|
+
def initialize(message, violations: [], key: nil)
|
|
46
|
+
super(message, key: key)
|
|
47
|
+
@violations = violations
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Raised when variable interpolation fails (e.g., circular reference).
|
|
52
|
+
class InterpolationError < Error; end
|
|
53
|
+
|
|
54
|
+
# Raised when decryption of an encrypted value fails.
|
|
55
|
+
class DecryptionError < Error; end
|
|
56
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dotlyte
|
|
4
|
+
# Variable interpolation engine for DOTLYTE v2.
|
|
5
|
+
#
|
|
6
|
+
# Supports ${VAR}, ${VAR:-default}, ${VAR:?error}, and $$ escape.
|
|
7
|
+
module Interpolation
|
|
8
|
+
# Interpolate ${VAR} references in a flat string hash.
|
|
9
|
+
#
|
|
10
|
+
# @param data [Hash<String, String>] key-value pairs
|
|
11
|
+
# @param context [Hash<String, String>] additional context
|
|
12
|
+
# @return [Hash<String, String>] resolved values
|
|
13
|
+
def self.interpolate(data, context = {})
|
|
14
|
+
resolved = {}
|
|
15
|
+
resolving = Set.new
|
|
16
|
+
|
|
17
|
+
data.each_key { |key| resolve(key, data, context, resolved, resolving) }
|
|
18
|
+
resolved
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Interpolate a deep hash (nested values).
|
|
22
|
+
#
|
|
23
|
+
# @param data [Hash] deep hash
|
|
24
|
+
# @param context [Hash] additional context
|
|
25
|
+
# @return [Hash] resolved deep hash
|
|
26
|
+
def self.interpolate_deep(data, context = {})
|
|
27
|
+
flat = flatten_to_strings(data)
|
|
28
|
+
ctx_flat = flatten_to_strings(context)
|
|
29
|
+
resolved = interpolate(flat, ctx_flat)
|
|
30
|
+
|
|
31
|
+
result = deep_copy(data)
|
|
32
|
+
resolved.each do |key, value|
|
|
33
|
+
set_nested(result, key, Coercion.coerce(value))
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def resolve(key, data, context, resolved, resolving)
|
|
42
|
+
return resolved[key] if resolved.key?(key)
|
|
43
|
+
|
|
44
|
+
if resolving.include?(key)
|
|
45
|
+
raise InterpolationError.new(
|
|
46
|
+
"Circular reference detected for variable: #{key}", key: key
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raw = data[key]
|
|
51
|
+
if raw.nil?
|
|
52
|
+
return context[key] if context.key?(key)
|
|
53
|
+
|
|
54
|
+
env = ENV[key.upcase]
|
|
55
|
+
return env || ""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
resolving.add(key)
|
|
59
|
+
val = resolve_string(raw.to_s, data, context, resolved, resolving)
|
|
60
|
+
resolving.delete(key)
|
|
61
|
+
resolved[key] = val
|
|
62
|
+
val
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_string(s, data, context, resolved, resolving)
|
|
66
|
+
s = s.gsub("$$", "\x00DOLLAR\x00")
|
|
67
|
+
result = +""
|
|
68
|
+
i = 0
|
|
69
|
+
|
|
70
|
+
while i < s.length
|
|
71
|
+
if i + 1 < s.length && s[i] == "$" && s[i + 1] == "{"
|
|
72
|
+
i += 2
|
|
73
|
+
depth = 1
|
|
74
|
+
inner = +""
|
|
75
|
+
while i < s.length && depth.positive?
|
|
76
|
+
ch = s[i]
|
|
77
|
+
if ch == "{"
|
|
78
|
+
depth += 1
|
|
79
|
+
elsif ch == "}"
|
|
80
|
+
depth -= 1
|
|
81
|
+
if depth.zero?
|
|
82
|
+
i += 1
|
|
83
|
+
break
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
inner << ch
|
|
87
|
+
i += 1
|
|
88
|
+
end
|
|
89
|
+
result << resolve_reference(inner, data, context, resolved, resolving)
|
|
90
|
+
else
|
|
91
|
+
result << s[i]
|
|
92
|
+
i += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result.gsub("\x00DOLLAR\x00", "$")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolve_reference(inner, data, context, resolved, resolving)
|
|
100
|
+
err_idx = inner.index(":?")
|
|
101
|
+
def_idx = inner.index(":-")
|
|
102
|
+
|
|
103
|
+
if err_idx
|
|
104
|
+
var_name = inner[0...err_idx].strip
|
|
105
|
+
error_msg = inner[(err_idx + 2)..]
|
|
106
|
+
elsif def_idx
|
|
107
|
+
var_name = inner[0...def_idx].strip
|
|
108
|
+
fallback = inner[(def_idx + 2)..]
|
|
109
|
+
else
|
|
110
|
+
var_name = inner.strip
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
lower = var_name.downcase
|
|
114
|
+
|
|
115
|
+
# Same-file
|
|
116
|
+
if data.key?(lower)
|
|
117
|
+
val = resolve(lower, data, context, resolved, resolving)
|
|
118
|
+
return val unless val.empty?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Context
|
|
122
|
+
if context.key?(lower)
|
|
123
|
+
val = context[lower]
|
|
124
|
+
return val if val && !val.empty?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Env
|
|
128
|
+
env = ENV[var_name] || ENV[var_name.upcase]
|
|
129
|
+
return env if env && !env.empty?
|
|
130
|
+
|
|
131
|
+
# Not found
|
|
132
|
+
if error_msg
|
|
133
|
+
raise InterpolationError.new("Required variable '#{var_name}': #{error_msg}", key: var_name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
fallback || ""
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def flatten_to_strings(hash, prefix = "", out = {})
|
|
140
|
+
hash.each do |key, value|
|
|
141
|
+
full_key = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
142
|
+
if value.is_a?(Hash)
|
|
143
|
+
flatten_to_strings(value, full_key, out)
|
|
144
|
+
elsif !value.nil?
|
|
145
|
+
out[full_key] = value.to_s
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
out
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def deep_copy(hash)
|
|
152
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
153
|
+
result[k] = v.is_a?(Hash) ? deep_copy(v) : v
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def set_nested(hash, key, value)
|
|
158
|
+
parts = key.split(".")
|
|
159
|
+
current = hash
|
|
160
|
+
parts[0..-2].each do |part|
|
|
161
|
+
current[part] ||= {}
|
|
162
|
+
current[part] = {} unless current[part].is_a?(Hash)
|
|
163
|
+
current = current[part]
|
|
164
|
+
end
|
|
165
|
+
current[parts.last] = value
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|