keyrack 0.3.0.pre → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/Gemfile +8 -15
  4. data/Guardfile +6 -0
  5. data/LICENSE.txt +4 -2
  6. data/Rakefile +2 -56
  7. data/keyrack.gemspec +22 -104
  8. data/lib/keyrack.rb +9 -1
  9. data/lib/keyrack/database.rb +64 -98
  10. data/lib/keyrack/event.rb +13 -0
  11. data/lib/keyrack/exceptions.rb +4 -0
  12. data/lib/keyrack/group.rb +225 -0
  13. data/lib/keyrack/migrator.rb +45 -0
  14. data/lib/keyrack/runner.rb +98 -44
  15. data/lib/keyrack/site.rb +92 -0
  16. data/lib/keyrack/ui/console.rb +234 -95
  17. data/lib/keyrack/utils.rb +0 -18
  18. data/lib/keyrack/version.rb +3 -0
  19. data/test/fixtures/database-3.dat +0 -0
  20. data/test/helper.rb +11 -1
  21. data/test/integration/test_interactive_console.rb +139 -0
  22. data/test/unit/store/test_filesystem.rb +21 -0
  23. data/test/unit/store/test_ssh.rb +32 -0
  24. data/test/unit/test_database.rb +201 -0
  25. data/test/unit/test_event.rb +41 -0
  26. data/test/unit/test_group.rb +703 -0
  27. data/test/unit/test_migrator.rb +105 -0
  28. data/test/unit/test_runner.rb +407 -0
  29. data/test/unit/test_site.rb +181 -0
  30. data/test/unit/test_store.rb +13 -0
  31. data/test/unit/test_utils.rb +8 -0
  32. data/test/unit/ui/test_console.rb +495 -0
  33. metadata +98 -94
  34. data/Gemfile.lock +0 -45
  35. data/TODO +0 -4
  36. data/VERSION +0 -1
  37. data/features/console.feature +0 -103
  38. data/features/step_definitions/keyrack_steps.rb +0 -61
  39. data/features/support/env.rb +0 -16
  40. data/test/fixtures/aes +0 -0
  41. data/test/fixtures/config.yml +0 -4
  42. data/test/fixtures/id_rsa +0 -54
  43. data/test/keyrack/store/test_filesystem.rb +0 -25
  44. data/test/keyrack/store/test_ssh.rb +0 -36
  45. data/test/keyrack/test_database.rb +0 -111
  46. data/test/keyrack/test_runner.rb +0 -111
  47. data/test/keyrack/test_store.rb +0 -15
  48. data/test/keyrack/test_utils.rb +0 -41
  49. data/test/keyrack/ui/test_console.rb +0 -308
