projects/sievemgr

changeset 5:4dff4c3f0fbb

Introduce configuration file where account information is specified

Introduce a configuration file where account information must be specified
instead of passing it with each invocation on the command line. Each account
has a name by which it can be selected and one may be specified as the default
account. This is intended to improve usability for productive usage.
Enforce strict permissions since passwords may be specified for non-interactive
usage.
Remove command-line flags for passing account information.
author Guido Berhoerster <guido+sievemgr@berhoerster.name>
date Tue Nov 03 23:44:45 2020 +0100 (4 months ago)
parents f925f15d8ce5
children 2130614cd64a
files cmd/sievemgr/activate.go cmd/sievemgr/common.go cmd/sievemgr/delete.go cmd/sievemgr/get.go cmd/sievemgr/info.go cmd/sievemgr/internal/config/config.go cmd/sievemgr/internal/config/config_test.go cmd/sievemgr/internal/config/parser.go cmd/sievemgr/internal/config/scanner.go cmd/sievemgr/list.go cmd/sievemgr/main.go cmd/sievemgr/put.go
line diff
     1.1 --- a/cmd/sievemgr/activate.go	Tue Oct 27 19:17:56 2020 +0100
     1.2 +++ b/cmd/sievemgr/activate.go	Tue Nov 03 23:44:45 2020 +0100
     1.3 @@ -21,53 +21,46 @@
     1.4  
     1.5  package main
     1.6  
     1.7 -import (
     1.8 -	"net"
     1.9 -)
    1.10 -
    1.11  func init() {
    1.12 -	cmdActivate.Flag.StringVar(&username, "u", "", "Set the username")
    1.13 -	cmdActivate.Flag.StringVar(&passwordFilename, "P", "",
    1.14 -		"Set the name of the password file")
    1.15 -	cmdDeactivate.Flag.StringVar(&username, "u", "", "Set the username")
    1.16 -	cmdDeactivate.Flag.StringVar(&passwordFilename, "P", "",
    1.17 -		"Set the name of the password file")
    1.18 +	cmdActivate.Flag.StringVar(&acctName, "a", "", "Select the account")
    1.19 +	cmdDeactivate.Flag.StringVar(&acctName, "a", "", "Select the account")
    1.20  }
    1.21  
    1.22  var cmdActivate = &command{
    1.23 -	UsageLine: "activate [options] host[:port] name",
    1.24 +	UsageLine: "activate [options] name",
    1.25  	Run:       runActivate,
    1.26  }
    1.27  
    1.28  var cmdDeactivate = &command{
    1.29 -	UsageLine: "deactivate [options] host[:port]",
    1.30 +	UsageLine: "deactivate [options]",
    1.31  	Run:       runActivate,
    1.32  }
    1.33  
    1.34  func runActivate(cmd *command, args []string) error {
    1.35 -	if (cmd.Name() == "activate" && len(args) != 2) ||
    1.36 -		(cmd.Name() == "deactivate" && len(args) != 1) {
    1.37 +	if (cmd.Name() == "activate" && len(args) != 1) ||
    1.38 +		(cmd.Name() == "deactivate" && len(args) != 0) {
    1.39  		return usageError("invalid number of arguments")
    1.40  	}
    1.41  
    1.42 -	host, port, err := parseHostPort(args[0])
    1.43 +	var scriptName string
    1.44 +	if len(args) > 0 {
    1.45 +		scriptName = args[0]
    1.46 +	}
    1.47 +
    1.48 +	acct, err := getAccount(&conf, acctName)
    1.49  	if err != nil {
    1.50  		return err
    1.51  	}
    1.52  
    1.53 -	var scriptName string
    1.54 -	if len(args) > 1 {
    1.55 -		scriptName = args[1]
    1.56 -	}
    1.57 -
    1.58 -	username, password, err := usernamePassword(host, port, username,
    1.59 -		passwordFilename)
    1.60 -	if err != nil {
    1.61 +	if err := lookupHostPort(acct); err != nil {
    1.62  		return err
    1.63  	}
    1.64  
    1.65 -	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
    1.66 -		password)
    1.67 +	if err := readPassword(acct); err != nil {
    1.68 +		return err
    1.69 +	}
    1.70 +
    1.71 +	c, err := dialPlainAuth(acct)
    1.72  	if err != nil {
    1.73  		return err
    1.74  	}
     2.1 --- a/cmd/sievemgr/common.go	Tue Oct 27 19:17:56 2020 +0100
     2.2 +++ b/cmd/sievemgr/common.go	Tue Nov 03 23:44:45 2020 +0100
     2.3 @@ -22,7 +22,6 @@
     2.4  package main
     2.5  
     2.6  import (
     2.7 -	"bufio"
     2.8  	"crypto/tls"
     2.9  	"errors"
    2.10  	"fmt"
    2.11 @@ -30,44 +29,36 @@
    2.12  	"io/ioutil"
    2.13  	"net"
    2.14  	"os"
    2.15 -	"os/user"
    2.16  	"runtime"
    2.17  	"strings"
    2.18  
    2.19  	"go.guido-berhoerster.org/managesieve"
    2.20 +	"go.guido-berhoerster.org/sievemgr/cmd/sievemgr/internal/config"
    2.21  	"golang.org/x/crypto/ssh/terminal"
    2.22  )
    2.23  
    2.24  var errTooBig = errors.New("too big")
    2.25  
    2.26 -func parseHostPort(s string) (string, string, error) {
    2.27 -	var host string
    2.28 -	host, port, err := net.SplitHostPort(s)
    2.29 -	if err != nil {
    2.30 -		// error may be due to a missing port but there is no usable
    2.31 -		// error value to test for, thus try again with a port added
    2.32 -		var tmpErr error
    2.33 -		host, _, tmpErr = net.SplitHostPort(s + ":4190")
    2.34 -		if tmpErr != nil {
    2.35 -			return "", "", err
    2.36 +func getAccount(conf *config.Configuration, name string) (*config.Account, error) {
    2.37 +	if name == "" {
    2.38 +		if conf.Default == nil {
    2.39 +			return nil, fmt.Errorf("no default account configured")
    2.40 +		}
    2.41 +		return conf.Default, nil
    2.42 +	}
    2.43 +	for _, acct := range conf.Accounts {
    2.44 +		if acct.Name == name {
    2.45 +			return acct, nil
    2.46  		}
    2.47  	}
    2.48 -	if port == "" {
    2.49 -		// no port given, try to look up a SRV record for given domain
    2.50 -		// and fall back to the domain and port 4190
    2.51 -		services, err := managesieve.LookupService(host)
    2.52 -		if err != nil {
    2.53 -			return "", "", err
    2.54 -		}
    2.55 -		host, port, err = net.SplitHostPort(services[0])
    2.56 -		if err != nil {
    2.57 -			return "", "", err
    2.58 -		}
    2.59 -	}
    2.60 -	return host, port, nil
    2.61 +	return nil, fmt.Errorf("account %q does not exist", name)
    2.62  }
    2.63  
    2.64 -func readPassword() (string, error) {
    2.65 +func readPassword(acct *config.Account) error {
    2.66 +	if acct.Password != "" {
    2.67 +		return nil
    2.68 +	}
    2.69 +
    2.70  	var tty *os.File
    2.71  	var fd int
    2.72  	var w io.Writer
    2.73 @@ -78,7 +69,7 @@
    2.74  		var err error
    2.75  		tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0666)
    2.76  		if err != nil {
    2.77 -			return "", err
    2.78 +			return err
    2.79  		}
    2.80  		defer tty.Close()
    2.81  		fd = int(tty.Fd())
    2.82 @@ -89,74 +80,47 @@
    2.83  	rawPassword, err := terminal.ReadPassword(fd)
    2.84  	io.WriteString(w, "\n")
    2.85  	if err != nil {
    2.86 -		return "", fmt.Errorf("failed to read password: %s", err)
    2.87 +		return fmt.Errorf("failed to read password: %s", err)
    2.88  	}
    2.89  	password := string(rawPassword)
    2.90  	if password == "" {
    2.91 -		return "", fmt.Errorf("invalid password")
    2.92 +		return fmt.Errorf("invalid password")
    2.93  	}
    2.94 -	return password, nil
    2.95 +	acct.Password = password
    2.96 +	return nil
    2.97  }
    2.98  
    2.99 -func readPasswordFile(filename string) (string, error) {
   2.100 -	f, err := os.Open(filename)
   2.101 +func lookupHostPort(acct *config.Account) error {
   2.102 +	if acct.Port != "" {
   2.103 +		return nil
   2.104 +	}
   2.105 +	// no port given, try to look up a SRV record for given domain
   2.106 +	// and fall back to the domain and port 4190
   2.107 +	services, err := managesieve.LookupService(acct.Host)
   2.108  	if err != nil {
   2.109 -		return "", err
   2.110 +		return fmt.Errorf("failed to look up service record: %s", err)
   2.111  	}
   2.112 -	defer f.Close()
   2.113 -	scanner := bufio.NewScanner(f)
   2.114 -	if !scanner.Scan() {
   2.115 -		err := scanner.Err()
   2.116 -		if err == nil {
   2.117 -			err = fmt.Errorf("failed to read from %q: unexpected EOF",
   2.118 -				filename)
   2.119 -		}
   2.120 -		return "", err
   2.121 +	host, port, err := net.SplitHostPort(services[0])
   2.122 +	if err != nil {
   2.123 +		return fmt.Errorf("failed to parse service record: %s", err)
   2.124  	}
   2.125 -	password := scanner.Text()
   2.126 -	if password == "" {
   2.127 -		return "", fmt.Errorf("invalid password")
   2.128 -	}
   2.129 -	return password, nil
   2.130 +	acct.Host = host
   2.131 +	acct.Port = port
   2.132 +	return nil
   2.133  }
   2.134  
   2.135 -func usernamePassword(host, port, username, passwordFile string) (string, string, error) {
   2.136 -	// fall back to the system username
   2.137 -	if username == "" {
   2.138 -		u, err := user.Current()
   2.139 -		if err != nil {
   2.140 -			return "", "",
   2.141 -				fmt.Errorf("failed to obtain username: %s", err)
   2.142 -		}
   2.143 -		username = u.Username
   2.144 -	}
   2.145 -
   2.146 -	var password string
   2.147 -	var err error
   2.148 -	if passwordFile != "" {
   2.149 -		password, err = readPasswordFile(passwordFilename)
   2.150 -	} else {
   2.151 -		password, err = readPassword()
   2.152 -	}
   2.153 -	if err != nil {
   2.154 -		return "", "", err
   2.155 -	}
   2.156 -
   2.157 -	return username, password, nil
   2.158 -}
   2.159 -
   2.160 -func dialPlainAuth(hostport, username, password string) (*managesieve.Client, error) {
   2.161 -	c, err := managesieve.Dial(hostport)
   2.162 +func dialPlainAuth(acct *config.Account) (*managesieve.Client, error) {
   2.163 +	c, err := managesieve.Dial(net.JoinHostPort(acct.Host, acct.Port))
   2.164  	if err != nil {
   2.165  		return nil, fmt.Errorf("failed to connect: %s", err)
   2.166  	}
   2.167  
   2.168 -	host, _, _ := net.SplitHostPort(hostport)
   2.169  	// switch to a TLS connection except for localhost
   2.170 -	if host != "localhost" && host != "127.0.0.1" && host != "::1" {
   2.171 +	if acct.Host != "localhost" && acct.Host != "127.0.0.1" &&
   2.172 +		acct.Host != "::1" {
   2.173  		tlsConf := &tls.Config{
   2.174 -			ServerName:         host,
   2.175 -			InsecureSkipVerify: skipCertVerify,
   2.176 +			ServerName:         acct.Host,
   2.177 +			InsecureSkipVerify: acct.Insecure,
   2.178  		}
   2.179  		if err := c.StartTLS(tlsConf); err != nil {
   2.180  			return nil,
   2.181 @@ -165,10 +129,10 @@
   2.182  		}
   2.183  	}
   2.184  
   2.185 -	auth := managesieve.PlainAuth("", username, password, host)
   2.186 +	auth := managesieve.PlainAuth("", acct.User, acct.Password, acct.Host)
   2.187  	if err := c.Authenticate(auth); err != nil {
   2.188  		return nil, fmt.Errorf("failed to authenticate user %s: %s",
   2.189 -			username, err)
   2.190 +			acct.User, err)
   2.191  	}
   2.192  
   2.193  	return c, nil
     3.1 --- a/cmd/sievemgr/delete.go	Tue Oct 27 19:17:56 2020 +0100
     3.2 +++ b/cmd/sievemgr/delete.go	Tue Nov 03 23:44:45 2020 +0100
     3.3 @@ -21,41 +21,36 @@
     3.4  
     3.5  package main
     3.6  
     3.7 -import (
     3.8 -	"net"
     3.9 -)
    3.10 -
    3.11  func init() {
    3.12 -	cmdDelete.Flag.StringVar(&username, "u", "", "Set the username")
    3.13 -	cmdDelete.Flag.StringVar(&passwordFilename, "P", "",
    3.14 -		"Set the name of the password file")
    3.15 +	cmdDelete.Flag.StringVar(&acctName, "a", "", "Select the account")
    3.16  }
    3.17  
    3.18  var cmdDelete = &command{
    3.19 -	UsageLine: "delete [options] host[:port] name",
    3.20 +	UsageLine: "delete [options] name",
    3.21  	Run:       runDelete,
    3.22  }
    3.23  
    3.24  func runDelete(cmd *command, args []string) error {
    3.25 -	if len(args) != 2 {
    3.26 +	if len(args) != 1 {
    3.27  		return usageError("invalid number of arguments")
    3.28  	}
    3.29  
    3.30 -	host, port, err := parseHostPort(args[0])
    3.31 +	scriptName := args[0]
    3.32 +
    3.33 +	acct, err := getAccount(&conf, acctName)
    3.34  	if err != nil {
    3.35  		return err
    3.36  	}
    3.37  
    3.38 -	scriptName := args[1]
    3.39 -
    3.40 -	username, password, err := usernamePassword(host, port, username,
    3.41 -		passwordFilename)
    3.42 -	if err != nil {
    3.43 +	if err := lookupHostPort(acct); err != nil {
    3.44  		return err
    3.45  	}
    3.46  
    3.47 -	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
    3.48 -		password)
    3.49 +	if err := readPassword(acct); err != nil {
    3.50 +		return err
    3.51 +	}
    3.52 +
    3.53 +	c, err := dialPlainAuth(acct)
    3.54  	if err != nil {
    3.55  		return err
    3.56  	}
     4.1 --- a/cmd/sievemgr/get.go	Tue Oct 27 19:17:56 2020 +0100
     4.2 +++ b/cmd/sievemgr/get.go	Tue Nov 03 23:44:45 2020 +0100
     4.3 @@ -22,41 +22,39 @@
     4.4  package main
     4.5  
     4.6  import (
     4.7 -	"net"
     4.8  	"os"
     4.9  )
    4.10  
    4.11  func init() {
    4.12 -	cmdGet.Flag.StringVar(&username, "u", "", "Set the username")
    4.13 -	cmdGet.Flag.StringVar(&passwordFilename, "P", "",
    4.14 -		"Set the name of the password file")
    4.15 +	cmdGet.Flag.StringVar(&acctName, "a", "", "Select the account")
    4.16  }
    4.17  
    4.18  var cmdGet = &command{
    4.19 -	UsageLine: "get [options] host[:port] name",
    4.20 +	UsageLine: "get [options] name",
    4.21  	Run:       runGet,
    4.22  }
    4.23  
    4.24  func runGet(cmd *command, args []string) error {
    4.25 -	if len(args) != 2 {
    4.26 +	if len(args) != 1 {
    4.27  		return usageError("invalid number of arguments")
    4.28  	}
    4.29  
    4.30 -	host, port, err := parseHostPort(args[0])
    4.31 +	scriptName := args[0]
    4.32 +
    4.33 +	acct, err := getAccount(&conf, acctName)
    4.34  	if err != nil {
    4.35  		return err
    4.36  	}
    4.37  
    4.38 -	scriptName := args[1]
    4.39 -
    4.40 -	username, password, err := usernamePassword(host, port, username,
    4.41 -		passwordFilename)
    4.42 -	if err != nil {
    4.43 +	if err := lookupHostPort(acct); err != nil {
    4.44  		return err
    4.45  	}
    4.46  
    4.47 -	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
    4.48 -		password)
    4.49 +	if err := readPassword(acct); err != nil {
    4.50 +		return err
    4.51 +	}
    4.52 +
    4.53 +	c, err := dialPlainAuth(acct)
    4.54  	if err != nil {
    4.55  		return err
    4.56  	}
     5.1 --- a/cmd/sievemgr/info.go	Tue Oct 27 19:17:56 2020 +0100
     5.2 +++ b/cmd/sievemgr/info.go	Tue Nov 03 23:44:45 2020 +0100
     5.3 @@ -27,40 +27,39 @@
     5.4  )
     5.5  
     5.6  func init() {
     5.7 -	cmdInfo.Flag.StringVar(&username, "u", "", "Set the username")
     5.8 -	cmdInfo.Flag.StringVar(&passwordFilename, "P", "",
     5.9 -		"Set the name of the password file")
    5.10 +	cmdInfo.Flag.StringVar(&acctName, "a", "", "Select the account")
    5.11  }
    5.12  
    5.13  var cmdInfo = &command{
    5.14 -	UsageLine: "info [options] host[:port]",
    5.15 +	UsageLine: "info [options]",
    5.16  	Run:       runInfo,
    5.17  }
    5.18  
    5.19  func runInfo(cmd *command, args []string) error {
    5.20 -	if len(args) != 1 {
    5.21 +	if len(args) != 0 {
    5.22  		return usageError("invalid number of arguments")
    5.23  	}
    5.24  
    5.25 -	host, port, err := parseHostPort(args[0])
    5.26 +	acct, err := getAccount(&conf, acctName)
    5.27  	if err != nil {
    5.28  		return err
    5.29  	}
    5.30  
    5.31 -	username, password, err := usernamePassword(host, port, username,
    5.32 -		passwordFilename)
    5.33 -	if err != nil {
    5.34 +	if err := lookupHostPort(acct); err != nil {
    5.35  		return err
    5.36  	}
    5.37  
    5.38 -	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
    5.39 -		password)
    5.40 +	if err := readPassword(acct); err != nil {
    5.41 +		return err
    5.42 +	}
    5.43 +
    5.44 +	c, err := dialPlainAuth(acct)
    5.45  	if err != nil {
    5.46  		return err
    5.47  	}
    5.48  	defer c.Logout()
    5.49  
    5.50 -	fmt.Printf("%s (%s)\n", net.JoinHostPort(host, port),
    5.51 +	fmt.Printf("%s (%s)\n", net.JoinHostPort(acct.Host, acct.Port),
    5.52  		c.Implementation())
    5.53  	if c.SupportsRFC5804() {
    5.54  		fmt.Println("RFC5804 supported")
     6.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     6.2 +++ b/cmd/sievemgr/internal/config/config.go	Tue Nov 03 23:44:45 2020 +0100
     6.3 @@ -0,0 +1,105 @@
     6.4 +// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
     6.5 +//
     6.6 +// Permission is hereby granted, free of charge, to any person obtaining
     6.7 +// a copy of this software and associated documentation files (the
     6.8 +// "Software"), to deal in the Software without restriction, including
     6.9 +// without limitation the rights to use, copy, modify, merge, publish,
    6.10 +// distribute, sublicense, and/or sell copies of the Software, and to
    6.11 +// permit persons to whom the Software is furnished to do so, subject to
    6.12 +// the following conditions:
    6.13 +//
    6.14 +// The above copyright notice and this permission notice shall be included
    6.15 +// in all copies or substantial portions of the Software.
    6.16 +//
    6.17 +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    6.18 +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    6.19 +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    6.20 +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    6.21 +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    6.22 +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    6.23 +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    6.24 +
    6.25 +package config
    6.26 +
    6.27 +import (
    6.28 +	"bufio"
    6.29 +	"bytes"
    6.30 +	"fmt"
    6.31 +	"os"
    6.32 +	"path/filepath"
    6.33 +)
    6.34 +
    6.35 +const bom = '\ufeff'
    6.36 +
    6.37 +type ParserError struct {
    6.38 +	Message string
    6.39 +	LineNo  int
    6.40 +}
    6.41 +
    6.42 +func (e *ParserError) Error() string {
    6.43 +	if e.LineNo > 0 {
    6.44 +		return fmt.Sprintf("error in line %d: %s", e.LineNo, e.Message)
    6.45 +	}
    6.46 +	return e.Message
    6.47 +}
    6.48 +
    6.49 +func NewParserError(message string, lineNo int) *ParserError {
    6.50 +	return &ParserError{message, lineNo}
    6.51 +}
    6.52 +
    6.53 +type Configuration struct {
    6.54 +	Accounts []*Account
    6.55 +	Default  *Account
    6.56 +}
    6.57 +
    6.58 +type Account struct {
    6.59 +	Name     string
    6.60 +	Host     string
    6.61 +	Port     string
    6.62 +	User     string
    6.63 +	Password string
    6.64 +	Insecure bool
    6.65 +}
    6.66 +
    6.67 +func Parse(data []byte, conf *Configuration) error {
    6.68 +	rd := bytes.NewReader(data)
    6.69 +	s := newScanner(bufio.NewReader(rd))
    6.70 +	p := &parser{s: s}
    6.71 +	return p.parse(conf)
    6.72 +}
    6.73 +
    6.74 +func DefaultFilename() (string, error) {
    6.75 +	dir, err := os.UserConfigDir()
    6.76 +	if err != nil {
    6.77 +		return "", fmt.Errorf("failed to determine the name of the configuration file: %s", err)
    6.78 +	}
    6.79 +	return filepath.Join(dir, "sievemgr", "sievemgr.conf"), nil
    6.80 +}
    6.81 +
    6.82 +func ParseFile(filename string, conf *Configuration) error {
    6.83 +	f, err := os.Open(filename)
    6.84 +	if os.IsNotExist(err) {
    6.85 +		return nil
    6.86 +	} else if err != nil {
    6.87 +		return fmt.Errorf("failed to open configuration file: %s", err)
    6.88 +	}
    6.89 +	defer f.Close()
    6.90 +
    6.91 +	// configutaion file must be a regular file with no more than 0600
    6.92 +	// permissions
    6.93 +	info, err := f.Stat()
    6.94 +	if err != nil {
    6.95 +		return fmt.Errorf("failed to stat configuration file: %s", err)
    6.96 +	}
    6.97 +	mode := info.Mode()
    6.98 +	if !mode.IsRegular() {
    6.99 +		return fmt.Errorf("configuration file is not a regular file")
   6.100 +	}
   6.101 +	if perm := mode.Perm(); perm&0077 != 0 {
   6.102 +		return fmt.Errorf("permissions %04o on configuration file are too open", perm)
   6.103 +	}
   6.104 +
   6.105 +	s := newScanner(bufio.NewReader(f))
   6.106 +	p := &parser{s: s}
   6.107 +	return p.parse(conf)
   6.108 +}
     7.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     7.2 +++ b/cmd/sievemgr/internal/config/config_test.go	Tue Nov 03 23:44:45 2020 +0100
     7.3 @@ -0,0 +1,247 @@
     7.4 +// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
     7.5 +//
     7.6 +// Permission is hereby granted, free of charge, to any person obtaining
     7.7 +// a copy of this software and associated documentation files (the
     7.8 +// "Software"), to deal in the Software without restriction, including
     7.9 +// without limitation the rights to use, copy, modify, merge, publish,
    7.10 +// distribute, sublicense, and/or sell copies of the Software, and to
    7.11 +// permit persons to whom the Software is furnished to do so, subject to
    7.12 +// the following conditions:
    7.13 +//
    7.14 +// The above copyright notice and this permission notice shall be included
    7.15 +// in all copies or substantial portions of the Software.
    7.16 +//
    7.17 +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    7.18 +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    7.19 +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    7.20 +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    7.21 +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    7.22 +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    7.23 +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    7.24 +
    7.25 +package config_test
    7.26 +
    7.27 +import (
    7.28 +	"testing"
    7.29 +
    7.30 +	"go.guido-berhoerster.org/sievemgr/cmd/sievemgr/internal/config"
    7.31 +)
    7.32 +
    7.33 +const basicConfig = "\ufeff" + `account "foo"
    7.34 +    host "imap.example.net"
    7.35 +    user "bar@example.net" pass "53cRe7"
    7.36 +    default
    7.37 +account "local"
    7.38 +    host "localhost"
    7.39 +    port 2000
    7.40 +    insecure
    7.41 +    user "foo"
    7.42 +    pass "s3cR3Et"
    7.43 +account "bar" host "imap.example.com" user "bar@example.com" pass "53cRe7"
    7.44 +`
    7.45 +
    7.46 +func TestBasicFunctionality(t *testing.T) {
    7.47 +	var conf config.Configuration
    7.48 +	if err := config.Parse([]byte(basicConfig), &conf); err != nil {
    7.49 +		t.Fatalf("failed to parse basic config file: %s", err)
    7.50 +	}
    7.51 +	if n := len(conf.Accounts); n != 3 {
    7.52 +		t.Fatalf("invalid number of parsed accounts, expected 2, got %d", n)
    7.53 +	}
    7.54 +	if conf.Accounts[0].Name != "foo" ||
    7.55 +		conf.Accounts[0].Host != "imap.example.net" ||
    7.56 +		conf.Accounts[0].Port != "4190" ||
    7.57 +		conf.Accounts[0].User != "bar@example.net" ||
    7.58 +		conf.Accounts[0].Password != "53cRe7" ||
    7.59 +		conf.Accounts[0].Insecure {
    7.60 +		t.Fatalf(`failed to parse account, expected &main.Account{Name:"foo", Host:"imap.example.net", Port:"4190", User:"bar@example.net", Password:"53cRe7", Insecure:false}, got %#+v`, conf.Accounts[0])
    7.61 +	}
    7.62 +	if conf.Default == nil {
    7.63 +		t.Fatalf("default account not found")
    7.64 +	}
    7.65 +	if conf.Default != conf.Accounts[0] {
    7.66 +		t.Fatalf("wrong default account, expected \"default\", got %q", conf.Default.Name)
    7.67 +	}
    7.68 +}
    7.69 +
    7.70 +const invalidBOMConfig = "\ufeff\ufeff" + `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
    7.71 +
    7.72 +func TestInvalidBOM(t *testing.T) {
    7.73 +	var conf config.Configuration
    7.74 +	err := config.Parse([]byte(invalidBOMConfig), &conf)
    7.75 +	if err == nil {
    7.76 +		t.Fatalf("expected error due to BOM not at the beginning but succeeded")
    7.77 +	}
    7.78 +	if _, ok := err.(*config.ParserError); !ok {
    7.79 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
    7.80 +			err)
    7.81 +	}
    7.82 +	t.Logf("reported error: %s", err)
    7.83 +}
    7.84 +
    7.85 +const invalidUTF8Config = `account "foo"` + "\xff" + ` host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
    7.86 +
    7.87 +func TestInvalidUTF8(t *testing.T) {
    7.88 +	var conf config.Configuration
    7.89 +	err := config.Parse([]byte(invalidUTF8Config), &conf)
    7.90 +	if err == nil {
    7.91 +		t.Fatalf("expected error due to invalid UTF-8 but succeeded")
    7.92 +	}
    7.93 +	if _, ok := err.(*config.ParserError); !ok {
    7.94 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
    7.95 +			err)
    7.96 +	}
    7.97 +	t.Logf("reported error: %s", err)
    7.98 +}
    7.99 +
   7.100 +const nulByteConfig = `account "foo` + "\x00" + `" host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
   7.101 +
   7.102 +func TestNulByte(t *testing.T) {
   7.103 +	var conf config.Configuration
   7.104 +	err := config.Parse([]byte(nulByteConfig), &conf)
   7.105 +	if err == nil {
   7.106 +		t.Fatalf("expected error due to nul byte but succeeded")
   7.107 +	}
   7.108 +	if _, ok := err.(*config.ParserError); !ok {
   7.109 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.110 +			err)
   7.111 +	}
   7.112 +	t.Logf("reported error: %s", err)
   7.113 +}
   7.114 +
   7.115 +const unexpectedRuneConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7" _in_valid default`
   7.116 +
   7.117 +func TestInvalidIdentifier(t *testing.T) {
   7.118 +	var conf config.Configuration
   7.119 +	err := config.Parse([]byte(unexpectedRuneConfig), &conf)
   7.120 +	if err == nil {
   7.121 +		t.Fatalf("expected error due to unexpected rune but succeeded")
   7.122 +	}
   7.123 +	if _, ok := err.(*config.ParserError); !ok {
   7.124 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.125 +			err)
   7.126 +	}
   7.127 +	t.Logf("reported error: %s", err)
   7.128 +}
   7.129 +
   7.130 +const unknownIdentifierConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7" invalid default`
   7.131 +
   7.132 +func TestUnknownIdentifier(t *testing.T) {
   7.133 +	var conf config.Configuration
   7.134 +	err := config.Parse([]byte(unknownIdentifierConfig), &conf)
   7.135 +	if err == nil {
   7.136 +		t.Fatalf("expected error due to unknown identifier but succeeded")
   7.137 +	}
   7.138 +	if _, ok := err.(*config.ParserError); !ok {
   7.139 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.140 +			err)
   7.141 +	}
   7.142 +	t.Logf("reported error: %s", err)
   7.143 +}
   7.144 +
   7.145 +const missingWhitespaceConfig = `account "foo" host "imap.example.net" port 2000default user "bar@example.net" pass "53cRe7"`
   7.146 +
   7.147 +func TestMissingSpace(t *testing.T) {
   7.148 +	var conf config.Configuration
   7.149 +	err := config.Parse([]byte(missingWhitespaceConfig), &conf)
   7.150 +	if err == nil {
   7.151 +		t.Fatalf("expected error due to missing whitespace between tokens but succeeded")
   7.152 +	}
   7.153 +	if _, ok := err.(*config.ParserError); !ok {
   7.154 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.155 +			err)
   7.156 +	}
   7.157 +	t.Logf("reported error: %s", err)
   7.158 +}
   7.159 +
   7.160 +const missingNameConfig = `account host "imap.example.net" user "bar@example.net" pass "53cRe7" default`
   7.161 +
   7.162 +func TestMissingName(t *testing.T) {
   7.163 +	var conf config.Configuration
   7.164 +	err := config.Parse([]byte(missingNameConfig), &conf)
   7.165 +	if err == nil {
   7.166 +		t.Fatalf("expected error due to missing account name but succeeded")
   7.167 +	}
   7.168 +	if _, ok := err.(*config.ParserError); !ok {
   7.169 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.170 +			err)
   7.171 +	}
   7.172 +	t.Logf("reported error: %s", err)
   7.173 +}
   7.174 +
   7.175 +const invalidTypeConfig = `account 1234 host "imap.example.net" user "bar@example.net" pass "53cRe7"`
   7.176 +
   7.177 +func TestInvalidType(t *testing.T) {
   7.178 +	var conf config.Configuration
   7.179 +	err := config.Parse([]byte(invalidTypeConfig), &conf)
   7.180 +	if err == nil {
   7.181 +		t.Fatalf("expected error due to invalid type but succeeded")
   7.182 +	}
   7.183 +	if _, ok := err.(*config.ParserError); !ok {
   7.184 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.185 +			err)
   7.186 +	}
   7.187 +	t.Logf("reported error: %s", err)
   7.188 +}
   7.189 +
   7.190 +const unterminatedStringConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass "53cRe7
   7.191 +account "bar" host "imap.example.com" user "bar@example.com" pass "53cRe7"
   7.192 +`
   7.193 +
   7.194 +func TestUnterminatedString(t *testing.T) {
   7.195 +	var conf config.Configuration
   7.196 +	err := config.Parse([]byte(unterminatedStringConfig), &conf)
   7.197 +	if err == nil {
   7.198 +		t.Fatalf("expected error due to an unterminated string but succeeded")
   7.199 +	}
   7.200 +	if _, ok := err.(*config.ParserError); !ok {
   7.201 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.202 +			err)
   7.203 +	}
   7.204 +	t.Logf("reported error: %s", err)
   7.205 +}
   7.206 +
   7.207 +const unexpectedEOFConfig = `account "foo" host "imap.example.net" user "bar@example.net" pass `
   7.208 +
   7.209 +func TestUnexpectedEOF(t *testing.T) {
   7.210 +	var conf config.Configuration
   7.211 +	err := config.Parse([]byte(unexpectedEOFConfig), &conf)
   7.212 +	if err == nil {
   7.213 +		t.Fatalf("expected error due to an unterminated string but succeeded")
   7.214 +	}
   7.215 +	if _, ok := err.(*config.ParserError); !ok {
   7.216 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.217 +			err)
   7.218 +	}
   7.219 +	t.Logf("reported error: %s", err)
   7.220 +}
   7.221 +
   7.222 +const missingHostConfig = `account "foo" user "bar@example.net" pass "53cRe7"`
   7.223 +
   7.224 +func TestMissingHost(t *testing.T) {
   7.225 +	var conf config.Configuration
   7.226 +	err := config.Parse([]byte(missingHostConfig), &conf)
   7.227 +	if err == nil {
   7.228 +		t.Fatalf("expected error due to missing host but succeeded")
   7.229 +	}
   7.230 +	if _, ok := err.(*config.ParserError); !ok {
   7.231 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.232 +			err)
   7.233 +	}
   7.234 +	t.Logf("reported error: %s", err)
   7.235 +}
   7.236 +
   7.237 +const missingUserConfig = `account "foo" host "imap.example.net" user "" pass "53cRe7"`
   7.238 +
   7.239 +func TestMissingUser(t *testing.T) {
   7.240 +	var conf config.Configuration
   7.241 +	err := config.Parse([]byte(missingUserConfig), &conf)
   7.242 +	if err == nil {
   7.243 +		t.Fatalf("expected error due to missing user but succeeded")
   7.244 +	}
   7.245 +	if _, ok := err.(*config.ParserError); !ok {
   7.246 +		t.Fatalf("expected config.ParserError, got %T (%q)", err,
   7.247 +			err)
   7.248 +	}
   7.249 +	t.Logf("reported error: %s", err)
   7.250 +}
     8.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     8.2 +++ b/cmd/sievemgr/internal/config/parser.go	Tue Nov 03 23:44:45 2020 +0100
     8.3 @@ -0,0 +1,148 @@
     8.4 +// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
     8.5 +//
     8.6 +// Permission is hereby granted, free of charge, to any person obtaining
     8.7 +// a copy of this software and associated documentation files (the
     8.8 +// "Software"), to deal in the Software without restriction, including
     8.9 +// without limitation the rights to use, copy, modify, merge, publish,
    8.10 +// distribute, sublicense, and/or sell copies of the Software, and to
    8.11 +// permit persons to whom the Software is furnished to do so, subject to
    8.12 +// the following conditions:
    8.13 +//
    8.14 +// The above copyright notice and this permission notice shall be included
    8.15 +// in all copies or substantial portions of the Software.
    8.16 +//
    8.17 +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    8.18 +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    8.19 +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    8.20 +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    8.21 +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    8.22 +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    8.23 +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    8.24 +
    8.25 +package config
    8.26 +
    8.27 +import (
    8.28 +	"fmt"
    8.29 +	"strconv"
    8.30 +)
    8.31 +
    8.32 +type parser struct {
    8.33 +	s *scanner
    8.34 +}
    8.35 +
    8.36 +func (p *parser) scanType(expectedTok token) (string, error) {
    8.37 +	tok, lit, err := p.s.scan()
    8.38 +	if err != nil {
    8.39 +		return lit, NewParserError(err.Error(), p.s.line)
    8.40 +	} else if tok != expectedTok {
    8.41 +		return lit, NewParserError(fmt.Sprintf("expected %s, got %s %q", expectedTok, tok, lit), p.s.line)
    8.42 +	}
    8.43 +	return lit, nil
    8.44 +}
    8.45 +
    8.46 +func (p *parser) validateAccount(acct *Account) error {
    8.47 +	if acct.Host == "" {
    8.48 +		return NewParserError(fmt.Sprintf("no host specified for account %q",
    8.49 +			acct.Name), 0)
    8.50 +	} else if acct.User == "" {
    8.51 +		return NewParserError(fmt.Sprintf("no user specified for account %q",
    8.52 +			acct.Name), 0)
    8.53 +	}
    8.54 +	return nil
    8.55 +}
    8.56 +
    8.57 +func (p *parser) parse(conf *Configuration) error {
    8.58 +	var acct *Account
    8.59 +	var isDefault bool
    8.60 +	var tok token
    8.61 +	var lit string
    8.62 +	var err error
    8.63 +parsing:
    8.64 +	for {
    8.65 +		tok, lit, err = p.s.scan()
    8.66 +		if err != nil {
    8.67 +			err = NewParserError(err.Error(), p.s.line)
    8.68 +			break parsing
    8.69 +		}
    8.70 +		if tok == tokenIllegal {
    8.71 +			err = NewParserError(fmt.Sprintf("illegal token %q", lit), p.s.line)
    8.72 +			break parsing
    8.73 +		} else if tok == tokenEOF {
    8.74 +			break parsing
    8.75 +		} else if tok != tokenIdent {
    8.76 +			err = NewParserError(fmt.Sprintf("expected identifier, got %s %q", tok, lit), p.s.line)
    8.77 +			break parsing
    8.78 +		}
    8.79 +		switch lit {
    8.80 +		case "account":
    8.81 +			if acct != nil {
    8.82 +				if err = p.validateAccount(acct); err != nil {
    8.83 +					break parsing
    8.84 +				}
    8.85 +				conf.Accounts = append(conf.Accounts, acct)
    8.86 +				if isDefault {
    8.87 +					conf.Default = acct
    8.88 +					isDefault = false
    8.89 +				}
    8.90 +			}
    8.91 +
    8.92 +			// account name
    8.93 +			if lit, err = p.scanType(tokenString); err != nil {
    8.94 +				break parsing
    8.95 +			}
    8.96 +			acct = &Account{
    8.97 +				Name: lit,
    8.98 +				Port: "4190",
    8.99 +			}
   8.100 +		case "default":
   8.101 +			isDefault = true
   8.102 +		case "host":
   8.103 +			if lit, err = p.scanType(tokenString); err != nil {
   8.104 +				break parsing
   8.105 +			}
   8.106 +			acct.Host = lit
   8.107 +		case "port":
   8.108 +			if lit, err = p.scanType(tokenNumber); err != nil {
   8.109 +				break parsing
   8.110 +			}
   8.111 +			var port int
   8.112 +			if port, err = strconv.Atoi(lit); err != nil {
   8.113 +				err = NewParserError(fmt.Sprintf("failed to parse port: %s", err), p.s.line)
   8.114 +				break parsing
   8.115 +			} else if port < 1 || port > 65535 {
   8.116 +				err = NewParserError(fmt.Sprintf("invalid port number %d", port), p.s.line)
   8.117 +				break parsing
   8.118 +			}
   8.119 +			acct.Port = lit
   8.120 +		case "user":
   8.121 +			if lit, err = p.scanType(tokenString); err != nil {
   8.122 +				break parsing
   8.123 +			}
   8.124 +			acct.User = lit
   8.125 +		case "pass":
   8.126 +			if lit, err = p.scanType(tokenString); err != nil {
   8.127 +				break parsing
   8.128 +			}
   8.129 +			acct.Password = lit
   8.130 +		case "insecure":
   8.131 +			acct.Insecure = true
   8.132 +		default:
   8.133 +			err = NewParserError(fmt.Sprintf("unknown %s: %q", tok, lit), p.s.line)
   8.134 +			break parsing
   8.135 +		}
   8.136 +	}
   8.137 +	if err != nil {
   8.138 +		return err
   8.139 +	}
   8.140 +	if acct != nil {
   8.141 +		if err = p.validateAccount(acct); err != nil {
   8.142 +			return err
   8.143 +		}
   8.144 +		conf.Accounts = append(conf.Accounts, acct)
   8.145 +		if isDefault {
   8.146 +			conf.Default = acct
   8.147 +		}
   8.148 +	}
   8.149 +
   8.150 +	return nil
   8.151 +}
     9.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     9.2 +++ b/cmd/sievemgr/internal/config/scanner.go	Tue Nov 03 23:44:45 2020 +0100
     9.3 @@ -0,0 +1,244 @@
     9.4 +// Copyright (C) 2020 Guido Berhoerster <guido+sievemgr@berhoerster.name>
     9.5 +//
     9.6 +// Permission is hereby granted, free of charge, to any person obtaining
     9.7 +// a copy of this software and associated documentation files (the
     9.8 +// "Software"), to deal in the Software without restriction, including
     9.9 +// without limitation the rights to use, copy, modify, merge, publish,
    9.10 +// distribute, sublicense, and/or sell copies of the Software, and to
    9.11 +// permit persons to whom the Software is furnished to do so, subject to
    9.12 +// the following conditions:
    9.13 +//
    9.14 +// The above copyright notice and this permission notice shall be included
    9.15 +// in all copies or substantial portions of the Software.
    9.16 +//
    9.17 +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    9.18 +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    9.19 +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    9.20 +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    9.21 +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    9.22 +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    9.23 +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    9.24 +
    9.25 +package config
    9.26 +
    9.27 +import (
    9.28 +	"bufio"
    9.29 +	"fmt"
    9.30 +	"io"
    9.31 +	"strings"
    9.32 +	"unicode"
    9.33 +)
    9.34 +
    9.35 +type token int
    9.36 +
    9.37 +const (
    9.38 +	tokenEOF token = -(iota + 1)
    9.39 +	tokenIllegal
    9.40 +	tokenIdent
    9.41 +	tokenNumber
    9.42 +	tokenString
    9.43 +)
    9.44 +
    9.45 +func (tok token) String() string {
    9.46 +	switch tok {
    9.47 +	case tokenIllegal:
    9.48 +		return "illegal"
    9.49 +	case tokenEOF:
    9.50 +		return "EOF"
    9.51 +	case tokenIdent:
    9.52 +		return "identifier"
    9.53 +	case tokenNumber:
    9.54 +		return "number"
    9.55 +	case tokenString:
    9.56 +		return "string"
    9.57 +	default:
    9.58 +		return "unknown"
    9.59 +	}
    9.60 +}
    9.61 +
    9.62 +func isWhitespaceRune(r rune) bool {
    9.63 +	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
    9.64 +}
    9.65 +
    9.66 +func isIdentRune(r rune) bool {
    9.67 +	return r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z'
    9.68 +}
    9.69 +
    9.70 +func isNumberRune(r rune) bool {
    9.71 +	return r >= '0' && r <= '9'
    9.72 +}
    9.73 +
    9.74 +type scanner struct {
    9.75 +	br       *bufio.Reader
    9.76 +	line     int  // line number for error messages
    9.77 +	r        rune // last read rune
    9.78 +	rdSize   int  // last read size
    9.79 +	rdOffset int  // offset from beginning of file
    9.80 +}
    9.81 +
    9.82 +func newScanner(br *bufio.Reader) *scanner {
    9.83 +	return &scanner{line: 1, br: br}
    9.84 +}
    9.85 +
    9.86 +func (s *scanner) read() error {
    9.87 +	var size int
    9.88 +again:
    9.89 +	r, size, err := s.br.ReadRune()
    9.90 +	if err != nil {
    9.91 +		return err
    9.92 +	}
    9.93 +
    9.94 +	// skip over BOM at the beginning of the file
    9.95 +	if r == bom && s.rdOffset == 0 {
    9.96 +		s.rdOffset += size
    9.97 +		goto again
    9.98 +	}
    9.99 +
   9.100 +	if s.r == '\n' {
   9.101 +		s.line++
   9.102 +	}
   9.103 +
   9.104 +	s.r = r
   9.105 +	s.rdOffset += size
   9.106 +	s.rdSize = size
   9.107 +
   9.108 +	if r == unicode.ReplacementChar && size == 1 {
   9.109 +		return fmt.Errorf("illegal UTF-8 sequence")
   9.110 +	} else if r == 0 {
   9.111 +		return fmt.Errorf("illegal nul byte")
   9.112 +	}
   9.113 +	return nil
   9.114 +}
   9.115 +
   9.116 +func (s *scanner) unread() {
   9.117 +	if s.br.UnreadRune() != nil {
   9.118 +		return
   9.119 +	}
   9.120 +
   9.121 +	if b, _ := s.br.Peek(1); b[0] == '\n' {
   9.122 +		// moved back to the previous line
   9.123 +		s.line--
   9.124 +	}
   9.125 +
   9.126 +	s.r = 0
   9.127 +	s.rdOffset -= s.rdSize
   9.128 +	s.rdSize = 0
   9.129 +}
   9.130 +
   9.131 +func (s *scanner) skipWhitespace() error {
   9.132 +	for {
   9.133 +		if err := s.read(); err == io.EOF {
   9.134 +			break
   9.135 +		} else if err != nil {
   9.136 +			return err
   9.137 +		} else if !isWhitespaceRune(s.r) {
   9.138 +			s.unread()
   9.139 +			break
   9.140 +		}
   9.141 +	}
   9.142 +	return nil
   9.143 +}
   9.144 +
   9.145 +func (s *scanner) scanIdent() (token, string, error) {
   9.146 +	var sb strings.Builder
   9.147 +	for {
   9.148 +		if err := s.read(); err == io.EOF {
   9.149 +			break
   9.150 +		} else if err != nil {
   9.151 +			return tokenIllegal, "", err
   9.152 +		}
   9.153 +		if isIdentRune(s.r) {
   9.154 +			sb.WriteRune(s.r)
   9.155 +		} else if isWhitespaceRune(s.r) {
   9.156 +			s.unread()
   9.157 +			break
   9.158 +		} else {
   9.159 +			return tokenIllegal, "", fmt.Errorf("illegal rune in identifier: %q", s.r)
   9.160 +		}
   9.161 +	}
   9.162 +	if sb.Len() == 0 {
   9.163 +		return tokenIllegal, "", fmt.Errorf("expected identifier, got %q", s.r)
   9.164 +	}
   9.165 +	return tokenIdent, sb.String(), nil
   9.166 +}
   9.167 +
   9.168 +func (s *scanner) scanNumber() (token, string, error) {
   9.169 +	var sb strings.Builder
   9.170 +	for {
   9.171 +		if err := s.read(); err == io.EOF {
   9.172 +			break
   9.173 +		} else if err != nil {
   9.174 +			return tokenIllegal, "", err
   9.175 +		}
   9.176 +		if isNumberRune(s.r) {
   9.177 +			sb.WriteRune(s.r)
   9.178 +		} else if isWhitespaceRune(s.r) {
   9.179 +			s.unread()
   9.180 +			break
   9.181 +		} else {
   9.182 +			return tokenIllegal, "", fmt.Errorf("illegal rune in number: %q", s.r)
   9.183 +		}
   9.184 +	}
   9.185 +	if sb.Len() == 0 {
   9.186 +		return tokenIllegal, "", fmt.Errorf("expected number, got %q", s.r)
   9.187 +	}
   9.188 +	return tokenNumber, sb.String(), nil
   9.189 +}
   9.190 +
   9.191 +func (s *scanner) scanString() (token, string, error) {
   9.192 +	if err := s.read(); err == io.EOF {
   9.193 +		return tokenIllegal, "", fmt.Errorf("unexpected EOF")
   9.194 +	} else if err != nil {
   9.195 +		return tokenIllegal, "", err
   9.196 +	}
   9.197 +
   9.198 +	if s.r != '"' {
   9.199 +		return tokenIllegal, "",
   9.200 +			fmt.Errorf("expected '\"', got %q", s.r)
   9.201 +	}
   9.202 +
   9.203 +	var sb strings.Builder
   9.204 +	var inEscape bool
   9.205 +	for {
   9.206 +		if err := s.read(); err == io.EOF {
   9.207 +			return tokenIllegal, "", fmt.Errorf("unterminated string")
   9.208 +		} else if err != nil {
   9.209 +			return tokenIllegal, "", err
   9.210 +		}
   9.211 +
   9.212 +		if s.r == '\\' && !inEscape {
   9.213 +			inEscape = true
   9.214 +		} else if s.r == '"' && !inEscape {
   9.215 +			break
   9.216 +		} else {
   9.217 +			sb.WriteRune(s.r)
   9.218 +			inEscape = false
   9.219 +		}
   9.220 +	}
   9.221 +
   9.222 +	return tokenString, sb.String(), nil
   9.223 +}
   9.224 +
   9.225 +func (s *scanner) scan() (token, string, error) {
   9.226 +	if err := s.skipWhitespace(); err != nil {
   9.227 +		return tokenIllegal, "", err
   9.228 +	}
   9.229 +
   9.230 +	if err := s.read(); err == io.EOF {
   9.231 +		return tokenEOF, "", nil
   9.232 +	} else if err != nil {
   9.233 +		return tokenIllegal, "", err
   9.234 +	}
   9.235 +	r := s.r
   9.236 +	s.unread()
   9.237 +
   9.238 +	switch {
   9.239 +	case isIdentRune(r):
   9.240 +		return s.scanIdent()
   9.241 +	case isNumberRune(r):
   9.242 +		return s.scanNumber()
   9.243 +	case r == '"':
   9.244 +		return s.scanString()
   9.245 +	}
   9.246 +	return tokenIllegal, string(r), nil
   9.247 +}
    10.1 --- a/cmd/sievemgr/list.go	Tue Oct 27 19:17:56 2020 +0100
    10.2 +++ b/cmd/sievemgr/list.go	Tue Nov 03 23:44:45 2020 +0100
    10.3 @@ -23,38 +23,36 @@
    10.4  
    10.5  import (
    10.6  	"fmt"
    10.7 -	"net"
    10.8  )
    10.9  
   10.10  func init() {
   10.11 -	cmdList.Flag.StringVar(&username, "u", "", "Set the username")
   10.12 -	cmdList.Flag.StringVar(&passwordFilename, "P", "",
   10.13 -		"Set the name of the password file")
   10.14 +	cmdList.Flag.StringVar(&acctName, "a", "", "Select the account")
   10.15  }
   10.16  
   10.17  var cmdList = &command{
   10.18 -	UsageLine: "list [options] host[:port]",
   10.19 +	UsageLine: "list [options]",
   10.20  	Run:       runList,
   10.21  }
   10.22  
   10.23  func runList(cmd *command, args []string) error {
   10.24 -	if len(args) != 1 {
   10.25 +	if len(args) != 0 {
   10.26  		return usageError("invalid number of arguments")
   10.27  	}
   10.28  
   10.29 -	host, port, err := parseHostPort(args[0])
   10.30 +	acct, err := getAccount(&conf, acctName)
   10.31  	if err != nil {
   10.32  		return err
   10.33  	}
   10.34  
   10.35 -	username, password, err := usernamePassword(host, port, username,
   10.36 -		passwordFilename)
   10.37 -	if err != nil {
   10.38 +	if err := lookupHostPort(acct); err != nil {
   10.39  		return err
   10.40  	}
   10.41  
   10.42 -	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
   10.43 -		password)
   10.44 +	if err := readPassword(acct); err != nil {
   10.45 +		return err
   10.46 +	}
   10.47 +
   10.48 +	c, err := dialPlainAuth(acct)
   10.49  	if err != nil {
   10.50  		return err
   10.51  	}
    11.1 --- a/cmd/sievemgr/main.go	Tue Oct 27 19:17:56 2020 +0100
    11.2 +++ b/cmd/sievemgr/main.go	Tue Nov 03 23:44:45 2020 +0100
    11.3 @@ -26,6 +26,8 @@
    11.4  	"flag"
    11.5  	"fmt"
    11.6  	"os"
    11.7 +
    11.8 +	"go.guido-berhoerster.org/sievemgr/cmd/sievemgr/internal/config"
    11.9  )
   11.10  
   11.11  const (
   11.12 @@ -40,12 +42,23 @@
   11.13  	return string(e)
   11.14  }
   11.15  
   11.16 +// command-line flags
   11.17  var (
   11.18 -	skipCertVerify   bool
   11.19 -	username         string
   11.20 -	passwordFilename string
   11.21 +	confFilename   string
   11.22 +	acctName       string
   11.23  )
   11.24  
   11.25 +var conf config.Configuration
   11.26 +
   11.27 +func init() {
   11.28 +	var err error
   11.29 +	confFilename, err = config.DefaultFilename()
   11.30 +	if err != nil {
   11.31 +		fmt.Fprintln(flag.CommandLine.Output(), err)
   11.32 +		os.Exit(exitFailure)
   11.33 +	}
   11.34 +}
   11.35 +
   11.36  var cmds = []*command{
   11.37  	cmdList,
   11.38  	cmdPut,
   11.39 @@ -71,8 +84,8 @@
   11.40  
   11.41  func main() {
   11.42  	flag.Usage = usage
   11.43 -	flag.BoolVar(&skipCertVerify, "I", false,
   11.44 -		"Skip TLS certificate verification")
   11.45 +	flag.StringVar(&confFilename, "f", confFilename,
   11.46 +		"Set the name of the configuration file")
   11.47  	flag.Parse()
   11.48  	if flag.NArg() == 0 {
   11.49  		fmt.Fprintln(flag.CommandLine.Output(), "missing subcommand")
   11.50 @@ -80,6 +93,11 @@
   11.51  		os.Exit(exitUsage)
   11.52  	}
   11.53  
   11.54 +	if err := config.ParseFile(confFilename, &conf); err != nil {
   11.55 +		fmt.Fprintln(flag.CommandLine.Output(), err)
   11.56 +		os.Exit(exitFailure)
   11.57 +	}
   11.58 +
   11.59  	name := flag.Arg(0)
   11.60  	var cmd *command
   11.61  	for _, c := range cmds {
    12.1 --- a/cmd/sievemgr/put.go	Tue Oct 27 19:17:56 2020 +0100
    12.2 +++ b/cmd/sievemgr/put.go	Tue Nov 03 23:44:45 2020 +0100
    12.3 @@ -24,60 +24,38 @@
    12.4  import (
    12.5  	"fmt"
    12.6  	"io"
    12.7 -	"net"
    12.8  	"os"
    12.9  
   12.10  	"go.guido-berhoerster.org/managesieve"
   12.11  )
   12.12  
   12.13  func init() {
   12.14 -	cmdPut.Flag.StringVar(&username, "u", "", "Set the username")
   12.15 -	cmdPut.Flag.StringVar(&passwordFilename, "P", "",
   12.16 -		"Set the name of the password file")
   12.17 -	cmdCheck.Flag.StringVar(&username, "u", "", "Set the username")
   12.18 -	cmdCheck.Flag.StringVar(&passwordFilename, "P", "",
   12.19 -		"Set the name of the password file")
   12.20 +	cmdPut.Flag.StringVar(&acctName, "a", "", "Select the account")
   12.21 +	cmdCheck.Flag.StringVar(&acctName, "a", "", "Select the account")
   12.22  }
   12.23  
   12.24  var cmdPut = &command{
   12.25 -	UsageLine: "put [options] host[:port] name [file]",
   12.26 +	UsageLine: "put [options] name [file]",
   12.27  	Run:       runPut,
   12.28  }
   12.29  
   12.30  var cmdCheck = &command{
   12.31 -	UsageLine: "check [options] host[:port] [file]",
   12.32 +	UsageLine: "check [options] [file]",
   12.33  	Run:       runPut,
   12.34  }
   12.35  
   12.36  func runPut(cmd *command, args []string) error {
   12.37 +	var err error
   12.38 +	acct, err := getAccount(&conf, acctName)
   12.39 +	if err != nil {
   12.40 +		return err
   12.41 +	}
   12.42 +
   12.43  	var scriptName string
   12.44  	var r io.Reader = os.Stdin
   12.45 -	var host, port string
   12.46 -	var err error
   12.47  	if cmd.Name() == "put" {
   12.48  		switch len(args) {
   12.49 -		case 3: // name and filename
   12.50 -			scriptFile, err := os.Open(args[2])
   12.51 -			if err != nil {
   12.52 -				return fmt.Errorf("failed to open script file: %s\n",
   12.53 -					err)
   12.54 -			}
   12.55 -			defer scriptFile.Close()
   12.56 -			r = scriptFile
   12.57 -			fallthrough
   12.58 -		case 2: // only name
   12.59 -			host, port, err = parseHostPort(args[0])
   12.60 -			if err != nil {
   12.61 -				return err
   12.62 -			}
   12.63 -
   12.64 -			scriptName = args[1]
   12.65 -		default:
   12.66 -			return usageError("invalid number of arguments")
   12.67 -		}
   12.68 -	} else if cmd.Name() == "check" {
   12.69 -		switch len(args) {
   12.70 -		case 2: // filename
   12.71 +		case 2: // name and filename
   12.72  			scriptFile, err := os.Open(args[1])
   12.73  			if err != nil {
   12.74  				return fmt.Errorf("failed to open script file: %s\n",
   12.75 @@ -86,11 +64,8 @@
   12.76  			defer scriptFile.Close()
   12.77  			r = scriptFile
   12.78  			fallthrough
   12.79 -		case 1:
   12.80 -			host, port, err = parseHostPort(args[0])
   12.81 -			if err != nil {
   12.82 -				return err
   12.83 -			}
   12.84 +		case 1: // only name
   12.85 +			scriptName = args[0]
   12.86  		default:
   12.87  			return usageError("invalid number of arguments")
   12.88  		}
   12.89 @@ -115,14 +90,15 @@
   12.90  		return fmt.Errorf("failed to read script: %s\n", err)
   12.91  	}
   12.92  
   12.93 -	username, password, err := usernamePassword(host, port, username,
   12.94 -		passwordFilename)
   12.95 -	if err != nil {
   12.96 +	if err := lookupHostPort(acct); err != nil {
   12.97  		return err
   12.98  	}
   12.99  
  12.100 -	c, err := dialPlainAuth(net.JoinHostPort(host, port), username,
  12.101 -		password)
  12.102 +	if err := readPassword(acct); err != nil {
  12.103 +		return err
  12.104 +	}
  12.105 +
  12.106 +	c, err := dialPlainAuth(acct)
  12.107  	if err != nil {
  12.108  		return err
  12.109  	}