git » chasquid » commit a531092

aliasesdb: Implement an aliases database resolver

author Alberto Bertogli
2016-09-19 21:45:58 UTC
committer Alberto Bertogli
2016-10-09 23:51:04 UTC
parent aacf8ffea72d1dce7f6e9c12598df773a9fd8785

aliasesdb: Implement an aliases database resolver

aliases databases can be very useful, so this patch adds a package to parse
and resolve aliases.

It uses an existing, well known and widely used format for aliases, although
it doesn't necessarily match 100% any existing implementation at the moment.

internal/aliases/aliases.go +304 -0
internal/aliases/aliases_test.go +247 -0

diff --git a/internal/aliases/aliases.go b/internal/aliases/aliases.go
new file mode 100644
index 0000000..cafce0c
--- /dev/null
+++ b/internal/aliases/aliases.go
@@ -0,0 +1,304 @@
+// Package aliases implements an email aliases resolver.
+//
+// The resolver can parse many files for different domains, and perform
+// lookups to resolve the aliases.
+//
+//
+// File format
+//
+// It generally follows the traditional aliases format used by sendmail and
+// exim.
+//
+// The file can contain lines of the form:
+//
+//   user: address, address
+//   user: | command
+//
+// Lines starting with "#" are ignored, as well as empty lines.
+// User names cannot contain spaces, ":" or commas, for parsing reasons. This
+// is a tradeoff between flexibility and keeping the file format easy to edit
+// for people.
+//
+// Usually there will be one database per domain, and there's no need to
+// include the "@" in the user (in this case, "@" will be forbidden).
+//
+//
+// Recipients
+//
+// Recipients can be of different types:
+//  - Email: the usual user@domain we all know and love, this is the default.
+//  - Pipe: if the right side starts with "| ", the rest of the line specifies
+//      a command to pipe the email through.
+//      Command and arguments are space separated. No quoting, escaping, or
+//      replacements of any kind.
+//
+//
+// Lookups
+//
+// The resolver will perform lookups recursively, until it finds all the final
+// recipients.
+//
+// There are recursion limits to avoid alias loops. If the limit is reached,
+// theat entire resolution will fail.
+//
+//
+// Suffix removal
+//
+// The resolver can also remove suffixes from emails, and drop characters
+// completely. This can be used to turn "user+blah@domain" into "user@domain",
+// and "us.er@domain" into "user@domain".
+//
+// Both are optional, and the characters configurable globally.
+package aliases
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+
+	"blitiri.com.ar/go/chasquid/internal/envelope"
+)
+
+// Recipient represents a single recipient, after resolving aliases.
+// They don't have any special interface, the callers will do a type switch
+// anyway.
+type Recipient struct {
+	Addr string
+	Type RType
+}
+
+type RType int
+
+const (
+	EMAIL RType = iota
+	PIPE
+)
+
+var (
+	ErrRecursionLimitExceeded = fmt.Errorf("recursion limit exceeded")
+
+	// How many levels of recursions we allow during lookups.
+	// We don't expect much recursion, so keeping this low to catch errors
+	// quickly.
+	recursionLimit = 10
+)
+
+// Resolver represents the aliases resolver.
+type Resolver struct {
+	// Suffix separator, to perform suffix removal.
+	SuffixSep string
+
+	// Characters to drop from the user part.
+	DropChars string
+
+	// Map of domain -> alias files for that domain.
+	// We keep track of them for reloading purposes.
+	files   map[string][]string
+	domains map[string]bool
+
+	// Map of address -> aliases.
+	aliases map[string][]Recipient
+
+	// Mutex protecting the structure.
+	mu sync.Mutex
+}
+
+func NewResolver() *Resolver {
+	return &Resolver{
+		files:   map[string][]string{},
+		domains: map[string]bool{},
+		aliases: map[string][]Recipient{},
+	}
+}
+
+func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+
+	return v.resolve(0, addr)
+}
+
+func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
+	if rcount >= recursionLimit {
+		return nil, ErrRecursionLimitExceeded
+	}
+
+	// Drop suffixes and chars to get the "clean" address before resolving.
+	// This also means that we will return the clean version if there's no
+	// match, which our callers can rely upon.
+	addr = v.cleanIfLocal(addr)
+
+	rcpts := v.aliases[addr]
+	if len(rcpts) == 0 {
+		return []Recipient{Recipient{addr, EMAIL}}, nil
+	}
+
+	ret := []Recipient{}
+	for _, r := range rcpts {
+		// Only recurse for email recipients.
+		if r.Type != EMAIL {
+			ret = append(ret, r)
+			continue
+		}
+
+		ar, err := v.resolve(rcount+1, r.Addr)
+		if err != nil {
+			return nil, err
+		}
+
+		ret = append(ret, ar...)
+	}
+
+	return ret, nil
+}
+
+func (v *Resolver) cleanIfLocal(addr string) string {
+	user, domain := envelope.Split(addr)
+
+	if !v.domains[domain] {
+		return addr
+	}
+
+	user = removeAllAfter(user, v.SuffixSep)
+	user = removeChars(user, v.DropChars)
+	return user + "@" + domain
+}
+
+func (v *Resolver) AddDomain(domain string) {
+	v.mu.Lock()
+	v.domains[domain] = true
+	v.mu.Unlock()
+}
+
+func (v *Resolver) AddAliasesFile(domain, path string) error {
+	aliases, err := parseFile(domain, path)
+	if err != nil {
+		return err
+	}
+
+	v.mu.Lock()
+	v.files[domain] = append(v.files[domain], path)
+	v.domains[domain] = true
+
+	// Add the aliases to the resolver, overriding any previous values.
+	for addr, rs := range aliases {
+		v.aliases[addr] = rs
+	}
+	v.mu.Unlock()
+
+	return nil
+}
+
+func (v *Resolver) Reload() error {
+	newAliases := map[string][]Recipient{}
+
+	for domain, paths := range v.files {
+		for _, path := range paths {
+			aliases, err := parseFile(domain, path)
+			if err != nil {
+				return fmt.Errorf("Error parsing %q: %v", path, err)
+			}
+
+			// Add the aliases to the resolver, overriding any previous values.
+			for addr, rs := range aliases {
+				newAliases[addr] = rs
+			}
+		}
+	}
+
+	v.mu.Lock()
+	v.aliases = newAliases
+	v.mu.Unlock()
+
+	return nil
+}
+
+func parseFile(domain, path string) (map[string][]Recipient, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	aliases := map[string][]Recipient{}
+
+	scanner := bufio.NewScanner(f)
+	for i := 1; scanner.Scan(); i++ {
+		line := strings.TrimSpace(scanner.Text())
+		if strings.HasPrefix(line, "#") {
+			continue
+		}
+
+		sp := strings.SplitN(line, ":", 2)
+		if len(sp) != 2 {
+			continue
+		}
+
+		addr, rawalias := strings.TrimSpace(sp[0]), strings.TrimSpace(sp[1])
+		if len(addr) == 0 || len(rawalias) == 0 {
+			continue
+		}
+
+		if strings.Contains(addr, "@") {
+			// It's invalid for lhs addresses to contain @ (for now).
+			continue
+		}
+
+		addr = addr + "@" + domain
+
+		if rawalias[0] == '|' {
+			cmd := strings.TrimSpace(rawalias[1:])
+			aliases[addr] = []Recipient{Recipient{cmd, PIPE}}
+		} else {
+			rs := []Recipient{}
+			for _, a := range strings.Split(rawalias, ",") {
+				a = strings.TrimSpace(a)
+				if a == "" {
+					continue
+				}
+				// Addresses with no domain get the current one added, so it's
+				// easier to share alias files.
+				if !strings.Contains(a, "@") {
+					a = a + "@" + domain
+				}
+				rs = append(rs, Recipient{a, EMAIL})
+			}
+			aliases[addr] = rs
+		}
+	}
+	if err := scanner.Err(); err != nil {
+		return nil, fmt.Errorf("reading %q: %v", path, err)
+	}
+
+	return aliases, nil
+}
+
+// removeAllAfter removes everything from s that comes after the separators,
+// including them.
+func removeAllAfter(s, seps string) string {
+	for _, c := range strings.Split(seps, "") {
+		if c == "" {
+			continue
+		}
+
+		i := strings.Index(s, c)
+		if i == -1 {
+			continue
+		}
+
+		s = s[:i]
+	}
+
+	return s
+}
+
+// removeChars removes the runes in "chars" from s.
+func removeChars(s, chars string) string {
+	for _, c := range strings.Split(chars, "") {
+		s = strings.Replace(s, c, "", -1)
+	}
+
+	return s
+}
diff --git a/internal/aliases/aliases_test.go b/internal/aliases/aliases_test.go
new file mode 100644
index 0000000..3f8991a
--- /dev/null
+++ b/internal/aliases/aliases_test.go
@@ -0,0 +1,247 @@
+package aliases
+
+import (
+	"io/ioutil"
+	"os"
+	"reflect"
+	"testing"
+)
+
+type Cases []struct {
+	addr   string
+	expect []Recipient
+}
+
+func (cases Cases) check(t *testing.T, r *Resolver) {
+	for _, c := range cases {
+		got, err := r.Resolve(c.addr)
+		if err != nil {
+			t.Errorf("case %q, got error: %v", c.addr, err)
+			continue
+		}
+		if !reflect.DeepEqual(got, c.expect) {
+			t.Errorf("case %q, got %+v, expected %+v", c.addr, got, c.expect)
+		}
+	}
+}
+
+func TestBasic(t *testing.T) {
+	resolver := NewResolver()
+	resolver.aliases = map[string][]Recipient{
+		"a@b": {{"c@d", EMAIL}, {"e@f", EMAIL}},
+		"e@f": {{"cmd", PIPE}},
+		"cmd": {{"x@y", EMAIL}}, // it's a trap!
+	}
+
+	cases := Cases{
+		{"a@b", []Recipient{{"c@d", EMAIL}, {"cmd", PIPE}}},
+		{"e@f", []Recipient{{"cmd", PIPE}}},
+		{"x@y", []Recipient{{"x@y", EMAIL}}},
+	}
+	cases.check(t, resolver)
+}
+
+func TestAddrRewrite(t *testing.T) {
+	resolver := NewResolver()
+	resolver.AddDomain("def")
+	resolver.AddDomain("p-q.com")
+	resolver.aliases = map[string][]Recipient{
+		"abc@def":  {{"x@y", EMAIL}},
+		"ñoño@def": {{"x@y", EMAIL}},
+		"recu@def": {{"ab+cd@p-q.com", EMAIL}},
+	}
+	resolver.DropChars = ".~"
+	resolver.SuffixSep = "-+"
+
+	cases := Cases{
+		{"abc@def", []Recipient{{"x@y", EMAIL}}},
+		{"a.b.c@def", []Recipient{{"x@y", EMAIL}}},
+		{"a~b~c@def", []Recipient{{"x@y", EMAIL}}},
+		{"a.b~c@def", []Recipient{{"x@y", EMAIL}}},
+		{"abc-ñaca@def", []Recipient{{"x@y", EMAIL}}},
+		{"abc-ñaca@def", []Recipient{{"x@y", EMAIL}}},
+		{"abc-xyz@def", []Recipient{{"x@y", EMAIL}}},
+		{"abc+xyz@def", []Recipient{{"x@y", EMAIL}}},
+		{"abc-x.y+z@def", []Recipient{{"x@y", EMAIL}}},
+
+		{"ñ.o~ño-ñaca@def", []Recipient{{"x@y", EMAIL}}},
+
+		// Don't mess with the domain, even if it's known.
+		{"a.bc-ñaca@p-q.com", []Recipient{{"abc@p-q.com", EMAIL}}},
+
+		// Clean the right hand side too (if it's a local domain).
+		{"recu+blah@def", []Recipient{{"ab@p-q.com", EMAIL}}},
+
+		// We should not mess with emails for domains we don't know.
+		{"xy@z.com", []Recipient{{"xy@z.com", EMAIL}}},
+		{"x.y@z.com", []Recipient{{"x.y@z.com", EMAIL}}},
+		{"x-@y-z.com", []Recipient{{"x-@y-z.com", EMAIL}}},
+		{"x+blah@y", []Recipient{{"x+blah@y", EMAIL}}},
+	}
+	cases.check(t, resolver)
+}
+
+func TestTooMuchRecursion(t *testing.T) {
+	resolver := Resolver{}
+	resolver.aliases = map[string][]Recipient{
+		"a@b": {{"c@d", EMAIL}},
+		"c@d": {{"a@b", EMAIL}},
+	}
+
+	rs, err := resolver.Resolve("a@b")
+	if err != ErrRecursionLimitExceeded {
+		t.Errorf("expected ErrRecursionLimitExceeded, got %v", err)
+	}
+
+	if rs != nil {
+		t.Errorf("expected nil recipients, got %+v", rs)
+	}
+}
+
+func mustWriteFile(t *testing.T, content string) string {
+	f, err := ioutil.TempFile("", "aliases_test")
+	if err != nil {
+		t.Fatalf("failed to get temp file: %v", err)
+	}
+	defer f.Close()
+
+	_, err = f.WriteString(content)
+	if err != nil {
+		t.Fatalf("failed to write temp file: %v", err)
+	}
+
+	return f.Name()
+}
+
+func TestAddFile(t *testing.T) {
+	cases := []struct {
+		contents string
+		expected []Recipient
+	}{
+		{"\n", []Recipient{{"a@dom", EMAIL}}},
+		{" # Comment\n", []Recipient{{"a@dom", EMAIL}}},
+		{":\n", []Recipient{{"a@dom", EMAIL}}},
+		{"a: \n", []Recipient{{"a@dom", EMAIL}}},
+		{"a@dom: b@c \n", []Recipient{{"a@dom", EMAIL}}},
+
+		{"a: b\n", []Recipient{{"b@dom", EMAIL}}},
+		{"a:b\n", []Recipient{{"b@dom", EMAIL}}},
+		{"a : b \n", []Recipient{{"b@dom", EMAIL}}},
+		{"a : b, \n", []Recipient{{"b@dom", EMAIL}}},
+
+		{"a: |cmd\n", []Recipient{{"cmd", PIPE}}},
+		{"a:|cmd\n", []Recipient{{"cmd", PIPE}}},
+		{"a:| cmd \n", []Recipient{{"cmd", PIPE}}},
+		{"a  :| cmd \n", []Recipient{{"cmd", PIPE}}},
+		{"a: | cmd  arg1 arg2\n", []Recipient{{"cmd  arg1 arg2", PIPE}}},
+
+		{"a: c@d, e@f, g\n",
+			[]Recipient{{"c@d", EMAIL}, {"e@f", EMAIL}, {"g@dom", EMAIL}}},
+	}
+
+	for _, c := range cases {
+		fname := mustWriteFile(t, c.contents)
+		defer os.Remove(fname)
+
+		resolver := NewResolver()
+		err := resolver.AddAliasesFile("dom", fname)
+		if err != nil {
+			t.Fatalf("error adding file: %v", err)
+		}
+
+		got, err := resolver.Resolve("a@dom")
+		if err != nil {
+			t.Errorf("case %q, got error: %v", c.contents, err)
+			continue
+		}
+		if !reflect.DeepEqual(got, c.expected) {
+			t.Errorf("case %q, got %v, expected %v", c.contents, got, c.expected)
+		}
+	}
+}
+
+const richFileContents = `
+# This is a "complex" alias file, with a few tricky situations.
+# It is used in TestRichFile.
+
+# First some valid cases.
+a: b
+c: d@e, f,
+x: | command
+
+# The following is invalid, should be ignored.
+a@dom: x@dom
+
+# Overrides.
+o1: a
+o1: b
+
+# Finally one to make the file NOT end in \n:
+y: z`
+
+func TestRichFile(t *testing.T) {
+	fname := mustWriteFile(t, richFileContents)
+	defer os.Remove(fname)
+
+	resolver := NewResolver()
+	err := resolver.AddAliasesFile("dom", fname)
+	if err != nil {
+		t.Fatalf("failed to add file: %v", err)
+	}
+
+	cases := Cases{
+		{"a@dom", []Recipient{{"b@dom", EMAIL}}},
+		{"c@dom", []Recipient{{"d@e", EMAIL}, {"f@dom", EMAIL}}},
+		{"x@dom", []Recipient{{"command", PIPE}}},
+		{"o1@dom", []Recipient{{"b@dom", EMAIL}}},
+		{"y@dom", []Recipient{{"z@dom", EMAIL}}},
+	}
+	cases.check(t, resolver)
+}
+
+func TestManyFiles(t *testing.T) {
+	files := map[string]string{
+		"d1":      mustWriteFile(t, "a: b\nc:d@e"),
+		"domain2": mustWriteFile(t, "a: b\nc:d@e"),
+		"dom3":    mustWriteFile(t, "x: y, z"),
+		"dom4":    mustWriteFile(t, "a: |cmd"),
+
+		// Cross-domain.
+		"xd1": mustWriteFile(t, "a: b@xd2"),
+		"xd2": mustWriteFile(t, "b: |cmd"),
+	}
+	for _, fname := range files {
+		defer os.Remove(fname)
+	}
+
+	resolver := NewResolver()
+	for domain, fname := range files {
+		err := resolver.AddAliasesFile(domain, fname)
+		if err != nil {
+			t.Fatalf("failed to add file: %v", err)
+		}
+	}
+
+	check := func() {
+		cases := Cases{
+			{"a@d1", []Recipient{{"b@d1", EMAIL}}},
+			{"c@d1", []Recipient{{"d@e", EMAIL}}},
+			{"x@d1", []Recipient{{"x@d1", EMAIL}}},
+			{"a@domain2", []Recipient{{"b@domain2", EMAIL}}},
+			{"c@domain2", []Recipient{{"d@e", EMAIL}}},
+			{"x@dom3", []Recipient{{"y@dom3", EMAIL}, {"z@dom3", EMAIL}}},
+			{"a@dom4", []Recipient{{"cmd", PIPE}}},
+			{"a@xd1", []Recipient{{"cmd", PIPE}}},
+		}
+		cases.check(t, resolver)
+	}
+
+	check()
+
+	// Reload, and check again just in case.
+	if err := resolver.Reload(); err != nil {
+		t.Fatalf("failed to reload: %v", err)
+	}
+
+	check()
+}