cloud_encrypted_sync 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ .redcar
2
+ config/config.yml
3
+ coverage
4
+ *.gem
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'http://rubygems.org'
2
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,31 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ cloud_encrypted_sync (0.0.1)
5
+
6
+ GEM
7
+ remote: http://rubygems.org/
8
+ specs:
9
+ activesupport (3.2.3)
10
+ i18n (~> 0.6)
11
+ multi_json (~> 1.0)
12
+ fakefs (0.4.0)
13
+ i18n (0.6.0)
14
+ metaclass (0.0.1)
15
+ mocha (0.11.4)
16
+ metaclass (~> 0.0.1)
17
+ multi_json (1.3.4)
18
+ simplecov (0.6.4)
19
+ multi_json (~> 1.0)
20
+ simplecov-html (~> 0.5.3)
21
+ simplecov-html (0.5.3)
22
+
23
+ PLATFORMS
24
+ ruby
25
+
26
+ DEPENDENCIES
27
+ activesupport
28
+ cloud_encrypted_sync!
29
+ fakefs
30
+ mocha
31
+ simplecov
data/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Copyright (c) 2012 Jonathan S. Garvin
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
4
+ software and associated documentation files (the "Software"), to deal in the Software
5
+ without restriction, including without limitation the rights to use, copy, modify, merge,
6
+ publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
7
+ to whom the Software is furnished to do so, subject to the following conditions:
8
+
9
+ The above copyright notice and this permission notice shall be included in all copies or
10
+ substantial portions of the Software.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
13
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
14
+ PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
15
+ FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
16
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # Cloud Encrypted Sync
2
+
3
+ Cloud Encrypted Sync (CES) is a command line tool distributed as a Ruby gem for syncing a local
4
+ folder to cloud storage via external adapters, with localy managed encryption (you control the
5
+ keys).
6
+
7
+ Even though you could simply use CES to backup a local folder to the cloud, it's original
8
+ intended purpose is to sync a folder across multiple computers, so it should work for that,
9
+ too.
10
+
11
+ ## Installation
12
+
13
+ gem install cloud_encrypted_sync
14
+
15
+ In addition to this gem you'll also need to install an adapter gem for the particular cloud
16
+ you want to backup to. At the time of this writing, the only available adapter is for Amazon
17
+ S3, although anyone can create an adapter to work with other clouds. Search rubygems.org for
18
+ "cloud encrypted sync" to find out if someone has already created an adapter for your
19
+ preferred cloud.
20
+
21
+ ## Getting started
22
+
23
+ CES runs as a command line tool and takes options as CLI arguments and/or from a config file.
24
+ Arguments passed at the command line take precedence over those in the config file.
25
+
26
+ ### Creating a valid encryption key and initialization vector.
27
+
28
+ TODO
29
+
30
+ ### Example
31
+
32
+ ces --adapter=s3 --bucket=my-backup-bucket \
33
+ --s3-credentials=ACCESS_KEY_ID,SECRET_ACCESS_KEY \
34
+ --encryption-key=MYENCRYPTIONKEY /path/to/source/folder
35
+
36
+ ## Configuration
37
+
38
+ Configuration options may be passed to CES via the command line, or via a config yaml file.
39
+
40
+ The default location for the config file is `~/.cloud_encrypted_sync/config.rc.yml`
41
+
42
+ ### Available Settings
43
+
44
+ CES requires the following configuration settings. Any of thse may alternatively be placed in
45
+ the `config.rc.yml` execpt for `--data-dir` (which tells CES which folder contains the config
46
+ file to use).
47
+
48
+ * `--adapter=ADAPTERNAME` The name of the adapter to use. See instructions for your preferred
49
+ adapter for instructions of what to place here.
50
+ * `--encryption-key=XXX` The encryption key (shocking, I know).
51
+
52
+ In addition to these settings, your chosen adapter will probably also have additional adapter
53
+ specific settings as well, such as credentials to log into your cloud storage account. Adapter
54
+ specific settings may work the same the the above standard settings in that they may be included
55
+ on the command or in the `config.rc.yml` file (unless the adapter author breaks convention and
56
+ tries to do something weird).
57
+
58
+ ## Creating your own adapter
59
+
60
+ TODO
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'rake/testtask'
2
+
3
+ task :default => :test
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << 'lib'
7
+ t.libs << 'test'
8
+ t.pattern = 'test/**/*_test.rb'
9
+ t.verbose = true
10
+ end
data/bin/ces ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require 'etc'
4
+ require File.expand_path('../../lib/cloud_encrypted_sync', __FILE__)
5
+ CloudEncryptedSync::Master.activate!
@@ -0,0 +1,22 @@
1
+ require File.expand_path('../lib/cloud_encrypted_sync/version', __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "cloud_encrypted_sync"
5
+ s.version = CloudEncryptedSync::VERSION
6
+ s.platform = Gem::Platform::RUBY
7
+ s.authors = ["Jonathan S. Garvin"]
8
+ s.email = ["jon@5valleys.com"]
9
+ s.homepage = "https://github.com/jsgarvin/cloud_encrypted_sync"
10
+ s.summary = %q{Encrypted sync of folder contents to/from cloud storage.}
11
+ s.description = %q{Encrypted sync of folder contents to/from cloud storage with user controlled encryption keys.}
12
+
13
+ s.add_development_dependency('mocha')
14
+ s.add_development_dependency('simplecov')
15
+ s.add_development_dependency('fakefs')
16
+ s.add_development_dependency('activesupport')
17
+
18
+ s.files = `git ls-files`.split("\n")
19
+ s.test_files = `git ls-files -- test/*`.split("\n")
20
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
21
+ s.require_paths = ["lib"]
22
+ end
@@ -0,0 +1,6 @@
1
+ require File.expand_path('../cloud_encrypted_sync/master', __FILE__)
2
+ require File.expand_path('../cloud_encrypted_sync/configuration', __FILE__)
3
+ require File.expand_path('../cloud_encrypted_sync/cryptographer', __FILE__)
4
+ require File.expand_path('../cloud_encrypted_sync/adapter_template', __FILE__)
5
+ require File.expand_path('../cloud_encrypted_sync/dummy_adapter', __FILE__)
6
+ require File.expand_path('../cloud_encrypted_sync/progress_meter', __FILE__)
@@ -0,0 +1,34 @@
1
+ module CloudEncryptedSync
2
+ module Adapters
3
+ class Template
4
+
5
+ class << self
6
+
7
+ def inherited(subclass)
8
+ Master.register(subclass)
9
+ end
10
+
11
+ def parse_command_line_options(opts,command_line_options)
12
+ raise 'called template method: parse_command_line_options'
13
+ end
14
+
15
+ def write(data, key)
16
+ raise 'called template method: write'
17
+ end
18
+
19
+ def read(key)
20
+ raise 'called template method: read'
21
+ end
22
+
23
+ def delete(key)
24
+ raise 'called template method: delete'
25
+ end
26
+
27
+ def key_exists?(key)
28
+ raise 'called template method: key_exists?'
29
+ end
30
+
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,82 @@
1
+ require 'yaml'
2
+
3
+ module CloudEncryptedSync
4
+ class Configuration
5
+
6
+ class << self
7
+
8
+ attr_reader :option_parser
9
+
10
+ def settings
11
+ @settings ||= load_settings
12
+ end
13
+
14
+ def data_folder_path
15
+ command_line_options[:data_dir]
16
+ end
17
+
18
+ #######
19
+ private
20
+ #######
21
+
22
+ def load_settings
23
+ touch_data_folder
24
+ loaded_settings = {}
25
+ loaded_settings = YAML.load_file(config_file_path) if File.exist?(config_file_path)
26
+ loaded_settings.merge!(command_line_options)
27
+ loaded_settings = loaded_settings.inject({}) do |options, (key, value)|
28
+ options[(key.to_sym rescue key) || key] = value
29
+ options
30
+ end
31
+ loaded_settings[:sync_path] = ARGV.shift unless ARGV.empty?
32
+
33
+ if loaded_settings[:sync_path].nil?
34
+ message = "You must supply a path to a folder to sync.\n\n#{option_parser.help}"
35
+ raise IncompleteConfigurationError.new(message)
36
+ elsif loaded_settings[:encryption_key].nil? or loaded_settings[:encryption_key].empty?
37
+ message = "You must supply an encryption key.\n\n#{option_parser.help}"
38
+ raise IncompleteConfigurationError.new(message)
39
+ end
40
+
41
+ return loaded_settings
42
+ end
43
+
44
+ def touch_data_folder
45
+ FileUtils.mkdir_p(data_folder_path) unless Dir.exists?(data_folder_path)
46
+ end
47
+
48
+ def config_file_path
49
+ data_folder_path+'/config.rc.yml'
50
+ end
51
+
52
+ def command_line_options
53
+ @command_line_options ||= parse_command_line_options
54
+ end
55
+
56
+ def parse_command_line_options
57
+ executable_name = File.basename($PROGRAM_NAME)
58
+ clo = {:data_dir => "#{Etc.getpwuid.dir}/.cloud_encrypted_sync"}
59
+
60
+ @option_parser = OptionParser.new do |opts|
61
+ opts.banner = "Usage: #{executable_name} [options] /path/to/folder/to/sync"
62
+ opts.on('--data-dir PATH',"Data directory where snapshots and config file are found.") do |path|
63
+ clo[:data_dir] = path
64
+ end
65
+ opts.on('--adapter ADAPTERNAME', 'Name of cloud adapter to use.') do |adapter_name|
66
+ clo[:adapter_name] = adapter_name
67
+ clo = Master.adapters[adapter_name.to_sym].parse_command_line_options(opts,clo)
68
+ end
69
+ opts.on('--encryption-key KEY') do |key|
70
+ clo[:encryption_key] = key
71
+ end
72
+ end
73
+ @option_parser.parse!
74
+
75
+ return clo
76
+ end
77
+
78
+ end
79
+ end
80
+
81
+ class IncompleteConfigurationError < RuntimeError; end
82
+ end
@@ -0,0 +1,61 @@
1
+ require 'openssl'
2
+ require 'digest'
3
+
4
+ module CloudEncryptedSync
5
+ class Cryptographer
6
+ ALGORITHM = 'AES-256-CBC'
7
+
8
+ class << self
9
+
10
+
11
+ def encrypt_data(data)
12
+ iv = generate_random_iv
13
+ encrypted_data = crypt_data(:encrypt, iv, data)
14
+ return iv + encrypted_data
15
+ end
16
+
17
+ def decrypt_data(ivdata)
18
+ iv= ivdata.byteslice(0..15)
19
+ data = ivdata.byteslice(16..-1)
20
+ crypt_data(:decrypt, iv, data)
21
+ end
22
+
23
+ def hash_data(data)
24
+ Digest::SHA2.hexdigest(data,512)
25
+ end
26
+
27
+ def generate_random_key
28
+ initialized_cipher.random_key.unpack('H*')[0]
29
+ end
30
+
31
+ #######
32
+ private
33
+ #######
34
+
35
+ def generate_random_iv
36
+ initialized_cipher.random_iv
37
+ end
38
+
39
+ def initialized_cipher(crypt = nil)
40
+ cipher = OpenSSL::Cipher::Cipher.new(ALGORITHM)
41
+ cipher.send(crypt) if crypt
42
+ return cipher
43
+ end
44
+
45
+ def setup_cipher(crypt,iv)
46
+ cipher = initialized_cipher(crypt)
47
+ cipher.key = hash_data(Configuration.settings[:encryption_key])
48
+ cipher.iv = iv
49
+ return cipher
50
+ end
51
+
52
+ def crypt_data(direction,iv,precrypted_data)
53
+ cipher = setup_cipher(direction,iv)
54
+ crypted_data = cipher.update(precrypted_data)
55
+ crypted_data << cipher.final
56
+ return crypted_data
57
+ end
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,47 @@
1
+ module CloudEncryptedSync
2
+ module Adapters
3
+ class Dummy < Template
4
+
5
+ class << self
6
+
7
+ def write(data,key)
8
+ stored_data[key] = data
9
+ end
10
+
11
+ def parse_command_line_options(opts,command_line_options)
12
+ opts.on('--bucket BUCKETNAME', 'Name of cloud adapter to use.') do |bucket_name|
13
+ command_line_options[:bucket_name] = bucket_name
14
+ end
15
+ return command_line_options
16
+ end
17
+
18
+ def read(key)
19
+ raise "key doesn't exist" unless key_exists?(key)
20
+ stored_data[bucket_name][key]
21
+ end
22
+
23
+ def delete(key)
24
+ stored_data[bucket_name].delete(key)
25
+ end
26
+
27
+ def key_exists?(key)
28
+ stored_data[bucket_name][key] ? true : false
29
+ end
30
+
31
+ #######
32
+ private
33
+ #######
34
+
35
+ def stored_data
36
+ @stored_data ||= { bucket_name => {} }
37
+ end
38
+
39
+ def bucket_name
40
+ raise RuntimeError, Configuration.settings.inspect
41
+ Configuration.settings[:bucket_name].to_sym
42
+ end
43
+
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,248 @@
1
+ require 'find'
2
+ require 'active_support/core_ext/string'
3
+
4
+ module CloudEncryptedSync
5
+ class Master
6
+
7
+ class << self
8
+ attr_accessor :finalize_required
9
+ attr_reader :command_line_options, :adapters
10
+ attr_writer :sync_path
11
+
12
+ def register(adapter)
13
+ @adapters ||= {}
14
+ name = adapter.name.match(/([^:]+)$/)[0].underscore.to_sym
15
+ raise RegistrationError, "#{name} already registered" if @adapters[name]
16
+ @adapters[name] = adapter
17
+ end
18
+
19
+ def activate!
20
+ find_and_require_adapters
21
+ sync
22
+ end
23
+
24
+ def sync
25
+ begin
26
+ CloudEncryptedSync::Master.delete_local_files!
27
+ CloudEncryptedSync::Master.delete_remote_files!
28
+ CloudEncryptedSync::Master.pull_files!
29
+ CloudEncryptedSync::Master.push_files!
30
+ CloudEncryptedSync::Master.finalize!
31
+ rescue IncompleteConfigurationError => exception
32
+ puts exception.message
33
+ end
34
+ end
35
+
36
+ def push_files!
37
+ progress_meter = ProgressMeter.new(files_to_pull.keys.size,:label => 'Pushing Files: ')
38
+ pushed_files_counter = 0
39
+ files_to_push.each_pair do |key,relative_path|
40
+ if adapter.key_exists?(key)
41
+ #already exists. probably left over from an earlier aborted push
42
+ puts "Not Pushing (already exists): #{relative_path}"
43
+ else
44
+ puts "Pushing: #{relative_path}"
45
+ encrypt_to_adapter(File.read(full_file_path(relative_path)),key)
46
+ self.finalize_required = true
47
+ end
48
+ pushed_files_counter += 1
49
+ print progress_meter.update(pushed_files_counter)
50
+ end
51
+ end
52
+
53
+ def pull_files!
54
+ progress_meter = ProgressMeter.new(files_to_pull.keys.size,:label => 'Pulling Files: ')
55
+ pulled_files_counter = 0
56
+ files_to_pull.each_pair do |key,relative_path|
57
+ full_path = full_file_path(relative_path)
58
+ if File.exist?(full_path) and (file_key(full_path) == key)
59
+ #already exists. probably left over from an earlier aborted pull
60
+ puts "Not Pulling (already exists): #{path}"
61
+ else
62
+ Dir.mkdir(File.dirname(full_path)) unless File.exist?(File.dirname(full_path))
63
+ puts "Pulling: #{relative_path}"
64
+ begin
65
+ File.open(full_path,'w') { |file| file.write(decrypt_from_adapter(key)) }
66
+ self.finalize_required = true
67
+ rescue #AWS::S3::Errors::NoSuchKey Should provide error for adapters to raise
68
+ puts "Failed to pull #{relative_path}"
69
+ end
70
+ end
71
+ pulled_files_counter += 1
72
+ print progress_meter.update(pulled_files_counter)
73
+ end
74
+ end
75
+
76
+ def delete_remote_files!
77
+ remote_files_to_delete.each_pair do |key,path|
78
+ puts "Deleting Remote: #{path}"
79
+ adapter.delete(key)
80
+ self.finalize_required = true
81
+ end
82
+ end
83
+
84
+ def delete_local_files!
85
+ local_files_to_delete.each_pair do |key,relative_path|
86
+ full_path = full_file_path(relative_path)
87
+ if !File.exist?(full_path) or (file_key(full_path) == key)
88
+ puts "Not Deleting Local: #{relative_path}"
89
+ else
90
+ puts "Deleting Local: #{relative_path}"
91
+ File.delete(full_path)
92
+ self.finalize_required = true
93
+ end
94
+ end
95
+ end
96
+
97
+ def finalize!
98
+ if finalize_required
99
+ store_directory_hash_file
100
+ File.open(snapshot_file_path, 'w') { |file| YAML.dump(directory_hash, file) }
101
+ end
102
+ end
103
+
104
+ #######
105
+ private
106
+ #######
107
+
108
+ def find_and_require_adapters
109
+ latest_versions_of_installed_adapters.each_pair do |adapter_name,adapter_version|
110
+ require File.expand_path("../../../../cloud_encrypted_sync_#{adapter_name}_adapter-#{adapter_version}", __FILE__)
111
+ end
112
+ end
113
+
114
+ def latest_versions_of_installed_adapters
115
+ glob_path = '../../../../cloud_encrypted_sync_*_adapter-*/lib/*.rb'
116
+ Dir.glob(File.expand_path(glob_path,__FILE__)).inject({}) do |hash,adapter_path|
117
+ if adapter_path.match(/cloud_encrypted_sync_(.+)_adapter-(.+)/)
118
+ adapter_name = $1
119
+ adapter_version = $2
120
+ if hash[adapter_name].to_s < adapter_version
121
+ hash[adapter_name] = adapter_version
122
+ end
123
+ end
124
+ hash
125
+ end
126
+ end
127
+
128
+ def adapter
129
+ @adapters[Configuration.settings[:adapter_name].to_sym]
130
+ end
131
+
132
+ def encrypt_to_adapter(data,key)
133
+ adapter.write(Cryptographer.encrypt_data(data),key)
134
+ end
135
+
136
+ def decrypt_from_adapter(key)
137
+ Cryptographer.decrypt_data(adapter.read(key))
138
+ end
139
+
140
+ def directory_hash
141
+ return @directory_hash if @directory_hash
142
+ @directory_hash = {}
143
+ progress_meter = ProgressMeter.new(Dir["#{normalized_sync_path}/**/*"].length,:label => 'Compiling Directory Analysis: ')
144
+ completed_files = 0
145
+ Find.find(normalized_sync_path) do |path|
146
+ print progress_meter.update(completed_files)
147
+ if FileTest.directory?(path)
148
+ completed_files += 1
149
+ next
150
+ else
151
+ @directory_hash[file_key(path)] = relative_file_path(path)
152
+ completed_files += 1
153
+ end
154
+ end
155
+ puts
156
+ return @directory_hash
157
+ end
158
+
159
+ def directory_key
160
+ @directory_key ||= Cryptographer.hash_data(Configuration.settings[:encryption_key])
161
+ end
162
+
163
+ def normalized_sync_path
164
+ @normalized_sync_path ||= normalize_sync_path
165
+ end
166
+
167
+ def normalize_sync_path
168
+ path = Configuration.settings[:sync_path]
169
+ if path.match(/\/$/)
170
+ return path
171
+ else
172
+ return path + '/'
173
+ end
174
+ end
175
+
176
+ def last_sync_date
177
+ @last_sync_date ||= File.exist?(snapshot_file_path) ? File.stat(snapshot_file_path).ctime : nil
178
+ end
179
+
180
+ def last_sync_hash
181
+ @last_sync_hash ||= File.exist?(snapshot_file_path) ? YAML.load(File.read(snapshot_file_path)) : {}
182
+ end
183
+
184
+ def files_to_push
185
+ syncable_files_check(directory_hash,remote_directory_hash)
186
+ end
187
+
188
+ def files_to_pull
189
+ syncable_files_check(remote_directory_hash,directory_hash)
190
+ end
191
+
192
+ def remote_files_to_delete
193
+ deletable_files_check(remote_directory_hash,directory_hash)
194
+ end
195
+
196
+ def local_files_to_delete
197
+ deletable_files_check(directory_hash,remote_directory_hash)
198
+ end
199
+
200
+ def remote_directory_hash
201
+ @remote_directory_hash ||= begin
202
+ YAML.parse(decrypt_from_adapter(directory_key)).to_ruby
203
+ rescue #AWS::S3::Errors::NoSuchKey should provide error for adapters to raise
204
+ {}
205
+ end
206
+ end
207
+
208
+ def store_directory_hash_file
209
+ @directory_hash = nil #force re-compile before pushing to remote
210
+ encrypt_to_adapter(directory_hash.to_yaml,directory_key)
211
+ end
212
+
213
+ def deletable_files_check(source_hash,comparison_hash)
214
+ combined_file_check(source_hash,comparison_hash,true)
215
+ end
216
+
217
+ def syncable_files_check(source_hash,comparison_hash)
218
+ combined_file_check(source_hash,comparison_hash,false)
219
+ end
220
+
221
+ def combined_file_check(source_hash,comparison_hash,last_sync_has_key)
222
+ source_hash.select{|k,v| !comparison_hash.has_key?(k) and (last_sync_has_key ? last_sync_hash.has_key?(k) : !last_sync_hash.has_key?(k)) }
223
+ end
224
+
225
+ def snapshot_file_path
226
+ "#{Configuration.data_folder_path}/#{snapshot_filename}"
227
+ end
228
+
229
+ def snapshot_filename
230
+ "#{normalized_sync_path.gsub(/[^A-Za-z0-9]/,'_')}.snapshot.yml"
231
+ end
232
+
233
+ def file_key(full_path)
234
+ Cryptographer.hash_data(relative_file_path(full_path) + File.open(full_path).read).to_s
235
+ end
236
+
237
+ def relative_file_path(full_path)
238
+ full_path.gsub(normalized_sync_path,'')
239
+ end
240
+
241
+ def full_file_path(relative_path)
242
+ normalized_sync_path+'/'+relative_path
243
+ end
244
+ end
245
+ end
246
+
247
+ class RegistrationError < RuntimeError; end
248
+ end
@@ -0,0 +1,43 @@
1
+ module CloudEncryptedSync
2
+ class ProgressMeter
3
+ attr_accessor :completed_index
4
+ attr_reader :max_index, :start_time, :label
5
+
6
+ def initialize(max_index,options = {})
7
+ @max_index = max_index.to_f
8
+ @label = options[:label] || ''
9
+ @completed_index = 0.0
10
+ @start_time = Time.now
11
+ end
12
+
13
+ def to_s
14
+ sprintf("\r#{label}%0.1f%% Complete. Time Remaining %s", percent_completed, estimated_time_remaining.strftime('%M:%S'))
15
+ end
16
+
17
+ def percent_completed
18
+ (completed_index/max_index)*100
19
+ end
20
+
21
+ def time_elapsed
22
+ Time.now - start_time
23
+ end
24
+
25
+ def estimated_finish_time
26
+ if percent_completed > 0
27
+ start_time + ((100/percent_completed)*time_elapsed)
28
+ else
29
+ start_time + 3600
30
+ end
31
+ end
32
+
33
+ def estimated_time_remaining
34
+ Time.at(estimated_finish_time - Time.now)
35
+ end
36
+
37
+ def update(completed_index)
38
+ self.completed_index = completed_index
39
+ return self
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,3 @@
1
+ module CloudEncryptedSync
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,63 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'simplecov'
4
+ require 'fakefs/safe'
5
+ require 'active_support/test_case'
6
+ require 'test/unit'
7
+ require 'etc'
8
+
9
+ SimpleCov.start
10
+
11
+ require 'cloud_encrypted_sync'
12
+
13
+ module CloudEncryptedSync
14
+ class ActiveSupport::TestCase
15
+
16
+ setup :activate_fake_fs
17
+ setup :preset_environment
18
+ setup :capture_stdout
19
+ teardown :deactivate_fake_fs
20
+ teardown :release_stdout
21
+
22
+ def preset_environment
23
+ Configuration.instance_variable_set(:@settings,nil)
24
+ Configuration.instance_variable_set(:@command_line_options,nil)
25
+ Master.instance_variable_set(:@directory_hash, nil)
26
+ FileUtils.mkdir_p test_source_folder
27
+ FileUtils.mkdir_p test_source_folder + '/test_sub_folder'
28
+ File.open(test_source_folder + '/test_sub_folder/test_file_one.txt', 'w') do |test_file|
29
+ test_file.write('Test File One')
30
+ end
31
+ end
32
+
33
+ def test_source_folder
34
+ @test_source_folder ||= File.expand_path('../test_folder', __FILE__)
35
+ end
36
+
37
+ def activate_fake_fs
38
+ FakeFS.activate!
39
+ FakeFS::FileSystem.clear
40
+ end
41
+
42
+ def deactivate_fake_fs
43
+ FakeFS.deactivate!
44
+ end
45
+
46
+ #Capture STDOUT from program for testing and not cluttering test output
47
+ def capture_stdout
48
+ @stdout = $stdout
49
+ $stdout = StringIO.new
50
+ end
51
+
52
+ def release_stdout
53
+ $stdout = @stdout
54
+ end
55
+
56
+ #Redirect intentional puts from within tests to the real STDOUT for troublshooting purposes.
57
+ def puts(*args)
58
+ @stdout.puts(*args)
59
+ end
60
+
61
+ end
62
+ end
63
+ require 'mocha'
@@ -0,0 +1,33 @@
1
+ require 'test_helper'
2
+
3
+ module CloudEncryptedSync
4
+ class ConfigurationTest < ActiveSupport::TestCase
5
+
6
+ def setup
7
+ Configuration.instance_variable_set(:@command_line_options,nil)
8
+ Configuration.instance_variable_set(:@settings,nil)
9
+ Configuration.instance_variable_set(:@option_parser,nil)
10
+ Object.send(:remove_const,:ARGV) #if defined?(::ARGV)
11
+ end
12
+
13
+ test 'should parse command line options' do
14
+ ::ARGV = '--adapter dummy --bucket foobar --data-dir ~/test/folder --encryption-key somestringofcharacters /some/path'.split(/\s/)
15
+ settings = Configuration.settings
16
+ assert_equal('dummy',settings[:adapter_name])
17
+ assert_equal('~/test/folder',settings[:data_dir])
18
+ assert_equal('somestringofcharacters',settings[:encryption_key])
19
+ assert_equal('foobar',settings[:bucket_name])
20
+ end
21
+
22
+ test 'should gracefully fail without path in ARGV' do
23
+ ::ARGV = '--adapter dummy --bucket foobar'.split(/\s/)
24
+ assert_raise(IncompleteConfigurationError) { Configuration.settings }
25
+ end
26
+
27
+ test 'should gracefully fail when not provided encryption_key and vector provided path in ARGV' do
28
+ ::ARGV = '--adapter dummy --bucket foobar /some/path/to/sync'.split(/\s/)
29
+ assert_raise(IncompleteConfigurationError) { Configuration.settings }
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ require 'test_helper'
2
+
3
+ module CloudEncryptedSync
4
+ class CryptographerTest < ActiveSupport::TestCase
5
+
6
+ def setup
7
+ Configuration.stubs(:settings).returns({
8
+ :encryption_key => 'asdf',
9
+ :initialization_vector => 'qwerty',
10
+ :adapter_name => 'dummy',
11
+ :bucket => "test-bucket",
12
+ :data_dir => "#{Etc.getpwuid.dir}/.cloud_encrypted_sync",
13
+ :sync_path => test_source_folder
14
+ })
15
+ end
16
+
17
+ test 'should hash data' do
18
+ hash = Cryptographer.hash_data('abc123')
19
+ assert_equal('c70b5dd9ebfb6f51d09d4132b7170c9d20750a7852f00680f65658f0310e810056e6763c34c9a00b0e940076f54495c169fc2302cceb312039271c43469507dc',hash)
20
+ end
21
+
22
+ test 'should encrypt and decrypt data' do
23
+ unencrypted_data = "123xyz"
24
+ encrypted_data = Cryptographer.encrypt_data(unencrypted_data)
25
+ decrypted_data = Cryptographer.decrypt_data(encrypted_data)
26
+ assert_equal(unencrypted_data,decrypted_data)
27
+ end
28
+
29
+ test 'test should generate random key' do
30
+ assert_equal(String,Cryptographer.generate_random_key.class)
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,155 @@
1
+ require 'test_helper'
2
+ require 'yaml'
3
+
4
+ module CloudEncryptedSync
5
+ class MasterTest < ActiveSupport::TestCase
6
+
7
+ def setup
8
+ Configuration.stubs(:settings).returns({
9
+ :encryption_key => 'asdf',
10
+ :initialization_vector => 'qwerty',
11
+ :adapter_name => 'dummy',
12
+ :bucket => "test-bucket",
13
+ :sync_path => test_source_folder
14
+ })
15
+ Configuration.stubs(:data_folder_path).returns("#{Etc.getpwuid.dir}/.cloud_encrypted_sync")
16
+ end
17
+
18
+ test 'should generate directory hash' do
19
+ assert_equal('',$stdout.string)
20
+ hash = Master.send(:directory_hash)
21
+ assert_match(/\% Complete/,$stdout.string)
22
+ assert_equal(1,hash.keys.size)
23
+ assert_equal('test_sub_folder/test_file_one.txt',hash[hash.keys.first])
24
+ end
25
+
26
+ test 'should_return_nil_if_never_synced_before' do
27
+ Master.stubs(:snapshot_file_path).returns('/non/existant/file')
28
+ assert_equal(nil,Master.send(:last_sync_date))
29
+ end
30
+
31
+ test 'should want to push everything on first run with local files and empty remote' do
32
+ Master.stubs(:remote_directory_hash).returns({})
33
+ Master.stubs(:directory_hash).returns({"old_file_key"=>"test_sub_folder/old_file.txt"})
34
+ Master.stubs(:last_sync_hash).returns({})
35
+ assert_equal(Master.directory_hash,Master.send(:files_to_push))
36
+ end
37
+
38
+ test 'should encrypt when writing' do
39
+ precrypted_data = File.read(test_source_folder + '/test_sub_folder/test_file_one.txt')
40
+ key = Cryptographer.hash_data('test_file_key')
41
+ Adapters::Dummy.expects(:write).with(anything,key).returns(true)
42
+ Master.send(:encrypt_to_adapter,precrypted_data,key)
43
+ end
44
+
45
+ test 'should decrypt_when_reading' do
46
+ precrypted_data = File.read(test_source_folder + '/test_sub_folder/test_file_one.txt')
47
+ encrypted_data = Cryptographer.encrypt_data(precrypted_data)
48
+ key = Cryptographer.hash_data('test_file_key')
49
+ Adapters::Dummy.expects(:read).with(key).returns(encrypted_data)
50
+ assert_equal(precrypted_data,Master.send(:decrypt_from_adapter,key))
51
+ end
52
+
53
+ test 'should push files' do
54
+ Master.stubs(:remote_directory_hash).returns({})
55
+ Master.stubs(:last_sync_hash).returns({})
56
+ Adapters::Dummy.stubs(:key_exists?).returns(false)
57
+ Adapters::Dummy.expects(:write).with(any_parameters).returns(true)
58
+ assert_equal('',$stdout.string)
59
+ Master.push_files!
60
+ assert_match(/\% Complete/,$stdout.string)
61
+ end
62
+
63
+ test 'should want to pull everything on first run with remote files and empty local' do
64
+ Master.stubs(:remote_directory_hash).returns({'new_file_key' => 'test_sub_folder/new_file.txt'})
65
+ Master.stubs(:directory_hash).returns({})
66
+ Master.stubs(:last_sync_hash).returns({})
67
+ assert_equal({'new_file_key' => 'test_sub_folder/new_file.txt'},Master.send(:files_to_pull))
68
+ end
69
+
70
+ test 'should pull files' do
71
+ Master.stubs(:remote_directory_hash).returns({'new_file_key' => 'test_sub_folder/new_file.txt'})
72
+ Master.stubs(:directory_hash).returns({})
73
+ Master.stubs(:last_sync_hash).returns({})
74
+ Adapters::Dummy.expects(:read).with('new_file_key').returns(Cryptographer.encrypt_data('foobar'))
75
+ assert_equal('',$stdout.string)
76
+ assert_difference('Dir["#{test_source_folder}/**/*"].length') do
77
+ Master.pull_files!
78
+ end
79
+ assert_match(/\% Complete/,$stdout.string)
80
+ end
81
+
82
+ test 'should only want to push new files on later run' do
83
+ Master.stubs(:remote_directory_hash).returns({'old_file_key' => 'test_sub_folder/old_file.txt'})
84
+ Master.stubs(:directory_hash).returns({'new_file_key' => 'test_sub_folder/new_file.txt', 'old_file_key' => 'test_sub_folder/old_file.txt'})
85
+ Master.stubs(:last_sync_hash).returns({'old_file_key' => 'test_sub_folder/old_file.txt'})
86
+ assert_equal({'new_file_key' => 'test_sub_folder/new_file.txt'},Master.send(:files_to_push))
87
+ end
88
+
89
+ test 'should want to pull new files from cloud' do
90
+ Master.stubs(:remote_directory_hash).returns({'new_file_key' => 'test_sub_folder/new_file.txt', 'old_file_key' => 'test_sub_folder/old_file.txt'})
91
+ Master.stubs(:directory_hash).returns({'old_file_key' => 'test_sub_folder/old_file.txt'})
92
+ Master.stubs(:last_sync_hash).returns({'old_file_key' => 'test_sub_folder/old_file.txt'})
93
+ assert_equal({'new_file_key' => 'test_sub_folder/new_file.txt'},Master.send(:files_to_pull))
94
+ end
95
+
96
+ test 'should want to delete locally missing files from cloud' do
97
+ Master.stubs(:remote_directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'})
98
+ Master.stubs(:directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt'})
99
+ Master.stubs(:last_sync_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'})
100
+ assert_equal({'deleted_file_key' => 'test_sub_folder/deleted_file.txt'},Master.send(:remote_files_to_delete))
101
+ end
102
+
103
+ test 'should delete files from cloud' do
104
+ Master.stubs(:remote_directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'})
105
+ Master.stubs(:directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt'})
106
+ Master.stubs(:last_sync_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'})
107
+ Adapters::Dummy.expects(:delete).with('deleted_file_key').returns(true)
108
+ Master.delete_remote_files!
109
+ end
110
+
111
+ test 'should want to delete appropriate files locally' do
112
+ Master.stubs(:remote_directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt'})
113
+ Master.stubs(:directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'})
114
+ Master.stubs(:last_sync_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'})
115
+ assert_equal({'deleted_file_key' => 'test_sub_folder/deleted_file.txt'},Master.send(:local_files_to_delete))
116
+ end
117
+
118
+ test 'should delete local files' do
119
+ Master.stubs(:remote_directory_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt'})
120
+ Master.stubs(:last_sync_hash).returns({'saved_file_key' => 'test_sub_folder/saved_file.txt', 'deleted_file_key' => 'test_sub_folder/deleted_file.txt'}.merge(Master.send(:directory_hash)))
121
+ assert_difference('Dir["#{test_source_folder}/**/*"].length',-1) do
122
+ Master.delete_local_files!
123
+ end
124
+ end
125
+
126
+ test 'should finalize' do
127
+ FileUtils.mkdir_p(Configuration.data_folder_path)
128
+ sample_directory_hash = {'sample_file_key' => 'test_sub_folder/sample_file.txt'}
129
+ Master.instance_variable_set(:@finalize_required,true)
130
+ Master.stubs(:directory_hash).returns(sample_directory_hash)
131
+ Adapters::Dummy.expects(:write).with(anything,Master.send(:directory_key)).returns(true)
132
+ Master.finalize!
133
+ end
134
+
135
+ test 'should decrypt remote directory file' do
136
+ #setup mock data
137
+ sample_directory_hash = {'sample_file_key' => 'test_sub_folder/sample_file.txt'}
138
+ encrypted_directory_hash = Cryptographer.encrypt_data(sample_directory_hash.to_yaml)
139
+ Adapters::Dummy.stubs(:read).with(Master.send(:directory_key)).returns(encrypted_directory_hash)
140
+
141
+ #do actual test
142
+ decrypted_remote_hash = Master.send(:remote_directory_hash)
143
+ assert_equal(sample_directory_hash,decrypted_remote_hash)
144
+ end
145
+
146
+ test 'should puts error message to stdout' do
147
+ Configuration.stubs(:settings).raises(IncompleteConfigurationError,'test message')
148
+ assert_equal('',$stdout.string)
149
+ Master.expects(:pull_files).never
150
+ Master.sync
151
+ assert_match(/test message/,$stdout.string)
152
+ end
153
+
154
+ end
155
+ end
@@ -0,0 +1,40 @@
1
+ require 'test_helper'
2
+
3
+ module CloudEncryptedSync
4
+ class ProgressMeterTest < ActiveSupport::TestCase
5
+
6
+ def setup
7
+ @progress_meter = ProgressMeter.new(4)
8
+ @progress_meter.instance_variable_set(:@start_time,Time.now-42)
9
+ end
10
+
11
+ test 'should calculate percent completed' do
12
+ @progress_meter.update(1)
13
+ assert_equal(25,@progress_meter.percent_completed)
14
+ end
15
+
16
+ test 'should calculate time elapsed' do
17
+ assert_in_delta(42,@progress_meter.time_elapsed,0.01)
18
+ end
19
+
20
+ test 'should estimate finish time' do
21
+ @progress_meter.update(1)
22
+ assert_in_delta(Time.now+(42*3),@progress_meter.estimated_finish_time,0.01)
23
+ end
24
+
25
+ test 'should estimate time remaining' do
26
+ @progress_meter.update(1)
27
+ assert_in_delta((42*3),@progress_meter.estimated_time_remaining.to_f,0.01)
28
+ end
29
+
30
+ test 'should update progress and return self' do
31
+ assert_difference('@progress_meter.completed_index',2) do
32
+ assert_equal(@progress_meter,@progress_meter.update(2))
33
+ end
34
+ end
35
+
36
+ test 'should render string' do
37
+ assert_match(/0\% Complete/,@progress_meter.to_s)
38
+ end
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cloud_encrypted_sync
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jonathan S. Garvin
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-11-14 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: mocha
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: simplecov
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: fakefs
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: activesupport
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ description: Encrypted sync of folder contents to/from cloud storage with user controlled
79
+ encryption keys.
80
+ email:
81
+ - jon@5valleys.com
82
+ executables:
83
+ - ces
84
+ extensions: []
85
+ extra_rdoc_files: []
86
+ files:
87
+ - .gitignore
88
+ - Gemfile
89
+ - Gemfile.lock
90
+ - LICENSE
91
+ - README.md
92
+ - Rakefile
93
+ - bin/ces
94
+ - cloud_encrypted_sync.gemspec
95
+ - lib/cloud_encrypted_sync.rb
96
+ - lib/cloud_encrypted_sync/adapter_template.rb
97
+ - lib/cloud_encrypted_sync/configuration.rb
98
+ - lib/cloud_encrypted_sync/cryptographer.rb
99
+ - lib/cloud_encrypted_sync/dummy_adapter.rb
100
+ - lib/cloud_encrypted_sync/master.rb
101
+ - lib/cloud_encrypted_sync/progress_meter.rb
102
+ - lib/cloud_encrypted_sync/version.rb
103
+ - test/test_helper.rb
104
+ - test/unit/configuration_test.rb
105
+ - test/unit/cryptographer_test.rb
106
+ - test/unit/master_test.rb
107
+ - test/unit/progress_meter_test.rb
108
+ homepage: https://github.com/jsgarvin/cloud_encrypted_sync
109
+ licenses: []
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ none: false
116
+ requirements:
117
+ - - ! '>='
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubyforge_project:
128
+ rubygems_version: 1.8.24
129
+ signing_key:
130
+ specification_version: 3
131
+ summary: Encrypted sync of folder contents to/from cloud storage.
132
+ test_files:
133
+ - test/test_helper.rb
134
+ - test/unit/configuration_test.rb
135
+ - test/unit/cryptographer_test.rb
136
+ - test/unit/master_test.rb
137
+ - test/unit/progress_meter_test.rb