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 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