From d7f9813484246ac2a80b9563cee682f763f1d521 Mon Sep 17 00:00:00 2001 From: Petr Date: Thu, 25 Jun 2026 21:48:10 +0200 Subject: [PATCH] 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. --- lib/volumen/config.rb | 33 ++++++++++++++++++++++++--------- test/config_test.rb | 8 ++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/volumen/config.rb b/lib/volumen/config.rb index e94cf79..465b4a3 100644 --- a/lib/volumen/config.rb +++ b/lib/volumen/config.rb @@ -10,7 +10,7 @@ module Volumen DEFAULT_PATH = "/etc/volumen/config.toml" DEFAULTS = { - "server" => { "host" => "0.0.0.0", "port" => 9090 }, + "server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze, "content_dir" => "/var/lib/volumen/posts", "users_file" => "/var/lib/volumen/users.toml", "site" => { @@ -19,12 +19,12 @@ module Volumen "base_url" => "https://example.com", "language" => "en", "author" => "Anonymous" - }, + }.freeze, "admin" => { "password_hash" => "", "session_key" => "", "session_ttl" => 86_400 - } + }.freeze }.freeze TEMPLATE = <<~TOML @@ -77,12 +77,27 @@ module Volumen end def self.deep_merge(base, override) - base.merge(override) do |_key, base_val, override_val| - if base_val.is_a?(Hash) && override_val.is_a?(Hash) - deep_merge(base_val, override_val) - else - override_val - end + 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) + deep_merge(base_val, override_val) + else + override_val end end diff --git a/test/config_test.rb b/test/config_test.rb index bc18a6d..a228312 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -27,6 +27,14 @@ class ConfigTest < Minitest::Test assert_equal "0.0.0.0", Volumen::Config.load(path: MISSING).host 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 Dir.mktmpdir do |dir| path = File.join(dir, "config.toml")