diff --git a/lib/puppet/indirector/json.rb b/lib/puppet/indirector/json.rb new file mode 100644 index 000000000..216a41801 --- /dev/null +++ b/lib/puppet/indirector/json.rb @@ -0,0 +1,76 @@ +require 'puppet/indirector/terminus' +require 'puppet/util/file_locking' + +# The base class for JSON indirection terminus implementations. +# +# This should generally be preferred to the YAML base for any future +# implementations, since it is ~ three times faster despite being pure Ruby +# rather than a C implementation. +class Puppet::Indirector::JSON < Puppet::Indirector::Terminus + include Puppet::Util::FileLocking + + def find(request) + load_json_from_file(path(request.key), request.key) + end + + def save(request) + filename = path(request.key) + FileUtils.mkdir_p(File.dirname(filename)) + writelock(filename, 0660) {|f| f.print to_json(request.instance) } + rescue TypeError => detail + Puppet.err "Could not save #{self.name} #{request.key}: #{detail}" + end + + def destroy(request) + File.unlink(path(request.key)) + rescue => detail + unless detail.is_a? Errno::ENOENT + raise Puppet::Error, "Could not destroy #{self.name} #{request.key}: #{detail}" + end + 1 # emulate success... + end + + def search(request) + Dir.glob(path(request.key)).collect do |file| + load_json_from_file(file, request.key) + end + end + + # Return the path to a given node's file. + def path(name, ext = '.json') + if name =~ Puppet::Indirector::BadNameRegexp then + Puppet.crit("directory traversal detected in #{self.class}: #{name.inspect}") + raise ArgumentError, "invalid key" + end + + base = Puppet.run_mode.master? ? Puppet[:server_datadir] : Puppet[:client_datadir] + File.join(base, self.class.indirection_name.to_s, name.to_s + ext) + end + + private + + def load_json_from_file(file, key) + json = nil + + begin + readlock(file) {|fh| json = fh.read } + rescue => detail + return nil unless FileTest.exist?(file) + raise Puppet::Error, "Could not read JSON data for #{indirection.name} #{key}: #{detail}" + end + + begin + return from_json(json) + rescue => detail + raise Puppet::Error, "Could not parse JSON data for #{indirection.name} #{key}: #{detail}" + end + end + + def from_json(text) + model.convert_from('pson', text) + end + + def to_json(object) + object.render('pson') + end +end diff --git a/spec/lib/puppet/indirector/indirector_testing/json.rb b/spec/lib/puppet/indirector/indirector_testing/json.rb new file mode 100644 index 000000000..cc60640d2 --- /dev/null +++ b/spec/lib/puppet/indirector/indirector_testing/json.rb @@ -0,0 +1,6 @@ +require 'puppet/indirector_testing' +require 'puppet/indirector/json' + +class Puppet::IndirectorTesting::JSON < Puppet::Indirector::JSON + desc "Testing the JSON indirector" +end diff --git a/spec/lib/puppet/indirector_testing.rb b/spec/lib/puppet/indirector_testing.rb new file mode 100644 index 000000000..883812158 --- /dev/null +++ b/spec/lib/puppet/indirector_testing.rb @@ -0,0 +1,27 @@ +require 'puppet/indirector' +require 'puppet/util/pson' + +class Puppet::IndirectorTesting + extend Puppet::Indirector + indirects :indirector_testing + + # We should have some way to identify if we got a valid object back with the + # current values, no? + attr_accessor :value + def initialize(value) + self.value = value + end + + PSON.register_document_type('IndirectorTesting',self) + def self.from_pson(data) + new(data['value']) + end + + def to_pson + { + 'document_type' => 'IndirectorTesting', + 'data' => { 'value' => value }, + 'metadata' => { 'api_version' => 1 } + }.to_pson + end +end diff --git a/spec/unit/indirector/json_spec.rb b/spec/unit/indirector/json_spec.rb new file mode 100755 index 000000000..baecae2ed --- /dev/null +++ b/spec/unit/indirector/json_spec.rb @@ -0,0 +1,193 @@ +#!/usr/bin/env rspec +require 'spec_helper' +require 'puppet_spec/files' +require 'puppet/indirector/indirector_testing/json' + +describe Puppet::Indirector::JSON do + include PuppetSpec::Files + + subject { Puppet::IndirectorTesting::JSON.new } + let :model do Puppet::IndirectorTesting end + let :indirection do model.indirection end + + context "#path" do + before :each do + Puppet[:server_datadir] = '/sample/datadir/master' + Puppet[:client_datadir] = '/sample/datadir/client' + end + + it "uses the :server_datadir setting if this is the master" do + Puppet.run_mode.stubs(:master?).returns(true) + expected = File.join(Puppet[:server_datadir], 'indirector_testing', 'testing.json') + subject.path('testing').should == expected + end + + it "uses the :client_datadir setting if this is not the master" do + Puppet.run_mode.stubs(:master?).returns(false) + expected = File.join(Puppet[:client_datadir], 'indirector_testing', 'testing.json') + subject.path('testing').should == expected + end + + it "overrides the default extension with a supplied value" do + Puppet.run_mode.stubs(:master?).returns(true) + expected = File.join(Puppet[:server_datadir], 'indirector_testing', 'testing.not-json') + subject.path('testing', '.not-json').should == expected + end + + ['../foo', '..\\foo', './../foo', '.\\..\\foo', + '/foo', '//foo', '\\foo', '\\\\goo', + "test\0/../bar", "test\0\\..\\bar", + "..\\/bar", "/tmp/bar", "/tmp\\bar", "tmp\\bar", + " / bar", " /../ bar", " \\..\\ bar", + "c:\\foo", "c:/foo", "\\\\?\\UNC\\bar", "\\\\foo\\bar", + "\\\\?\\c:\\foo", "//?/UNC/bar", "//foo/bar", + "//?/c:/foo", + ].each do |input| + it "should resist directory traversal attacks (#{input.inspect})" do + expect { subject.path(input) }.to raise_error ArgumentError, 'invalid key' + end + end + end + + context "handling requests" do + before :each do + Puppet.run_mode.stubs(:master?).returns(true) + Puppet[:server_datadir] = tmpdir('jsondir') + FileUtils.mkdir_p(File.join(Puppet[:server_datadir], 'indirector_testing')) + end + + let :file do subject.path(request.key) end + + def with_content(text) + FileUtils.mkdir_p(File.dirname(file)) + File.open(file, 'w') {|f| f.puts text } + yield if block_given? + end + + it "data saves and then loads again correctly" do + subject.save(indirection.request(:save, 'example', model.new('banana'))) + subject.find(indirection.request(:find, 'example')).value.should == 'banana' + end + + context "#find" do + let :request do indirection.request(:find, 'example') end + + it "returns nil if the file doesn't exist" do + subject.find(request).should be_nil + end + + it "raises a descriptive error when the file can't be read" do + with_content(model.new('foo').to_pson) do + # I don't like this, but there isn't a credible alternative that + # also works on Windows, so a stub it is. At least the expectation + # will fail if the implementation changes. Sorry to the next dev. + File.expects(:open).with(file).raises(Errno::EPERM) + expect { subject.find(request) }. + to raise_error Puppet::Error, /Could not read JSON/ + end + end + + it "raises a descriptive error when the file content is invalid" do + with_content("this is totally invalid JSON") do + expect { subject.find(request) }. + to raise_error Puppet::Error, /Could not parse JSON data/ + end + end + + it "should return an instance of the indirected object when valid" do + with_content(model.new(1).to_pson) do + instance = subject.find(request) + instance.should be_an_instance_of model + instance.value.should == 1 + end + end + end + + context "#save" do + let :instance do model.new(4) end + let :request do indirection.request(:find, 'example', instance) end + + it "should save the instance of the request as JSON to disk" do + subject.save(request) + content = File.read(file) + content.should =~ /"document_type"\s*:\s*"IndirectorTesting"/ + content.should =~ /"value"\s*:\s*4/ + end + + it "should create the indirection directory if required" do + target = File.join(Puppet[:server_datadir], 'indirector_testing') + Dir.rmdir(target) + + subject.save(request) + + File.should be_directory target + end + end + + context "#destroy" do + let :request do indirection.request(:find, 'example') end + + it "removes an existing file" do + with_content('hello') do + subject.destroy(request) + end + File.should_not be_exist file + end + + it "silently succeeds when files don't exist" do + File.unlink(file) rescue nil + subject.destroy(request).should be_true + end + + it "raises an informative error for other failures" do + File.stubs(:unlink).with(file).raises(Errno::EPERM, 'fake permission problem') + with_content('hello') do + expect { subject.destroy(request) }.to raise_error Puppet::Error + end + File.unstub(:unlink) # thanks, mocha + end + end + end + + context "#search" do + before :each do + Puppet.run_mode.stubs(:master?).returns(true) + Puppet[:server_datadir] = tmpdir('jsondir') + FileUtils.mkdir_p(File.join(Puppet[:server_datadir], 'indirector_testing')) + end + + def request(glob) + indirection.request(:search, glob) + end + + def create_file(name, value = 12) + File.open(subject.path(name, ''), 'w') do |f| + f.puts Puppet::IndirectorTesting.new(value).to_pson + end + end + + it "returns an empty array when nothing matches the key as a glob" do + subject.search(request('*')).should == [] + end + + it "returns an array with one item if one item matches" do + create_file('foo.json', 'foo') + create_file('bar.json', 'bar') + subject.search(request('f*')).map(&:value).should == ['foo'] + end + + it "returns an array of items when more than one item matches" do + create_file('foo.json', 'foo') + create_file('bar.json', 'bar') + create_file('baz.json', 'baz') + subject.search(request('b*')).map(&:value).should =~ ['bar', 'baz'] + end + + it "only items with the .json extension" do + create_file('foo.json', 'foo-json') + create_file('foo.pson', 'foo-pson') + create_file('foo.json~', 'foo-backup') + subject.search(request('f*')).map(&:value).should == ['foo-json'] + end + end +end