diff --git a/lib/puppet/util/inifile.rb b/lib/puppet/util/inifile.rb index 49a7b1cc5..88640df92 100644 --- a/lib/puppet/util/inifile.rb +++ b/lib/puppet/util/inifile.rb @@ -1,378 +1,431 @@ # Module Puppet::IniConfig # A generic way to parse .ini style files and manipulate them in memory # One 'file' can be made up of several physical files. Changes to sections # on the file are tracked so that only the physical files in which # something has changed are written back to disk # Great care is taken to preserve comments and blank lines from the original # files # # The parsing tries to stay close to python's ConfigParser require 'puppet/util/filetype' require 'puppet/error' module Puppet::Util::IniConfig # A section in a .ini file class Section attr_reader :name, :file, :entries attr_writer :destroy def initialize(name, file) @name = name @file = file @dirty = false @entries = [] @destroy = false end # Does this section need to be updated in/removed from the associated file? # # @note This section is dirty if a key has been modified _or_ if the # section has been modified so the associated file can be rewritten # without this section. def dirty? @dirty or @destroy end def mark_dirty @dirty = true end # Should only be used internally def mark_clean @dirty = false end # Should the file be destroyed? def destroy? @destroy end # Add a line of text (e.g., a comment) Such lines # will be written back out in exactly the same # place they were read in def add_line(line) @entries << line end # Set the entry 'key=value'. If no entry with the # given key exists, one is appended to teh end of the section def []=(key, value) entry = find_entry(key) @dirty = true if entry.nil? @entries << [key, value] else entry[1] = value end end # Return the value associated with KEY. If no such entry # exists, return nil def [](key) entry = find_entry(key) return(entry.nil? ? nil : entry[1]) end # Format the section as text in the way it should be # written to file def format if @destroy text = "" else text = "[#{name}]\n" @entries.each do |entry| if entry.is_a?(Array) key, value = entry text << "#{key}=#{value}\n" unless value.nil? else text << entry end end end text end private def find_entry(key) @entries.each do |entry| return entry if entry.is_a?(Array) && entry[0] == key end nil end end # A logical .ini-file that can be spread across several physical # files. For each physical file, call #read with the filename class File def initialize @files = {} end # Add the contents of the file with name FILE to the # already existing sections def read(file) text = Puppet::Util::FileType.filetype(:flat).new(file).read raise "Could not find #{file}" if text.nil? section = nil # The name of the current section optname = nil # The name of the last option in section line = 0 @files[file] = [] text.each_line do |l| line += 1 if l.strip.empty? || "#;".include?(l[0,1]) || (l.split(nil, 2)[0].downcase == "rem" && l[0,1].downcase == "r") # Whitespace or comment if section.nil? @files[file] << l else section.add_line(l) end elsif " \t\r\n\f".include?(l[0,1]) && section && optname # continuation line section[optname] += "\n#{l.chomp}" elsif l =~ /^\[([^\]]+)\]/ # section heading section.mark_clean unless section.nil? section = add_section($1, file) optname = nil elsif l =~ /^\s*([^\s=]+)\s*\=(.*)$/ # We allow space around the keys, but not the values # For the values, we don't know if space is significant if section.nil? raise "#{file}:#{line}:Key/value pair outside of a section for key #{$1}" else section[$1] = $2 optname = $1 end else raise "#{file}:#{line}: Can't parse '#{l.chomp}'" end end section.mark_clean unless section.nil? end # Store all modifications made to sections in this file back # to the physical files. If no modifications were made to # a physical file, nothing is written def store @files.each do |file, lines| text = "" dirty = false destroy = false lines.each do |l| if l.is_a?(Section) destroy ||= l.destroy? dirty ||= l.dirty? text << l.format l.mark_clean else text << l end end # We delete the file and then remove it from the list of files. if destroy ::File.unlink(file) @files.delete(file) else if dirty Puppet::Util::FileType.filetype(:flat).new(file).write(text) return file end end end end # Execute BLOCK, passing each section in this file # as an argument def each_section(&block) @files.each do |file, list| list.each do |entry| yield(entry) if entry.is_a?(Section) end end end # Execute BLOCK, passing each file constituting this inifile # as an argument def each_file(&block) @files.keys.each do |file| yield(file) end end # Return the Section with the given name or nil def [](name) name = name.to_s each_section do |section| return section if section.name == name end nil end # Return true if the file contains a section with name NAME def include?(name) ! self[name].nil? end # Add a section to be stored in FILE when store is called def add_section(name, file) raise "A section with name #{name} already exists" if include?(name) result = Section.new(name, file) @files[file] ||= [] @files[file] << result result end end class PhysicalFile # @!attribute [r] filetype # @api private # @return [Puppet::Util::FileType::FileTypeFlat] attr_reader :filetype # @!attribute [r] contents # @api private # @return [Array] attr_reader :contents # @!attribute [rw] destroy_empty # Whether empty files should be removed if no sections are defined. # Defaults to false attr_accessor :destroy_empty def initialize(file, options = {}) @file = file @contents = [] @filetype = Puppet::Util::FileType.filetype(:flat).new(file) @destroy_empty = options.fetch(:destroy_empty, false) end # Read and parse the on-disk file associated with this object def read text = @filetype.read if text.nil? raise IniParseError, "Cannot read nonexistent file #{@file.inspect}" end parse(text) end INI_COMMENT = Regexp.union( /^\s*$/, /^[#;]/, /^\s*rem\s/i ) INI_CONTINUATION = /^[ \t\r\n\f]/ INI_SECTION_NAME = /^\[([^\]]+)\]/ INI_PROPERTY = /^\s*([^\s=]+)\s*\=(.*)$/ # @api private def parse(text) section = nil # The name of the current section optname = nil # The name of the last option in section line_num = 0 text.each_line do |l| line_num += 1 if l.match(INI_COMMENT) # Whitespace or comment if section.nil? @contents << l else section.add_line(l) end elsif l.match(INI_CONTINUATION) && section && optname # continuation line section[optname] += "\n#{l.chomp}" elsif (match = l.match(INI_SECTION_NAME)) # section heading section.mark_clean if section section_name = match[1] if get_section(section_name) raise IniParseError.new( "Section #{section_name.inspect} is already defined, cannot redefine", @file, line_num ) end - section = create_section(section_name) + section = add_section(section_name) optname = nil elsif (match = l.match(INI_PROPERTY)) # We allow space around the keys, but not the values # For the values, we don't know if space is significant key = match[1] val = match[2] if section.nil? raise IniParseError.new("Property with key #{key.inspect} outside of a section") end section[key] = val optname = key else raise IniParseError.new("Can't parse line '#{l.chomp}'", @file, line_num) end end section.mark_clean unless section.nil? end # @return [Array] All sections defined in # this file. def sections @contents.select { |entry| entry.is_a? Section } end # @return [Puppet::Util::IniConfig::Section, nil] The section with the # given name if it exists, else nil. def get_section(name) @contents.find { |entry| entry.is_a? Section and entry.name == name } end def format text = "" @contents.each do |content| if content.is_a? Section text << content.format else text << content end end text end def store if @destroy_empty and (sections.empty? or sections.all?(&:destroy?)) ::File.unlink(@file) elsif sections.any?(&:dirty?) text = self.format @filetype.write(text) end sections.each(&:mark_clean) end - private - # Create a new section and store it in the file contents # # @api private # @param name [String] The name of the section to create # @return [Puppet::Util::IniConfig::Section] - def create_section(name) + def add_section(name) section = Section.new(name, @file) @contents << section section end end + class FileCollection + + attr_reader :files + + def initialize + @files = {} + end + + def read(file) + physical_file = PhysicalFile.new(file) + physical_file.read + @files[file] = physical_file + end + + def store + @files.values.each do |file| + file.store + end + end + + def each_section(&block) + @files.values.each do |file| + file.sections.each do |section| + yield section + end + end + end + + def each_file(&block) + @files.keys.each do |path| + yield path + end + end + + def get_section(name) + sect = nil + @files.values.each do |file| + if (current = file.get_section(name)) + sect = current + end + end + sect + end + alias [] get_section + + def include?(name) + !! get_section(name) + end + + def add_section(name, file) + @files[file] ||= PhysicalFile.new(file) + @files[file].add_section(name) + end + end + class IniParseError < Puppet::Error include Puppet::ExternalFileError end end diff --git a/spec/unit/util/inifile_spec.rb b/spec/unit/util/inifile_spec.rb index 207653346..9a8159c85 100644 --- a/spec/unit/util/inifile_spec.rb +++ b/spec/unit/util/inifile_spec.rb @@ -1,345 +1,481 @@ require 'spec_helper' require 'puppet/util/inifile' describe Puppet::Util::IniConfig::Section do subject { described_class.new('testsection', '/some/imaginary/file') } describe "determining if the section is dirty" do it "is not dirty on creation" do expect(subject).to_not be_dirty end it "is dirty if a key is changed" do subject['hello'] = 'world' expect(subject).to be_dirty end it "is dirty if the section has been explicitly marked as dirty" do subject.mark_dirty expect(subject).to be_dirty end it "is dirty if the section is marked for deletion" do subject.destroy = true expect(subject).to be_dirty end it "is clean if the section has been explicitly marked as clean" do subject['hello'] = 'world' subject.mark_clean expect(subject).to_not be_dirty end end describe "reading an entry" do it "returns nil if the key is not present" do expect(subject['hello']).to be_nil end it "returns the value if the key is specified" do subject.entries << ['hello', 'world'] expect(subject['hello']).to eq 'world' end it "ignores comments when looking for a match" do subject.entries << '#this = comment' expect(subject['#this']).to be_nil end end describe "formatting the section" do it "prefixes the output with the section header" do expect(subject.format).to eq "[testsection]\n" end it "restores comments and blank lines" do subject.entries << "#comment\n" subject.entries << " " expect(subject.format).to eq( "[testsection]\n" + "#comment\n" + " " ) end it "adds all keys that have values" do subject.entries << ['somekey', 'somevalue'] expect(subject.format).to eq("[testsection]\nsomekey=somevalue\n") end it "excludes keys that have a value of nil" do subject.entries << ['empty', nil] expect(subject.format).to eq("[testsection]\n") end it "preserves the order of the section" do subject.entries << ['firstkey', 'firstval'] subject.entries << "# I am a comment, hear me roar\n" subject.entries << ['secondkey', 'secondval'] expect(subject.format).to eq( "[testsection]\n" + "firstkey=firstval\n" + "# I am a comment, hear me roar\n" + "secondkey=secondval\n" ) end it "is empty if the section is marked for deletion" do subject.entries << ['firstkey', 'firstval'] subject.destroy = true expect(subject.format).to eq('') end end end describe Puppet::Util::IniConfig::PhysicalFile do subject { described_class.new('/some/nonexistent/file') } let(:first_sect) do sect = Puppet::Util::IniConfig::Section.new('firstsection', '/some/imaginary/file') sect.entries << "# comment\n" << ['onefish', 'redfish'] << "\n" sect end let(:second_sect) do sect = Puppet::Util::IniConfig::Section.new('secondsection', '/some/imaginary/file') sect.entries << ['twofish', 'bluefish'] sect end describe "when reading a file" do it "raises an error if the file does not exist" do subject.filetype.stubs(:read) expect { subject.read }.to raise_error(%r[Cannot read nonexistent file .*/some/nonexistent/file]) end it "passes the contents of the file to #parse" do subject.filetype.stubs(:read).returns "[section]" subject.expects(:parse).with("[section]") subject.read end end describe "when parsing a file" do describe "parsing sections" do it "creates new sections the first time that the section is found" do text = "[mysect]\n" subject.parse(text) expect(subject.contents).to have(1).items sect = subject.contents[0] expect(sect.name).to eq "mysect" end it "raises an error if a section is redefined in the file" do text = "[mysect]\n[mysect]\n" expect { subject.parse(text) }.to raise_error(Puppet::Util::IniConfig::IniParseError, /Section "mysect" is already defined, cannot redefine/) end it "raises an error if a section is redefined in the file collection" end describe "parsing properties" do it "raises an error if the property is not within a section" do text = "key=val\n" expect { subject.parse(text) }.to raise_error(Puppet::Util::IniConfig::IniParseError, /Property with key "key" outside of a section/) end it "adds the property to the current section" do text = "[main]\nkey=val\n" subject.parse(text) expect(subject.contents).to have(1).items sect = subject.contents[0] expect(sect['key']).to eq "val" end end describe "parsing line continuations" do it "adds the continued line to the last parsed property" do text = "[main]\nkey=val\n moreval" subject.parse(text) expect(subject.contents).to have(1).items sect = subject.contents[0] expect(sect['key']).to eq "val\n moreval" end end describe "parsing comments and whitespace" do it "treats # as a comment leader" do text = "# octothorpe comment" subject.parse(text) expect(subject.contents).to eq ["# octothorpe comment"] end it "treats ; as a comment leader" do text = "; semicolon comment" subject.parse(text) expect(subject.contents).to eq ["; semicolon comment"] end it "treates 'rem' as a comment leader" do text = "rem rapid eye movement comment" subject.parse(text) expect(subject.contents).to eq ["rem rapid eye movement comment"] end it "stores comments and whitespace in a section in the correct section" do text = "[main]\n; main section comment" subject.parse(text) sect = subject.get_section("main") expect(sect.entries).to eq ["; main section comment"] end end end it "can return all sections" do text = "[first]\n" + "; comment\n" + "[second]\n" + "key=value" subject.parse(text) sections = subject.sections expect(sections).to have(2).items expect(sections[0].name).to eq "first" expect(sections[1].name).to eq "second" end it "can retrieve a specific section" do text = "[first]\n" + "; comment\n" + "[second]\n" + "key=value" subject.parse(text) section = subject.get_section("second") expect(section.name).to eq "second" expect(section["key"]).to eq "value" end describe "formatting" do it "concatenates each formatted section in order" do subject.contents << first_sect << second_sect expected = "[firstsection]\n" + "# comment\n" + "onefish=redfish\n" + "\n" + "[secondsection]\n" + "twofish=bluefish\n" expect(subject.format).to eq expected end it "includes comments that are not within a section" do subject.contents << "# This comment is not in a section\n" << first_sect << second_sect expected = "# This comment is not in a section\n" + "[firstsection]\n" + "# comment\n" + "onefish=redfish\n" + "\n" + "[secondsection]\n" + "twofish=bluefish\n" expect(subject.format).to eq expected end it "excludes sections that are marked to be destroyed" do subject.contents << first_sect << second_sect first_sect.destroy = true expected = "[secondsection]\n" + "twofish=bluefish\n" expect(subject.format).to eq expected end end describe "storing the file" do describe "with empty contents" do describe "and destroy_empty is true" do before { subject.destroy_empty = true } it "removes the file if there are no sections" do File.expects(:unlink) subject.store end it "removes the file if all sections are marked to be destroyed" do subject.contents << first_sect << second_sect first_sect.destroy = true second_sect.destroy = true File.expects(:unlink) subject.store end it "doesn't remove the file if not all sections are marked to be destroyed" do subject.contents << first_sect << second_sect first_sect.destroy = true second_sect.destroy = false File.expects(:unlink).never subject.filetype.stubs(:write) subject.store end end it "rewrites the file if destroy_empty is false" do subject.contents << first_sect << second_sect first_sect.destroy = true second_sect.destroy = true File.expects(:unlink).never subject.stubs(:format).returns "formatted" subject.filetype.expects(:write).with("formatted") subject.store end end it "rewrites the file if any section is dirty" do subject.contents << first_sect << second_sect first_sect.mark_dirty second_sect.mark_clean subject.stubs(:format).returns "formatted" subject.filetype.expects(:write).with("formatted") subject.store end it "doesn't modify the file if all sections are clean" do subject.contents << first_sect << second_sect first_sect.mark_clean second_sect.mark_clean subject.stubs(:format).returns "formatted" subject.filetype.expects(:write).never subject.store end end end + +describe Puppet::Util::IniConfig::FileCollection do + + let(:path_a) { '/some/nonexistent/file/a' } + let(:path_b) { '/some/nonexistent/file/b' } + + let(:file_a) { Puppet::Util::IniConfig::PhysicalFile.new(path_a) } + let(:file_b) { Puppet::Util::IniConfig::PhysicalFile.new(path_b) } + + let(:sect_a1) { Puppet::Util::IniConfig::Section.new('sect_a1', path_a) } + let(:sect_a2) { Puppet::Util::IniConfig::Section.new('sect_a2', path_a) } + + let(:sect_b1) { Puppet::Util::IniConfig::Section.new('sect_b1', path_b) } + let(:sect_b2) { Puppet::Util::IniConfig::Section.new('sect_b2', path_b) } + + before do + file_a.contents << sect_a1 << sect_a2 + file_b.contents << sect_b1 << sect_b2 + end + + describe "reading a file" do + let(:stub_file) { stub('Physical file') } + + it "creates a new PhysicalFile and uses that to read the file" do + stub_file.expects(:read) + Puppet::Util::IniConfig::PhysicalFile.expects(:new).with(path_a).returns stub_file + + subject.read(path_a) + end + + it "stores the PhysicalFile and the path to the file" do + stub_file.stubs(:read) + Puppet::Util::IniConfig::PhysicalFile.stubs(:new).with(path_a).returns stub_file + subject.read(path_a) + + path, physical_file = subject.files.first + + expect(path).to eq(path_a) + expect(physical_file).to eq stub_file + end + end + + describe "storing all files" do + before do + subject.files[path_a] = file_a + subject.files[path_b] = file_b + end + + it "stores all files in the collection" do + file_a.expects(:store).once + file_b.expects(:store).once + + subject.store + end + end + + describe "iterating over sections" do + before do + subject.files[path_a] = file_a + subject.files[path_b] = file_b + end + + it "yields every section from every file" do + [sect_a1, sect_a2, sect_b1, sect_b2].each do |sect| + sect.expects(:touch).once + end + + subject.each_section do |sect| + sect.touch + end + end + end + + describe "iterating over files" do + before do + subject.files[path_a] = file_a + subject.files[path_b] = file_b + end + + it "yields the path to every file in the collection" do + seen = [] + subject.each_file do |file| + seen << file + end + + expect(seen).to include(path_a) + expect(seen).to include(path_b) + end + end + + describe "retrieving a specific section" do + before do + subject.files[path_a] = file_a + subject.files[path_b] = file_b + end + + it "retrieves the first section defined" do + expect(subject.get_section('sect_b1')).to eq sect_b1 + end + + it "returns nil if there was no section with the given name" do + expect(subject.get_section('nope')).to be_nil + end + + it "allows #[] to be used as an alias to #get_section" do + expect(subject['b2']).to eq subject.get_section('b2') + end + end + + describe "checking if a section has been defined" do + before do + subject.files[path_a] = file_a + subject.files[path_b] = file_b + end + + it "is true if a section with the given name is defined" do + expect(subject.include?('sect_a1')).to be_true + end + + it "is false if a section with the given name can't be found" do + expect(subject.include?('nonexistent')).to be_false + end + end + + describe "adding a new section" do + before do + subject.files[path_a] = file_a + subject.files[path_b] = file_b + end + + it "adds the section to the appropriate file" do + file_a.expects(:add_section).with('newsect') + subject.add_section('newsect', path_a) + end + end +end