]> err.no Git - pwstore/blob - pws
Try to handle the case where we cannot exec gnupg
[pwstore] / pws
1 #!/usr/bin/ruby
2
3 # password store management tool
4
5 # Copyright (c) 2008, 2009 Peter Palfrader <peter@palfrader.org>
6 #
7 # Permission is hereby granted, free of charge, to any person obtaining
8 # a copy of this software and associated documentation files (the
9 # "Software"), to deal in the Software without restriction, including
10 # without limitation the rights to use, copy, modify, merge, publish,
11 # distribute, sublicense, and/or sell copies of the Software, and to
12 # permit persons to whom the Software is furnished to do so, subject to
13 # the following conditions:
14 #
15 # The above copyright notice and this permission notice shall be
16 # included in all copies or substantial portions of the Software.
17 #
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22 # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25
26 require 'optparse'
27 require 'thread'
28 require 'tempfile'
29
30 require 'yaml'
31 Thread.abort_on_exception = true
32
33 GNUPG = "/usr/bin/gpg"
34
35 $program_name = File.basename($0, '.*')
36
37 $editor = ENV['EDITOR']
38 if $editor == nil
39   %w{/usr/bin/sensible-editor /usr/bin/editor /usr/bin/vi}.each do |editor|
40     if FileTest.executable?(editor)
41       $editor = editor
42       break
43     end
44   end
45 end
46
47 class GnuPG
48   @@my_keys = nil
49   @@my_fprs = nil
50   @@keyid_fpr_mapping = {}
51
52   def GnuPG.readwrite3(intxt, infd, stdoutfd, stderrfd, statusfd)
53     outtxt, stderrtxt, statustxt = ''
54     thread_in = Thread.new {
55       infd.print intxt
56       infd.close
57     }
58     thread_out = Thread.new {
59       outtxt = stdoutfd.read
60       stdoutfd.close
61     }
62     thread_err = Thread.new {
63       errtxt = stderrfd.read
64       stderrfd.close
65     }
66     thread_status = Thread.new {
67       statustxt = statusfd.read
68       statusfd.close
69     } if (statusfd)
70
71     thread_in.join
72     thread_out.join
73     thread_err.join
74     thread_status.join if thread_status
75
76     return outtxt, stderrtxt, statustxt
77   end
78
79   def GnuPG.gpgcall(intxt, args, require_success = false)
80     inR, inW = IO.pipe
81     outR, outW = IO.pipe
82     errR, errW = IO.pipe
83     statR, statW = IO.pipe
84
85     pid = Kernel.fork do
86       inW.close
87       outR.close
88       errR.close
89       statR.close
90       STDIN.reopen(inR)
91       STDOUT.reopen(outW)
92       STDERR.reopen(errW)
93       begin
94         exec(GNUPG, "--status-fd=#{statW.fileno}",  *args)
95       rescue Exception => e
96         outW.puts("[PWSEXECERROR]: #{e}")
97         exit(1)
98       end
99       raise ("Calling gnupg failed")
100     end
101     inR.close
102     outW.close
103     errW.close
104     statW.close
105     (outtxt, stderrtxt, statustxt) = readwrite3(intxt, inW, outR, errR, statR);
106     wpid, status = Process.waitpid2 pid
107     throw "Unexpected pid: #{pid} vs #{wpid}" unless pid == wpid
108     throw "Process has not exited!?" unless status.exited?
109     throw "gpg call did not exit sucessfully" if (require_success and status.exitstatus != 0)
110     if m=/^\[PWSEXECERROR\]: (.*)/.match(outtxt) then
111       STDERR.puts "Could not run GnuPG: #{m[1]}"
112       exit(1)
113     end
114     return outtxt, stderrtxt, statustxt, status.exitstatus
115   end
116
117   def GnuPG.init_keys()
118     return if @@my_keys
119     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', %w{--fast-list-mode --with-colons --with-fingerprint --list-secret-keys}, true)
120     @@my_keys = []
121     @@my_fprs = []
122     outtxt.split("\n").each do |line|
123       parts = line.split(':')
124       if (parts[0] == "ssb" or parts[0] == "sec")
125         @@my_keys.push parts[4]
126       elsif (parts[0] == "fpr")
127         @@my_fprs.push parts[9]
128       end
129     end
130   end
131   # This is for my private keys, so we can tell if a file is encrypted to us
132   def GnuPG.get_my_keys()
133     init_keys
134     @@my_keys
135   end
136   # And this is for my private keys also, so we can tell if we are encrypting to ourselves
137   def GnuPG.get_my_fprs()
138     init_keys
139     @@my_fprs
140   end
141
142   # This maps public keyids to fingerprints, so we can figure
143   # out if a file that is encrypted to a bunch of keys is
144   # encrypted to the fingerprints it should be encrypted to
145   def GnuPG.get_fpr_from_keyid(keyid)
146     fpr = @@keyid_fpr_mapping[keyid]
147     # this can be null, if we tried to find the fpr but failed to find the key in our keyring
148     unless fpr
149       STDERR.puts "Warning: No key found for keyid #{keyid}"
150     end
151     return fpr
152   end
153   def GnuPG.get_fprs_from_keyids(keyids)
154     learn_fingerprints_from_keyids(keyids)
155     return keyids.collect{ |k| get_fpr_from_keyid(k) or "unknown" }
156   end
157
158   # this is to load the keys we will soon be asking about into
159   # our keyid-fpr-mapping hash
160   def GnuPG.learn_fingerprints_from_keyids(keyids)
161     need_to_learn = keyids.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }
162     if need_to_learn.size > 0
163       # we can't use --fast-list-mode here because GnuPG is broken
164       # and does not show elmo's fingerprint in a call like
165       # gpg --with-colons --fast-list-mode --with-fingerprint --list-key D7C3F131AB2A91F5
166       args = %w{--with-colons --with-fingerprint --list-keys}
167       args.concat need_to_learn
168       (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', args, true)
169
170       pub = nil
171       fpr = nil
172       outtxt.split("\n").each do |line|
173         parts = line.split(':')
174         if (parts[0] == "pub")
175           pub = parts[4]
176         elsif (parts[0] == "fpr")
177           fpr = parts[9]
178           @@keyid_fpr_mapping[pub] = fpr
179         elsif (parts[0] == "sub")
180           @@keyid_fpr_mapping[parts[4]] = fpr
181         end
182       end
183     end
184     need_to_learn.reject{ |k| @@keyid_fpr_mapping.has_key?(k) }.each { |k| @@keyid_fpr_mapping[k] = nil }
185   end
186 end
187
188 def read_input(query, default_yes=true)
189   if default_yes
190     append = '[Y/n]'
191   else
192     append = '[y/N]'
193   end
194
195   while true
196     print "#{query} #{append} "
197     i = STDIN.readline.chomp.downcase
198     if i==""
199       return default_yes
200     elsif i=="y"
201       return true
202     elsif i=="n"
203       return false
204     end
205   end
206 end
207
208 class GroupConfig
209   def initialize
210     parse_file
211     expand_groups
212   end
213
214   def verify(content)
215     begin
216       f = File.open(ENV['HOME']+'/.pws-trusted-users')
217     rescue Exception => e
218       STDERR.puts e
219       exit(1)
220     end
221
222     trusted = []
223     f.readlines.each do |line|
224       line.chomp!
225       next if line =~ /^$/
226       next if line =~ /^#/
227
228       trusted.push line
229     end
230
231     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, %w{})
232     goodsig = false
233     validsig = nil
234     statustxt.split("\n").each do |line|
235       if m = /^\[GNUPG:\] GOODSIG/.match(line)
236         goodsig = true
237       elsif m = /^\[GNUPG:\] VALIDSIG \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([0-9A-F]+)/.match(line)
238         validsig = m[1]
239       end
240     end
241
242     if not goodsig
243       STDERR.puts ".users file is not signed properly.  GnuPG said on stdout:"
244       STDERR.puts outtxt
245       STDERR.puts "and on stderr:"
246       STDERR.puts stderrtxt
247       STDERR.puts "and via statusfd:"
248       STDERR.puts statustxt
249       exit(1)
250     end
251
252     if not trusted.include?(validsig)
253       STDERR.puts ".users file is signed by #{validsig} which is not in ~/.pws-trusted-users"
254       exit(1)
255     end
256
257     if not exitstatus==0
258       STDERR.puts "gpg verify failed for .users file"
259       exit(1)
260     end
261
262     return outtxt
263   end
264
265   def parse_file
266     begin
267       f = File.open('.users')
268     rescue Exception => e
269       STDERR.puts e
270       exit(1)
271     end
272
273     users = f.read
274     f.close
275
276     users = verify(users)
277
278     @users = {}
279     @groups = {}
280
281     lno = 0
282     users.split("\n").each do |line|
283       lno = lno+1
284       next if line =~ /^$/
285       next if line =~ /^#/
286       if (m = /^([a-zA-Z0-9-]+)\s*=\s*([0-9A-Fa-f]{40})\s*$/.match line)
287         user = m[1]
288         fpr = m[2]
289         if @users.has_key?(user)
290           STDERR.puts "User #{user} redefined at line #{lno}!"
291           exit(1)
292         end
293         @users[user] = fpr
294       elsif (m = /^(@[a-zA-Z0-9-]+)\s*=\s*(.*)$/.match line)
295         group = m[1]
296         members = m[2].strip
297         if @groups.has_key?(group)
298           STDERR.puts "Group #{group} redefined at line #{lno}!"
299           exit(1)
300         end
301         members = members.split(/[\t ,]+/)
302         @groups[group] = { "members" => members }
303       end
304     end
305   end
306
307   def is_group(name)
308     return (name =~ /^@/)
309   end
310   def check_exists(x, whence, fatal=true)
311     ok=true
312     if is_group(x)
313       ok=false unless (@groups.has_key?(x))
314     else
315       ok=false unless @users.has_key?(x)
316     end
317     unless ok
318       STDERR.puts( (fatal ? "Error: " : "Warning: ") + "#{whence} contains unknown member #{x}")
319       exit(1) if fatal
320     end
321     return ok
322   end
323   def expand_groups
324     @groups.each_pair do |groupname, group|
325       group['members'].each do |member|
326         check_exists(member, "Group #{groupname}")
327       end
328       group['members_to_do'] = group['members'].clone
329     end
330
331     while true
332       had_progress = false
333       all_expanded = true
334       @groups.each_pair do |groupname, group|
335         group['keys'] = [] unless group['keys'] 
336
337         still_contains_groups = false
338         group['members_to_do'].clone.each do |member|
339           if is_group(member)
340             if @groups[member]['members_to_do'].size == 0
341               group['keys'].concat @groups[member]['keys']
342               group['members_to_do'].delete(member)
343               had_progress = true
344             else
345               still_contains_groups = true
346             end
347           else
348             group['keys'].push @users[member]
349             group['members_to_do'].delete(member)
350             had_progress = true
351           end
352         end
353         all_expanded = false if still_contains_groups
354       end
355       break if all_expanded
356       unless had_progress
357         cyclic_groups = @groups.keys.reject{|name| @groups[name]['members_to_do'].size == 0}.join(", ")
358         STDERR.puts "Cyclic group memberships in #{cyclic_groups}?"
359         exit(1)
360       end
361     end
362   end
363
364   def expand_targets(targets)
365     fprs = []
366     ok = true
367     targets.each do |t|
368       unless check_exists(t, "access line", false)
369         ok = false
370         next
371       end
372       if is_group(t)
373         fprs.concat @groups[t]['keys']
374       else
375         fprs.push @users[t]
376       end
377     end
378     return ok, fprs.uniq
379   end
380
381   def get_users()
382     return @users
383   end
384 end
385
386 class EncryptedFile
387   attr_reader :accessible, :encrypted, :readable, :readers
388
389   def EncryptedFile.determine_readable(readers)
390     GnuPG.get_my_keys.each do |keyid|
391       return true if readers.include?(keyid)
392     end
393     return false
394   end
395
396   def EncryptedFile.list_readers(statustxt)
397     readers = []
398     statustxt.split("\n").each do |line|
399       m = /^\[GNUPG:\] ENC_TO ([0-9A-F]+)/.match line
400       next unless m
401       readers.push m[1]
402     end
403     return readers
404   end
405
406   def EncryptedFile.targets(text)
407     metaline = text.split("\n").first
408     m = /^access: (.*)/.match metaline
409     return [] unless m
410     return m[1].strip.split(/[\t ,]+/)
411   end
412
413
414   def initialize(filename, new=false)
415     @groupconfig = GroupConfig.new
416     @new = new
417     if @new
418       @readers = []
419     end
420
421     @filename = filename
422     unless FileTest.readable?(filename)
423       @accessible = false
424       return
425     end
426     @accessible = true
427     @encrypted_content = File.read(filename)
428     (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall(@encrypted_content, %w{--with-colons --no-options --no-default-keyring --secret-keyring=/dev/null --keyring=/dev/null})
429     @encrypted = !(statustxt =~ /\[GNUPG:\] NODATA/)
430     if @encrypted
431       @readers = EncryptedFile.list_readers(statustxt)
432       @readable = EncryptedFile.determine_readable(@readers)
433     end
434   end
435
436   def decrypt
437     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(@encrypted_content, %w{--decrypt})
438     if !@new and exitstatus != 0
439       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when decrypting #{@filename}.  Proceed?", false)
440       exit(0) unless proceed
441     elsif !@new and outtxt.length == 0
442       proceed = read_input("Warning: #{@filename} decrypted to an empty file.  Proceed?")
443       exit(0) unless proceed
444     end
445
446     return outtxt
447   end
448
449   def encrypt(content, recipients)
450     args = recipients.collect{ |r| "--recipient=#{r}"}
451     args.push "--trust-model=always"
452     args.push "--keyring=./.keyring" if FileTest.exists?(".keyring")
453     args.push "--armor"
454     args.push "--encrypt"
455     (outtxt, stderrtxt, statustxt, exitstatus) = GnuPG.gpgcall(content, args)
456
457     invalid = []
458     statustxt.split("\n").each do |line|
459       m = /^\[GNUPG:\] INV_RECP \S+ ([0-9A-F]+)/.match line
460       next unless m
461       invalid.push m[1]
462     end
463     if invalid.size > 0
464       again = read_input("Warning: the following recipients are invalid: #{invalid.join(", ")}. Try again (or proceed)?")
465       return false if again
466     end
467     if outtxt.length == 0
468       tryagain = read_input("Error: #{@filename} encrypted to an empty file.  Edit again (or exit)?")
469       return false if tryagain
470       exit(0)
471     end
472     if exitstatus != 0
473       proceed = read_input("Warning: gpg returned non-zero exit status #{exitstatus} when encrypting #{@filename}. Said:\n#{stderrtxt}\n#{statustxt}\n\nProceed (or try again)?")
474       return false unless proceed
475     end
476
477     return true, outtxt
478   end
479
480
481   def determine_encryption_targets(content)
482     targets = EncryptedFile.targets(content)
483     if targets.size == 0
484       tryagain = read_input("Warning: Did not find targets to encrypt to in header.  Try again (or exit)?", true)
485       return false if tryagain
486       exit(0)
487     end
488
489     ok, expanded = @groupconfig.expand_targets(targets)
490     if (expanded.size == 0)
491       tryagain = read_input("Errors in access header.  Edit again (or exit)?", true)
492       return false if tryagain
493       exit(0)
494     elsif (not ok)
495       tryagain = read_input("Warnings in access header.  Edit again (or continue)?", true)
496       return false if tryagain
497     end
498
499     to_me = false
500     GnuPG.get_my_fprs.each do |fpr|
501       if expanded.include?(fpr)
502         to_me = true
503         break
504       end
505     end
506     unless to_me
507       tryagain = read_input("File is not being encrypted to you.  Edit again (or continue)?", true)
508       return false if tryagain
509     end
510
511     return true, expanded
512   end
513
514   def write_back(content, targets)
515     ok, encrypted = encrypt(content, targets)
516     return false unless ok
517
518     File.open(@filename,"w").write(encrypted)
519     return true
520   end
521 end
522
523 class Ls
524   def help(parser, code=0, io=STDOUT)
525     io.puts "Usage: #{$program_name} ls [<directory> ...]"
526     io.puts parser.summarize
527     io.puts "Lists the contents of the given directory/directories, or the current"
528     io.puts "directory if none is given.  For each file show whether it is PGP-encrypted"
529     io.puts "file, and if yes whether we can read it."
530     exit(code)
531   end
532
533   def ls_dir(dirname)
534     begin
535       dir = Dir.open(dirname)
536     rescue Exception => e
537       STDERR.puts e
538       return
539     end
540     puts "#{dirname}:"
541     Dir.chdir(dirname) do
542       unless FileTest.exists?(".users")
543         STDERR.puts "The .users file does not exists here.  This is not a password store, is it?"
544         exit(1)
545       end
546       dir.sort.each do |filename|
547         next if (filename =~ /^\./) and not (@all >= 3)
548         stat = File::Stat.new(filename)
549         if stat.symlink?
550           puts "(sym)      #{filename}" if (@all >= 2)
551         elsif stat.directory?
552           puts "(dir)      #{filename}" if (@all >= 2)
553         elsif !stat.file?
554           puts "(other)    #{filename}" if (@all >= 2)
555         else
556           f = EncryptedFile.new(filename)
557           if !f.accessible
558             puts "(!perm)    #{filename}"
559           elsif !f.encrypted
560             puts "(file)     #{filename}" if (@all >= 2)
561           elsif f.readable
562             puts "(ok)       #{filename}"
563           else
564             puts "(locked)   #{filename}" if (@all >= 1)
565           end
566         end
567       end
568     end
569   end
570
571   def initialize()
572     @all = 0
573     ARGV.options do |opts|
574       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
575       opts.on_tail("-a", "--all" , "Show all files (use up to 3 times to show even more than all)") { @all = @all+1 }
576       opts.parse!
577     end
578
579     dirs = ARGV
580     dirs.push('.') unless dirs.size > 0
581     dirs.each { |dir| ls_dir(dir) }
582   end
583 end
584
585 class Ed
586   def help(parser, code=0, io=STDOUT)
587     io.puts "Usage: #{$program_name} ed <filename>"
588     io.puts parser.summarize
589     io.puts "Decrypts the file, spawns an editor, and encrypts it again"
590     exit(code)
591   end
592
593   def edit(filename)
594     encrypted_file = EncryptedFile.new(filename, @new)
595     if !@new and !encrypted_file.readable && !@force
596       STDERR.puts "#{filename} is probably not readable"
597       exit(1)
598     end
599
600     encrypted_to = GnuPG.get_fprs_from_keyids(encrypted_file.readers).sort
601
602     content = encrypted_file.decrypt
603     original_content = content
604     while true
605       oldsize = content.length
606       tempfile = Tempfile.open('pws')
607       tempfile.puts content
608       tempfile.flush
609       system($editor, tempfile.path)
610       status = $?
611       throw "Process has not exited!?" unless status.exited?
612       unless status.exitstatus == 0
613         proceed = read_input("Warning: Editor did not exit successfully (exit code #{status.exitstatus}.  Proceed?")
614         exit(0) unless proceed
615       end
616       tempfile.seek(0, IO::SEEK_SET)
617       content = tempfile.read
618
619       # zero the file
620       newsize = content.length
621       tempfile.seek(0, IO::SEEK_SET)
622       clearsize = (newsize > oldsize) ? newsize : oldsize
623       tempfile.print "\0"*clearsize
624       tempfile.fsync
625       tempfile.close(true)
626
627       if content.length == 0
628         proceed = read_input("Warning: Content is now empty.  Proceed?")
629         exit(0) unless proceed
630       end
631
632       ok, targets = encrypted_file.determine_encryption_targets(content)
633       next unless ok
634
635       if (original_content == content)
636         if (targets.sort == encrypted_to)
637           proceed = read_input("Nothing changed.  Re-encrypt anyway?", false)
638           exit(0) unless proceed
639         else
640           STDERR.puts("Info: Content not changed but re-encrypting anyway because the list of keys changed")
641         end
642       end
643
644       success = encrypted_file.write_back(content, targets)
645       break if success
646     end
647   end
648
649   def initialize()
650     ARGV.options do |opts|
651       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
652       opts.on_tail("-n", "--new" , "Edit new file") { |@new| }
653       opts.on_tail("-f", "--force" , "Spawn an editor even if the file is probably not readable") { |@force| }
654       opts.parse!
655     end
656     help(ARGV.options, 1, STDERR) if ARGV.length != 1
657     filename = ARGV.shift
658
659     if @new
660       if FileTest.exists?(filename)
661         STDERR.puts "#{filename} does exist"
662         exit(1)
663       end
664     else
665       if !FileTest.exists?(filename)
666         STDERR.puts "#{filename} does not exist"
667         exit(1)
668       elsif !FileTest.file?(filename)
669         STDERR.puts "#{filename} is not a regular file"
670         exit(1)
671       elsif !FileTest.readable?(filename)
672         STDERR.puts "#{filename} is not accessible (unix perms)"
673         exit(1)
674       end
675     end
676
677     dirname = File.dirname(filename)
678     basename = File.basename(filename)
679     Dir.chdir(dirname) {
680       edit(basename)
681     }
682   end
683 end
684
685 class KeyringUpdater
686   def help(parser, code=0, io=STDOUT)
687     io.puts "Usage: #{$program_name} update-keyring [<keyserver>]"
688     io.puts parser.summarize
689     io.puts "Updates the local .keyring file"
690     exit(code)
691   end
692
693   def initialize()
694     ARGV.options do |opts|
695       opts.on_tail("-h", "--help" , "Display this help screen") { help(opts) }
696       opts.parse!
697     end
698     help(ARGV.options, 1, STDERR) if ARGV.length > 1
699     keyserver = ARGV.shift
700     keyserver = 'keys.gnupg.net' unless keyserver
701
702     groupconfig = GroupConfig.new
703     users = groupconfig.get_users()
704     args = %w{--with-colons --no-options --no-default-keyring --keyring=./.keyring}
705
706     system('touch', '.keyring')
707     users.each_pair() do |uid, keyid|
708       cmd = args.clone()
709       cmd << "--keyserver=#{keyserver}"
710       cmd << "--recv-keys"
711       cmd << keyid
712       puts "Fetching key for #{uid}"
713       (outtxt, stderrtxt, statustxt) = GnuPG.gpgcall('', cmd)
714       unless (statustxt =~ /^\[GNUPG:\] IMPORT_OK /)
715         STDERR.puts "Warning: did not find IMPORT_OK token in status output"
716         STDERR.puts "gpg exited with exit code #{ecode})"
717         STDERR.puts "Command was gpg #{cmd.join(' ')}"
718         STDERR.puts "stdout was #{outtxt}"
719         STDERR.puts "stderr was #{stderrtxt}"
720         STDERR.puts "statustxt was #{statustxt}"
721       end
722
723       cmd = args.clone()
724       cmd << '--batch' << '--edit' << keyid << 'minimize' << 'save'
725       (outtxt, stderrtxt, statustxt, ecode) = GnuPG.gpgcall('', cmd)
726     end
727
728
729   end
730 end
731
732 def help(code=0, io=STDOUT)
733   io.puts "Usage: #{$program_name} ed"
734   io.puts "       #{$program_name} ls"
735   io.puts "       #{$program_name} update-keyring"
736   io.puts "       #{$program_name} help"
737   io.puts "Call #{$program_name} <command> --help for additional options/parameters"
738   exit(code)
739 end
740
741
742 def parse_command
743   case ARGV.shift
744     when 'ls': Ls.new
745     when 'ed': Ed.new
746     when 'update-keyring': KeyringUpdater.new
747     when 'help': 
748       case ARGV.length
749         when 0: help
750         when 1:
751           ARGV.push "--help"
752           parse_command
753         else help(1, STDERR)
754       end
755     else
756       help(1, STDERR)
757   end
758 end
759
760 parse_command
761
762 # vim:set shiftwidth=2:
763 # vim:set et:
764 # vim:set ts=2: