Browse Source

Adding config package

Blallo 3 years ago
parent
commit
483f84a443
8 changed files with 548 additions and 0 deletions
  1. 177 0
      config/config.go
  2. 52 0
      config/config_test.go
  3. 129 0
      config/data.go
  4. 83 0
      config/data_test.go
  5. 54 0
      config/helper_test.go
  6. 21 0
      config/parse.go
  7. 10 0
      go.mod
  8. 22 0
      go.sum

+ 177 - 0
config/config.go

@@ -0,0 +1,177 @@
+package config
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"log"
+	"os"
+	"path"
+
+	"git.lattuga.net/blallo/papero/worker"
+	"github.com/mitchellh/go-homedir"
+)
+
+var ErrPortOutOfRange = errors.New("port out of range")
+var ErrMissingPassword = errors.New("password has not been set")
+
+// CastingErrors is a simple wrapper around map[string]error. It's endowed with a utility
+// function to check if there are any errors.
+type CastingErrors map[string]error
+
+// Check if there are any errors, i.e. if any value of the map is not nil.
+func (c CastingErrors) Check() bool {
+	for _, err := range c {
+		if err != nil {
+			return false
+		}
+	}
+	return true
+}
+
+// MemConfig is the data structure that will be used program-wise. It holds all the
+// necessary information to authenticate and manage each provided account.
+type MemConfig struct {
+	Accounts map[string]*AccountData
+	Workers  map[string]*worker.Worker
+}
+
+func initMemConfig() MemConfig {
+	m := MemConfig{}
+	m.Accounts = make(map[string]*AccountData)
+	m.Workers = make(map[string]*worker.Worker)
+	return m
+}
+
+// AccountData holds the data for the single account.
+type AccountData struct {
+	Host            string
+	Port            int
+	Username        string
+	Password        string
+	ExcludedFolders []string
+	MailboxPath     string
+	Messages        int
+}
+
+func (a *AccountData) String() string {
+	var res string
+	res = "{"
+	res += fmt.Sprintf("Host: %s, ", a.Host)
+	res += fmt.Sprintf("Port: %d, ", a.Port)
+	res += fmt.Sprintf("Username: %s, ", a.Username)
+	res += fmt.Sprintf("Password: ********, ")
+	res += fmt.Sprintf("ExcludedFolders: %s, ", a.ExcludedFolders)
+	res += fmt.Sprintf("MailboxPath: %s}", a.MailboxPath)
+	res += fmt.Sprintf("Messages: %d}", a.Messages)
+	return res
+}
+
+// parseConfig translates a *fileConfig, as obtained from a file read, into a usable
+// *MemConfig that could be used in computation, together with a custom CastingErrors
+// type, that holds the translation errors for each account.
+func parseConfig(fileConfig *fileConfig) (*MemConfig, CastingErrors) {
+	outConfig := initMemConfig()
+	basePath, err := getOrDefaultMailbox(fileConfig.MailboxPath)
+	defaultMessages := getOrDefaultMessages(fileConfig.DefaultMessages)
+	if err != nil {
+		log.Fatal("Could not determine base path")
+	}
+	errors := make(map[string]error)
+	for _, account := range fileConfig.Accounts {
+		outConfig.Accounts[account.Name], errors[account.Name] = parseData(&account, basePath, defaultMessages)
+		outConfig.Workers[account.Name] = &worker.Worker{}
+	}
+	return &outConfig, errors
+}
+
+func getOrDefaultMailbox(mailboxPath string) (string, error) {
+	if mailboxPath == "" {
+		if homePath, err := homedir.Dir(); err != nil {
+			return "", err
+		} else {
+			return path.Join(homePath, ".papero"), nil
+		}
+	}
+	return mailboxPath, nil
+}
+
+func getOrDefaultMessages(messages maybeInt) int {
+	if messages.empty() {
+		return 50
+	}
+	return messages.value()
+}
+
+func valueOrEmptySlice(value []string) []string {
+	if value == nil {
+		return []string{}
+	}
+	return value
+}
+
+func valueOrDefaultInt(value maybeInt, defaultVal int) int {
+	if value.empty() {
+		return defaultVal
+	}
+	return value.value()
+}
+
+func parseData(a *account, basePath string, defaultMessages int) (*AccountData, error) {
+	port, err := validatePort(a.ConnectionInfo.Port)
+	if err != nil {
+		return nil, err
+	}
+	password, err := getPassword(a.ConnectionInfo)
+	if err != nil {
+		return nil, err
+	}
+	accountData := AccountData{
+		Host:            a.ConnectionInfo.Host,
+		Port:            port,
+		Username:        a.ConnectionInfo.Username,
+		Password:        password,
+		ExcludedFolders: valueOrEmptySlice(a.ExcludedFolders),
+		MailboxPath:     getMailboxPath(a.Name, a.MailboxPath, basePath),
+		Messages:        valueOrDefaultInt(a.Messages, defaultMessages),
+	}
+	return &accountData, nil
+}
+
+func validatePort(port int) (int, error) {
+	if port > 0 && port < 2<<15 {
+		return port, nil
+	}
+	return 0, ErrPortOutOfRange
+}
+
+func getPassword(connection *connectionInfo) (string, error) {
+	if connection.PasswordExec.Present() {
+		return connection.PasswordExec.Run()
+	}
+	if connection.PasswordFile != "" {
+		file, err := os.Open(connection.PasswordFile)
+		if err != nil {
+			return "", err
+		}
+		reader := bufio.NewReader(file)
+		line, _, err := reader.ReadLine()
+		return string(line), err
+	}
+	if connection.Password == "" {
+		return "", ErrMissingPassword
+	}
+	return connection.Password, nil
+}
+
+func getMailboxPath(name, configPath, basePath string) string {
+	log.Printf("name: %s,\tconfig_path: %s,\tbase_path: %s\n", name, configPath, basePath)
+	if configPath != "" {
+		if info, err := os.Stat(configPath); err == nil || err != os.ErrNotExist && info != nil {
+			if info.IsDir() {
+				return configPath
+			}
+		}
+	}
+	return path.Join(basePath, name)
+}