@@ -0,0 +1,13 @@
1
+ module Keyrack
2
+ class Event
3
+ attr_reader :owner, :name, :parent
4
+ attr_accessor :attribute_name, :previous_value, :new_value,
5
+ :collection_name, :object
6
+
7
+ def initialize(owner, name, parent = nil)
8
+ @owner = owner
9
+ @name = name
10
+ @parent = parent
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,4 @@
1
+ module Keyrack
2
+ class SiteError < Exception; end
3
+ class GroupError < Exception; end
4
+ end
@@ -0,0 +1,225 @@
1
+ module Keyrack
2
+ class Group < Hash
3
+ def initialize(arg = nil)
4
+ case arg
5
+ when String
6
+ self['name'] = arg
7
+ self['sites'] = []
8
+ self['groups'] = {}
9
+ when Hash
10
+ load(arg)
11
+ when nil
12
+ @uninitialized = true
13
+ end
14
+
15
+ @after_event = []
16
+ end
17
+
18
+ def load(hash)
19
+ @loading = true
20
+
21
+ if !hash.has_key?('name')
22
+ raise ArgumentError, "hash is missing the 'name' key"
23
+ end
24
+ if !hash['name'].is_a?(String)
25
+ raise ArgumentError, "name is not a String"
26
+ end
27
+ self['name'] = hash['name']
28
+
29
+ if !hash.has_key?('sites')
30
+ raise ArgumentError, "hash is missing the 'sites' key"
31
+ end
32
+ if !hash['sites'].is_a?(Array)
33
+ raise ArgumentError, "sites is not an Array"
34
+ end
35
+
36
+ if !hash.has_key?('groups')
37
+ raise ArgumentError, "hash is missing the 'groups' key"
38
+ end
39
+ if !hash['groups'].is_a?(Hash)
40
+ raise ArgumentError, "groups is not a Hash"
41
+ end
42
+
43
+ self['sites'] = []
44
+ hash['sites'].each_with_index do |site_hash, site_index|
45
+ if !site_hash.is_a?(Hash)
46
+ raise ArgumentError, "site #{site_index} is not a Hash"
47
+ end
48
+
49
+ begin
50
+ site = Site.new(site_hash)
51
+ add_site_without_callbacks(site)
52
+ rescue SiteError => e
53
+ raise ArgumentError, "site #{site_index} is not valid: #{e.message}"
54
+ end
55
+ end
56
+
57
+ self['groups'] = {}
58
+ hash['groups'].each_pair do |group_name, group_hash|
59
+ if !group_name.is_a?(String)
60
+ raise ArgumentError, "group key is not a String"
61
+ end
62
+ if !group_hash.is_a?(Hash)
63
+ raise ArgumentError, "group value for #{group_name.inspect} is not a Hash"
64
+ end
65
+
66
+ begin
67
+ group = Group.new(group_hash)
68
+ add_group_without_callbacks(group)
69
+ rescue ArgumentError => e
70
+ raise ArgumentError, "group #{group_name.inspect} is not valid: #{e.message}"
71
+ end
72
+
73
+ if group.name != group_name
74
+ raise ArgumentError, "group name mismatch: #{group_name.inspect} != #{group.name.inspect}"
75
+ end
76
+ end
77
+
78
+ @loading = false
79
+ @uninitialized = false
80
+ end
81
+
82
+ def change_attribute(name, value)
83
+ event = Event.new(self, 'change')
84
+ event.attribute_name = name
85
+ event.previous_value = self[name]
86
+ event.new_value = value
87
+
88
+ self[name] = value
89
+ trigger(event)
90
+ end
91
+
92
+ def name
93
+ self['name']
94
+ end
95
+
96
+ def name=(name)
97
+ change_attribute('name', name)
98
+ end
99
+
100
+ def sites
101
+ self['sites']
102
+ end
103
+
104
+ def groups
105
+ self['groups']
106
+ end
107
+
108
+ def add_site(site)
109
+ raise "add_site is not allowed until Group is initialized" if @uninitialized && !@loading
110
+ add_site_without_callbacks(site)
111
+
112
+ event = Event.new(self, 'add')
113
+ event.collection_name = 'sites'
114
+ event.object = site
115
+ trigger(event)
116
+ end
117
+
118
+ def site(index)
119
+ sites[index]
120
+ end
121
+
122
+ def remove_site(site)
123
+ raise "remove_site is not allowed until Group is initialized" if @uninitialized && !@loading
124
+ index = sites.index(site)
125
+ if index.nil?
126
+ raise GroupError, "site doesn't exist"
127
+ end
128
+ site = sites.delete_at(index)
129
+
130
+ event = Event.new(self, 'remove')
131
+ event.collection_name = 'sites'
132
+ event.object = site
133
+ trigger(event)
134
+ end
135
+
136
+ def add_group(group)
137
+ raise "add_group is not allowed until Group is initialized" if @uninitialized && !@loading
138
+ add_group_without_callbacks(group)
139
+
140
+ event = Event.new(self, 'add')
141
+ event.collection_name = 'groups'
142
+ event.object = group
143
+ trigger(event)
144
+ end
145
+
146
+ def remove_group(group_name)
147
+ raise "remove_group is not allowed until Group is initialized" if @uninitialized && !@loading
148
+ if !groups.has_key?(group_name)
149
+ raise GroupError, "group doesn't exist"
150
+ end
151
+ group = groups.delete(group_name)
152
+
153
+ event = Event.new(self, 'remove')
154
+ event.collection_name = 'groups'
155
+ event.object = group
156
+ trigger(event)
157
+ end
158
+
159
+ def group(group_name)
160
+ groups[group_name]
161
+ end
162
+
163
+ def group_names
164
+ groups.keys
165
+ end
166
+
167
+ def after_event(&block)
168
+ @after_event << block
169
+ end
170
+
171
+ def encode_with(coder)
172
+ coder.represent_map(nil, self)
173
+ end
174
+
175
+ private
176
+
177
+ def add_site_without_callbacks(site)
178
+ if !site.is_a?(Site)
179
+ raise GroupError, "site is not a Site"
180
+ end
181
+ if sites.include?(site)
182
+ raise GroupError,
183
+ "site (#{site.name.inspect}, #{site.username.inspect}) already exists"
184
+ end
185
+ sites << site
186
+ add_site_hooks_for(site)
187
+ end
188
+
189
+ def add_group_without_callbacks(group)
190
+ if !group.is_a?(Group)
191
+ raise GroupError, "group is not a Group"
192
+ end
193
+ if groups.has_key?(group.name)
194
+ raise GroupError, "group already exists"
195
+ end
196
+ groups[group.name] = group
197
+ add_group_hooks_for(group)
198
+ end
199
+
200
+ def trigger(event)
201
+ @after_event.each do |block|
202
+ block.call(event)
203
+ end
204
+ end
205
+
206
+ def add_site_hooks_for(site)
207
+ site.after_event do |site_event|
208
+ trigger(Event.new(self, 'change', site_event))
209
+ end
210
+ end
211
+
212
+ def add_group_hooks_for(group)
213
+ group.after_event do |group_event|
214
+ if group_event.name == 'change' && group_event.attribute_name == 'name'
215
+ key, value = self.groups.find { |(k, v)| v.equal?(group) }
216
+ if key
217
+ self['groups'][group.name] = self['groups'].delete(key)
218
+ end
219
+ end
220
+
221
+ trigger(Event.new(self, 'change', group_event))
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,45 @@
1
+ module Keyrack
2
+ # Migrate databases from one version to the next.
3
+ class Migrator
4
+ def self.run(database)
5
+ new(database).run
6
+ end
7
+
8
+ def initialize(database)
9
+ @database = database
10
+ @version = database['version']
11
+ end
12
+
13
+ def run
14
+ case @version
15
+ when 3
16
+ migrate_3_to_4(@database.clone)
17
+ when 4
18
+ @database
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def migrate_3_to_4(database)
25
+ groups = [database['groups']['top']]
26
+ until groups.empty?
27
+ group = groups.pop
28
+ group['sites'] =
29
+ group['sites'].inject([]) do |arr, (site_name, site_hash)|
30
+ site_hash['logins'].each_pair do |username, password|
31
+ arr.push({
32
+ 'name' => site_name,
33
+ 'username' => username,
34
+ 'password' => password
35
+ })
36
+ end
37
+ arr
38
+ end
39
+ groups.push(*group['groups'].values)
40
+ end
41
+ database['version'] = 4
42
+ database
43
+ end
44
+ end
45
+ end
@@ -1,81 +1,135 @@
1
1
  module Keyrack
