s7n 0.1.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.
- data/.gitignore +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +28 -0
- data/LICENCE +24 -0
- data/README.rdoc +54 -0
- data/Rakefile +67 -0
- data/bin/s7ncli +23 -0
- data/lib/s7n.rb +12 -0
- data/lib/s7n/action.rb +7 -0
- data/lib/s7n/attribute.rb +226 -0
- data/lib/s7n/cipher.rb +110 -0
- data/lib/s7n/configuration.rb +41 -0
- data/lib/s7n/entry.rb +106 -0
- data/lib/s7n/entry_collection.rb +44 -0
- data/lib/s7n/entry_template.rb +10 -0
- data/lib/s7n/exception.rb +116 -0
- data/lib/s7n/file.rb +77 -0
- data/lib/s7n/gpass_file.rb +203 -0
- data/lib/s7n/key.rb +83 -0
- data/lib/s7n/message_catalog.rb +5 -0
- data/lib/s7n/s7n_file.rb +47 -0
- data/lib/s7n/s7ncli.rb +226 -0
- data/lib/s7n/s7ncli/attribute_command.rb +83 -0
- data/lib/s7n/s7ncli/command.rb +215 -0
- data/lib/s7n/s7ncli/entry_collection_command.rb +63 -0
- data/lib/s7n/s7ncli/entry_command.rb +728 -0
- data/lib/s7n/s7ncli/option.rb +101 -0
- data/lib/s7n/secret_generator.rb +30 -0
- data/lib/s7n/undo_stack.rb +7 -0
- data/lib/s7n/unicode_data.rb +29 -0
- data/lib/s7n/utils.rb +37 -0
- data/lib/s7n/version.rb +3 -0
- data/lib/s7n/world.rb +158 -0
- data/po/ja/s7n.po +533 -0
- data/po/s7n.pot +533 -0
- data/s7n.gemspec +30 -0
- data/test/s7n/attribute_test.rb +50 -0
- data/test/s7n/gpass_file_test.rb +169 -0
- data/test/s7n/gpass_file_test/passwords.gps.empty +1 -0
- data/test/s7n/gpass_file_test/passwords.gps.one_entry +2 -0
- data/test/s7n/gpass_file_test/passwords.gps.three_entries +3 -0
- data/test/s7n/gpass_file_test/passwords.gps.with_folders +0 -0
- data/test/s7n/secret_generator_test.rb +29 -0
- data/test/s7n/unicode_data_test.rb +28 -0
- data/test/s7n/world_test.rb +35 -0
- data/test/test_helper.rb +11 -0
- metadata +153 -0
@@ -0,0 +1,83 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
require "s7n/s7ncli/command"
|
4
|
+
require "open3"
|
5
|
+
|
6
|
+
module S7n
|
7
|
+
class S7nCli
|
8
|
+
# 機密情報の属性のうち、指定したものの値をクリップボードにコピーするコ
|
9
|
+
# マンドを表現する。
|
10
|
+
class AttributeCopyCommand < Command
|
11
|
+
command("copy",
|
12
|
+
["attribute:copy",
|
13
|
+
"c"],
|
14
|
+
_("Copy the attribute value to the clipboard."),
|
15
|
+
_("usage: copy [ID] [ATTRIBUTE NAME] [OPTIONS]"))
|
16
|
+
|
17
|
+
def initialize(*args)
|
18
|
+
super
|
19
|
+
copy_commands = ["/usr/bin/pbcopy", "/usr/bin/xclip"]
|
20
|
+
copy_commands.each do |cmd|
|
21
|
+
if File.executable?(cmd)
|
22
|
+
@config["copy_command"] = cmd
|
23
|
+
end
|
24
|
+
end
|
25
|
+
@config["inclement_rate"] = true
|
26
|
+
@options.push(IntOption.new("i", "id", "ID", _("Entry ID.")),
|
27
|
+
StringOption.new("n", "name", _("Attribute name.")),
|
28
|
+
BoolOption.new("inclement_rate",
|
29
|
+
_("Inclement the entry's rate after copied.")),
|
30
|
+
BoolOption.new("l", "lock",
|
31
|
+
_("Lock screen after copied.")),
|
32
|
+
StringOption.new("copy_command", _("Copy command.")))
|
33
|
+
end
|
34
|
+
|
35
|
+
# コマンドを実行する。
|
36
|
+
def run(argv)
|
37
|
+
option_parser.parse!(argv)
|
38
|
+
if argv.length > 0
|
39
|
+
config["id"] = argv.shift.to_i
|
40
|
+
end
|
41
|
+
if argv.length > 0
|
42
|
+
config["name"] = argv.shift
|
43
|
+
else
|
44
|
+
config["name"] = "password"
|
45
|
+
end
|
46
|
+
if config["id"].nil? || config["name"].nil?
|
47
|
+
puts(_("Too few arguments."))
|
48
|
+
help
|
49
|
+
return true
|
50
|
+
end
|
51
|
+
puts(_("Copy the attribute value to the clipboard."))
|
52
|
+
entry = world.entry_collection.find(config["id"])
|
53
|
+
if entry.nil?
|
54
|
+
raise NoSuchEntry.new("id" => config["id"])
|
55
|
+
end
|
56
|
+
attr = entry.get_attribute(config["name"])
|
57
|
+
if attr.nil?
|
58
|
+
raise NoSuchAttribute.new("id" => config["id"],
|
59
|
+
"name" => config["name"])
|
60
|
+
end
|
61
|
+
copy_command = @config["copy_command"]
|
62
|
+
res, status = Open3.capture2(copy_command,
|
63
|
+
stdin_data: attr.value.to_s,
|
64
|
+
binmode: true)
|
65
|
+
if status != 0
|
66
|
+
raise CommandFailed.new(copy_command, status)
|
67
|
+
end
|
68
|
+
if config["inclement_rate"]
|
69
|
+
# TODO: undo スタックに操作を追加する。
|
70
|
+
entry.rate += 1
|
71
|
+
puts(_("Changed rate to %d.") % entry.rate)
|
72
|
+
world.changed = true
|
73
|
+
end
|
74
|
+
if config["lock"]
|
75
|
+
sleep(1)
|
76
|
+
lock_command = Command.create_instance("lock", world)
|
77
|
+
return lock_command.run([])
|
78
|
+
end
|
79
|
+
return true
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,215 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
require "gettext"
|
4
|
+
|
5
|
+
module S7n
|
6
|
+
class S7nCli
|
7
|
+
# コマンドを表現する。
|
8
|
+
class Command
|
9
|
+
include GetText
|
10
|
+
|
11
|
+
# オプションの配列。
|
12
|
+
attr_reader :options
|
13
|
+
|
14
|
+
# 設定。
|
15
|
+
attr_reader :config
|
16
|
+
|
17
|
+
# World オブジェクト。
|
18
|
+
attr_reader :world
|
19
|
+
|
20
|
+
@@command_classes = {}
|
21
|
+
|
22
|
+
class << self
|
23
|
+
# s で指定した文字列をコマンド名とオプションに分割する。
|
24
|
+
def split_name_and_argv(s)
|
25
|
+
s = s.strip
|
26
|
+
name = s.slice!(/\A\w+/)
|
27
|
+
argv = s.split
|
28
|
+
return name, argv
|
29
|
+
end
|
30
|
+
|
31
|
+
def create_instance(name, world)
|
32
|
+
klass = @@command_classes[name]
|
33
|
+
if klass
|
34
|
+
return klass.new(world)
|
35
|
+
else
|
36
|
+
return nil
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def command(name, aliases, description, usage)
|
43
|
+
@@command_classes[name] = self
|
44
|
+
self.const_set("NAME", name)
|
45
|
+
aliases.each do |s|
|
46
|
+
@@command_classes[s] = self
|
47
|
+
end
|
48
|
+
self.const_set("ALIASES", aliases)
|
49
|
+
self.const_set("DESCRIPTION", description)
|
50
|
+
self.const_set("USAGE", usage)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(world)
|
55
|
+
@world = world
|
56
|
+
@config = {}
|
57
|
+
@options = []
|
58
|
+
end
|
59
|
+
|
60
|
+
# コマンド名を取得する。
|
61
|
+
def name
|
62
|
+
return self.class.const_get("NAME")
|
63
|
+
end
|
64
|
+
|
65
|
+
# 別名の配列を取得する。
|
66
|
+
def aliases
|
67
|
+
return self.class.const_get("ALIASES")
|
68
|
+
end
|
69
|
+
|
70
|
+
# 概要を取得する。
|
71
|
+
def description
|
72
|
+
return self.class.const_get("DESCRIPTION")
|
73
|
+
end
|
74
|
+
|
75
|
+
# 使用例を取得する。
|
76
|
+
def usage
|
77
|
+
return self.class.const_get("USAGE")
|
78
|
+
end
|
79
|
+
|
80
|
+
# コマンドラインオプションを解析するための OptionParser オブジェ
|
81
|
+
# クトを取得する。
|
82
|
+
def option_parser
|
83
|
+
return OptionParser.new { |opts|
|
84
|
+
opts.banner = [
|
85
|
+
"#{name} (#{aliases.join(",")}): #{description}",
|
86
|
+
usage,
|
87
|
+
].join("\n")
|
88
|
+
if options && options.length > 0
|
89
|
+
opts.separator("")
|
90
|
+
opts.separator("Valid options:")
|
91
|
+
for option in options
|
92
|
+
option.define(opts, config)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
}
|
96
|
+
end
|
97
|
+
|
98
|
+
# 使用方法を示す文字列を返す。
|
99
|
+
def help
|
100
|
+
puts(option_parser)
|
101
|
+
return
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
# キャンセルした旨を出力し、true を返す。
|
107
|
+
# Command オブジェクトの run メソッドで return canceled という使
|
108
|
+
# い方を想定している。
|
109
|
+
def canceled
|
110
|
+
puts("\n" + Canceled::MESSAGE)
|
111
|
+
return true
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# プログラムを終了するコマンドを表現する。
|
116
|
+
class HelpCommand < Command
|
117
|
+
command("help",
|
118
|
+
["usage", "h"],
|
119
|
+
_("Describe the commands."),
|
120
|
+
_("usage: help [COMMAND]"))
|
121
|
+
|
122
|
+
def run(argv)
|
123
|
+
if argv.length > 0
|
124
|
+
command_name = argv.shift
|
125
|
+
command = Command.create_instance(command_name, world)
|
126
|
+
if command
|
127
|
+
command.help
|
128
|
+
else
|
129
|
+
puts(_("Unknown command: %s") % command_name)
|
130
|
+
end
|
131
|
+
else
|
132
|
+
puts(_("Type 'help <command>' for help on a specific command."))
|
133
|
+
puts("")
|
134
|
+
puts(_("Available commands:"))
|
135
|
+
command_classes = @@command_classes.values.uniq.sort_by { |klass|
|
136
|
+
klass.const_get("NAME")
|
137
|
+
}
|
138
|
+
command_classes.each do |klass|
|
139
|
+
s = " #{klass.const_get("NAME")}"
|
140
|
+
aliases = klass.const_get("ALIASES")
|
141
|
+
if aliases.length > 0
|
142
|
+
s << " (#{aliases.sort_by { |s| s.length}.first})"
|
143
|
+
end
|
144
|
+
puts(s)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
return true
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# 現在の状態を保存するコマンドを表現する。
|
152
|
+
class SaveCommand < Command
|
153
|
+
command("save", [], _("Save."), _("usage: save"))
|
154
|
+
|
155
|
+
def initialize(*args)
|
156
|
+
super
|
157
|
+
@options.push(BoolOption.new("f", "force", _("Force.")))
|
158
|
+
end
|
159
|
+
|
160
|
+
def run(argv)
|
161
|
+
option_parser.parse!(argv)
|
162
|
+
world.save(config["force"])
|
163
|
+
puts(_("Saved."))
|
164
|
+
return true
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# スクリーンロックし、マスターキーを入力するまでは操作できないよう
|
169
|
+
# にするコマンドを表現する。
|
170
|
+
class LockCommand < Command
|
171
|
+
command("lock", [], _("Lock screen after saved."), _("usage: lock"))
|
172
|
+
|
173
|
+
def run(argv)
|
174
|
+
system("clear")
|
175
|
+
puts(_("Locked screen."))
|
176
|
+
world.save
|
177
|
+
prompt = _("Enter the master key for s7n: ")
|
178
|
+
begin
|
179
|
+
input = S7Cli.input_string_without_echo(prompt)
|
180
|
+
if input != world.master_key
|
181
|
+
raise InvalidPassphrase
|
182
|
+
end
|
183
|
+
puts(_("Unlocked."))
|
184
|
+
return true
|
185
|
+
rescue Interrupt
|
186
|
+
rescue ApplicationError => e
|
187
|
+
puts($!.message + _("Try again."))
|
188
|
+
retry
|
189
|
+
rescue Exception => e
|
190
|
+
puts(e.message)
|
191
|
+
if world.debug
|
192
|
+
puts(_("----- back trace -----"))
|
193
|
+
e.backtrace.each do |line|
|
194
|
+
puts(" #{line}")
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
puts(_("Quit."))
|
199
|
+
return false
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# プログラムを終了するコマンドを表現する。
|
204
|
+
class QuitCommand < Command
|
205
|
+
command("quit",
|
206
|
+
["exit", "q"],
|
207
|
+
_("Quit."),
|
208
|
+
_("usage: quit"))
|
209
|
+
|
210
|
+
def run(argv)
|
211
|
+
return false
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
require "s7n/s7ncli/command"
|
4
|
+
|
5
|
+
module S7n
|
6
|
+
class S7nCli
|
7
|
+
# 機密情報をインポートするコマンドを表現する。
|
8
|
+
class EntryCollectionImportCommand < Command
|
9
|
+
command("import",
|
10
|
+
["entry_collection:import",
|
11
|
+
"entry_collection:read",
|
12
|
+
"entry_collection:load",
|
13
|
+
"read",
|
14
|
+
"load"],
|
15
|
+
_("Import the entry colleciton from file."),
|
16
|
+
_("usage: import [PATH] [OPTIONS]"))
|
17
|
+
|
18
|
+
def initialize(*args)
|
19
|
+
super
|
20
|
+
@config["type"] = "gpass"
|
21
|
+
@config["path"] = "~/.gpass/passwords.gps"
|
22
|
+
@options.push(StringOption.new("type", _("type")),
|
23
|
+
StringOption.new("path", _("path")))
|
24
|
+
end
|
25
|
+
|
26
|
+
# コマンドを実行する。
|
27
|
+
def run(argv)
|
28
|
+
option_parser.parse!(argv)
|
29
|
+
if argv.length > 0
|
30
|
+
config["path"] = argv.shift
|
31
|
+
end
|
32
|
+
case config["type"]
|
33
|
+
when "gpass"
|
34
|
+
path = File.expand_path(config["path"])
|
35
|
+
if !File.file?(path)
|
36
|
+
raise NotExist.new(path)
|
37
|
+
end
|
38
|
+
puts(_("Import the entry collection in the GPass file: %s") % path)
|
39
|
+
gpass_file = GPassFile.new
|
40
|
+
begin
|
41
|
+
msg = _("Enter the master passphrase for GPass(^D is cancel): ")
|
42
|
+
passphrase = S7nCli.input_string_without_echo(msg)
|
43
|
+
if passphrase
|
44
|
+
ec = gpass_file.read(passphrase, path)
|
45
|
+
# TODO: UNDO スタックにタグの追加と機密情報の追加の操作を積む。
|
46
|
+
world.entry_collection.merge(ec)
|
47
|
+
world.changed = true
|
48
|
+
puts(_("Imported %d entries.") % ec.entries.length)
|
49
|
+
else
|
50
|
+
canceled
|
51
|
+
end
|
52
|
+
rescue InvalidPassphrase => e
|
53
|
+
puts(e.message)
|
54
|
+
retry
|
55
|
+
end
|
56
|
+
else
|
57
|
+
raise ArgumentError, _("not supported type: %s") % config["type"]
|
58
|
+
end
|
59
|
+
return true
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,728 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
|
3
|
+
require "s7n/s7ncli/command"
|
4
|
+
require "s7n/secret_generator"
|
5
|
+
require "s7n/unicode_data"
|
6
|
+
|
7
|
+
module S7n
|
8
|
+
class S7nCli
|
9
|
+
# 機密情報の操作をするコマンドで使用するメソッド群。
|
10
|
+
module EntryCommandUtils
|
11
|
+
include GetText
|
12
|
+
|
13
|
+
EDITING_USAGE =
|
14
|
+
["-----",
|
15
|
+
_("<n>,e<n>:edit attribute a:add attribute d<n>:delete attribute"),
|
16
|
+
_("t:edit tags s:save")].join("\n")
|
17
|
+
|
18
|
+
# ユーザに属性名を入力するように促し、入力された値を返す。
|
19
|
+
def input_attribute_name(default = nil)
|
20
|
+
names = (@entry.attributes.collect(&:name) +
|
21
|
+
@attributes.collect(&:name)).uniq
|
22
|
+
if default
|
23
|
+
s = " (#{default})"
|
24
|
+
else
|
25
|
+
s = ""
|
26
|
+
end
|
27
|
+
prompt = _("Attribute name%s: ") % s
|
28
|
+
begin
|
29
|
+
if input = S7nCli.input_string(prompt)
|
30
|
+
input.strip!
|
31
|
+
if input.empty?
|
32
|
+
input = default
|
33
|
+
elsif names.include?(input)
|
34
|
+
raise ApplicationError, _("Already exist: %s") % input
|
35
|
+
end
|
36
|
+
end
|
37
|
+
return input
|
38
|
+
rescue ApplicationError => e
|
39
|
+
puts("")
|
40
|
+
puts(e.message)
|
41
|
+
retry
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# ユーザに属性の値を入力するように促し、入力された値を返す。
|
46
|
+
# ^D を入力されて、なおかつ protected が false の場合、nil を返す。
|
47
|
+
def input_value(value, secret, protected)
|
48
|
+
if value.length > 0
|
49
|
+
current = _(" (%s)") % value
|
50
|
+
else
|
51
|
+
current = ""
|
52
|
+
end
|
53
|
+
prompt = _("Value%s: ") % current
|
54
|
+
begin
|
55
|
+
if secret
|
56
|
+
if input = S7nCli.input_string_without_echo(prompt)
|
57
|
+
confirmation =
|
58
|
+
S7nCli.input_string_without_echo(_("Comfirm value: "))
|
59
|
+
if input != confirmation
|
60
|
+
raise ApplicationError, _("Mismatched.")
|
61
|
+
end
|
62
|
+
end
|
63
|
+
else
|
64
|
+
input = S7nCli.input_string(prompt)
|
65
|
+
end
|
66
|
+
if input
|
67
|
+
input.strip!
|
68
|
+
if input.empty? && value
|
69
|
+
input = value
|
70
|
+
end
|
71
|
+
end
|
72
|
+
if protected && (input.nil? || input.empty?)
|
73
|
+
raise ApplicationError, _("The value is empty.")
|
74
|
+
end
|
75
|
+
rescue ApplicationError => e
|
76
|
+
puts("")
|
77
|
+
puts(e.message + _("Try again."))
|
78
|
+
retry
|
79
|
+
end
|
80
|
+
return input
|
81
|
+
end
|
82
|
+
|
83
|
+
# ユーザにタグの入力を促す。入力内容を解析し、タグ名の配列を返す。
|
84
|
+
# ^D を入力された場合、nil を返す。
|
85
|
+
def input_tags(tags)
|
86
|
+
if tags.length > 0
|
87
|
+
current = " (" + tags.join(", ") + ")"
|
88
|
+
else
|
89
|
+
current = ""
|
90
|
+
end
|
91
|
+
prompt = _("Edit tags%s: ") % current
|
92
|
+
if input = S7nCli.input_string(prompt)
|
93
|
+
return input.split(",").collect { |t| t.strip }
|
94
|
+
else
|
95
|
+
return nil
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
# ユーザに機密情報かどうかの入力を促す。
|
100
|
+
def input_secret(default)
|
101
|
+
prompt = _("Is this a secret? (%s): ") % (default ? "yes" : "no")
|
102
|
+
return S7nCli.input_boolean(prompt, default)
|
103
|
+
end
|
104
|
+
|
105
|
+
# ユーザに属性の型の入力を促す。
|
106
|
+
def input_attribute_type
|
107
|
+
prompt = _("Attribute type: ")
|
108
|
+
types = Attribute.types
|
109
|
+
Readline.completion_proc = Proc.new { |text|
|
110
|
+
begin
|
111
|
+
s = Readline.line_buffer.lstrip
|
112
|
+
rescue NotImplementedError, NoMethodError
|
113
|
+
s = text
|
114
|
+
end
|
115
|
+
types.select { |type|
|
116
|
+
type =~ /\A#{s}/
|
117
|
+
}
|
118
|
+
}
|
119
|
+
begin
|
120
|
+
if input = S7nCli.input_string(prompt)
|
121
|
+
input.strip!
|
122
|
+
if !types.include?(input)
|
123
|
+
raise ApplicationError, _("Unknown type: %s") % input
|
124
|
+
end
|
125
|
+
end
|
126
|
+
return input
|
127
|
+
rescue ApplicationError => e
|
128
|
+
puts(e.message)
|
129
|
+
puts(_("Try again."))
|
130
|
+
retry
|
131
|
+
ensure
|
132
|
+
begin
|
133
|
+
Readline.completion_proc = nil
|
134
|
+
rescue ArgumentError
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# ユーザにコマンドの入力を促す。終了コマンドを入力するまで繰り返す。
|
140
|
+
def command_loop
|
141
|
+
begin
|
142
|
+
loop do
|
143
|
+
begin
|
144
|
+
puts("-----")
|
145
|
+
@attributes.each.with_index do |attr, i|
|
146
|
+
value = attr.display_value
|
147
|
+
printf(" %2d) %s: %s\n", i, _(attr.name), value)
|
148
|
+
end
|
149
|
+
printf(" t) %s: %s\n", _("tags"), @tags.join(", "))
|
150
|
+
puts("-----")
|
151
|
+
prompt = _("Command (h:help, s:save, ^C:cancel): ")
|
152
|
+
input = S7nCli.input_string(prompt)
|
153
|
+
if input.nil?
|
154
|
+
raise Interrupt
|
155
|
+
end
|
156
|
+
begin
|
157
|
+
if !dispatch_command(input)
|
158
|
+
break
|
159
|
+
end
|
160
|
+
rescue Interrupt
|
161
|
+
raise Canceled
|
162
|
+
end
|
163
|
+
rescue ApplicationError => e
|
164
|
+
puts(e.message)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
rescue Interrupt
|
168
|
+
canceled
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# ユーザからの入力 input を解析し、適切なコマンドを処理する。
|
173
|
+
def dispatch_command(input)
|
174
|
+
case input
|
175
|
+
when /\A\s*(\d+)\z/, /\Ae\s*(\d+)\z/
|
176
|
+
index = $1.to_i
|
177
|
+
attr = @attributes[index]
|
178
|
+
if attr.nil?
|
179
|
+
raise ApplicationError, _("Invalid index: %d") % index
|
180
|
+
end
|
181
|
+
puts(_("Edit attribute: %s") % _(attr.name))
|
182
|
+
if attr.protected?
|
183
|
+
name = attr.name
|
184
|
+
secret = attr.secret?
|
185
|
+
else
|
186
|
+
name = input_attribute_name(attr.name)
|
187
|
+
if name.nil?
|
188
|
+
raise Canceled
|
189
|
+
end
|
190
|
+
secret = input_secret(attr.secret?)
|
191
|
+
end
|
192
|
+
if secret
|
193
|
+
prompt = _("Generate value? (no): ")
|
194
|
+
if S7nCli.input_boolean(prompt, false)
|
195
|
+
prompt = _("Length (8): ")
|
196
|
+
length = S7nCli.input_string(prompt).to_i
|
197
|
+
if length <= 0
|
198
|
+
length = 8
|
199
|
+
end
|
200
|
+
value = SecretGenerator.generate(length: length,
|
201
|
+
characters: [:alphabet, :number])
|
202
|
+
end
|
203
|
+
end
|
204
|
+
if value.nil?
|
205
|
+
value =
|
206
|
+
input_value(attr.display_value(secret), secret, attr.protected?)
|
207
|
+
end
|
208
|
+
if value.nil?
|
209
|
+
raise Canceled
|
210
|
+
end
|
211
|
+
attr.name = name
|
212
|
+
attr.secret = secret
|
213
|
+
attr.value = value
|
214
|
+
puts(_("Updated '%s'.") % _(attr.name))
|
215
|
+
when "a"
|
216
|
+
puts(_("Add attribute."))
|
217
|
+
name = input_attribute_name
|
218
|
+
if name.nil?
|
219
|
+
raise Canceled
|
220
|
+
end
|
221
|
+
type = input_attribute_type
|
222
|
+
if type.nil?
|
223
|
+
raise Canceled
|
224
|
+
end
|
225
|
+
secret = input_secret(false)
|
226
|
+
if secret
|
227
|
+
prompt = _("Generate value? (no): ")
|
228
|
+
if S7nCli.input_boolean(prompt, false)
|
229
|
+
# TODO: pwgen コマンドを呼び出す生成機を選択できるようにする。
|
230
|
+
prompt = _("Length (8): ")
|
231
|
+
length = S7nCli.input_string(prompt).to_i
|
232
|
+
if length <= 0
|
233
|
+
length = 8
|
234
|
+
end
|
235
|
+
# TODO: 文字種も選べるようにする。
|
236
|
+
value = SecretGenerator.generate(length: length)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
if value.nil?
|
240
|
+
value = input_value("", secret, false)
|
241
|
+
end
|
242
|
+
if value.nil?
|
243
|
+
raise Canceled
|
244
|
+
end
|
245
|
+
options = {
|
246
|
+
"name" => name,
|
247
|
+
"secret" => secret,
|
248
|
+
"value" => value,
|
249
|
+
}
|
250
|
+
attr = Attribute.create_instance(type, options)
|
251
|
+
@attributes.push(attr)
|
252
|
+
puts(_("Added attribute: '%s'") % _(attr.name))
|
253
|
+
when /\Ad\s*(\d+)\z/
|
254
|
+
index = $1.to_i
|
255
|
+
attr = @attributes[index]
|
256
|
+
if attr.nil?
|
257
|
+
raise ApplicationError, _("Invalid index: %d") % index
|
258
|
+
end
|
259
|
+
if attr.protected?
|
260
|
+
msg = _("Can't delete attribute: %s") % _(attr.name)
|
261
|
+
raise ApplicationError, msg
|
262
|
+
end
|
263
|
+
prompt = _("Delete attribute '%s'? (no): ") % _(attr.name)
|
264
|
+
if !S7nCli.input_boolean(prompt)
|
265
|
+
raise Canceled
|
266
|
+
end
|
267
|
+
@attributes.delete(attr)
|
268
|
+
puts(_("Deleted attribute: '%s'") % _(attr.name))
|
269
|
+
when "t"
|
270
|
+
if input = input_tags(@tags)
|
271
|
+
@tags = input
|
272
|
+
else
|
273
|
+
raise Canceled
|
274
|
+
end
|
275
|
+
puts(_("Updated tags."))
|
276
|
+
when "s"
|
277
|
+
save
|
278
|
+
world.changed = true
|
279
|
+
puts(_("Saved: %d") % @entry.id)
|
280
|
+
return false
|
281
|
+
when "h"
|
282
|
+
puts(EDITING_USAGE)
|
283
|
+
else
|
284
|
+
raise ApplicationError, _("Unknown command: %s") % input
|
285
|
+
end
|
286
|
+
return true
|
287
|
+
end
|
288
|
+
|
289
|
+
# 機密情報に対する属性とタグの変更を反映させる。
|
290
|
+
def apply_entry_changes
|
291
|
+
@entry.tags.replace(@tags)
|
292
|
+
d = @entry.attributes.collect(&:name) - @attributes.collect(&:name)
|
293
|
+
if d.length > 0
|
294
|
+
@entry.attributes.delete_if { |attr|
|
295
|
+
attr.editable? && d.include?(attr.name)
|
296
|
+
}
|
297
|
+
end
|
298
|
+
@entry.add_attributes(@attributes)
|
299
|
+
end
|
300
|
+
|
301
|
+
# entry に指定した機密情報を表示する。
|
302
|
+
def display_entry(entry)
|
303
|
+
attr_names = entry.attributes.collect { |attr|
|
304
|
+
_(attr.name)
|
305
|
+
}
|
306
|
+
n = attr_names.collect(&:length).max
|
307
|
+
entry.attributes.each do |attr|
|
308
|
+
secret = attr.secret? && !config["show_secret"]
|
309
|
+
value = attr.display_value(secret)
|
310
|
+
puts("%-#{n}s: %s" % [_(attr.name), value])
|
311
|
+
end
|
312
|
+
puts("%-#{n}s: %s" % [_("tag"), entry.tags.join(", ")])
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
# 機密情報を追加するコマンドを表現する。
|
317
|
+
class EntryAddCommand < Command
|
318
|
+
include EntryCommandUtils
|
319
|
+
|
320
|
+
command("add",
|
321
|
+
["entry:add",
|
322
|
+
"entry:new",
|
323
|
+
"entry:create",
|
324
|
+
"new",
|
325
|
+
"create",
|
326
|
+
"a"],
|
327
|
+
_("Add the entry."),
|
328
|
+
_("usage: add [NAME] [TAGS] [OPTIONS]"))
|
329
|
+
|
330
|
+
def initialize(*args)
|
331
|
+
super
|
332
|
+
@config["tags"] = []
|
333
|
+
@config["attributes"] = []
|
334
|
+
@options.push(StringOption.new("n", "name", _("Name.")),
|
335
|
+
ArrayOption.new("t", "tags", _("Tags.")),
|
336
|
+
IntOption.new("c", "copy", "ID",
|
337
|
+
_("Entry ID that is copied.")))
|
338
|
+
end
|
339
|
+
|
340
|
+
# 機密情報を追加する。
|
341
|
+
def save
|
342
|
+
apply_entry_changes
|
343
|
+
# TODO: undo スタックに操作を追加する。
|
344
|
+
world.entry_collection.add_entries(@entry)
|
345
|
+
end
|
346
|
+
|
347
|
+
# コマンドを実行する。
|
348
|
+
def run(argv)
|
349
|
+
option_parser.parse!(argv)
|
350
|
+
if argv.length > 0
|
351
|
+
config["name"] = argv.shift
|
352
|
+
end
|
353
|
+
if argv.length > 0
|
354
|
+
config["tags"].push(*argv.shift.split(","))
|
355
|
+
end
|
356
|
+
if config["copy"]
|
357
|
+
from_entry = world.entry_collection.find(config["copy"])
|
358
|
+
if !from_entry
|
359
|
+
raise NoSuchEntry.new("id" => config["copy"])
|
360
|
+
end
|
361
|
+
end
|
362
|
+
puts(_("Add the entry."))
|
363
|
+
@entry = Entry.new
|
364
|
+
if from_entry
|
365
|
+
@entry.add_attributes(from_entry.duplicate_attributes)
|
366
|
+
end
|
367
|
+
@attributes = @entry.attributes.select { |attr|
|
368
|
+
attr.editable?
|
369
|
+
}
|
370
|
+
attr = @entry.get_attribute("name")
|
371
|
+
if config["name"]
|
372
|
+
attr.value = config["name"]
|
373
|
+
end
|
374
|
+
if attr.value.to_s.empty?
|
375
|
+
puts(_("Edit attribute: %s") % _(attr.name))
|
376
|
+
attr.value =
|
377
|
+
input_value(attr.display_value, attr.secret?, attr.protected?)
|
378
|
+
end
|
379
|
+
if config["tags"].length > 0
|
380
|
+
@tags = config["tags"]
|
381
|
+
else
|
382
|
+
@tags = @entry.tags
|
383
|
+
end
|
384
|
+
if @tags.empty?
|
385
|
+
@tags = input_tags(@tags) || []
|
386
|
+
end
|
387
|
+
command_loop
|
388
|
+
return true
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
# 機密情報を追加するコマンドを表現する。
|
393
|
+
class EntryEditCommand < Command
|
394
|
+
include EntryCommandUtils
|
395
|
+
|
396
|
+
command("edit",
|
397
|
+
["entry:edit",
|
398
|
+
"entry:update",
|
399
|
+
"update",
|
400
|
+
"e"],
|
401
|
+
_("Edit the entry."),
|
402
|
+
_("usage: edit <ID> [OPTIONS]"))
|
403
|
+
|
404
|
+
def initialize(*args)
|
405
|
+
super
|
406
|
+
@options.push(IntOption.new("i", "id", "ID", _("Entry ID.")))
|
407
|
+
end
|
408
|
+
|
409
|
+
# 機密情報を追加する。
|
410
|
+
def save
|
411
|
+
# TODO: undo スタックに操作を追加する。
|
412
|
+
apply_entry_changes
|
413
|
+
end
|
414
|
+
|
415
|
+
# コマンドを実行する。
|
416
|
+
def run(argv)
|
417
|
+
option_parser.parse!(argv)
|
418
|
+
if argv.length > 0
|
419
|
+
config["id"] = argv.shift.to_i
|
420
|
+
end
|
421
|
+
if config["id"].nil?
|
422
|
+
puts(_("Too few arguments."))
|
423
|
+
help
|
424
|
+
return true
|
425
|
+
end
|
426
|
+
@entry = world.entry_collection.find(config["id"])
|
427
|
+
if @entry.nil?
|
428
|
+
raise NoSuchEntry.new("id" => config["id"])
|
429
|
+
end
|
430
|
+
puts(_("Edit the entry."))
|
431
|
+
@attributes = @entry.duplicate_attributes
|
432
|
+
@tags = @entry.tags.dup
|
433
|
+
if @tags.empty?
|
434
|
+
@tags = input_tags(@tags) || []
|
435
|
+
end
|
436
|
+
command_loop
|
437
|
+
return true
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
441
|
+
# 機密情報を検索するコマンドを表現する。
|
442
|
+
class EntryShowCommand < Command
|
443
|
+
include EntryCommandUtils
|
444
|
+
|
445
|
+
command("show",
|
446
|
+
["entry:show",
|
447
|
+
"s"],
|
448
|
+
_("Show the entry."),
|
449
|
+
_("usage: show <ID> [OPTIONS]"))
|
450
|
+
|
451
|
+
def initialize(*args)
|
452
|
+
super
|
453
|
+
@config["show_secret"] = false
|
454
|
+
@options.push(IntOption.new("id", _("Entry ID.")),
|
455
|
+
BoolOption.new("s", "show_secret",
|
456
|
+
_("Show value of secret attributes.")))
|
457
|
+
end
|
458
|
+
|
459
|
+
# コマンドを実行する。
|
460
|
+
def run(argv)
|
461
|
+
option_parser.parse!(argv)
|
462
|
+
if argv.length > 0
|
463
|
+
config["id"] = argv.shift.to_i
|
464
|
+
end
|
465
|
+
if config["id"].nil?
|
466
|
+
puts(_("Too few arguments."))
|
467
|
+
help
|
468
|
+
return true
|
469
|
+
end
|
470
|
+
entries = world.entry_collection.entries
|
471
|
+
entry = entries.find { |entry|
|
472
|
+
entry.id == config["id"]
|
473
|
+
}
|
474
|
+
if !entry
|
475
|
+
raise NoSuchEntry.new("id" => config["id"])
|
476
|
+
end
|
477
|
+
display_entry(entry)
|
478
|
+
if config["show_secret"]
|
479
|
+
# TODO: undo スタックに操作を追加する。
|
480
|
+
entry.rate += 1
|
481
|
+
puts(_("Changed rate to %d.") % entry.rate)
|
482
|
+
world.changed = true
|
483
|
+
end
|
484
|
+
return true
|
485
|
+
end
|
486
|
+
end
|
487
|
+
|
488
|
+
# 機密情報を検索するコマンドを表現する。
|
489
|
+
class EntrySearchCommand < Command
|
490
|
+
include EntryCommandUtils
|
491
|
+
|
492
|
+
command("search",
|
493
|
+
["entry:search",
|
494
|
+
"entry:list",
|
495
|
+
"list",
|
496
|
+
"ls",
|
497
|
+
"l"],
|
498
|
+
_("Search the entry."),
|
499
|
+
_("usage: search [QUERY] [OPTIONS]"))
|
500
|
+
|
501
|
+
def initialize(*args)
|
502
|
+
super
|
503
|
+
@config["query"] = ""
|
504
|
+
@config["tags"] = []
|
505
|
+
@config["num"] = 10
|
506
|
+
@config["ignore-case"] = false
|
507
|
+
@options.push(StringOption.new("query", _("Query.")),
|
508
|
+
ArrayOption.new("tags", _("Tags.")),
|
509
|
+
IntOption.new("n", "num", _("Number of entries.")),
|
510
|
+
BoolOption.new("i", "ignore_case", _("Ignore case.")))
|
511
|
+
end
|
512
|
+
|
513
|
+
def search(entries, words, tags, ignore_case)
|
514
|
+
if words.empty?
|
515
|
+
matched_entries = entries
|
516
|
+
else
|
517
|
+
matched_entries = entries.select { |entry|
|
518
|
+
s = entry.attributes.collect { |attr|
|
519
|
+
attr.value.to_s
|
520
|
+
}.join("\n")
|
521
|
+
res = true
|
522
|
+
if ignore_case
|
523
|
+
s.downcase!
|
524
|
+
words.each(&:downcase!)
|
525
|
+
end
|
526
|
+
words.each do |word|
|
527
|
+
if !s.include?(word)
|
528
|
+
res = false
|
529
|
+
break
|
530
|
+
end
|
531
|
+
end
|
532
|
+
res
|
533
|
+
}
|
534
|
+
end
|
535
|
+
if tags.length > 0
|
536
|
+
matched_entries = matched_entries.select { |entry|
|
537
|
+
tags == (entry.tags & tags)
|
538
|
+
}
|
539
|
+
end
|
540
|
+
return matched_entries
|
541
|
+
end
|
542
|
+
|
543
|
+
# コマンドを実行する。
|
544
|
+
def run(argv)
|
545
|
+
option_parser.parse!(argv)
|
546
|
+
if argv.length > 0
|
547
|
+
config["query"].concat(" " + argv.join(" "))
|
548
|
+
end
|
549
|
+
words = config["query"].split
|
550
|
+
entries = search(world.entry_collection.entries,
|
551
|
+
words, config["tags"], config["ignore_case"])
|
552
|
+
if entries.empty?
|
553
|
+
puts(_("No entry."))
|
554
|
+
else
|
555
|
+
sort_key = :id
|
556
|
+
page_no = 1
|
557
|
+
per_page = ENV["LINES"].to_i
|
558
|
+
if per_page <= 3
|
559
|
+
per_page = 25 - 3
|
560
|
+
else
|
561
|
+
per_page -= 3
|
562
|
+
end
|
563
|
+
current_mode = :next
|
564
|
+
|
565
|
+
begin
|
566
|
+
loop do
|
567
|
+
begin
|
568
|
+
if words.length > 0
|
569
|
+
print(_("Search result of '%s', %d entries.") %
|
570
|
+
[words.join(" "), entries.length])
|
571
|
+
else
|
572
|
+
print(_("List of %d entries.") % entries.length)
|
573
|
+
end
|
574
|
+
num_pages = (entries.length.to_f / per_page).ceil
|
575
|
+
puts(_(" [page %d/%d]") % [page_no, num_pages])
|
576
|
+
|
577
|
+
list_entries(entries, sort_key, page_no, per_page)
|
578
|
+
|
579
|
+
if num_pages == 1
|
580
|
+
raise Interrupt
|
581
|
+
end
|
582
|
+
|
583
|
+
prompt = _("Command (p:prev, n:next, s:sort, q:quit): ")
|
584
|
+
input = S7nCli.input_string(prompt)
|
585
|
+
if input.nil?
|
586
|
+
raise Interrupt
|
587
|
+
end
|
588
|
+
begin
|
589
|
+
case input
|
590
|
+
when /\A(n|next)\z/i
|
591
|
+
page_no += 1
|
592
|
+
current_mode = :next
|
593
|
+
when /\A(p|prev)\z/i
|
594
|
+
page_no -= 1
|
595
|
+
current_mode = :prev
|
596
|
+
when /\A(s|sort)\z/i
|
597
|
+
sort_key = next_sort_key(sort_key)
|
598
|
+
when /\A(q|quit)/i
|
599
|
+
break
|
600
|
+
else
|
601
|
+
if current_mode == :next
|
602
|
+
page_no += 1
|
603
|
+
else
|
604
|
+
page_no -= 1
|
605
|
+
end
|
606
|
+
end
|
607
|
+
if page_no < 1
|
608
|
+
page_no = 1
|
609
|
+
elsif page_no >= num_pages
|
610
|
+
page_no = num_pages
|
611
|
+
end
|
612
|
+
end
|
613
|
+
rescue ApplicationError => e
|
614
|
+
puts(e.message)
|
615
|
+
end
|
616
|
+
end
|
617
|
+
rescue Interrupt
|
618
|
+
puts("\n")
|
619
|
+
end
|
620
|
+
end
|
621
|
+
return true
|
622
|
+
end
|
623
|
+
|
624
|
+
# ソート対象の属性の配列。
|
625
|
+
SORT_KEYS = [:id, :name, :tags, :rate]
|
626
|
+
|
627
|
+
def next_sort_key(current)
|
628
|
+
return SORT_KEYS[(SORT_KEYS.index(current) + 1) % SORT_KEYS.length]
|
629
|
+
end
|
630
|
+
|
631
|
+
# 指定した文字列 str の表示幅が width よりも長い場合は切り詰める。
|
632
|
+
# width よりも短い場合、left が true だと右部分にスペースをつめ、
|
633
|
+
# left が false だと、左部分にスペースをつめる。
|
634
|
+
def truncate_or_add_space(str, width, left = true)
|
635
|
+
len = 0
|
636
|
+
truncate_index = nil
|
637
|
+
truncate_with_space = false
|
638
|
+
str.chars.each_with_index do |chr, i|
|
639
|
+
case UnicodeData.east_asian_width(chr)
|
640
|
+
when :H, :N
|
641
|
+
n = 1
|
642
|
+
else
|
643
|
+
n = 2
|
644
|
+
end
|
645
|
+
len += n
|
646
|
+
if truncate_index.nil? && len >= width
|
647
|
+
truncate_index = i
|
648
|
+
if len > width
|
649
|
+
truncate_with_space = true
|
650
|
+
end
|
651
|
+
end
|
652
|
+
end
|
653
|
+
if len < width
|
654
|
+
space = " " * (width - len)
|
655
|
+
if left
|
656
|
+
res = str + space
|
657
|
+
else
|
658
|
+
res = space + str
|
659
|
+
end
|
660
|
+
elsif len == width
|
661
|
+
res = str
|
662
|
+
else
|
663
|
+
res = str[0..truncate_index]
|
664
|
+
if truncate_with_space
|
665
|
+
res[-1] = " "
|
666
|
+
end
|
667
|
+
end
|
668
|
+
return res
|
669
|
+
end
|
670
|
+
|
671
|
+
def list_entries(entries, sort_key, page_no, per_page)
|
672
|
+
sort_marks = [" ", " ", " ", " "]
|
673
|
+
sort_marks[SORT_KEYS.index(sort_key)] = ">"
|
674
|
+
puts(_("%sID |%sName |%sTags(s) |%sRate") % sort_marks)
|
675
|
+
sorted_entries = entries.sort_by { |e| e.send(sort_key) }
|
676
|
+
sorted_entries[((page_no - 1) * per_page), per_page].each do |entry|
|
677
|
+
puts([truncate_or_add_space(entry.id.to_s, 4, false),
|
678
|
+
truncate_or_add_space(entry.name, 48),
|
679
|
+
truncate_or_add_space(entry.tags.join(","), 20),
|
680
|
+
truncate_or_add_space(entry.rate.to_s, 5, false)].join("|"))
|
681
|
+
end
|
682
|
+
end
|
683
|
+
end
|
684
|
+
|
685
|
+
# 機密情報を削除するコマンドを表現する。
|
686
|
+
class EntryDeleteCommand < Command
|
687
|
+
include EntryCommandUtils
|
688
|
+
|
689
|
+
command("delete",
|
690
|
+
["entry:delete",
|
691
|
+
"entry:destroy",
|
692
|
+
"entry:remove",
|
693
|
+
"destroy",
|
694
|
+
"remove",
|
695
|
+
"del",
|
696
|
+
"rm",
|
697
|
+
"d"],
|
698
|
+
_("Delete the entry."),
|
699
|
+
_("usage: delete <ID>"))
|
700
|
+
|
701
|
+
# コマンドを実行する。
|
702
|
+
def run(argv)
|
703
|
+
if argv.length > 0
|
704
|
+
id = argv.shift.to_i
|
705
|
+
end
|
706
|
+
if id.nil?
|
707
|
+
puts(_("Too few arguments."))
|
708
|
+
help
|
709
|
+
return true
|
710
|
+
end
|
711
|
+
entry = world.entry_collection.find(id)
|
712
|
+
if entry.nil?
|
713
|
+
raise NoSuchEntry.new("id" => id)
|
714
|
+
end
|
715
|
+
display_entry(entry)
|
716
|
+
puts("-----")
|
717
|
+
if S7nCli.input_boolean(_("Delete? (no): "))
|
718
|
+
# TODO: undo スタックに操作を追加。
|
719
|
+
world.entry_collection.delete_entries(entry)
|
720
|
+
puts(_("Deleted: %d") % entry.id)
|
721
|
+
else
|
722
|
+
canceled
|
723
|
+
end
|
724
|
+
return true
|
725
|
+
end
|
726
|
+
end
|
727
|
+
end
|
728
|
+
end
|