#!/bin/sh
#| -*- mode: scheme; coding: utf-8; -*-
exec gosh -I. -- $0 "$@"

Copyright (C) 2025 Jens Thiele <karme@karme.de>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

Runtime dependencies:
new enough openssl (debian bullseye is too old), zopfli (only for
encoding/creation and optional but recommended)

Build dependencies:
gauche scheme http://practical-scheme.net/gauche/, gauche zlib, bash,
coreutils, help2man, expect (for testing)

Suggested dependencies:

gnupg (for the gpg-agent password agent), zenity (to ask questions if
there is no controlling tty)

To install all dependencies on debian like systems:
$ sudo apt-get -y install \
     openssl zopfli \
     gauche gauche-zlib bash coreutils help2man expect \
     gnupg zenity

Recommended is the installation as gosh script (needs gauche scheme at
runtime then):

$ make check && sudo make install

but if you are really very impatient and on linux/amd64 (aka x86_64)
then you also can install the binary:

$ ./binary-install.sh

You know an existing better tool serving the same purpose?
mail me!
(upx-ucl didn't work for small files:
CantPackException: file is too small)
gzexe/bzexe aren't intended for very small files and pasting

History

Version
0.0.1 - initial version

0.0.2 - many breaking changes (the last ones I promise ;-) new
alphabet, added signatures, allow decoding by parameter, drop fd 3
usage in "API" and ask questions on controlling tty

0.0.3 - add missing test.exp file to release

0.0.4 - allow to decode/run without controlling tty if key is known,
improve error handling

0.0.5 - allow to use gnupg agent for the passphrase

0.0.6 - converted from shell+scheme mix to pure gauche scheme script

0.0.17 - release tarball now includes a binary for amd64

0.0.18 - binary-install.sh script for the impatient

0.0.19 - if there is no controlling tty use zenity as fallback to ask
questions

0.0.20 - fall back to gauche-zlib if zopfli isn't available

0.0.21 - fix binary-update.sh script.

0.0.28 - release process improvements.

0.1.0  - new header ßŹ instead of zs (4 utf-8 bytes instead of 2)

0.1.1  - reduced memory usage, cleanup

misc notes:
- always use a shebang for shell scripts