2
2
  class Runner
3
3
  def initialize(argv)
4
- @config_path = "~/.keyrack"
5
- OptionParser.new do |opts|
6
- opts.on("-d", "--directory [PATH]", "Specify configuration path (Default: #{@config_path}") do |f|
7
- @config_path = f
4
+ opts = { :path => "~/.keyrack" }
5
+ OptionParser.new do |optparse|
6
+ optparse.on("-d", "--directory [PATH]", "Specify configuration path (Default: #{@config_path}") do |path|
7
+ opts[:path] = path
8
8
  end
9
9
  end.parse(argv)
10
- @config_path = File.expand_path(@config_path)
10
+ @config_path = File.expand_path(opts[:path])
11
11
  @ui = UI::Console.new
12
12
 
13
+ database_exists = false
13
14
  if Dir.exist?(@config_path)
14
- @options = YAML.load_file(File.join(@config_path, "config"))
15
- password = @ui.get_password
16
- rsa_key = Utils.open_rsa_key(File.expand_path(@options['rsa'], @config_path), password)
17
- aes_data = Utils.open_aes_data(File.expand_path(@options['aes'], @config_path), rsa_key)
18
- else
19
- Dir.mkdir(@config_path)
15
+ config_filename = File.join(@config_path, "config.yml")
16
+ if File.exist?(config_filename)
17
+ database_exists = true
18
+ @options = YAML.load_file(config_filename)
19
+ password = @ui.get_password
20
+ end
21
+ end
22
+
23
+ if !database_exists
24
+ FileUtils.mkdir_p(@config_path)
20
25
  @options = {}
21
26
  @ui.display_first_time_notice
22
27
 
