From 2d4ba3e30eca11286baaecd22dcfc6cc0ccb7476 Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Fri, 25 Sep 2026 13:56:21 -0700 Subject: [PATCH] Handle empty and non-mapping YAML and JSON config files An empty or comment-only YAML file parses to nil, which crashed from_hash with NoMethodError. Treat it as an empty config. A file whose top level was a list either crashed (JSON) or silently set each element as an option with a nil value (YAML). Raise an ArgumentError that names the file instead. Signed-off-by: Tim Smith --- lib/mixlib/config.rb | 17 +++++++++++++++-- spec/mixlib/config_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/mixlib/config.rb b/lib/mixlib/config.rb index 38c5e2a..476b0b5 100644 --- a/lib/mixlib/config.rb +++ b/lib/mixlib/config.rb @@ -74,7 +74,8 @@ def from_yaml(filename) # objects (a deserialization RCE vector). Symbols are permitted because # they are commonly used in config files; this matches the default # behavior of YAML.load on Psych 4 while remaining safe on older Psych. - from_hash(YAML.safe_load(IO.read(filename), permitted_classes: [Symbol], aliases: false)) + # An empty or comment-only file parses to nil; treat it as an empty config. + from_parsed_file(filename, YAML.safe_load(IO.read(filename), permitted_classes: [Symbol], aliases: false) || {}) end # Parses valid JSON structure into Ruby @@ -83,7 +84,7 @@ def from_yaml(filename) # filename:: A filename to read from def from_json(filename) require "json" unless defined?(JSON) - from_hash(JSON.parse(IO.read(filename))) + from_parsed_file(filename, JSON.parse(IO.read(filename))) end def from_toml(filename) @@ -578,6 +579,18 @@ def apply_nested_hash(hash) private + # Applies the result of parsing a config file, which must be a Hash. + # + # === Raises + # :: If the file's top level is not a mapping. + def from_parsed_file(filename, parsed) + unless parsed.is_a?(Hash) + raise ArgumentError, "#{filename} must contain a mapping of config options at the top level, not #{parsed.class}" + end + + from_hash(parsed) + end + # Given a (nested) Hash, turn it into a single top-level hash using dots as # nesting notation. This allows for direction translation into method-style # setting of Config. diff --git a/spec/mixlib/config_spec.rb b/spec/mixlib/config_spec.rb index e26e38f..36b8581 100644 --- a/spec/mixlib/config_spec.rb +++ b/spec/mixlib/config_spec.rb @@ -1262,6 +1262,24 @@ class StrictClass3 ConfigIt.from_file("config.yml") end.to raise_error(Psych::DisallowedClass) end + + it "treats an empty YAML file as an empty config" do + allow(IO).to receive(:read).with("config.yml").and_return("") + + expect { ConfigIt.from_file("config.yml") }.to_not raise_error + end + + it "treats a comment-only YAML file as an empty config" do + allow(IO).to receive(:read).with("config.yml").and_return("# nothing set yet\n") + + expect { ConfigIt.from_file("config.yml") }.to_not raise_error + end + + it "raises an ArgumentError naming the file when the top level is not a mapping" do + allow(IO).to receive(:read).with("config.yml").and_return("- one\n- two\n") + + expect { ConfigIt.from_file("config.yml") }.to raise_error(ArgumentError, /config\.yml/) + end end describe ".from_json" do @@ -1290,6 +1308,12 @@ class StrictClass3 expect(ConfigIt.foo).to eql(%w{ bar baz matazz }) expect(ConfigIt.alpha).to eql("beta") end + + it "raises an ArgumentError naming the file when the top level is not an object" do + allow(IO).to receive(:read).with("config.json").and_return("[1, 2]") + + expect { ConfigIt.from_file("config.json") }.to raise_error(ArgumentError, /config\.json/) + end end describe ".from_toml" do