Emacs integration:
(add-to-list 'jka-compr-compression-info-list ["\\.zs\\'" "zs compressing" "zs" ("-e") "zs uncompressing" "zs" ("-d") nil nil "zs" nil])
(jka-compr-update)
|#

(use file.util)
(use gauche.process)
(use gauche.termios)
(use gauche.uvector)
(use gauche.sequence)
(use gauche.vport)
(use rfc.base64)
(use srfi.13)
(use rfc.zlib)
(use rfc.uri)
(use sxml.adaptor) ;; for assert macro

(debug-print-width 4000)

(define *zsversion* "0.1.1")
(define *zsdir* (or (sys-getenv "ZSDIR")
                    (string-append (home-directory) "/.zeichensalat")))

(define (help)
  ;; note: output should be suitable for help2man
  (print "
Usage: zs [OPTION|zeichensalat]

zs, short for Zeichensalat (German for mojibake) is a tool to make/run
compressed, signed executables copy/pastes using less than 500 unicode
characters. It is intended to be used on the fediverse (or in chats).
Note: the goal is to reduce the number of characters not bytes.  At
the moment each deflate compressed byte is represented by one unicode
character. The encoding is very similar to base256U but doesn't use
some problematic symbols. As signature algorithm Ed25519 is
used. Future versions might use different compression, signature and
encoding algorithms.

Options:

  -h, --help display this help

  -c         create private and public key pair if it doesn't exist yet

  -E         create an executable shell script (executable is read
             from stdin and result is written to stdout)

  -e         just encode from stdin (similar to base64)

  -d         just decode from stdin (similar to base64 -d)

  -p         display own public key

  --version  display version

with exactly one non-option argument that argument is processed as
input otherwise without any arguments:

Decode from stdin to file, check signature (ask if unknown signatory),
decompress and run. This mode of operation is used by the shell
scripts created with -E at the moment.

*EXAMPLES*

# create private and public key pair

$ zs -c

# compress, sign and encode the example to /tmp/x

$ zs -e < example > /tmp/x

# decode, verify signature and decompress from /tmp/x

$ zs -d < /tmp/x

# decode, verify signature, decompress and run from /tmp/x

$ zs < /tmp/x

# decode, verify signature, decompress and run from argument

$ zs $(cat /tmp/x)

# create executable shell script

$ zs -E < example

# hello world (my public key is wWHzHvnWWnNC2ZbVtLS+ouOblhdSIeUg6LkYrM7mHws=)

$ zs 'ßŹŅäŷUŽŚÝöÄŝĚřĸĸłĦŧğĚN
ÔXũWŬĽOİŒŪVBÑõŢķJÙĔðëkNœĿGžū
ĊŞĉĝŃþţJńčĪuĪĚ0ÍoÍĠŝŰŶĶĒĎYÂŶ
ŅČļĂĳųεŵąøŋŽņÃÇĔÓeβ1ÕÙŘÑŎŐŗl
ŒŤÌÏŒŌÚŌÊőōōū20'

*FILES*

~/.zeichensalat/known_keys
        Public key store including name, key and trust value.

~/.zeichensalat/private.pem
        Passphrase encrypted private key.

~/.zeichensalat/public.bin
        Own public key.

*CREDITS*

Thanks to seb for testing and feedback.
Thanks to cendyne for
<https://cendyne.dev/posts/2022-03-06-ed25519-signatures.html>.
Thanks to KIAaze for
<https://stackoverflow.com/questions/77244714/how-can-i-extract-the-32-byte-ed25519-public-key-from-a-pem-file-and-how-can-i>
Thanks to Burak Gökmen for
<https://www.baeldung.com/linux/expect-script-process-exit-code>

*AUTHORS*

Jens Thiele (karme@karme.de)

*SEE ALSO*
base64(1), gzexe(1), bzexe(1), upx-ucl(1), makeself(1),
<https://en.wikipedia.org/wiki/Fediverse>,
base256U <https://github.com/fleschutz/base256U>,
deflate compression <https://en.wikipedia.org/wiki/Deflate>
"))

;; The alphabet of 256 characters used

;; - should have good fixed width font support.

;;   Especially the fonts misc-fixed, Noto Sans, DejaVu Sans Mono and
;;   Roboto Mono (the fixed width font on Android) should support the
;;   alphabet. Note: "ſƀƁƂƃ" are not supported by Roboto Mono.
;;   Ideally other systems default fixed width fonts work, too.

;; - don't waste too much space (in bytes) if UTF-8 encoded

;; - double click to copy should work

;; very similar is base256U: if I am not mistaken only the last 5
;; characters are different

;; Another encoding with a bigger alphabet would be
;; <https://github.com/qntm/base2048>
;; but I don't like its alphabet and it is more complex

;; todo: remove the ligatures (ĲĳŒœ) from the alphabet and add some
;; more nice greek letters (ζηθι)? but maybe they aren't that nice?
;; but hand-picking letters also is somehow ugly?

(define *alphabet* (string->vector
                    "0123456789\
                    ABCDEFGHIJKLMNOPQRSTUVWXYZ\
                    abcdefghijklmnopqrstuvwxyz\
                    ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ\
                    ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ\
                    ĀāĂăĄąĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħ\
                    ĨĩĪīĬĭĮįİıĲĳĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňŉŊŋŌōŎŏŐőŒœ\
                    ŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽž\
                    αβγδε"))

(assert (= (vector-length *alphabet*) 256))
(let1 l (vector->list *alphabet*)
  (assert (= (length l) (length (delete-duplicates l)))))

(define (test-alphabet)
  (for-each (lambda(i)
              (cond [(zero? i)
                     (print "```")]
                    [(zero? (modulo i 31))
                     (newline)
                     (print "0123456789012345678901234567890")])
              (write-char (vector-ref *alphabet* i)))
            (iota 256))
  (newline)
  (print "```"))

;; i: number of chars already written
;; return number of chars written
(define (encode-2 i)
  (let1 b (read-byte)
    (cond [(eof-object? b)
           (newline)
           (+ i 1)]
          [else
           (write-char (vector-ref *alphabet* b))
           (encode-2 (+ i 1))])))

;; return number of chars written
(define (encode filename)
  ;; let's waste 4 bytes as file magic and to make future versions
  ;; possible
  ;; note:
  ;; first wanted to use żŝ as magic marker but then file says it is a
  ;; openpgp secret key => not so good
  ;; => ßŹ works better
  (write-char #\ß)
  (write-char #\Ź)
  (with-input-from-file filename
    (lambda()
      (encode-2 2))))

(define *lut* (make-hash-table 'equal?))

(for-each-with-index (lambda(i c)
                       (set! (~ *lut* c) i)) *alphabet*)

(define (file-format-version header)
  (cond [(string=? header "zs")
         0]
        [(and (char=? (~ header 0) #\ß)
              (member (~ header 1) (string->list "ŹźŻżŽž")))
         (logand (~ (string->u8vector (subseq header 1)) 1) 7)]
        [else
         (error "Not a Zeichensalat file?")]))

(assert (zero? (file-format-version "zs")))
(assert (= (file-format-version "ßŹ") 1))
(assert (= (file-format-version "ßź") 2))
(assert (= (file-format-version "ßž") 6))

(define (open-decoding-port source)
  (let* ((header (read-string 2 source))
         (version (file-format-version header)))
    (when (> version 1)
      (error #`"File format version ,|version| not yet supported. Update Zeichensalat?")))
  (make <virtual-input-port>
    :getb (lambda() (let loop ((c (read-char source)))
                      (cond [(eof-object? c)
                             c]
                            [(char-set-contains? #[\s\\] c)
                             (loop (read-char source))]
                            [else
                             (~ *lut* c)])))))

(define (tty-or-dev-null)
  (or (open-input-file (console-device) :if-does-not-exist #f)
      (open-input-file (null-device))))

(define (with-input-from-tty-or-dev-null proc)
  (let1 port (tty-or-dev-null)
    (unwind-protect
     (with-input-from-port port proc)
     (close-input-port port))))

;; inflate (decompress) stdin to stdout
(define (inflate)
  (let1 port (open-inflating-port (current-input-port)
                                  :window-bits -15)
    (unwind-protect
     (copy-port port (current-output-port))
     (close-input-port port))))

(define (verify-signature bin-public-key bin-signature message-filename)
  (call-with-temporary-file
      (lambda(sig-port sig-file)
        (write-uvector bin-signature sig-port)
        (flush sig-port)
        ;; create DER format public key
        ;; s.a. <https://stackoverflow.com/questions/77244714/how-can-i-extract-the-32-byte-ed25519-public-key-from-a-pem-file-and-how-can-i>
        (call-with-temporary-file
            (lambda(der-port der-file)
              (write-uvector #u8(#x30 #x2a #x30 #x05 #x06 #x03 #x2b #x65 #x70 #x03 #x21 #x0) der-port)
              (write-uvector bin-public-key der-port)
              (flush der-port)
              ;; verify signature
              (do-process! `(openssl pkeyutl -verify -sigfile ,sig-file -in ,message-filename -rawin -pubin
                                     -keyform DER -inkey ,der-file)
                           :redirects `((> 1 2))))))))

;; decode stdin to oport
;; return public key (base64 encoded)
(define (zs-decode oport)
  (with-input-from-port (open-decoding-port (current-input-port))
    (lambda()
      (let* ((pubkey (read-uvector <u8vector> 32))
             (signature (read-uvector <u8vector> 64)))
        (assert (= (size-of pubkey) 32))
        (assert (= (size-of signature) 64))
        (with-input-from-tty-or-dev-null
         (lambda()
           (check-public-key (base64-encode-message pubkey))))

        ;; todo: do we have to check the public key for validity or will
        ;; openssl do this?

        ;; verify signature
        (call-with-temporary-file
            (lambda(message-port message-file)
              (copy-port (current-input-port) message-port)
              (flush message-port)
              (verify-signature pubkey signature message-file)
              (with-output-to-port oport
                (lambda()
                  (with-input-from-file message-file
                    (lambda()
                      (inflate)))))))
        (base64-encode-message pubkey)))))

(define (private-key-file)
  (string-append *zsdir* "/private.pem"))

(define (public-key-file)
  (string-append *zsdir* "/public.bin"))

(define (terminal-available?)
  (let1 p (open-input-file (console-device) :if-does-not-exist #f)
    (cond [p
           (close-input-port p)
           #t]
          [else
           #f])))

(define (terminal-required)
  (assert (terminal-available?)))

;; public-key: public key in base64 or #f when creating new key
(define (getpassphrase public-key)
  (cond [(sys-getenv "GPG_AGENT_INFO")
         (with-input-from-process
             (if public-key
               (let1 text (uri-encode-string (string-append "Passphrase for private key "
                                                            (public-key->string public-key)))
                 `(gpg-connect-agent ,#`"GET_PASSPHRASE --data zs:,|public-key| X Zeichensalat ,text" "/bye"))
               `(gpg-connect-agent ,#`"GET_PASSPHRASE --data --repeat=1 --qualitybar X X Zeichensalat private+key+passphrase" "/bye"))
           (lambda()
             (let1 c (read-char)
               (when (not (char=? c #\D))
                 ;; todo: likely a timeout
                 ;; note: use "pinentry-timeout 0" in your ~/.gnupg/gpg-agent.conf to disable timeouts
                 (with-output-to-port (current-error-port)
                   (lambda()
                     (print "Unexpected agent answer:")
                     (print (string-append (make-string 1 c)
                                           (read-line)))
                     (error "Unexpected agent answer"))))
               (assert (char=? (read-char) #\space))
               (read-line)))
           :on-abnormal-exit :error)]
        [else
         ;; todo: fallback to zenity here, too?
         (terminal-required)
         (display "Enter passphrase for your private key:\n" (current-error-port))
         (without-echoing #f read-line)]))

(define (clearpassphrase public-key)
  (cond [(sys-getenv "GPG_AGENT_INFO")
         (with-input-from-process
             `(gpg-connect-agent ,#`"CLEAR_PASSPHRASE zs:,|public-key|" "/bye")
           (lambda()
             (assert (char=? (read-char) #\O))
             (assert (char=? (read-char) #\K)))
           :on-abnormal-exit :error)]
        [else
         #f]))

;; return binary signature of file as uvector
(define (sign filename)
  (let1 p (getpassphrase (my-public-key))
    (rlet1 r (guard (e [else
                        (clearpassphrase (my-public-key))
                        (raise e)])
                    ;; todo: can't we use do-process! with redir here?
                    ;; note: openssl in debian/bullseye doesn't know -rawin
                    (call-with-process-io `(openssl pkeyutl -in ,filename -rawin -sign -inkey ,(private-key-file) -passin stdin)
                                          (lambda(iport oport)
                                            (display p oport)
                                            (newline oport)
                                            (flush oport)
                                            (port->uvector iport))))
           (assert (= (size-of r) 64)))))

(define (cat filename)
  (with-input-from-file filename
    (lambda()
      (copy-port (current-input-port) (current-output-port)))))

(define (deflate)
  (let1 port (open-deflating-port (current-output-port)
                                  :compression-level Z_BEST_COMPRESSION
                                  ;; :buffer-size 512
                                  :window-bits -15
                                  :memory-level 9
                                  ;; :strategy Z_FIXED
                                  )
    (unwind-protect
     (copy-port (current-input-port) port)
     (close-output-port port))))

;; encode file to stdout
;; return number of characters written
(define (zs-encode filename)
  ;; note: we use raw deflate => there is no error checking
  ;; using zlib would add 6 bytes (2 header bytes + 4 checksum bytes)
  ;; but decoding in gauche doesn't use the checksum anyway?
  ;; As we now sign the compressed bytes this should be no problem.

  ;; For compression for now we depend on zopfli as it produces
  ;; slightly smaller output
  (call-with-temporary-file
      (lambda(compressed-port compressed-file)
        (cond [(find-file-in-paths "zopfli")
               (do-process! `(zopfli -c --deflate ,filename)
                            :redirects `((> 1 ,compressed-port)
                                         (> 2 :null)))]
              [else
               (with-output-to-port (current-error-port)
                 (lambda()
                   (print "WARNING: didn't find zopfli. Falling back to zlib for compression.")))
               (with-input-from-file filename
                 (lambda()
                   (with-output-to-port compressed-port
                     (lambda()
                       (deflate)
                       (flush)))))])

        ;; brotli didn't perform better
        ;; brotli -q 11 -w 0 < "$1" > $C
        ;; zstd didn't perform better
        ;; zstd < "$1" > $C

        (let1 signature (sign compressed-file)
          (call-with-temporary-file
              (lambda(raw-port raw-file)
                (with-output-to-port raw-port
                  (lambda()
                    (cat (public-key-file)) ; note: binary public key not base64 encoded
                    (write-uvector signature)
                    (cat compressed-file)
                    (flush)))
                (encode raw-file)))))))

;; return public key as uvector
(define (derive-pubkey private-key-file passphrase)
  ;; todo: is this big or little endian? always?
  (call-with-process-io `(openssl pkey -in ,|private-key-file|
                                  -passin stdin -pubout -outform der)
                        (lambda(iport oport)
                          (display passphrase oport)
                          (newline oport)
                          (flush oport)
                          (let1 v (port->uvector iport)
                            ;; todo: add some checks here that this is really the expected bitstring?
                            (subseq v (- (size-of v) 32))))))

(define (check-private-key)
  (when (not (file-exists? (private-key-file)))
    (with-output-to-port (current-error-port)
      (lambda()
        (print "Have to create a private key first.")))
    (let1 passphrase (getpassphrase #f)
      ;; todo: is the option -aes256 really good enough?
      ;; do we really need a passphrase at all?
      (with-output-to-process
          `(openssl genpkey -algorithm ED25519 -pass stdin 
                    -aes256 -out ,(string-append *zsdir* "/private.pem.tmp"))
        (lambda()
          (print passphrase))
        :on-abnormal-exit :error)
      ;; todo: better create with correct mode in the first place?
      ;; it isn't really a race condition because our directory
      ;; is mode 700
      (sys-chmod (string-append *zsdir* "/private.pem.tmp") #o600)
      ;; todo: use better format? ssh format? or just keep DER format
      ;; then we wouldn't have to create it in verify-signature?
      (let1 v (derive-pubkey (string-append *zsdir* "/private.pem.tmp") passphrase)
        (with-output-to-file (string-append *zsdir* "/public.bin.tmp")
          (lambda()
            (write-uvector v)))
        (sys-rename (string-append *zsdir* "/private.pem.tmp") (private-key-file))
        (sys-rename (string-append *zsdir* "/public.bin.tmp") (public-key-file))
        (with-output-to-port (current-error-port)
          (lambda()
            (print "Your public key:")
            (display-my-public-key)))))))

;; decode from stdin
(define (zs-decode-and-execute)
  (receive (oport ofile)
      (sys-mkstemp (build-path (temporary-directory) "zs"))
    (let1 trusted?
        (unwind-protect
         (guard (e [else
                    (sys-unlink ofile)
                    (raise e)])
                (public-key-trusted? (zs-decode oport)))
         (flush oport)
         (close-output-port oport))
      (if trusted?
        (unwind-protect
         (call-with-temporary-directory (lambda(dir)
                                          (sys-chdir dir)
                                          (sys-chmod ofile #o700)
                                          ;; todo: really exit on error here?
                                          (do-process! `(,ofile))))
         (sys-unlink ofile))
        ;; untrusted public key (don't remove output file)
        (with-output-to-port (current-error-port)
          (lambda()
            (print #`"Untrusted key => not executing only decode. Decoded to ',ofile':")
            ;; todo: not usefull with binaries
            ;; note: we have to redirect explicitely :-(
            (do-process! `(cat "-v" ,ofile)
                         :redirects '((> 1 2)))))))))

(define (with-stdin-as-tmpfile proc)
  (call-with-temporary-file (lambda(oport ofile)
                              (copy-port (current-input-port)
                                         oport)
                              (flush oport)
                              (proc ofile))))

(define (known-keys-file)
  (string-append (string-append *zsdir* "/known_keys")))

(define (read-public-keys)
  (let1 keyfile (known-keys-file)
    (if (file-exists? keyfile)
      (with-input-from-file keyfile
        (lambda()
          (let1 r '()
            (until (read-line) eof-object? => row
                   (push! r (with-input-from-string row read)))
            (reverse r))))
      '())))

(define (write-public-keys l)
  (with-output-to-file (string-append (known-keys-file) ".tmp")
    (lambda()
      (for-each (lambda(k)
                  (write k)
                  (newline))
                l)))
  (sys-rename (string-append (known-keys-file) ".tmp")
              (string-append (known-keys-file))))

;; at the moment we present the public key simply as bas64 encoded
;; string to the user => identity function
;; if we ever want to change that it should suffice to adjust this
;; function
(define (public-key->string base64-key)
  base64-key)

(define (display-public-key k)
  (print (public-key->string k))
  ;; (let1 v (base64-decode-string-to <u8vector> k)
  ;;   (with-input-from-string (u8vector->string v)
  ;;     (lambda()
  ;;       (encode-2 0)))
  ;;   (for-each (lambda(b) (format #t "~2,'0x" b)) v))
  ;;   (newline))
  )

(define (my-public-key)
  (base64-encode-message
   (file->string
    (public-key-file))))

(define (display-my-public-key)
  (display-public-key (my-public-key)))

(define (get-public-key-info pubkey)
  (let1 keys (read-public-keys)
    (find (lambda(key) (string=? (vector-ref key 1) pubkey)) keys)))

(define (yes? prompt)
  (print prompt)
  (let1 answer (read-line)
    (cond [(not (string? answer))
           #f]
          [(#/[yY]/ answer)
           #t]
          [(#/[nN]/ answer)
           #f]
          [else
           (yes? prompt)])))

(define (zenity-available?)
  (do-process! '(zenity --version)
               :redirects `((> 1 ,(null-device))
                            (> 2 ,(null-device)))))

;; note: title might not be shown
(define (zenity-get-text title text)
  (process-output->string `(zenity --entry --text ,text --title ,title)
                          :on-abnormal-exit :error))

(define (zenity-question? title question)
  ;; exit status should be 0 => yes or 1 => no
  ;; closing the window also returns 1
  ;; for now we treat errors like no
  (guard (e [else #f])
         (do-process! `(zenity --question --text ,question --title ,title --default-cancel))))

(define-condition-type <eof-error> <error>
  eof-error?)

;; todo: try to use a single zenity dialog?
(define (get-name-and-trust pubkey)
  (let ((title "Unknown signature")
        (text #`"Unknown signature.
Please make sure you have the correct public key:
,(public-key->string pubkey)
Who do you think signed this message?"))
    (receive (name trusted?)
        (guard (e [else
                   (print "Failed to get name and trust.
Maybe re-run zs in a terminal or install zenity to allow me to ask questions.")
                   (raise e)])
               (guard (e [(and (<eof-error> e)
                               (zenity-available?))
                          (print "Failed to ask questions on terminal. Trying zenity.")
                          (let1 name (zenity-get-text title text)
                            (values name
                                    (zenity-question? "Do you trust?"
                                                      #`"Do you really trust ,|name|?
Public key:
,(public-key->string pubkey)")))])
                      ;; (print title)
                      (print text)
                      (flush)
                      (let1 name (read-line)
                        (when (eof-object? name) (error <eof-error>))
                        (values name
                                (yes? #`"Do you really trust ,|name|? (Y/N)")))))
      (assert (and (string? name) (not (string-null? name))))
      (assert (boolean? trusted?))
      (values name trusted?))))

(define (check-public-key pubkey)
  (with-output-to-port (current-error-port)
    (lambda()
      (print "Public key is:")
      (display-public-key pubkey)
      (let1 key-info (get-public-key-info pubkey)
        (cond [key-info
               (print "Known signature from "
                      (vector-ref key-info 0)
                      " ("
                      (if (vector-ref key-info 2)
                        "trusted"
                        "untrusted")
                      ")")]
              [else
               (receive (name trusted?)
                   (get-name-and-trust pubkey)
                 ;; todo: lock file?
                 (write-public-keys
                  (append (read-public-keys)
                          (list
                           (vector name pubkey trusted?)))))])))))

(define (public-key-trusted? pubkey)
  (if-let1 key-info (get-public-key-info pubkey)
           (vector-ref key-info 2)
           #f))

(define (main args)
  (make-directory* *zsdir* #o700)
  (cond [(< (length args) 2)
         (zs-decode-and-execute)]
        [(string=? (cadr args) "-c")
         (check-private-key)]
        [(string=? (cadr args) "-e")
         (check-private-key)
         (with-stdin-as-tmpfile zs-encode)]
        [(string=? (cadr args) "-E")
         (check-private-key)
         (call-with-temporary-file
             (lambda(oport ofile)
               (let1 written
                   (with-output-to-port oport
                     (lambda()
                       (with-stdin-as-tmpfile zs-encode)))
                 (flush oport)
                 ;; todo: should we use the single argument mode?
                 ;; might have troubles with wrap-around?
                 (display "#!/bin/sh
zs <<_
")
                 (cat ofile)
                 (print "_")
                 (flush))))]
        [(string=? (cadr args) "-d")
         (zs-decode (current-output-port))]
        [(or (string=? (cadr args) "-h")
             (string=? (cadr args) "--help"))
         (help)]
        [(string=? (cadr args) "--version")
         (print #`"zs (Zeichensalat) ,*zsversion*")]
        [(string=? (cadr args) "-p")
         (display-my-public-key)]
        [(= (length args) 2)
         ;; decode and execute one parameter
         (call-with-temporary-file
             (lambda(oport ofile)
               (display (cadr args) oport)
               (flush oport)
               (with-input-from-file ofile
                 (lambda()
                   (zs-decode-and-execute)))))]
        [else
         (error "Try -h")])
  0)
