first commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package sieve
|
||||
|
||||
import "strings"
|
||||
|
||||
// Result is the outcome of running a script against one message.
|
||||
type Result struct {
|
||||
Action string // "fileinto" | "discard" | "keep" (default if nothing else fired)
|
||||
Folder string // set only when Action == "fileinto"
|
||||
}
|
||||
|
||||
// Execute runs script against the given headers (case-insensitive header
|
||||
// names, matching real email header semantics) and returns the first
|
||||
// decisive action encountered. "stop" halts execution immediately with
|
||||
// whatever result has accumulated so far. If no action fires, the default
|
||||
// result is "keep" (deliver to INBOX), matching RFC 5228 §2.10's implicit
|
||||
// keep behavior.
|
||||
//
|
||||
// Simplification: real Sieve treats keep/fileinto/discard as an
|
||||
// accumulating SET of actions (a message can be filed into a folder AND
|
||||
// kept in INBOX, for instance) — this implementation tracks only the single
|
||||
// most recent action instead, last-one-wins. This still matches RFC 5228's
|
||||
// core rule that "discard cancels the implicit keep, but an explicit keep
|
||||
// after it still delivers" (§4.4) — a discard followed by an unconditional
|
||||
// keep with no stop in between DOES deliver the message, correctly. What's
|
||||
// NOT supported is a script that intends both fileinto AND keep to fire
|
||||
// simultaneously (message copied to a folder AND left in INBOX) — write
|
||||
// "stop;" after the decisive action if that's not the intended behavior,
|
||||
// same as real Sieve authors are advised to do to avoid ambiguity.
|
||||
func Execute(script *Script, headers map[string]string) Result {
|
||||
result := Result{Action: "keep"}
|
||||
execStatements(script.Statements, headers, &result)
|
||||
return result
|
||||
}
|
||||
|
||||
// execStatements returns true if execution should stop (a "stop" action fired).
|
||||
func execStatements(stmts []Statement, headers map[string]string, result *Result) bool {
|
||||
for _, stmt := range stmts {
|
||||
switch s := stmt.(type) {
|
||||
case Action:
|
||||
switch s.Name {
|
||||
case "fileinto":
|
||||
result.Action = "fileinto"
|
||||
result.Folder = s.Arg
|
||||
case "discard":
|
||||
result.Action = "discard"
|
||||
case "keep":
|
||||
result.Action = "keep"
|
||||
case "stop":
|
||||
return true
|
||||
}
|
||||
case IfStatement:
|
||||
if evalTest(s.Test, headers) {
|
||||
if execStatements(s.Then, headers, result) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
for _, ei := range s.ElseIfs {
|
||||
if evalTest(ei.Test, headers) {
|
||||
matched = true
|
||||
if execStatements(ei.Then, headers, result) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched && s.HasElse {
|
||||
if execStatements(s.Else, headers, result) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evalTest(t Test, headers map[string]string) bool {
|
||||
switch t.Kind {
|
||||
case "true":
|
||||
return true
|
||||
case "header":
|
||||
actual, ok := lookupHeader(headers, t.Header)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch t.MatchType {
|
||||
case "contains":
|
||||
return strings.Contains(strings.ToLower(actual), strings.ToLower(t.Value))
|
||||
case "is":
|
||||
return strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(t.Value))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lookupHeader(headers map[string]string, name string) (string, bool) {
|
||||
// Case-insensitive lookup — email headers are case-insensitive per RFC 5322.
|
||||
for k, v := range headers {
|
||||
if strings.EqualFold(k, name) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package sieve implements a Sieve (RFC 5228) interpreter covering the
|
||||
// common mail-filtering subset: header tests (:contains, :is), if/elsif/else,
|
||||
// and the fileinto/discard/keep/stop actions. Not full RFC 5228 — no
|
||||
// extensions (vacation, reject, notify), no envelope/size/address tests,
|
||||
// no allof/anyof boolean combinators. This covers what real users actually
|
||||
// write for "move mail matching X to folder Y" / "discard mail from Z",
|
||||
// which is the overwhelming majority of real-world Sieve scripts; broader
|
||||
// grammar support is a natural follow-up once client compatibility testing
|
||||
// calls for it.
|
||||
package sieve
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type tokenKind int
|
||||
|
||||
const (
|
||||
tokIdent tokenKind = iota
|
||||
tokString
|
||||
tokTag // :contains, :is, etc.
|
||||
tokSemicolon
|
||||
tokLBrace
|
||||
tokRBrace
|
||||
tokEOF
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokenKind
|
||||
value string
|
||||
}
|
||||
|
||||
type lexer struct {
|
||||
input []rune
|
||||
pos int
|
||||
}
|
||||
|
||||
func newLexer(script string) *lexer {
|
||||
return &lexer{input: []rune(script)}
|
||||
}
|
||||
|
||||
func (l *lexer) next() (token, error) {
|
||||
l.skipWhitespaceAndComments()
|
||||
if l.pos >= len(l.input) {
|
||||
return token{kind: tokEOF}, nil
|
||||
}
|
||||
|
||||
c := l.input[l.pos]
|
||||
switch {
|
||||
case c == ';':
|
||||
l.pos++
|
||||
return token{kind: tokSemicolon, value: ";"}, nil
|
||||
case c == '{':
|
||||
l.pos++
|
||||
return token{kind: tokLBrace, value: "{"}, nil
|
||||
case c == '}':
|
||||
l.pos++
|
||||
return token{kind: tokRBrace, value: "}"}, nil
|
||||
case c == '"':
|
||||
return l.readString()
|
||||
case c == ':':
|
||||
return l.readTag()
|
||||
case unicode.IsLetter(c):
|
||||
return l.readIdent()
|
||||
default:
|
||||
return token{}, fmt.Errorf("unexpected character %q at position %d", c, l.pos)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lexer) skipWhitespaceAndComments() {
|
||||
for l.pos < len(l.input) {
|
||||
c := l.input[l.pos]
|
||||
if unicode.IsSpace(c) {
|
||||
l.pos++
|
||||
continue
|
||||
}
|
||||
// Single-line comment: # ... end of line
|
||||
if c == '#' {
|
||||
for l.pos < len(l.input) && l.input[l.pos] != '\n' {
|
||||
l.pos++
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Bracketed comment: /* ... */
|
||||
if c == '/' && l.pos+1 < len(l.input) && l.input[l.pos+1] == '*' {
|
||||
l.pos += 2
|
||||
for l.pos+1 < len(l.input) && !(l.input[l.pos] == '*' && l.input[l.pos+1] == '/') {
|
||||
l.pos++
|
||||
}
|
||||
l.pos += 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lexer) readString() (token, error) {
|
||||
l.pos++ // skip opening quote
|
||||
var sb strings.Builder
|
||||
for l.pos < len(l.input) && l.input[l.pos] != '"' {
|
||||
if l.input[l.pos] == '\\' && l.pos+1 < len(l.input) {
|
||||
l.pos++
|
||||
}
|
||||
sb.WriteRune(l.input[l.pos])
|
||||
l.pos++
|
||||
}
|
||||
if l.pos >= len(l.input) {
|
||||
return token{}, fmt.Errorf("unterminated string literal")
|
||||
}
|
||||
l.pos++ // skip closing quote
|
||||
return token{kind: tokString, value: sb.String()}, nil
|
||||
}
|
||||
|
||||
func (l *lexer) readTag() (token, error) {
|
||||
start := l.pos
|
||||
l.pos++ // skip ':'
|
||||
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || l.input[l.pos] == '-') {
|
||||
l.pos++
|
||||
}
|
||||
return token{kind: tokTag, value: string(l.input[start:l.pos])}, nil
|
||||
}
|
||||
|
||||
func (l *lexer) readIdent() (token, error) {
|
||||
start := l.pos
|
||||
for l.pos < len(l.input) && (unicode.IsLetter(l.input[l.pos]) || unicode.IsDigit(l.input[l.pos]) || l.input[l.pos] == '_') {
|
||||
l.pos++
|
||||
}
|
||||
return token{kind: tokIdent, value: string(l.input[start:l.pos])}, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package sieve
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ── AST ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Script struct {
|
||||
Statements []Statement
|
||||
}
|
||||
|
||||
// Statement is either an Action or an IfStatement.
|
||||
type Statement interface{ isStatement() }
|
||||
|
||||
type Action struct {
|
||||
Name string // "fileinto" | "discard" | "keep" | "stop"
|
||||
Arg string // folder name for fileinto, empty otherwise
|
||||
}
|
||||
|
||||
func (Action) isStatement() {}
|
||||
|
||||
type IfStatement struct {
|
||||
Test Test
|
||||
Then []Statement
|
||||
ElseIfs []ElseIf
|
||||
Else []Statement
|
||||
HasElse bool
|
||||
}
|
||||
|
||||
func (IfStatement) isStatement() {}
|
||||
|
||||
type ElseIf struct {
|
||||
Test Test
|
||||
Then []Statement
|
||||
}
|
||||
|
||||
// Test is a condition — this pass supports only header tests, the
|
||||
// overwhelming majority of real-world filtering rules.
|
||||
type Test struct {
|
||||
Kind string // "header" | "true"
|
||||
MatchType string // "contains" | "is"
|
||||
Header string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type parser struct {
|
||||
lex *lexer
|
||||
cur token
|
||||
}
|
||||
|
||||
func Parse(script string) (*Script, error) {
|
||||
p := &parser{lex: newLexer(script)}
|
||||
if err := p.advance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Script{}
|
||||
for p.cur.kind != tokEOF {
|
||||
stmt, err := p.parseStatement()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Statements = append(s.Statements, stmt)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (p *parser) advance() error {
|
||||
t, err := p.lex.next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.cur = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) expect(kind tokenKind, desc string) (token, error) {
|
||||
if p.cur.kind != kind {
|
||||
return token{}, fmt.Errorf("expected %s, got %q", desc, p.cur.value)
|
||||
}
|
||||
t := p.cur
|
||||
if err := p.advance(); err != nil {
|
||||
return token{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseStatement() (Statement, error) {
|
||||
if p.cur.kind != tokIdent {
|
||||
return nil, fmt.Errorf("expected statement, got %q", p.cur.value)
|
||||
}
|
||||
|
||||
switch p.cur.value {
|
||||
case "if":
|
||||
return p.parseIf()
|
||||
case "fileinto":
|
||||
if err := p.advance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arg, err := p.expect(tokString, "folder name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.expect(tokSemicolon, ";"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Action{Name: "fileinto", Arg: arg.value}, nil
|
||||
case "discard", "keep", "stop":
|
||||
name := p.cur.value
|
||||
if err := p.advance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.expect(tokSemicolon, ";"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Action{Name: name}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported command %q", p.cur.value)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseIf() (Statement, error) {
|
||||
if err := p.advance(); err != nil { // skip "if"
|
||||
return nil, err
|
||||
}
|
||||
test, err := p.parseTest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
then, err := p.parseBlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stmt := IfStatement{Test: test, Then: then}
|
||||
|
||||
for p.cur.kind == tokIdent && p.cur.value == "elsif" {
|
||||
if err := p.advance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elifTest, err := p.parseTest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elifThen, err := p.parseBlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt.ElseIfs = append(stmt.ElseIfs, ElseIf{Test: elifTest, Then: elifThen})
|
||||
}
|
||||
|
||||
if p.cur.kind == tokIdent && p.cur.value == "else" {
|
||||
if err := p.advance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elseBlock, err := p.parseBlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt.Else = elseBlock
|
||||
stmt.HasElse = true
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseTest() (Test, error) {
|
||||
if p.cur.kind != tokIdent {
|
||||
return Test{}, fmt.Errorf("expected test, got %q", p.cur.value)
|
||||
}
|
||||
|
||||
if p.cur.value == "true" {
|
||||
if err := p.advance(); err != nil {
|
||||
return Test{}, err
|
||||
}
|
||||
return Test{Kind: "true"}, nil
|
||||
}
|
||||
|
||||
if p.cur.value != "header" {
|
||||
return Test{}, fmt.Errorf("unsupported test %q (only 'header' and 'true' supported)", p.cur.value)
|
||||
}
|
||||
if err := p.advance(); err != nil {
|
||||
return Test{}, err
|
||||
}
|
||||
|
||||
if p.cur.kind != tokTag {
|
||||
return Test{}, fmt.Errorf("expected match type (:contains or :is), got %q", p.cur.value)
|
||||
}
|
||||
matchType := p.cur.value[1:] // strip leading ':'
|
||||
if matchType != "contains" && matchType != "is" {
|
||||
return Test{}, fmt.Errorf("unsupported match type %q (only :contains and :is supported)", matchType)
|
||||
}
|
||||
if err := p.advance(); err != nil {
|
||||
return Test{}, err
|
||||
}
|
||||
|
||||
headerTok, err := p.expect(tokString, "header name")
|
||||
if err != nil {
|
||||
return Test{}, err
|
||||
}
|
||||
valueTok, err := p.expect(tokString, "match value")
|
||||
if err != nil {
|
||||
return Test{}, err
|
||||
}
|
||||
|
||||
return Test{Kind: "header", MatchType: matchType, Header: headerTok.value, Value: valueTok.value}, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseBlock() ([]Statement, error) {
|
||||
if _, err := p.expect(tokLBrace, "{"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var stmts []Statement
|
||||
for p.cur.kind != tokRBrace {
|
||||
if p.cur.kind == tokEOF {
|
||||
return nil, fmt.Errorf("unterminated block, expected }")
|
||||
}
|
||||
stmt, err := p.parseStatement()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmts = append(stmts, stmt)
|
||||
}
|
||||
if _, err := p.expect(tokRBrace, "}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stmts, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package sieve
|
||||
|
||||
import "testing"
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
f.Add(`if header :contains "subject" "invoice" { fileinto "Invoices"; stop; }`)
|
||||
f.Add(`if header :is "from" "boss@example.com" { fileinto "Important"; } elsif header :contains "subject" "urgent" { fileinto "Important"; } else { keep; }`)
|
||||
f.Add("")
|
||||
f.Add("keep;")
|
||||
f.Add("if true { discard; }")
|
||||
f.Add(`if header :contains "subject" { fileinto "X" }`)
|
||||
f.Add("if header { }")
|
||||
f.Add("{{{{{{{")
|
||||
f.Add(`if header :contains "a" "b`)
|
||||
f.Add("if header :bogus \"x\" \"y\" { keep; }")
|
||||
f.Add("fileinto;")
|
||||
|
||||
f.Fuzz(func(t *testing.T, data string) {
|
||||
// This is the fuzz target most directly exposed to untrusted input
|
||||
// in production — every ManageSieve PUTSCRIPT is parsed by this
|
||||
// exact function before storage. A crash here would be a remotely
|
||||
// triggerable DoS against an authenticated user's own session, so
|
||||
// "never panics" matters more here than for the calendar/contact
|
||||
// parsers.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Parse panicked on input %q: %v", data, r)
|
||||
}
|
||||
}()
|
||||
Parse(data)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user