Adding config package

This commit is contained in:
Blallo 2021-01-09 17:06:13 +01:00
parent cee6d56d27
commit 483f84a443
No known key found for this signature in database
GPG key ID: 0CBE577C9B72DC3F
8 changed files with 548 additions and 0 deletions

177
config/config.go Normal file
View file

@ -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
config/config_test.go Normal file
View file

@ -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
config/data.go Normal file
View file

@ -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
config/data_test.go Normal file
View file

@ -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
config/helper_test.go Normal file
View file

@ -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
config/parse.go Normal file
View file

@ -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
go.mod Normal file
View file

@ -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
go.sum Normal file
View file

@ -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=