+ 52 - 0
config/config_test.go

@@ -0,0 +1,52 @@
+package config
+
+import (
+	"testing"
+
+	"git.lattuga.net/blallo/papero/worker"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestParseConfig(t *testing.T) {
+	expected := &MemConfig{
+		Accounts: map[string]*AccountData{
+			"First Account": &AccountData{
+				Host:            "mx.example.com",
+				Port:            993,
+				Username:        "first@example.com",
+				Password:        "123qweasdzxc",
+				ExcludedFolders: []string{"Draft", "Junk"},
+				MailboxPath:     "/opt",
+				Messages:        30,
+			},
+			"Other Account": &AccountData{
+				Host:            "mail.personal.me",
+				Port:            666,
+				Username:        "h4x0R@personal.me",
+				Password:        "mySup3r5ekre7p4ssw0rd123",
+				ExcludedFolders: []string{},
+				MailboxPath:     "../base/mailbox/Other Account",
+				Messages:        50,
+			},
+		},
+		Workers: map[string]*worker.Worker{
+			"First Account": &worker.Worker{},
+			"Other Account": &worker.Worker{},
+		},
+	}
+	file := writeToTempFile(t, "papero.*.toml", testConfig)
+
+	fileConfig, err := parseFile(file.Name())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	result, errs := parseConfig(fileConfig)
+	if !errs.Check() {
+		t.Fatal(errs)
+	}
+
+	if !cmp.Equal(result.Accounts, expected.Accounts) {
+		t.Errorf("Result and expected result differ: %+v\n", cmp.Diff(result.Accounts, expected.Accounts))
+	}
+}

+ 129 - 0
config/data.go

@@ -0,0 +1,129 @@
+package config
+
+import (
+	"fmt"
+	"log"
+	"os/exec"
+	"strconv"
+	"strings"
+
+	"github.com/BurntSushi/toml"
+)
+
+// maybeInt is an integer that may be nil
+type maybeInt struct {
+	int
+	bool
+}
+
+func (m maybeInt) empty() bool {
+	return !m.bool
+}
+
+func (m maybeInt) value() int {
+	return m.int
+}
+
+func initMaybeInt(value int) maybeInt {
+	return maybeInt{value, true}
+}
+
+func (m *maybeInt) UnmarshalText(text []byte) error {
+	var err error
+	var i int64
+	if text != nil {
+		str := string(text)
+		i, err = strconv.ParseInt(str, 10, 64)
+		m.int = int(i)
+		m.bool = true
+	}
+	return err
+}
+
+type fileConfig struct {
+	MailboxPath     string    `toml:"mailbox_path"`
+	Accounts        []account `toml:"account"`
+	DefaultMessages maybeInt  `toml:"default_messages"`
+}
+
+type account struct {
+	Name            string          `toml:"name"`
+	MailboxPath     string          `toml:"mailbox_path"`
+	ConnectionInfo  *connectionInfo `toml:"connection"`
+	ExcludedFolders []string        `toml:"excluded_folders"`
+	Messages        maybeInt        `toml:"messages"`
+}
+
+type connectionInfo struct {
+	Host         string       `toml:"hostname"`
+	Port         int          `toml:"port"`
+	Username     string       `toml:"username"`
+	PasswordExec *ShellScript `toml:"password_exec"`
+	PasswordFile string       `toml:"password_file"`
+	Password     string       `toml:"password"`
+}
+
+func (c *connectionInfo) String() string {
+	var res string
+	res += "{"
+	res += fmt.Sprintf("Host: %s, ", c.Host)
+	res += fmt.Sprintf("Port: %d, ", c.Port)
+	res += fmt.Sprintf("Username: %s, ", c.Username)
+	res += fmt.Sprintf("Password: **********, ")
+	res += fmt.Sprintf("PasswordFile: %s, ", c.PasswordFile)
+	res += fmt.Sprintf("PasswordExec: %s}", c.PasswordExec)
+	return res
+}
+
+// ShellScript might be either:
+//  - the path to an executable
+//  - an executable in path
+//  - a shell command with its argument
+// all these must return on stdout the password after successful execution.
+type ShellScript struct {
+	Executable string
+}
+
+func (s *ShellScript) UnmarshalText(text []byte) error {
+	s.Executable = string(text)
+	return nil
+}
+
+func (s *ShellScript) String() string {
+	return s.Executable
+}
+
+// Present indicates if the value has been filled in into the config
+func (s *ShellScript) Present() bool {
+	if s != nil {
+		return s.Executable != ""
+	}
+	return false
+}
+
+// Run tries to execute the command, first looking for a corresponding executable and
+// invoking it, then invoking the string as a command followed by its parameters.
+func (s *ShellScript) Run() (string, error) {
+	if path, err := exec.LookPath(s.Executable); err == nil {
+		return doExec(path)
+	}
+	parts := strings.Split(s.Executable, " ")
+	return doExec(parts[0], parts[1:]...)
+}
+
+func doExec(path string, args ...string) (string, error) {
+	cmd := exec.Command(path, args...)
+	out, err := cmd.Output()
+	if err != nil {
+		log.Println("Error running:", cmd)
+		return "", err
+	}
+	return string(out), nil
+}
+
+// parseFile reads the given config file and, if succeeding, returns a *FileConfig.
+func parseFile(path string) (*fileConfig, error) {
+	newConfig := fileConfig{}
+	_, err := toml.DecodeFile(path, &newConfig)
+	return &newConfig, err
+}

+ 83 - 0
config/data_test.go

@@ -0,0 +1,83 @@
+package config
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestShellScriptRunInlineScript(t *testing.T) {
+	s := ShellScript{Executable: "echo -n hello world!"}
+	doTestShellScriptRun(t, &s)
+}
+
+func TestShellScriptRunExecutable(t *testing.T) {
+	file := writeToTempFile(t, "papero.*.sh", "#!/usr/bin/env sh\necho -n hello world!\n")
+	s := ShellScript{Executable: file.Name()}
+	doTestShellScriptRun(t, &s)
+}
+
+func TestShellScriptRunExecutableWithArgs(t *testing.T) {
+	file := writeToTempFile(t, "papero.*.sh", "#!/usr/bin/env sh\necho -n ${@}\n")
+	s := ShellScript{Executable: fmt.Sprintf("%s %s %s", file.Name(), "hello", "world!")}
+	doTestShellScriptRun(t, &s)
+}
+
+func TestParseFile(t *testing.T) {
+	expected := &fileConfig{
+		MailboxPath:     "../base/mailbox",
+		DefaultMessages: initMaybeInt(50),
+		Accounts: []account{
+			{
+				Name:            "First Account",
+				MailboxPath:     "/other/path",
+				ExcludedFolders: []string{"Draft", "Junk"},
+				Messages:        initMaybeInt(30),
+				ConnectionInfo: &connectionInfo{
+					Host:         "mx.example.com",
+					Port:         993,
+					Username:     "first@example.com",
+					Password:     "",
+					PasswordFile: "",
+					PasswordExec: &ShellScript{Executable: "echo -n 123qweasdzxc"},
+				},
+			},
+			{
+				Name:            "Other Account",
+				MailboxPath:     "",
+				ExcludedFolders: []string{},
+				Messages:        maybeInt{},
+				ConnectionInfo: &connectionInfo{
+					Host:         "mail.personal.me",
+					Port:         666,
+					Username:     "h4x0R@personal.me",
+					Password:     "mySup3r5ekre7p4ssw0rd123",
+					PasswordFile: "",
+					PasswordExec: nil,
+				},
+			},
+		},
+	}
+
+	file := writeToTempFile(t, "papero.*.toml", testConfig)
+
+	result, err := parseFile(file.Name())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	opt := cmp.Comparer(func(a, b maybeInt) bool {
+		if a.empty() {
+			if b.empty() {
+				return true
+			}
+			return false
+		}
+		return a.value() == b.value()
+	})
+
+	if cmp.Equal(result, expected, opt) {
+		t.Errorf("Result and expected result differ: %+v\n", cmp.Diff(*result, *expected))
+	}
+}

+ 54 - 0
config/helper_test.go

@@ -0,0 +1,54 @@
+package config
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+)
+
+const testConfig = `
+mailbox_path = "../base/mailbox"
+default_messages = 50
+
+[[account]]
+name = "First Account"
+mailbox_path = "/opt"
+messages = 30
+excluded_folders = ["Draft", "Junk"]
+    [account.connection]
+    hostname = "mx.example.com"
+    port = 993
+    username = "first@example.com"
+    password_exec = "echo -n 123qweasdzxc"
+
+[[account]]
+name = "Other Account"
+    [account.connection]
+    hostname = "mail.personal.me"
+    port = 666
+    username = "h4x0R@personal.me"
+    password = "mySup3r5ekre7p4ssw0rd123"
+    `
+
+func doTestShellScriptRun(t *testing.T, s *ShellScript) {
+	if res, err := s.Run(); res != "hello world!" || err != nil {
+		t.Errorf("Unexpected result:\n\tres -> %s (type: %T)\n\terr -> %s", res, res, err)
+	}
+}
+
+func writeToTempFile(t *testing.T, fileName, content string) *os.File {
+	dir := t.TempDir()
+	file, err := ioutil.TempFile(dir, fileName)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err = ioutil.WriteFile(file.Name(), []byte(content), 0777); err != nil {
+		t.Fatal(err)
+	}
+	err = os.Chmod(file.Name(), 0777)
+	if err != nil {
+		t.Fatal(err)
+	}
+	file.Close()
+	return file
+}

