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