fix: prevent Config DEFAULTS mutation through deep_merge

Freeze inner hashes in DEFAULTS and rewrite deep_merge to build fresh
hashes with {}.merge for uncopied inner maps. Previously, inner hashes
shared objects with the frozen-looking DEFAULTS, so mutating a loaded
config silently poisoned all later loads.
This commit is contained in:
2026-06-25 22:06:49 +02:00
parent 263e794669
commit d7f9813484
2 changed files with 32 additions and 9 deletions
+20 -5
View File
@@ -10,7 +10,7 @@ module Volumen
DEFAULT_PATH = "/etc/volumen/config.toml" DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = { DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 }, "server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
"content_dir" => "/var/lib/volumen/posts", "content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml", "users_file" => "/var/lib/volumen/users.toml",
"site" => { "site" => {
@@ -19,12 +19,12 @@ module Volumen
"base_url" => "https://example.com", "base_url" => "https://example.com",
"language" => "en", "language" => "en",
"author" => "Anonymous" "author" => "Anonymous"
}, }.freeze,
"admin" => { "admin" => {
"password_hash" => "", "password_hash" => "",
"session_key" => "", "session_key" => "",
"session_ttl" => 86_400 "session_ttl" => 86_400
} }.freeze
}.freeze }.freeze
TEMPLATE = <<~TOML TEMPLATE = <<~TOML
@@ -77,14 +77,29 @@ module Volumen
end end
def self.deep_merge(base, override) def self.deep_merge(base, override)
base.merge(override) do |_key, base_val, override_val| result = {}
base.each do |key, base_val|
result[key] = if override.key?(key)
merge_leaf(base_val, override[key])
elsif base_val.is_a?(Hash)
{}.merge(base_val)
else
base_val
end
end
override.each do |key, val|
result[key] = val unless result.key?(key)
end
result
end
def self.merge_leaf(base_val, override_val)
if base_val.is_a?(Hash) && override_val.is_a?(Hash) if base_val.is_a?(Hash) && override_val.is_a?(Hash)
deep_merge(base_val, override_val) deep_merge(base_val, override_val)
else else
override_val override_val
end end
end end
end
def self.apply_overrides(data, overrides) def self.apply_overrides(data, overrides)
server = data["server"].dup server = data["server"].dup
+8
View File
@@ -27,6 +27,14 @@ class ConfigTest < Minitest::Test
assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host
end end
def test_does_not_mutate_default_inner_hashes
config = Volumen::Config.load(path: MISSING)
config.site["title"] = "MUTATED"
fresh = Volumen::Config.load(path: MISSING)
assert_equal "My Blog", fresh.site["title"],
"mutating a loaded config must not poison DEFAULTS for later loads"
end
def test_loads_and_merges_file def test_loads_and_merges_file
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
path = File.join(dir, "config.toml") path = File.join(dir, "config.toml")