+ 21 - 0
config/parse.go

@@ -0,0 +1,21 @@
+package config
+
+import (
+	"errors"
+	"log"
+)
+
+var ErrFailedToParseConfig = errors.New("unable to cast into usable configuration")
+
+func Parse(filePath string) (*MemConfig, error) {
+	fileConfig, err := parseFile(filePath)
+	if err != nil {
+		return nil, err
+	}
+	memConfig, castingErrs := parseConfig(fileConfig)
+	if !castingErrs.Check() {
+		log.Print(castingErrs)
+		return nil, ErrFailedToParseConfig
+	}
+	return memConfig, nil
+}

+ 10 - 0
go.mod

@@ -0,0 +1,10 @@
+module git.lattuga.net/blallo/papero
+
+go 1.15
+
+require (
+	github.com/BurntSushi/toml v0.3.1
+	github.com/emersion/go-imap v1.0.6 // indirect
+	github.com/google/go-cmp v0.5.4
+	github.com/mitchellh/go-homedir v1.1.0
+)

+ 22 - 0
go.sum

@@ -0,0 +1,22 @@
+github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/emersion/go-imap v1.0.6 h1:N9+o5laOGuntStBo+BOgfEB5evPsPD+K5+M0T2dctIc=
+github.com/emersion/go-imap v1.0.6/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU=
+github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY=
+github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs=
+github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
+github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
+github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=