23
- # RSA
24
- rsa_options = @ui.rsa_setup
25
- rsa_key, rsa_pem = Utils.generate_rsa_key(rsa_options['password'])
26
- rsa_path = File.expand_path(rsa_options['path'], @config_path)
27
- File.open(rsa_path, 'w') { |f| f.write(rsa_pem) }
28
- @options['rsa'] = rsa_path
29
-
30
- # AES
31
- aes_data = {
32
- 'key' => Utils.generate_aes_key,
33
- 'iv' => Utils.generate_aes_key
34
- }
35
- dump = Marshal.dump(aes_data)
36
- aes_path = File.expand_path('aes', @config_path)
37
- File.open(aes_path, 'w') { |f| f.write(rsa_key.public_encrypt(dump)) }
38
- @options['aes'] = aes_path
28
+ # Password
29
+ password = @ui.password_setup
39
30
 
40
31
  # Store
41
- store_options = @ui.store_setup
42
- if store_options['type'] == 'filesystem'
43
- store_options['path'] = File.expand_path(store_options['path'], @config_path)
44
- end
45
- @options['store'] = store_options
32
+ @options['store'] = @ui.store_setup
46
33
 
47
34
  # Write out config
48
- File.open(File.expand_path('config', @config_path), 'w') { |f| f.print(@options.to_yaml) }
35
+ File.open(File.expand_path('config.yml', @config_path), 'w') { |f| f.print(@options.to_yaml) }
36
+ end
37
+
38
+ # Expand relative paths, using config_path as parent
39
+ if @options['store']['type'] == 'filesystem' &&
40
+ @options['store'].has_key?('path')
41
+
42
+ @options['store']['path'] =
43
+ File.expand_path(@options['store']['path'], @config_path)
49
44
  end
45
+
50
46
  store = Store[@options['store']['type']].new(@options['store'].reject { |k, _| k == 'type' })
51
- @database = Database.new(aes_data['key'], aes_data['iv'], store)
52
- @ui.database = @database
47
+ @database = Database.new(password, store)
53
48
  main_loop
54
49
  end
55
50
 
56
51
  def main_loop
57
- options = {}
52
+ group_tree = [@database.top_group]
58
53
  loop do
59
- choice = @ui.menu(options)
54
+ current_group = group_tree.last
55
+ menu_options = {
56
+ :group => current_group,
57
+ :at_top => at_top?(current_group),
58
+ :dirty => @database.dirty?,
59
+ :enable_up => group_tree.length > 2
60
+ }
61
+ choice = @ui.menu(menu_options)
60
62
 
61
63
  case choice
62
64
  when :new
63
65
  result = @ui.get_new_entry
64
- @database.add(result[:site], result[:username], result[:password], options)
65
- when :delete
66
- @ui.delete_entry(options)
66
+ next if result.nil?
67
+
68
+ new_site = Site.new(*result.values_at(:site, :username, :password))
69
+ if site = current_group.sites.find { |s| s == new_site }
70
+ if @ui.confirm_overwrite_entry(site)
71
+ site.password = new_site.password
72
+ end
73
+ else
74
+ current_group.add_site(new_site)
75
+ end
76
+ when :edit
77
+ result = @ui.choose_entry_to_edit(current_group)
78
+ next if result.nil?
79
+ site = result[:site]
80
+
81
+ loop do
82
+ which = @ui.edit_entry(site)
83
+ case which
84
+ when :change_username
85
+ new_username = @ui.change_username(site.username)
86
+ if new_username
87
+ site.username = new_username
88
+ end
89
+ when :change_password
90
+ new_password = @ui.get_new_password
91
+ if new_password
92
+ site.password = new_password
93
+ end
94
+ when :delete
95
+ if @ui.confirm_delete_entry(site)
96
+ current_group.remove_site(site)
97
+ break
98
+ end
99
+ when nil
100
+ break
101
+ end
102
+ end
67
103
  when :new_group
68
- options = @ui.get_new_group
104
+ group_name = @ui.get_new_group
105
+ group = Group.new(group_name)
106
+ current_group.add_group(group)
107
+ group_tree << group
69
108
  when :save
70
- @database.save
109
+ password = @ui.get_password
110
+ if !@database.save(password)
111
+ @ui.display_invalid_password_notice
112
+ end
71
113
  when :quit
72
114
  break
73
115
  when Hash
74
- options = choice
116
+ if choice[:group]
117
+ group_tree << choice[:group]
118
+ end
75
119
  when :top
76
- options = {}
120
+ while group_tree.length > 1
121
+ group_tree.pop
122
+ end
123
+ when :up
124
+ group_tree.pop
77
125
  end
78
126
  end
79
127
  end
128
+
129
+ private
130
+
131
+ def at_top?(group)
132
+ group.equal?(@database.top_group)
133
+ end
80
134
  end
81
135
  end