# HG changeset patch # User Guido Berhoerster # Date 1602745865 -7200 # Node ID 6369453d47a3924ae4db9edd6aa05245f792ae0c Initial revision diff -r 000000000000 -r 6369453d47a3 COPYING --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/COPYING Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,20 @@ +Copyright (C) 2020 Guido Berhoerster + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff -r 000000000000 -r 6369453d47a3 README --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,80 @@ +managesieve +=========== + +Description +----------- + +The package managesieve implements the ManageSieve protocol as specified in +RFC 5804. It covers all mandatory parts of the protocol with the exception of +the SCRAM-SHA-1 SASL mechanism. Additional SASL authentication mechanisms can +be provided by consumers. + +A command-line ManageSieve client called sievemgr is included and provides an +example of how the package may be used. + +Usage +----- + +API documentation and usage examples can be displayed using the `go doc` +command or accessed on [pkg.go.dev][1]. + +[1]: https://pkg.go.dev/go.guido-berhoerster.org/managesieve + "managesieve documentation" + +Build Instructions +------------------ + +managesieve is a Go module and requires Go version 1.14 or later. It can be +used by including its canonical name "go.guido-berhoerster.org/managesieve". +See the Go documentation for details. + +The sievemgr utility can be built using the `go build` command. See the Go +documentation for details. + +Contact +------- + +Please send any feedback, translations or bug reports via email to +. + +Bug Reports +----------- + +When sending bug reports, please always mention the exact version of the +managesieve package with which the issue occurs as well as the Go +compiler and version and version of the operating system you are using +and make sure that you provide sufficient information to reproduce the issue +and include any input, output, any error messages. + +In case of build issues, please also specify the implementations and versions +of the tools used to build the package and/or program, in particular the Go +compiler. + +In case of crashes, please attach the full backtrace to the bug report. + +License +------- + +Except otherwise noted, all files are Copyright (C) 2020 Guido Berhoerster and +distributed under the following license terms: + +Copyright (C) 2020 Guido Berhoerster + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff -r 000000000000 -r 6369453d47a3 auth.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,123 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package managesieve + +import ( + "errors" + "fmt" +) + +// this API is inspired by the SASL authentication API in net/smtp + +// ServerInfo stores information about the ManageSieve server. +type ServerInfo struct { + Name string // hostname of the server + TLS bool // whether a verified TLS connection is used + Auth []string // authentication methods advertised in capabilities +} + +// Check whether the server supports the wanted SASL authentication mechanism. +func (s *ServerInfo) HaveAuth(wanted string) bool { + for _, m := range s.Auth { + if m == wanted { + return true + } + } + return false +} + +type Auth interface { + // Initiate SASL authentication. A non-nil response will be sent in + // response to an empty challenge from the server if mandated by the + // authentication mechanism. The name of the SASL authentication + // mechanism is returned in mechanism. If an error is returned SASL + // authentication will be aborted and an AuthenticationError will be + // returned to the caller. + Start(server *ServerInfo) (mechanism string, response []byte, err error) + // Handle a challenge received from the server, if more is true the + // server expects a response, otherwise the response should be nil. If + // an error is returned SASL authentication will be aborted and an + // AuthenticationError will be returned to the caller. + Next(challenge []byte, more bool) (response []byte, err error) +} + +var ( + // ErrPlainAuthNotSupported is returned if the server does not support + // the SASL PLAIN authentication mechanism. + ErrPlainAuthNotSupported = errors.New("the server does not support PLAIN authentication") + // ErrPlainAuthTLSRequired is returned when the SASL PLAIN + // authentication mechanism is used without TLS against a server other + // than localhost. + ErrPlainAuthTLSRequired = errors.New("PLAIN authentication requires a TLS connection") +) + +// HostNameVerificationError is returned when the hostname which was passed to +// the Auth implementation could not be verified against the TLS certificate. +type HostNameVerificationError struct { + ExpectedHost, ActualHost string +} + +func (e *HostNameVerificationError) Error() string { + return fmt.Sprintf("host name mismatch: %s != %s", e.ActualHost, + e.ExpectedHost) +} + +type plainAuth struct { + identity string + username string + password string + host string +} + +func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { + if !server.HaveAuth("PLAIN") { + return "PLAIN", nil, ErrPlainAuthNotSupported + } + + // enforce TLS for non-local servers in order to avoid leaking + // credentials via unencrypted connections or DNS spoofing + if !server.TLS && server.Name != "localhost" && + server.Name != "127.0.0.1" && server.Name != "::1" { + return "PLAIN", nil, ErrPlainAuthTLSRequired + } + + // verify server hostname before sending credentials + if server.Name != a.host { + return "PLAIN", nil, + &HostNameVerificationError{a.host, server.Name} + } + + resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password) + return "PLAIN", resp, nil +} + +func (a *plainAuth) Next(challenge []byte, more bool) ([]byte, error) { + return nil, nil +} + +// PlainAuth provides an Auth implementation of SASL PLAIN authentication as +// specified in RFC 4616 using the provided authorization identity, username +// and password. If the identity is an empty string the server will derive an +// identity from the credentials. +func PlainAuth(identity, username, password, host string) Auth { + return &plainAuth{identity, username, password, host} +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/activate.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/activate.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,78 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "net" +) + +func init() { + cmdActivate.Flag.StringVar(&username, "u", "", "Set the username") + cmdActivate.Flag.StringVar(&passwordFilename, "P", "", + "Set the name of the password file") +} + +var cmdActivate = &command{ + UsageLine: "activate [options] host[:port] name", + Run: runActivate, +} + +var cmdDeactivate = &command{ + UsageLine: "deactivate [options] host[:port]", + Run: runActivate, +} + +func runActivate(cmd *command, args []string) error { + if (cmd.Name() == "activate" && len(args) != 2) || + (cmd.Name() == "deactivate" && len(args) != 1) { + return usageError("invalid number of arguments") + } + + host, port, err := parseHostPort(args[0]) + if err != nil { + return err + } + + var scriptName string + if len(args) > 1 { + scriptName = args[1] + } + + username, password, err := usernamePassword(host, port, username, + passwordFilename) + if err != nil { + return err + } + + c, err := dialPlainAuth(net.JoinHostPort(host, port), username, + password) + if err != nil { + return err + } + defer c.Logout() + + if err := c.ActivateScript(scriptName); err != nil { + return err + } + + return nil +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/command.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/command.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,45 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "flag" + "fmt" + "strings" +) + +type command struct { + UsageLine string + Flag flag.FlagSet + Run func(cmd *command, args []string) error +} + +func (c *command) Usage() { + fmt.Fprintf(flag.CommandLine.Output(), "usage:\n %s %s\n", + flag.CommandLine.Name(), c.UsageLine) + fmt.Fprintln(flag.CommandLine.Output(), "options:") + c.Flag.PrintDefaults() +} + +func (c *command) Name() string { + return strings.SplitN(strings.Trim(c.UsageLine, " "), " ", 2)[0] +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/common.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/common.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,192 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "bufio" + "crypto/tls" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "os" + "os/user" + "runtime" + "strings" + + "go.guido-berhoerster.org/managesieve" + "golang.org/x/crypto/ssh/terminal" +) + +var errTooBig = errors.New("too big") + +func parseHostPort(s string) (string, string, error) { + var host string + host, port, err := net.SplitHostPort(s) + if err != nil { + // error may be due to a missing port but there is no usable + // error value to test for, thus try again with a port added + var tmpErr error + host, _, tmpErr = net.SplitHostPort(s + ":4190") + if tmpErr != nil { + return "", "", err + } + } + if port == "" { + // no port given, try to look up a SRV record for given domain + // and fall back to the domain and port 4190 + services, err := managesieve.LookupService(host) + if err != nil { + return "", "", err + } + host, port, err = net.SplitHostPort(services[0]) + if err != nil { + return "", "", err + } + } + return host, port, nil +} + +func readPassword() (string, error) { + var tty *os.File + var fd int + var w io.Writer + if runtime.GOOS == "windows" { + fd = int(os.Stdin.Fd()) + w = os.Stdout + } else { + var err error + tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0666) + if err != nil { + return "", err + } + defer tty.Close() + fd = int(tty.Fd()) + w = tty + } + + io.WriteString(w, "Password: ") + rawPassword, err := terminal.ReadPassword(fd) + io.WriteString(w, "\n") + if err != nil { + return "", fmt.Errorf("failed to read password: %s", err) + } + password := string(rawPassword) + if password == "" { + return "", fmt.Errorf("invalid password") + } + return password, nil +} + +func readPasswordFile(filename string) (string, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + err := scanner.Err() + if err == nil { + err = fmt.Errorf("failed to read from %q: unexpected EOF", + filename) + } + return "", err + } + password := scanner.Text() + if password == "" { + return "", fmt.Errorf("invalid password") + } + return password, nil +} + +func usernamePassword(host, port, username, passwordFile string) (string, string, error) { + // fall back to the system username + if username == "" { + u, err := user.Current() + if err != nil { + return "", "", + fmt.Errorf("failed to obtain username: %s", err) + } + username = u.Username + } + + var password string + var err error + if passwordFile != "" { + password, err = readPasswordFile(passwordFilename) + } else { + password, err = readPassword() + } + if err != nil { + return "", "", err + } + + return username, password, nil +} + +func dialPlainAuth(hostport, username, password string) (*managesieve.Client, error) { + c, err := managesieve.Dial(hostport) + if err != nil { + return nil, fmt.Errorf("failed to connect: %s", err) + } + + host, _, _ := net.SplitHostPort(hostport) + // switch to a TLS connection except for localhost + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + tlsConf := &tls.Config{ + ServerName: host, + InsecureSkipVerify: skipCertVerify, + } + if err := c.StartTLS(tlsConf); err != nil { + return nil, + fmt.Errorf("failed to start TLS connection: %s", + err) + } + } + + auth := managesieve.PlainAuth("", username, password, host) + if err := c.Authenticate(auth); err != nil { + return nil, fmt.Errorf("failed to authenticate user %s: %s", + username, err) + } + + return c, nil +} + +func readLimitedString(r io.Reader, n int64) (string, error) { + var s strings.Builder + _, err := io.CopyN(&s, r, n) + if err == nil { + // check for EOF + _, err = io.CopyN(ioutil.Discard, r, 1) + if err == nil { + return s.String(), errTooBig + } + } + if err != io.EOF { + return s.String(), err + } + + return s.String(), nil +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/delete.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/delete.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,69 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "net" +) + +func init() { + cmdDelete.Flag.StringVar(&username, "u", "", "Set the username") + cmdDelete.Flag.StringVar(&passwordFilename, "P", "", + "Set the name of the password file") +} + +var cmdDelete = &command{ + UsageLine: "delete [options] host[:port] name", + Run: runDelete, +} + +func runDelete(cmd *command, args []string) error { + if len(args) != 2 { + return usageError("invalid number of arguments") + } + + host, port, err := parseHostPort(args[0]) + if err != nil { + return err + } + + scriptName := args[1] + + username, password, err := usernamePassword(host, port, username, + passwordFilename) + if err != nil { + return err + } + + c, err := dialPlainAuth(net.JoinHostPort(host, port), username, + password) + if err != nil { + return err + } + defer c.Logout() + + if err := c.DeleteScript(scriptName); err != nil { + return err + } + + return nil +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/get.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/get.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,72 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "net" + "os" +) + +func init() { + cmdGet.Flag.StringVar(&username, "u", "", "Set the username") + cmdGet.Flag.StringVar(&passwordFilename, "P", "", + "Set the name of the password file") +} + +var cmdGet = &command{ + UsageLine: "get [options] host[:port] name", + Run: runGet, +} + +func runGet(cmd *command, args []string) error { + if len(args) != 2 { + return usageError("invalid number of arguments") + } + + host, port, err := parseHostPort(args[0]) + if err != nil { + return err + } + + scriptName := args[1] + + username, password, err := usernamePassword(host, port, username, + passwordFilename) + if err != nil { + return err + } + + c, err := dialPlainAuth(net.JoinHostPort(host, port), username, + password) + if err != nil { + return err + } + defer c.Logout() + + script, err := c.GetScript(scriptName) + if err != nil { + return err + } + os.Stdout.WriteString(script) + + return nil +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/list.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/list.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,77 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "fmt" + "net" +) + +func init() { + cmdList.Flag.StringVar(&username, "u", "", "Set the username") + cmdList.Flag.StringVar(&passwordFilename, "P", "", + "Set the name of the password file") +} + +var cmdList = &command{ + UsageLine: "list [options] host[:port]", + Run: runList, +} + +func runList(cmd *command, args []string) error { + if len(args) != 1 { + return usageError("invalid number of arguments") + } + + host, port, err := parseHostPort(args[0]) + if err != nil { + return err + } + + username, password, err := usernamePassword(host, port, username, + passwordFilename) + if err != nil { + return err + } + + c, err := dialPlainAuth(net.JoinHostPort(host, port), username, + password) + if err != nil { + return err + } + defer c.Logout() + + scripts, active, _ := c.ListScripts() + if err != nil { + return err + } + fmt.Println("ACTIVE SCRIPT") + for _, script := range scripts { + isActive := "no" + if script == active { + isActive = "yes" + } + fmt.Printf("%-3s %s\n", isActive, script) + } + + return nil +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/main.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/main.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,115 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "errors" + "flag" + "fmt" + "os" +) + +const ( + exitSuccess = iota + exitFailure + exitUsage +) + +type usageError string + +func (e usageError) Error() string { + return string(e) +} + +var ( + skipCertVerify bool + username string + passwordFilename string +) + +var cmds = []*command{ + cmdList, + cmdPut, + cmdGet, + cmdActivate, + cmdDeactivate, + cmdDelete, +} + +func usage() { + fmt.Fprintf(flag.CommandLine.Output(), + "usage:\n %s [options] [subcommand [options] [arguments]]\n", + flag.CommandLine.Name()) + fmt.Fprintln(flag.CommandLine.Output(), "subcommands:") + for _, cmd := range cmds { + fmt.Fprintf(flag.CommandLine.Output(), " %s\n", cmd.Name()) + } + fmt.Fprintln(flag.CommandLine.Output(), "global options:") + flag.PrintDefaults() +} + +func main() { + flag.Usage = usage + flag.BoolVar(&skipCertVerify, "I", false, + "Skip TLS certificate verification") + flag.Parse() + if flag.NArg() == 0 { + fmt.Fprintln(flag.CommandLine.Output(), "missing subcommand") + usage() + os.Exit(exitUsage) + } + + name := flag.Arg(0) + var cmd *command + for _, c := range cmds { + if c.Name() == name { + cmd = c + break + } + } + if cmd == nil { + fmt.Fprintf(flag.CommandLine.Output(), + "unknown subcommand %q\n", name) + usage() + os.Exit(exitUsage) + } + + cmd.Flag.Init(cmd.Name(), flag.ExitOnError) + cmd.Flag.Usage = cmd.Usage + args := flag.Args() + if err := cmd.Flag.Parse(args[1:]); err != nil { + fmt.Fprintln(flag.CommandLine.Output(), err) + os.Exit(exitFailure) + } + + if err := cmd.Run(cmd, cmd.Flag.Args()); err != nil { + fmt.Fprintln(flag.CommandLine.Output(), err) + + var uerr usageError + if errors.As(err, &uerr) { + cmd.Usage() + os.Exit(exitUsage) + } + + os.Exit(exitFailure) + } +} diff -r 000000000000 -r 6369453d47a3 cmd/sievemgr/put.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cmd/sievemgr/put.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,93 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "fmt" + "io" + "net" + "os" + + "go.guido-berhoerster.org/managesieve" +) + +func init() { + cmdPut.Flag.StringVar(&username, "u", "", "Set the username") + cmdPut.Flag.StringVar(&passwordFilename, "P", "", + "Set the name of the password file") +} + +var cmdPut = &command{ + UsageLine: "put [options] host[:port] name [file]", + Run: runPut, +} + +func runPut(cmd *command, args []string) error { + var scriptName string + var r io.Reader = os.Stdin + var host, port string + var err error + switch len(args) { + case 3: // name and filename + scriptFile, err := os.Open(args[2]) + if err != nil { + return fmt.Errorf("failed to open script file: %s\n", + err) + } + defer scriptFile.Close() + r = scriptFile + fallthrough + case 2: // only name + host, port, err = parseHostPort(args[0]) + if err != nil { + return err + } + + scriptName = args[1] + default: + return usageError("invalid number of arguments") + } + + script, err := readLimitedString(r, managesieve.ReadLimit) + if err != nil { + return fmt.Errorf("failed to read script: %s\n", err) + } + + username, password, err := usernamePassword(host, port, username, + passwordFilename) + if err != nil { + return err + } + + c, err := dialPlainAuth(net.JoinHostPort(host, port), username, + password) + if err != nil { + return err + } + defer c.Logout() + + if err := c.PutScript(scriptName, script); err != nil { + return err + } + + return nil +} diff -r 000000000000 -r 6369453d47a3 example_test.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/example_test.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,133 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package managesieve_test + +import ( + "crypto/tls" + "fmt" + "log" + + "go.guido-berhoerster.org/managesieve" +) + +func Example() { + host := "mail.example.org" + username := "foo" + password := "S3cR3T" + script := `require "fileinto"; + +if address :is "from" "foo@example.com" { + fileinto "INBOX/foo"; +} +` + scriptName := "newscript" + + // Try to look up a SRV record for given domain and fall back to the + // domain and port 4190. + var hostport string + if services, err := managesieve.LookupService(host); err != nil { + hostport = host + ":4190" + } else { + hostport = services[0] + } + + // Connect to the ManageSieve server. + c, err := managesieve.Dial(hostport) + if err != nil { + log.Fatalf("failed to connect: %s", err) + } + + // Establish a TLS connection. + tlsConf := &tls.Config{ServerName: host} + if err := c.StartTLS(tlsConf); err != nil { + log.Fatalf("failed to start TLS connection: %s", err) + } + + // Authenticate the user using the PLAIN SASL mechanism. + auth := managesieve.PlainAuth("", username, password, host) + if err := c.Authenticate(auth); err != nil { + log.Fatalf("failed to authenticate user %s: %s", username, err) + } + + // Check the validity of the script. + if err = c.CheckScript(script); err != nil { + log.Fatalf("script %q is not valid: %s", scriptName, err) + } + + // Check whether ther is sufficient space for uploading the script. + if ok, err := c.HaveSpace(scriptName, int64(len(script))); err != nil { + log.Fatalf("failed to determine whether there is enough space: %s", + err) + } else if !ok { + log.Fatalf("not enough space to upload script %q", scriptName) + } + + // Upload the script. + if err = c.PutScript(scriptName, script); err != nil { + log.Fatalf("failed to upload script %q: %s", scriptName, err) + } + + // Activate the uploaded script + if err = c.ActivateScript(scriptName); err != nil { + log.Fatalf("failed to set script %q active: %s", scriptName, + err) + } + + // Get a list of the names of all scripts on the server and determine + // the currently active script. + scripts, active, err := c.ListScripts() + if err != nil { + log.Fatalf("failed to list scripts: %s", err) + } + if active != scriptName { + log.Fatalf("%q is not the active script", scriptName) + } + // Download each script from the list. + for _, name := range scripts { + if name == active { + fmt.Printf("%q:\n", name) + } else { + fmt.Printf("%q (active):\n", name) + } + + content, err := c.GetScript(name) + if err != nil { + log.Fatalf("failed to get script %q: %s", name, err) + } + fmt.Println(content) + } + + // Rename the script to "temp". + if err = c.RenameScript(scriptName, "temp"); err != nil { + log.Fatalf("RENAMESCRIPT failed: %s", err) + } + + // Delete the script. + if err = c.DeleteScript("temp"); err != nil { + log.Fatalf("DELETESCRIPT failed: %s", err) + } + + // Log out and close the connection. + if err = c.Logout(); err != nil { + log.Fatalf("failed to log out: %s", err) + } +} diff -r 000000000000 -r 6369453d47a3 go.mod --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/go.mod Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,8 @@ +module go.guido-berhoerster.org/managesieve + +go 1.14 + +require ( + golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 + golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect +) diff -r 000000000000 -r 6369453d47a3 go.sum --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/go.sum Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,10 @@ +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff -r 000000000000 -r 6369453d47a3 managesieve.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/managesieve.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,546 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Package managesieve implements the MANAGESIEVE protocol as specified in +// RFC 5804. It covers all mandatory parts of the protocol with the exception +// of the SCRAM-SHA-1 SASL mechanism. Additional SASL authentication +// mechanisms can be provided by consumers. +package managesieve + +import ( + "crypto/tls" + "encoding/base64" + "fmt" + "math" + "net" + "strconv" + "strings" +) + +// ParserError represents a syntax error encountered while parsing a response +// from the server. +type ParserError string + +func (e ParserError) Error() string { + return "parse error: " + string(e) +} + +// ProtocolError represents a MANAGESIEVE protocol violation. +type ProtocolError string + +func (e ProtocolError) Error() string { + return "protocol error: " + string(e) +} + +// NotSupportedError is returned if an operation requires an extension that is +// not available. +type NotSupportedError string + +func (e NotSupportedError) Error() string { + return "not supported: " + string(e) +} + +// AuthenticationError is returned if an authentication attempt has failed. +type AuthenticationError string + +func (e AuthenticationError) Error() string { + return "authentication failed: " + string(e) +} + +// A ServerError is represents an error returned by the server in the form of a +// NO response. +type ServerError struct { + Code string // optional response code of the error + Msg string // optional human readable error message +} + +func (e *ServerError) Error() string { + if e.Msg != "" { + return e.Msg + } + return "unspecified server error" +} + +// The ConnClosedError is returned if the server has closed the connection. +type ConnClosedError struct { + Code string + Msg string +} + +func (e *ConnClosedError) Error() string { + msg := "the server has closed to connection" + if e.Msg != "" { + return msg + ": " + e.Msg + } + return msg +} + +// Tries to look up the MANAGESIEVE SRV record for the domain and returns an +// slice of strings containing hostnames and ports. If no SRV record was found +// it falls back to the given domain name and port 4190. +func LookupService(domain string) ([]string, error) { + _, addrs, err := net.LookupSRV("sieve", "tcp", domain) + if err != nil { + if dnserr, ok := err.(*net.DNSError); ok { + if dnserr.IsNotFound { + // no SRV record found, fall back to port 4190 + services := [1]string{domain + ":4190"} + return services[:], nil + } + } + return nil, err + } + services := make([]string, len(addrs)) + // addrs is already ordered by priority + for _, addr := range addrs { + services = append(services, + fmt.Sprintf("%s:%d", addr.Target, addr.Port)) + } + return services, nil +} + +// Checks whtether the given string conforms to the "Unicode Format for Network +// Interchange" specified in RFC 5198. +func IsNetUnicode(s string) bool { + for _, c := range s { + if c <= 0x1f || (c >= 0x7f && c <= 0x9f) || + c == 0x2028 || c == 0x2029 { + return false + } + } + return true +} + +func quoteString(s string) string { + return fmt.Sprintf("{%d+}\r\n%s", len(s), s) +} + +// Client represents a client connection to a MANAGESIEVE server. +type Client struct { + conn net.Conn + p *parser + isAuth bool + capa map[string]string + serverName string +} + +// Dial creates a new connection to a MANAGESIEVE server. The given addr must +// contain both a hostname or IP address and post. +func Dial(addr string) (*Client, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, err + } + return NewClient(conn, host) +} + +// NewClient create a new client based on an existing connection to a +// MANAGESIEVE server where host specifies the hostname of the remote end of +// the connection. +func NewClient(conn net.Conn, host string) (*Client, error) { + s := newScanner(conn) + p := &parser{s} + c := &Client{ + conn: conn, + p: p, + serverName: host, + } + + r, err := c.p.readReply() + if err != nil { + c.Close() + return nil, err + } + switch r.resp { + case responseOk: + c.capa, err = parseCapabilities(r) + case responseNo: + return c, &ServerError{r.code, r.msg} + case responseBye: + return c, &ConnClosedError{r.code, r.msg} + } + return c, err +} + +// SupportsRFC5804 returns true if the server conforms to RFC 5804. +func (c *Client) SupportsRFC5804() bool { + _, ok := c.capa["VERSION"] + return ok +} + +// SupportsTLS returns true if the server supports TLS connections via the +// STARTTLS command. +func (c *Client) SupportsTLS() bool { + _, ok := c.capa["STARTTLS"] + return ok +} + +// Extensions returns the Sieve script extensions supported by the Sieve engine. +func (c *Client) Extensions() []string { + return strings.Fields(c.capa["SIEVE"]) +} + +// MaxRedirects returns the limit on the number of Sieve "redirect" during a +// single evaluation. +func (c *Client) MaxRedirects() int { + n, err := strconv.ParseUint(c.capa["MAXREDIRECTS"], 10, 32) + if err != nil { + return 0 + } + return int(n) +} + +// NotifyMethods returns the URI schema parts for supported notification +// methods. +func (c *Client) NotifyMethods() []string { + return strings.Fields(c.capa["NOTIFY"]) +} + +// SASLMechanisms returns the SASL authentication mechanism supported by the +// server. This may change depending on whether a TLS connection is used. +func (c *Client) SASLMechanisms() []string { + splitFunc := func(r rune) bool { + return r == ' ' + } + return strings.FieldsFunc(c.capa["SASL"], splitFunc) +} + +func (c *Client) cmd(args ...interface{}) (*reply, error) { + // write each arg separated by a space and terminated by CR+LF + for i, arg := range args { + if i > 0 { + if _, err := c.conn.Write([]byte{' '}); err != nil { + return nil, err + } + } + if _, err := fmt.Fprint(c.conn, arg); err != nil { + return nil, err + } + } + if _, err := c.conn.Write([]byte("\r\n")); err != nil { + return nil, err + } + + r, err := c.p.readReply() + if err != nil { + return nil, err + } + if r.resp == responseNo { + return r, &ServerError{r.code, r.msg} + } else if r.resp == responseBye { + return r, &ConnClosedError{r.code, r.msg} + } + return r, nil +} + +// StartTLS upgrades the connection to use TLS encryption based on the given +// configuration. +func (c *Client) StartTLS(config *tls.Config) error { + if _, ok := c.conn.(*tls.Conn); ok { + return ProtocolError("already using a TLS connection") + } + if c.isAuth { + return ProtocolError("cannot STARTTLS in authenticated state") + } + if _, ok := c.capa["STARTTLS"]; !ok { + return NotSupportedError("STARTTLS") + } + if _, err := c.cmd("STARTTLS"); err != nil { + return err + } + c.conn = tls.Client(c.conn, config) + s := newScanner(c.conn) + c.p = &parser{s} + + r, err := c.p.readReply() + if err != nil { + return err + } + // capabilities are no longer valid if STARTTLS succeeded + c.capa, err = parseCapabilities(r) + return err +} + +// TLSConnectionState returns the ConnectionState of the current TLS +// connection. +func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { + tc, ok := c.conn.(*tls.Conn) + if !ok { + return + } + return tc.ConnectionState(), ok +} + +// Authenticate authenticates a client using the given authentication +// mechanism. In case of an AuthenticationError the client remains in a defined +// state and can continue to be used. +func (c *Client) Authenticate(a Auth) error { + encoding := base64.StdEncoding + _, isTLS := c.conn.(*tls.Conn) + info := &ServerInfo{c.serverName, isTLS, c.SASLMechanisms()} + mech, resp, err := a.Start(info) + if err != nil { + return err + } + if _, err = fmt.Fprintf(c.conn, "AUTHENTICATE \"%s\" \"%s\"\r\n", + mech, encoding.EncodeToString(resp)); err != nil { + return err + } + + var line []*token + // handle SASL challenge-response messages exchanged as base64-encoded + // strings until the server sends a MANAGESIEVE response which may + // contain some final SASL data + for { + line, err = c.p.readLine() + if err != nil { + return err + } + + if c.p.isResponseLine(line) { + break + } else if len(line) != 1 || + (line[0].typ != tokenQuotedString && + line[0].typ != tokenLiteralString) { + return ParserError("failed to parse SASL data: expected string") + } + msg, err := encoding.DecodeString(line[0].literal) + if err != nil { + return ParserError("failed to decode SASL data: " + + err.Error()) + } + + // perform next step in authentication process + resp, authErr := a.Next(msg, true) + if authErr != nil { + // this error should be recoverable, abort + // authentication + if _, err := fmt.Fprintf(c.conn, "\"*\"\r\n"); err != nil { + return err + } + + line, err = c.p.readLine() + if err != nil { + return err + } + if r, err := c.p.parseResponseLine(line); err != nil { + return err + } else if r.resp != responseNo { + return ProtocolError("invalid response to aborted authentication: expected NO") + } + + return AuthenticationError(authErr.Error()) + } + + // send SASL response + if _, err := fmt.Fprintf(c.conn, "\"%s\"\r\n", + encoding.EncodeToString(resp)); err != nil { + return err + } + } + + // handle MANAGESIEVE response + r, err := c.p.parseResponseLine(line) + if err != nil { + return err + } + if r.resp == responseNo { + return AuthenticationError(r.msg) + } else if r.resp == responseBye { + return &ConnClosedError{r.code, r.msg} + } + + // check for SASL response code with final SASL data as the response + // code argument + if r.code == "SASL" { + if len(r.codeArgs) != 1 { + return ParserError("failed to parse SASL code argument: expected a single argument") + } + msg64 := r.codeArgs[0] + msg, err := encoding.DecodeString(msg64) + if err != nil { + return ParserError("failed to decode SASL code argument: " + err.Error()) + } + + if _, err = a.Next(msg, false); err != nil { + return AuthenticationError(err.Error()) + } + } + + // capabilities are no longer valid after succesful authentication + r, err = c.cmd("CAPABILITY") + if err != nil { + return err + } + c.capa, err = parseCapabilities(r) + return err +} + +// HaveSpace queries the server if there is sufficient space to store a script +// with the given name and size. An already existing script with the same name +// will be treated as if it were replaced with a script of the given size. +func (c *Client) HaveSpace(name string, size int64) (bool, error) { + if size < 0 { + return false, + ProtocolError(fmt.Sprintf("invalid script size: %d", + size)) + } + if size > math.MaxInt32 { + return false, ProtocolError("script exceeds maximum size") + } + r, err := c.cmd("HAVESPACE", quoteString(name), size) + if err != nil { + if r.code == "QUOTA" || r.code == "QUOTA/MAXSIZE" { + err = nil + } + } + return r.resp == responseOk, err +} + +// PutScript stores the script content with the given name on the server. An +// already existing script with the same name will be replaced. +func (c *Client) PutScript(name, content string) error { + if !IsNetUnicode(name) { + return ProtocolError("script name must comply with Net-Unicode") + } + _, err := c.cmd("PUTSCRIPT", quoteString(name), quoteString(content)) + return err +} + +// ListScripts returns the names of all scripts on the server and the name of +// the currently active script. If there is no active script it returns the +// empty string. +func (c *Client) ListScripts() ([]string, string, error) { + r, err := c.cmd("LISTSCRIPTS") + if err != nil { + return nil, "", err + } + + var scripts []string = make([]string, 0) + var active string + for _, tokens := range r.lines { + if tokens[0].typ != tokenQuotedString && + tokens[0].typ != tokenLiteralString { + return nil, "", ParserError("failed to parse script list: expected string") + } + switch len(tokens) { + case 2: + if tokens[1].typ != tokenAtom || + tokens[1].literal != "ACTIVE" { + return nil, "", ParserError("failed to parse script list: expected atom ACTIVE") + } + active = tokens[0].literal + fallthrough + case 1: + scripts = append(scripts, tokens[0].literal) + default: + return nil, "", ParserError("failed to parse script list: trailing data") + } + } + return scripts, active, nil +} + +// ActivateScript activates a script. Only one script can be active at the same +// time, activating a script will deactivate the previously active script. If +// the name is the empty string the currently active script will be +// deactivated. +func (c *Client) ActivateScript(name string) error { + _, err := c.cmd("SETACTIVE", quoteString(name)) + return err +} + +// GetScript returns the content of the script with the given name. +func (c *Client) GetScript(name string) (string, error) { + r, err := c.cmd("GETSCRIPT", quoteString(name)) + if err != nil { + return "", err + } + if len(r.lines) != 1 || + (r.lines[0][0].typ != tokenQuotedString && + r.lines[0][0].typ != tokenLiteralString) { + return "", ParserError("failed to parse script: expected string") + } + return r.lines[0][0].literal, nil +} + +// DeleteScript deletes the script with the given name from the server. +func (c *Client) DeleteScript(name string) error { + _, err := c.cmd("DELETESCRIPT", quoteString(name)) + return err +} + +// RenameScript renames a script on the server. This operation is only +// available if the server conforms to RFC 5804. +func (c *Client) RenameScript(oldName, newName string) error { + if !c.SupportsRFC5804() { + return NotSupportedError("RENAMESCRIPT") + } + if !IsNetUnicode(newName) { + return ProtocolError("script name must comply with Net-Unicode") + } + _, err := c.cmd("RENAMESCRIPT", quoteString(oldName), + quoteString(newName)) + return err +} + +// CheckScript checks if the given script contains any errors. This operation +// is only available if the server conforms to RFC 5804. +func (c *Client) CheckScript(content string) error { + if !c.SupportsRFC5804() { + return NotSupportedError("CHECKSCRIPT") + } + _, err := c.cmd("CHECKSCRIPT", quoteString(content)) + return err +} + +// Noop does nothing but contact the server and can be used to prevent timeouts +// and to check whether the connection is still alive. This operation is only +// available if the server conforms to RFC 5804. +func (c *Client) Noop() error { + if !c.SupportsRFC5804() { + return NotSupportedError("NOOP") + } + _, err := c.cmd("NOOP") + return err +} + +// Close closes the connection to the server immediately without informing the +// remote end that the client has finished. Under normal circumstances Logout +// should be used instead. +func (c *Client) Close() error { + return c.conn.Close() +} + +// Logout first indicates to the server that the client is finished and +// subsequently closes the connection. No further commands can be sent after +// this. +func (c *Client) Logout() error { + _, err := c.cmd("LOGOUT") + cerr := c.Close() + if err == nil { + err = cerr + } + return err +} diff -r 000000000000 -r 6369453d47a3 managesieve_test.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/managesieve_test.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,1189 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package managesieve_test + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net" + "strings" + "testing" + "time" + + "go.guido-berhoerster.org/managesieve" +) + +func quoteClientString(s string) string { + return fmt.Sprintf("{%d+}\r\n%s", len(s), s) +} + +func quoteServerString(s string) string { + return fmt.Sprintf("{%d}\r\n%s", len(s), s) +} + +// turn LF into CRLF line endeings and substitute passed key-value string pairs +func fixClientOutput(s string, replacements ...string) string { + return strings.NewReplacer(append(replacements, + "\n", "\r\n")...).Replace(s) +} + +type fakeConn struct { + io.ReadWriter + buf bytes.Buffer + w *bufio.Writer +} + +func newFakeConn(s string) *fakeConn { + f := &fakeConn{} + r := bufio.NewReader(strings.NewReader(s)) + f.w = bufio.NewWriter(&f.buf) + f.ReadWriter = bufio.NewReadWriter(r, f.w) + return f +} + +func (f *fakeConn) Close() error { + return nil +} + +func (f *fakeConn) LocalAddr() net.Addr { + return nil +} + +func (f *fakeConn) RemoteAddr() net.Addr { + return nil +} + +func (f *fakeConn) SetDeadline(time.Time) error { + return nil +} + +func (f *fakeConn) SetReadDeadline(time.Time) error { + return nil +} + +func (f *fakeConn) SetWriteDeadline(time.Time) error { + return nil +} + +func (f *fakeConn) Written() []byte { + f.w.Flush() + return f.buf.Bytes() +} + +// basic test of net-unicode validation +func TestNetUnicode(t *testing.T) { + if !managesieve.IsNetUnicode("abc\u00a9") { + t.Fatalf("expected valid net-unicode") + } + if managesieve.IsNetUnicode("a\tbc") { + t.Fatalf("expected invalid net-unicode") + } + if managesieve.IsNetUnicode("a\u0080bc") { + t.Fatalf("expected invalid net-unicode") + } + if managesieve.IsNetUnicode("a\u2028bc") { + t.Fatalf("expected invalid net-unicode") + } +} + +var validScript string = `redirect "111@example.net"; + +if size :under 10k { + redirect "mobile@cell.example.com"; +} + +if envelope :contains "to" "tmartin+lists" { + redirect "lists@groups.example.com"; +} +` + +// basic functionality +var basicServer string = `"IMPlemENTATION" "Example1 ManageSieved v001" +"SASl" "PLAIN DIGEST-MD5 GSSAPI" +"SIeVE" "fileinto vacation" +"StaRTTLS" +"NOTIFY" "xmpp mailto" +"MAXREdIRECTS" "5" +"VERSION" "1.0" +OK +OK +"IMPlemENTATION" "Example1 ManageSieved v001" +"SASl" "PLAIN DIGEST-MD5 GSSAPI" +"SIeVE" "fileinto vacation" +"StaRTTLS" +"NOTIFY" "xmpp mailto" +"MAXREdIRECTS" "5" +"VERSION" "1.0" +OK +OK (WARNINGS) "line 8: server redirect action limit is 2, this redirect might be ignored" +OK +OK (WARNINGS) "line 8: server redirect action limit is 2, this redirect might be ignored" +OK +"default" ACTIVE +OK +` + quoteServerString(validScript) + ` +OK +OK +OK +OK +OK +OK +` + +var basicExpectedOutput string = fixClientOutput(`AUTHENTICATE "PLAIN" "AGZvbwBTM2NSM1Q=" +CAPABILITY +CHECKSCRIPT @quotedScript@ +HAVESPACE {7+} +default @scriptLength@ +PUTSCRIPT {7+} +default @quotedScript@ +SETACTIVE {7+} +default +LISTSCRIPTS +GETSCRIPT {7+} +default +SETACTIVE {0+} + +RENAMESCRIPT {7+} +default {4+} +test +DELETESCRIPT {4+} +test +NOOP +LOGOUT +`, + "@quotedScript@", quoteClientString(validScript), + "@scriptLength@", fmt.Sprint(len(validScript))) + +func TestBasicFunctionality(t *testing.T) { + conn := newFakeConn(basicServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + if !c.SupportsRFC5804() { + t.Fatal("RFC5804 support unexpectedly not detected") + } + + if !c.SupportsTLS() { + t.Fatal("STARTTLS support unexpectedly not detected") + } + + if ext := c.Extensions(); strings.Join(ext, " ") != + "fileinto vacation" { + t.Fatalf("expected extensions: [fileinto vacation], got: %v", + ext) + } + + if notify := c.NotifyMethods(); strings.Join(notify, " ") != + "xmpp mailto" { + t.Fatalf("expected notify methods: [xmpp mailto], got: %v", + notify) + } + + if redir := c.MaxRedirects(); redir != 5 { + t.Fatalf("expected max redirects: 5 got: %d", redir) + } + + if strings.Join(c.SASLMechanisms(), " ") != "PLAIN DIGEST-MD5 GSSAPI" { + t.Fatal("failed check SASL methods") + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + if err := c.Authenticate(auth); err != nil { + t.Fatalf("plain authentication failed: %s", err) + } + + if err = c.CheckScript(validScript); err != nil { + t.Fatalf("CHECKSCRIPT failed: %s", err) + } + + if ok, err := c.HaveSpace("default", int64(len(validScript))); err != nil { + t.Fatalf("HAVESPACE failed: %s", err) + } else if !ok { + t.Fatal("HaveSpace unexpectedly returned false") + } + + if err = c.PutScript("default", validScript); err != nil { + t.Fatalf("PUTSCRIPT failed: %s", err) + } + + if err = c.ActivateScript("default"); err != nil { + t.Fatalf("SETACTIVE failed: %s", err) + } + + if scripts, active, err := c.ListScripts(); err != nil { + t.Fatalf("LISTSCRIPTS failed: %s", err) + } else if active != "default" { + t.Fatalf("failed to get active script") + } else if len(scripts) != 1 || scripts[0] != "default" { + t.Fatal("failed to get scripts") + } + + if recievedScript, err := c.GetScript("default"); err != nil { + t.Fatalf("GETSCRIPT failed: %s", err) + } else if recievedScript != validScript { + t.Fatalf("GETSCRIPT expected:\n%s\ngot:\n%s\n", + validScript, recievedScript) + } + + if err = c.ActivateScript(""); err != nil { + t.Fatalf("SETACTIVE failed: %s", err) + } + + if err = c.RenameScript("default", "test"); err != nil { + t.Fatalf("RENAMESCRIPT failed: %s", err) + } + + if err = c.DeleteScript("test"); err != nil { + t.Fatalf("DELETESCRIPT failed: %s", err) + } + + if err = c.Noop(); err != nil { + t.Fatalf("NOOP failed unexpectedly: %s", err) + } + + if err = c.Logout(); err != nil { + t.Fatalf("failed to log out: %s", err) + } + + clientOutput := string(conn.Written()) + if clientOutput != basicExpectedOutput { + t.Fatalf("expected:\n%s\ngot:\n%s\n", basicExpectedOutput, + clientOutput) + } +} + +// unexpected EOF after length in literal string +func TestUnexpectedEOFInLiteral(t *testing.T) { + conn := newFakeConn(`"IMPLEMENTATION" {10}`) + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if err != io.ErrUnexpectedEOF { + t.Fatalf("expected io.ErrUnexpectedEOF, got %T (%q)", err, err) + } +} + +// CR without LF after length in literal string +func TestInvalidCRInLiteral(t *testing.T) { + conn := newFakeConn("{2}\rXX\nOK\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// overlong literal string +func TestOverlongLiteral(t *testing.T) { + conn := newFakeConn(fmt.Sprintf("{%d}\r\n", managesieve.ReadLimit+1)) + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// string in place of an response code atom +func TestInvalidResponseCode(t *testing.T) { + conn := newFakeConn("NO (\"XXX\")\r\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// EOF after response code +func TestResponseUnexpectedEOF(t *testing.T) { + conn := newFakeConn("NO (XXX") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if err != io.ErrUnexpectedEOF { + t.Fatalf("expected io.ErrUnexpectedEOF, got %T (%q)", err, err) + } +} + +// invalid atom in place of the response message string +func TestInvalidResponseMessage(t *testing.T) { + conn := newFakeConn("BYE XXX\r\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// trailing invalid atom after response message +func TestResponseTrailingData(t *testing.T) { + conn := newFakeConn("BYE \"XXX\" XXX\r\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// capabilities with atom instead of key string +func TestCapabilitiesInvalidKey(t *testing.T) { + conn := newFakeConn("IMPLEMENTATION \"Example\"\r\nOK\r\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// capabilities with atom instead of value string +func TestCapabilitiesInvalidValue(t *testing.T) { + conn := newFakeConn("\"IMPLEMENTATION\" Example\r\nOK\r\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +var unexpectedResponseServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +` + +// unexpected EOF +func TestUnexpectedEOF(t *testing.T) { + conn := newFakeConn(unexpectedResponseServer) + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if err != io.ErrUnexpectedEOF { + t.Fatalf("expected io.ErrUnexpectedEOF, got %T (%q)", err, err) + } +} + +// unexpected NO +func TestUnexpectedNo(t *testing.T) { + conn := newFakeConn(unexpectedResponseServer + "NO\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(*managesieve.ServerError); !ok { + t.Fatalf("expected *managesieve.ServerError, got %T (%q)", err, + err) + } +} + +// unexpected BYE +func TestUnexpectedBye(t *testing.T) { + conn := newFakeConn(unexpectedResponseServer + "BYE\n") + _, err := managesieve.NewClient(conn, "imap.example.net") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(*managesieve.ConnClosedError); !ok { + t.Fatalf("expected *managesieve.ConnClosedError, got %T (%q)", + err, err) + } +} + +var invalidMaxRedirectsServer = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"MAxREDIRECTS" "-1" +"VERSION" "1.0" +OK +` + +func TestInvalidMaxRedirects(t *testing.T) { + conn := newFakeConn(invalidMaxRedirectsServer) + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + if redir := c.MaxRedirects(); redir != 0 { + t.Fatalf("invalid MAXREDIRECTS, expected 0, got %d", redir) + } +} + +var minimalServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +` + +var haveSpaceServer string = minimalServer + `NO +NO (QUOTA) +NO (QUOTA/MAXSIZE) "Quota exceeded" +` + +// handling of unknown errors and quota exceeded errors +func TestHaveSpace(t *testing.T) { + conn := newFakeConn(haveSpaceServer) + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + var ok bool + // unknown error response + ok, err = c.HaveSpace("foo", 999999) + if ok { + t.Fatalf("expected failure due to unknown error response to HAVESPACE but succeeded") + } + if err == nil { + t.Fatalf("expected error due to unknown error response to HAVESPACE") + } + if _, ok = err.(*managesieve.ServerError); !ok { + t.Fatalf("expected *managesieve.ServerError, got %T (%q)", err, + err) + } + + // invalid script size + _, err = c.HaveSpace("foo", -999999) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok = err.(managesieve.ProtocolError); !ok { + t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", err, + err) + } + + // quota exceeded + ok, err = c.HaveSpace("foo", 999999) + if ok { + t.Fatalf("expected script to exceed quota") + } + if err != nil { + t.Fatalf("failed to determine whether script exceeds quota: %s", + err) + } +} + +// handling of errors in response to PUTSCRIPT commands +func TestPutScript(t *testing.T) { + conn := newFakeConn(minimalServer + "BYE\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + // invalid script name + err = c.PutScript("def\u2028ault", validScript) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ProtocolError); !ok { + t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", err, + err) + } + + // EOF during upload + err = c.PutScript("default", validScript) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(*managesieve.ConnClosedError); !ok { + t.Fatalf("expected *managesieve.ConnClosedError, got %T (%q)", + err, err) + } +} + +// handling of a literal string with an invalid length +func TestInvalidScriptLength(t *testing.T) { + conn := newFakeConn(minimalServer + "{-1}\r\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, err = c.GetScript("default") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// missing script in GETSCRIPT response +func TestEmptyGetScript(t *testing.T) { + conn := newFakeConn(minimalServer + "OK\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, err = c.GetScript("default") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// invalid script list item which is not a string +func TestListScriptsNonString(t *testing.T) { + conn := newFakeConn(minimalServer + "XXX\nOK\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, _, err = c.ListScripts() + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// script list item with an invalid atom +func TestListScriptsInvalidAtom(t *testing.T) { + conn := newFakeConn(minimalServer + "\"default\" XXX\nOK\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, _, err = c.ListScripts() + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// script list with multiple active scripts +var multipleActiveServer string = minimalServer + `"default" ACTIVE +"alternative" ACTIVE +"foo" +OK +` + +func TestListScriptsMultipleActive(t *testing.T) { + conn := newFakeConn(multipleActiveServer) + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, active, err := c.ListScripts() + if err != nil { + t.Fatalf("failed to get list of scripts: %s", err) + } + // although not allowed, the last script marked as active will be + // returned + if active != "alternative" { + t.Fatalf("expected active script \"alternative\", got \"%s\"", + active) + } +} + +// script list with an empty line +var listScriptsEmptyLineServer string = minimalServer + `"default" ACTIVE +"alternative" + +OK +` + +func TestListScriptsEmptyLine(t *testing.T) { + conn := newFakeConn(listScriptsEmptyLineServer) + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, _, err = c.ListScripts() + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// script list item with trailing string +func TestListScriptsTrailingData(t *testing.T) { + conn := newFakeConn(minimalServer + + "\"default\" ACTIVE \"alternative\"\nOK\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + _, _, err = c.ListScripts() + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, + err) + } +} + +// renaming to an invalid name +func TestRenameInvalidName(t *testing.T) { + conn := newFakeConn(minimalServer + "NO\n") + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + err = c.RenameScript("default", "a\u2028bc") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ProtocolError); !ok { + t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", + err, err) + } +} + +// legacy server which does not implement RFC 5804 +var legacyServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +OK +NO +NO +NO +` + +func TestLegacyServer(t *testing.T) { + conn := newFakeConn(legacyServer) + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + err = c.Noop() + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.NotSupportedError); !ok { + t.Fatalf("expected managesieve.NotSupportedError, got %T (%q)", + err, err) + } + + err = c.RenameScript("default", "alternative") + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.NotSupportedError); !ok { + t.Fatalf("expected managesieve.NotSupportedError, got %T (%q)", + err, err) + } + + err = c.CheckScript(validScript) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.NotSupportedError); !ok { + t.Fatalf("expected managesieve.NotSupportedError, got %T (%q)", + err, err) + } +} + +// PLAIN authentication without TLS on non-localhost server +func TestPlainAuthNoTLS(t *testing.T) { + conn := newFakeConn(minimalServer) + c, err := managesieve.NewClient(conn, "imap.example.net") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "imap.example.net") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("error expected due to SASL PLAIN authentication without a TLS connection") + } + if err != managesieve.ErrPlainAuthTLSRequired { + t.Fatalf("expected managesieve.ErrPlainAuthTLSRequired, got %T (%q)", err, err) + } +} + +// mismatch between actual and expected hostname +func TestPlainAuthHostnameMismatch(t *testing.T) { + conn := newFakeConn(minimalServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "imap.example.net") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(*managesieve.HostNameVerificationError); !ok { + t.Fatalf("expected *managesieve.HostNameVerificationError, got %T (%q)", err, err) + } +} + +// authentication failure due to invalid credentials +func TestPlainAuthDenied(t *testing.T) { + conn := newFakeConn(minimalServer + + "NO \"invalid username or password\"\n") + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + if err = c.Authenticate(auth); err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.AuthenticationError); !ok { + t.Fatalf("expected managesieve.AuthenticationError, got %T (%q)", err, err) + } +} + +// missing SASL PLAIN authentication method +var plainAuthMissingServer string = `"IMPLEMENTATION" "Example" +"SASL" "TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +` + +func TestPlainMissingDenied(t *testing.T) { + conn := newFakeConn(plainAuthMissingServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but authentication succeeded") + } + if err != managesieve.ErrPlainAuthNotSupported { + t.Fatalf("expected managesieve.ErrPlainAuthNotSupported, got %T (%q)", err, err) + } +} + +// custom SASL authentication method handling with a challenge-response exchange +var customAuthServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +"cXV1eCxnYXJwbHk=" +"d2FsZG8sZnJlZA==" +OK (SASL "eHl6enkgdGh1ZA==") +"IMPLEMENTATION" "Example" +"SASL" "PLAIN TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +` + +var ( + ErrTestAuthNotSupported = errors.New("the server does not support TEST authentication") + ErrTestAuthFailed = errors.New("client authentication failed") +) + +type testAuth struct { + WantError bool + i int +} + +func (a *testAuth) Start(server *managesieve.ServerInfo) (string, []byte, error) { + if !server.HaveAuth("TEST") { + return "TEST", nil, ErrTestAuthNotSupported + } + + return "TEST", []byte("baz,qux"), nil +} + +func (a *testAuth) Next(challenge []byte, more bool) ([]byte, error) { + if a.i == 0 && a.WantError { + return nil, ErrTestAuthFailed + } + + var resp []byte + if !more { + resp = []byte("plugh,xyzzy") + } else { + a.i++ + resp = []byte(fmt.Sprintf("qux=%d", a.i)) + } + return resp, nil +} + +// authentication using a custom authentication method +func TestCustomAuth(t *testing.T) { + conn := newFakeConn(customAuthServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := &testAuth{} + if err = c.Authenticate(auth); err != nil { + t.Fatalf("failed to authenticate using custom authentication method: %s", err) + } +} + +// requesting a non-existent SASL authentication method +func TestNonexistentAuth(t *testing.T) { + conn := newFakeConn(minimalServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := &testAuth{} + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if err != ErrTestAuthNotSupported { + t.Fatalf("expected ErrAuthMethodNotSupported, got %T (%q)", + err, err) + } +} + +// SASL authentication method aborted by the client +var abortAuthServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +"cXV1eCxnYXJwbHk=" +NO "aborted by client" +` + +// handle error raised by client-side custom authentication handler during +// challenge-response exchange and abort authentication +func TestAbortAuth(t *testing.T) { + conn := newFakeConn(abortAuthServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := &testAuth{WantError: true} + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.AuthenticationError); !ok { + t.Fatalf("expected managesieve.AuthenticationError, got %T (%q)", err, err) + } +} + +// custom SASL authentication method handling with a challenge which is not a +// bas64-encoded string +var corruptAuthServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +"*****" +` + +func TestCustomAuthCorrupt(t *testing.T) { + conn := newFakeConn(corruptAuthServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := &testAuth{} + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err) + } +} + +// response to aborted SASL authentication is not NO +var invalidAuthAbortServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +"cXV1eCxnYXJwbHk=" +OK +` + +func TestInvalidAuthAbort(t *testing.T) { + conn := newFakeConn(invalidAuthAbortServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := &testAuth{WantError: true} + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ProtocolError); !ok { + t.Fatalf("expected managesieve.ProtocolError, got %T (%q)", err, err) + } +} + +// invalid response to SASL authentication attempt +var invalidAuthServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +XXX +` + +func TestInvalidAuthResponse(t *testing.T) { + conn := newFakeConn(invalidAuthServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err) + } +} + +// invalid trailing argument after first SASL response code argument +var trailingSASLResponseArgServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +OK (SASL "Zm9v" XXX) +` + +func TestTrailingSASLResponseArg(t *testing.T) { + conn := newFakeConn(trailingSASLResponseArgServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err) + } +} + +// trailing argument after first SASL response code argument +var trailingSASLResonseArgServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +OK (SASL "Zm9v" "bar") +` + +func TestTrailingSASLResonseArg(t *testing.T) { + conn := newFakeConn(trailingSASLResonseArgServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err) + } +} + +// SASL response code argument is not base64-encoded +var invalidSASLResponseArgServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +OK (SASL "*****") +` + +func TestInvalidSASLResponseArg(t *testing.T) { + conn := newFakeConn(invalidSASLResponseArgServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.ParserError); !ok { + t.Fatalf("expected managesieve.ParserError, got %T (%q)", err, err) + } +} + +// argument passed with SASL response code rejected by client authentication +// handler +var saslResponseRejectedServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN TEST" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +OK (SASL "Zm9v") +` + +func TestSASLResponseRejected(t *testing.T) { + conn := newFakeConn(saslResponseRejectedServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := &testAuth{WantError: true} + if err = c.Authenticate(auth); err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(managesieve.AuthenticationError); !ok { + t.Fatalf("expected managesieve.AuthenticationError, got %T (%q)", err, err) + } +} + +// CAPABILITIES command after authentication failed +var authCapabilitiesFailedServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +OK +NO +` + +func TestAuthCapabilitiesFailed(t *testing.T) { + conn := newFakeConn(authCapabilitiesFailedServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(*managesieve.ServerError); !ok { + t.Fatalf("expected *managesieve.ServerError, got %T (%q)", err, err) + } +} + +// BYE in response to SASL authentication attempt +var authByeServer string = `"IMPLEMENTATION" "Example" +"SASL" "PLAIN" +"SIEVE" "fileinto vacation" +"StARTTLS" +"VERSION" "1.0" +OK +BYE "authentication denied" +` + +func TestAuthBye(t *testing.T) { + conn := newFakeConn(authByeServer) + c, err := managesieve.NewClient(conn, "localhost") + if err != nil { + t.Fatalf("failed to create client: %s", err) + } + + auth := managesieve.PlainAuth("", "foo", "S3cR3T", "localhost") + err = c.Authenticate(auth) + if err == nil { + t.Fatalf("expected error but succeeded") + } + if _, ok := err.(*managesieve.ConnClosedError); !ok { + t.Fatalf("expected *managesieve.ConnClosedError, got %T (%q)", err, err) + } +} diff -r 000000000000 -r 6369453d47a3 parser.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/parser.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,197 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package managesieve + +import ( + "strings" +) + +type response int + +const ( + responseInvalid response = iota + responseOk + responseNo + responseBye +) + +func lookupResponse(s string) response { + switch s { + case "OK": + return responseOk + case "NO": + return responseNo + case "BYE": + return responseBye + } + return responseInvalid +} + +type reply struct { + lines [][]*token + resp response + code string + codeArgs []string + msg string +} + +func parseCapabilities(r *reply) (map[string]string, error) { + capa := make(map[string]string) + for _, tokens := range r.lines { + var k, v string + if tokens[0].typ != tokenQuotedString && + tokens[0].typ != tokenLiteralString { + return nil, ParserError("failed to parse capability name: expected string") + } + k = strings.ToUpper(tokens[0].literal) + + if len(tokens) > 1 { + if tokens[1].typ != tokenQuotedString && + tokens[1].typ != tokenLiteralString { + return nil, ParserError("failed to parse capability value: expected string") + } + v = tokens[1].literal + } + capa[k] = v + } + return capa, nil +} + +type parser struct { + s *scanner +} + +func (p *parser) isResponseLine(tokens []*token) bool { + return tokens[0].typ == tokenAtom && + lookupResponse(tokens[0].literal) != responseInvalid +} + +func (p *parser) parseResponseLine(tokens []*token) (*reply, error) { + var i int + next := func() (*token, bool) { + if i >= len(tokens) { + return nil, false + } + tok := tokens[i] + i++ + return tok, true + } + + // response + tok, cont := next() + r := &reply{resp: lookupResponse(tok.literal)} + + // code starts with left parenthesis + tok, cont = next() + if !cont { + // only response without code and/or message + return r, nil + } + if tok.typ == tokenLeftParenthesis { + // code atom + tok, cont = next() + if !cont || tok.typ != tokenAtom { + return nil, ParserError("failed to parse response code: expected atom") + } + r.code = tok.literal + + // followed by zero or more string arguments + for { + tok, cont = next() + if !cont { + return nil, ParserError("failed to parse response code: unexpected end of line") + } + if tok.typ != tokenQuotedString && + tok.typ != tokenLiteralString { + break + } + r.codeArgs = append(r.codeArgs, tok.literal) + } + + // terminated by a right parenthesis + if tok.typ != tokenRightParenthesis { + return nil, ParserError("failed to parse response code: expected right parenthesis") + } + + tok, cont = next() + if !cont { + // response with code but no message + return r, nil + } + } + + // message string + if tok.typ != tokenQuotedString && + tok.typ != tokenLiteralString { + return nil, ParserError("failed to parse response message: expected string") + } + r.msg = strings.TrimSpace(tok.literal) + + // end of line + if _, cont = next(); cont { + return nil, ParserError("failed to parse response line: unexpected trailing data") + } + + return r, nil +} + +func (p *parser) readLine() ([]*token, error) { + tokens := make([]*token, 0) + for { + tok, err := p.s.scan() + if err != nil { + return nil, err + } + if tok.typ == tokenCRLF { + break + } + tokens = append(tokens, tok) + } + + return tokens, nil +} + +func (p *parser) readReply() (*reply, error) { + var r *reply + var lines [][]*token = make([][]*token, 0, 1) + for { + tokens, err := p.readLine() + if err != nil { + return nil, err + } + if len(tokens) == 0 { + return nil, ParserError("unexpected empty line") + } + // check for response tokens + if p.isResponseLine(tokens) { + r, err = p.parseResponseLine(tokens) + if err != nil { + return nil, err + } + r.lines = lines + break + } + lines = append(lines, tokens) + } + + return r, nil +} diff -r 000000000000 -r 6369453d47a3 scanner.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/scanner.go Thu Oct 15 09:11:05 2020 +0200 @@ -0,0 +1,276 @@ +// Copyright (C) 2020 Guido Berhoerster +// +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +package managesieve + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" + "unicode" +) + +const ReadLimit = 1 * 1024 * 1024 // 1 MiB + +type tokenType int + +const ( + tokenInvalid tokenType = iota + tokenCRLF + tokenLeftParenthesis + tokenRightParenthesis + tokenAtom + tokenQuotedString + tokenLiteralString +) + +type token struct { + typ tokenType + literal string +} + +func (t token) String() string { + switch t.typ { + case tokenInvalid: + return "Invalid" + case tokenLeftParenthesis: + return "LeftParenthesis: " + t.literal + case tokenRightParenthesis: + return "RightParenthesis: " + t.literal + case tokenAtom: + return "Atom: " + t.literal + case tokenCRLF: + return fmt.Sprintf("CRLF: %q", t.literal) + case tokenQuotedString: + return fmt.Sprintf("QuotedString: %q", t.literal) + case tokenLiteralString: + return fmt.Sprintf("LiteralString: %q", t.literal) + } + return fmt.Sprintf("unknown token: %q", t.literal) +} + +type scanner struct { + lr *io.LimitedReader // do not read from this, only for access to N + br *bufio.Reader // wraps LimitReader +} + +func newScanner(r io.Reader) *scanner { + lr := &io.LimitedReader{R: r, N: ReadLimit} + br := bufio.NewReader(lr) + return &scanner{lr, br} +} + +func (s *scanner) scanCRLF() (*token, error) { + c, _, err := s.br.ReadRune() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + literal := string(c) + // accept LF without CR + if c == '\r' { + c, _, err = s.br.ReadRune() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + literal += string(c) + } + if c != '\n' { + return nil, ParserError(fmt.Sprintf(`expected '\n', got %q`, c)) + } + return &token{typ: tokenCRLF, literal: literal}, nil +} + +func (s *scanner) scanParenthesis() (*token, error) { + c, _, err := s.br.ReadRune() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + var typ tokenType + if c == '(' { + typ = tokenLeftParenthesis + } else if c == ')' { + typ = tokenRightParenthesis + } else { + return nil, + ParserError(fmt.Sprintf("expected parenthesis, got %q", + c)) + } + return &token{typ: typ, literal: string(c)}, nil +} + +func isAtomRune(c rune) bool { + return c == '!' || + (c >= 0x23 && c <= 0x27) || + (c >= 0x2a && c <= 0x5b) || + (c >= 0x5d && c <= 0x7a) || + (c >= 0x7c && c <= 0x7e) +} + +func (s *scanner) scanAtom() (*token, error) { + var sb strings.Builder + var c rune + for { + c, _, err := s.br.ReadRune() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + if isAtomRune(c) { + sb.WriteRune(unicode.ToUpper(c)) + } else { + s.br.UnreadRune() + break + } + } + if sb.Len() == 0 { + return nil, ParserError(fmt.Sprintf("expected atom, got %q", c)) + } + return &token{typ: tokenAtom, literal: sb.String()}, nil +} + +func (s *scanner) scanQuotedString() (*token, error) { + c, _, err := s.br.ReadRune() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + if c != '"' { + return nil, ParserError(fmt.Sprintf("expected '\"', got %q", c)) + } + qs, err := s.br.ReadString('"') + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + return &token{typ: tokenQuotedString, literal: qs[:len(qs)-1]}, + nil +} + +func (s *scanner) scanLiteralString() (*token, error) { + c, _, err := s.br.ReadRune() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + if c != '{' { + return nil, ParserError(fmt.Sprintf("expected '{', got %q", c)) + } + nstr, err := s.br.ReadString('}') + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + n, err := strconv.ParseUint(nstr[:len(nstr)-1], 10, 32) + if err != nil { + return nil, ParserError("failed to parse literal string length: " + err.Error()) + } + if n > uint64(s.lr.N) { + return nil, ParserError(fmt.Sprintf("string too long: %d", n)) + } + + if _, err := s.scanCRLF(); err != nil { + return nil, err + } + + b := make([]byte, n) + _, err = io.ReadFull(s.br, b) + ls := string(b) + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + + return &token{typ: tokenLiteralString, literal: ls}, nil +} + +func (s *scanner) skipSpace() error { + for { + b, err := s.br.ReadByte() + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return err + } + + if b != ' ' { + s.br.UnreadByte() + break + } + } + + return nil +} + +func (s *scanner) scan() (*token, error) { + if err := s.skipSpace(); err != nil { + return nil, err + } + + buf, err := s.br.Peek(1) + if err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + b := buf[0] + switch { + case b == '\r': + fallthrough + case b == '\n': + return s.scanCRLF() + case b == '"': + return s.scanQuotedString() + case b == '{': + return s.scanLiteralString() + case b == '(': + fallthrough + case b == ')': + return s.scanParenthesis() + case isAtomRune(rune(b)): + return s.scanAtom() + } + return nil, ParserError(fmt.Sprintf("invalid character: %q", b)) +}