diff --git a/.gitignore b/.gitignore index 7e34c69..783118d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ # rspec failure tracking .rspec_status /Gemfile.lock +/spec/fixtures/sandbox diff --git a/lib/koine/file_system.rb b/lib/koine/file_system.rb index 1a99c71..07fa38a 100644 --- a/lib/koine/file_system.rb +++ b/lib/koine/file_system.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true require 'koine/file_system/version' +require 'koine/file_system/file_system' +require 'koine/file_system/path_sanitizer' +require 'koine/file_system/adapters/base' +require 'koine/file_system/adapters/local' module Koine module FileSystem class Error < StandardError; end - # Your code goes here... + class FileNotFound < Error; end end end diff --git a/lib/koine/file_system/adapters/base.rb b/lib/koine/file_system/adapters/base.rb new file mode 100644 index 0000000..f159973 --- /dev/null +++ b/lib/koine/file_system/adapters/base.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Koine + module FileSystem + module Adapters + # rubocop:disable Lint/UnusedMethodArgument + class Base + def read(_path) + raise NotImplementedError + end + + def read_stream(_path) + raise NotImplementedError + end + + def write(_path, _contents, options: {}) + raise NotImplementedError + end + + def write_stream(_path, _contents, options: {}) + raise NotImplementedError + end + + def update(_path, _contents, options: {}) + raise NotImplementedError + end + + def update_stream(_path, _contents, options: {}) + raise NotImplementedError + end + + def has?(_path) + raise NotImplementedError + end + + def delete(_path) + raise NotImplementedError + end + + def read_and_delete(_path) + raise NotImplementedError + end + + def rename(_from, _to) + raise NotImplementedError + end + + def copy(_from, _to) + raise NotImplementedError + end + + def mime_type(_path) + raise NotImplementedError + end + + def size(_path) + raise NotImplementedError + end + + def create_dir(_path) + raise NotImplementedError + end + + def delete_dir(_path) + raise NotImplementedError + end + + def list_contents(_path) + raise NotImplementedError + end + + def list(_path, recursive: false) + raise NotImplementedError + end + + private + + def raise_not_found(file) + raise FileNotFound, "File not found: #{file}" + end + end + # rubocop:enable Lint/UnusedMethodArgument + end + end +end diff --git a/lib/koine/file_system/adapters/local.rb b/lib/koine/file_system/adapters/local.rb new file mode 100644 index 0000000..f3a539a --- /dev/null +++ b/lib/koine/file_system/adapters/local.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Koine + module FileSystem + module Adapters + class Local < Base + def initialize(root:, path_sanitizer: PathSanitizer.new) + @root = root + @path_sanitizer = path_sanitizer + end + + def read(path) + if has?(path) + file = File.open(full_path(path), 'rb') + content = file.read + file.close + return content + # return File.read(full_path(path)) + end + raise_not_found(path) + end + + def has?(path) + File.exist?(full_path(path)) + end + + def write(path, content, options: {}) + path = full_path(path) + ensure_target_dir(path) + File.open(path, 'w') do |f| + f.puts(content) + end + end + + private + + def full_path(path) + File.expand_path(sanitize_path(path), @root) + end + + def ensure_target_dir(path) + dir = File.dirname(path) + unless Dir.exist?(dir) + FileUtils.mkdir_p(dir) + end + end + + def sanitize_path(path) + @path_sanitizer.sanitize(path) + end + + # def read_stream(_path) + # def write(_path, _contents, options: {}) + # def write_stream(_path, _contents, options: {}) + # def update(_path, _contents, options: {}) + # def update_stream(_path, _contents, options: {}) + # def has?(_path) + # def delete(_path) + # def read_and_delete(_path) + # def rename(_from, _to) + # def copy(_from, _to) + # def mime_type(_path) + # def size(_path) + # def create_dir(_path) + # def delete_dir(_path) + # def list_contents(_path) + # def list(_path, recursive: false) + end + end + end +end diff --git a/lib/koine/file_system/file_system.rb b/lib/koine/file_system/file_system.rb new file mode 100644 index 0000000..02c37a2 --- /dev/null +++ b/lib/koine/file_system/file_system.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Koine + module FileSystem + # Inspired on + # https://flysystem.thephpleague.com/v1/docs/usage/filesystem-api/ + class FileSystem + def initialize(adapter) + @adapter = adapter + end + + def read(path, &block) + @adapter.read(path, &block) + end + + def read_stream(path, &block) + @adapter.read_stream(path, &block) + end + + def write(path, contents, options: {}) + @adapter.write(path, contents, options: options) + end + + def write_stream(path, contents, options: {}) + @adapter.write_stream(path, contents, options: options) + end + + def update(path, contents, options: {}) + @adapter.update(path, contents, options: options) + end + + def update_stream(path, contents, options: {}) + @adapter.update_stream(path, contents, options: options) + end + + def has?(path) + @adapter.has?(path) + end + + def delete(path) + @adapter.delete(path) + end + + def read_and_delete(path) + @adapter.read_and_delete(path) + end + + def rename(from, to) + @adapter.rename(from, to) + end + + def copy(from, to) + @adapter.copy(from, to) + end + + def mime_type(path) + @adapter.mime_type(path) + end + + def size(path) + @adapter.size(path) + end + + def create_dir(path) + @adapter.create_dir(path) + end + + def delete_dir(path) + @adapter.delete_dir(path) + end + + def list(path, recursive: false) + @adapter.list(path, recursive: recursive) + end + end + end +end diff --git a/lib/koine/file_system/path_sanitizer.rb b/lib/koine/file_system/path_sanitizer.rb new file mode 100644 index 0000000..6730172 --- /dev/null +++ b/lib/koine/file_system/path_sanitizer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Koine + module FileSystem + class PathSanitizer + def sanitize(path) + path.to_s.gsub('/../', '/').gsub(%r{\.?\./}, '') + end + end + end +end diff --git a/spec/fixtures/sample.png b/spec/fixtures/sample.png new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/sample.txt b/spec/fixtures/sample.txt new file mode 100644 index 0000000..6154c9a --- /dev/null +++ b/spec/fixtures/sample.txt @@ -0,0 +1,2 @@ +This is a sample file +For local adapter diff --git a/spec/koine/file_system/adapters/base_spec.rb b/spec/koine/file_system/adapters/base_spec.rb new file mode 100644 index 0000000..aef6ce9 --- /dev/null +++ b/spec/koine/file_system/adapters/base_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Koine::FileSystem::Adapters::Base do + subject(:adapter) { described_class.new } + + let(:path) { 'foo' } + let(:contents) { 'bar' } + let(:options) { {} } + let(:from) { 'from' } + let(:to) { 'to' } + + # rubocop:disable RSpec/ExampleLength + it 'raises on every method' do + expect do + adapter.read(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.read_stream(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.write(path, contents, options: {}) + end.to raise_error(NotImplementedError) + + expect do + adapter.write_stream(path, contents, options: {}) + end.to raise_error(NotImplementedError) + + expect do + adapter.update(path, contents, options: {}) + end.to raise_error(NotImplementedError) + + expect do + adapter.update_stream(path, contents, options: {}) + end.to raise_error(NotImplementedError) + + expect do + adapter.has?(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.delete(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.read_and_delete(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.rename(from, to) + end.to raise_error(NotImplementedError) + + expect do + adapter.copy(from, to) + end.to raise_error(NotImplementedError) + + expect do + adapter.mime_type(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.size(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.create_dir(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.delete_dir(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.list_contents(path) + end.to raise_error(NotImplementedError) + + expect do + adapter.list(path, recursive: false) + end.to raise_error(NotImplementedError) + end + # rubocop:enable RSpec/ExampleLength +end diff --git a/spec/koine/file_system/adapters/local_spec.rb b/spec/koine/file_system/adapters/local_spec.rb new file mode 100644 index 0000000..4d3298b --- /dev/null +++ b/spec/koine/file_system/adapters/local_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'digest' + +RSpec.describe Koine::FileSystem::Adapters::Local do + let(:adapter) { described_class.new(root: FIXTURES_PATH) } + + before do + system("rm -rf #{FIXTURES_PATH}/sandbox") + end + + describe '#read' do + it 'raises when file does not exist' do + expect do + adapter.read('unexisting') + end.to raise_error( + Koine::FileSystem::FileNotFound, + 'File not found: unexisting' + ) + end + + it 'returns the content of the file when it exists' do + contents = adapter.read('sample.txt') + + expected = <<~STR + This is a sample file + For local adapter + STR + + expect(contents).to eq(expected) + end + end + + describe '#write' do + let(:content) do + <<~STR + This is a sample file + For local adapter + STR + end + + it 'creates a file' do + adapter.write('sandbox/foo/bar.txt', content) + + expect(File.read(FIXTURES_PATH + '/sandbox/foo/bar.txt')).to eq(content) + end + + it 'creates filters malicious files' do + adapter.write('../../sandbox/../.././../../foo/bar.txt', content) + + expect(File.read(FIXTURES_PATH + '/sandbox/foo/bar.txt')).to eq(content) + end + + it 'keeps the original file digest' do + content = adapter.read('sample.txt') + adapter.write('sandbox/text/sample.txt', content) + + original_content_digest = Digest::SHA1.hexdigest(adapter.read('sample.txt')) + copy_content_digest = Digest::SHA1.hexdigest(adapter.read('sandbox/text/sample.txt')) + + expect(copy_content_digest).to eq(original_content_digest) + end + + it 'writes binary file' do + content = adapter.read('sample.png') + adapter.write('sandbox/binary/sample.png', content) + + # da39a3ee5e6b4b0d3255bfef95601890afd80709 spec/fixtures/sample.png + # adc83b19e793491b1c6ea0fd8b46cd9f32e592fc spec/fixtures/sandbox/binary/sample.png + + original_content_digest = Digest::SHA1.hexdigest(adapter.read('sample.png')) + copy_content_digest = Digest::SHA1.hexdigest(adapter.read('sandbox/binary/sample.png')) + + expect(copy_content_digest).to eq(original_content_digest) + + original = Digest::SHA1.file("#{FIXTURES_PATH}/sample.png") + copy = Digest::SHA1.file("#{FIXTURES_PATH}/sandbox/binary/sample.png") + + expect(copy).to eq(original) + end + end +end diff --git a/spec/koine/file_system/file_system_spec.rb b/spec/koine/file_system/file_system_spec.rb new file mode 100644 index 0000000..18f745f --- /dev/null +++ b/spec/koine/file_system/file_system_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Koine::FileSystem::FileSystem do + subject(:fs) { described_class.new(adapter) } + + let(:adapter) { instance_double(described_class) } + + it 'delegates #read to adapter' do + stub(:read, 'path').and_return('foo') + + expect(fs.read('path')).to eq('foo') + end + + it 'delegates #read_stream to adapter' do + stub(:read_stream, 'path').and_return('foo') + + expect(fs.read_stream('path')).to eq('foo') + end + + it 'delegates #write to adapter' do + stub(:write, 'path', 'content', options: 'the-options').and_return('foo') + + expect(fs.write('path', 'content', options: 'the-options')).to eq('foo') + end + + it 'delegates #write_stream to adapter' do + stub(:write_stream, 'path', 'content', options: 'the-options').and_return('foo') + + expect(fs.write_stream('path', 'content', options: 'the-options')).to eq('foo') + end + + it 'delegates #update to adapter' do + stub(:update, 'path', 'content', options: 'the-options').and_return('foo') + + expect(fs.update('path', 'content', options: 'the-options')).to eq('foo') + end + + it 'delegates #update_stream to adapter' do + stub(:update_stream, 'path', 'content', options: 'the-options').and_return('foo') + + expect(fs.update_stream('path', 'content', options: 'the-options')).to eq('foo') + end + + it 'delegates #has to adapter' do + stub(:has?, 'path').and_return('foo') + + expect(fs.has?('path')).to eq('foo') + end + + it 'delegates #delete to adapter' do + stub(:delete, 'path').and_return('foo') + + expect(fs.delete('path')).to eq('foo') + end + + it 'delegates #read_and_delete to adapter' do + stub(:read_and_delete, 'path').and_return('foo') + + expect(fs.read_and_delete('path')).to eq('foo') + end + + it 'delegates #rename to adapter' do + stub(:rename, 'foo', 'bar').and_return('baz') + + expect(fs.rename('foo', 'bar')).to eq('baz') + end + + it 'delegates #copy to adapter' do + stub(:copy, 'foo', 'bar').and_return('baz') + + expect(fs.copy('foo', 'bar')).to eq('baz') + end + + it 'delegates #mime_type to adapter' do + stub(:mime_type, 'path').and_return('foo') + + expect(fs.mime_type('path')).to eq('foo') + end + + it 'delegates #size to adapter' do + stub(:size, 'path').and_return('foo') + + expect(fs.size('path')).to eq('foo') + end + + it 'delegates #create_dir to adapter' do + stub(:create_dir, 'path').and_return('foo') + + expect(fs.create_dir('path')).to eq('foo') + end + + it 'delegates #delete_dir to adapter' do + stub(:delete_dir, 'path').and_return('foo') + + expect(fs.delete_dir('path')).to eq('foo') + end + + it 'delegates #list to adapter' do + stub(:list, 'path', recursive: 'r').and_return('foo') + + expect(fs.list('path', recursive: 'r')).to eq('foo') + end + + private + + def stub(*args) + method = args.shift + + allow(adapter).to receive(method).with(*args) + end +end diff --git a/spec/koine/file_system/path_sanitizer_spec.rb b/spec/koine/file_system/path_sanitizer_spec.rb new file mode 100644 index 0000000..6a0e1c1 --- /dev/null +++ b/spec/koine/file_system/path_sanitizer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Koine::FileSystem::PathSanitizer do + subject(:sanitizer) { described_class.new } + + describe '#sanitize' do + SAMPLES = { + '.././foo/bar/.././../../file.txt' => 'foo/bar/file.txt' + } + + SAMPLES.each do |key, value| + it "transforms #{key} in #{value}"do + expect(sanitizer.sanitize(key)).to eq(value) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3e36825..20c4086 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -13,6 +13,9 @@ end require 'koine/file_system' +require 'rspec' + +FIXTURES_PATH = File.expand_path('fixtures', __dir__) RSpec.configure do |config| # Enable flags like --only-failures and --next-failure