├── .github └── workflows │ └── go.yml ├── .gitmodules ├── LICENSE ├── README.md ├── cmd └── sieve-run │ ├── main.go │ ├── msg.eml │ ├── msgB.eml │ ├── sieve-run │ └── test.sieve ├── execute_test.go ├── go.mod ├── go.sum ├── interp ├── action.go ├── control.go ├── dovecot_testsuite.go ├── load.go ├── load_action.go ├── load_control.go ├── load_dovecot.go ├── load_generic.go ├── load_test.go ├── load_tests.go ├── load_variables.go ├── match.go ├── matchertest.go ├── message_static.go ├── relational.go ├── runtime.go ├── script.go ├── strings.go ├── test.go ├── test_string.go └── variables.go ├── lexer ├── lex.go ├── lex_fuzz_test.go ├── lex_test.go ├── stream.go ├── token.go └── write.go ├── parser ├── command.go ├── parse.go └── parse_test.go ├── sieve.go └── tests ├── base_test.go ├── comparators_test.go ├── compile_test.go ├── encodedcharacter_test.go ├── envelope_test.go ├── match_test.go ├── relational_test.go ├── run.go └── variables_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | submodules: 'true' 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.20' 22 | 23 | - name: Build 24 | run: go build -v ./... 25 | 26 | - name: Test 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/pigeonhole"] 2 | path = tests/pigeonhole 3 | url = https://github.com/dovecot/pigeonhole.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2022 Max Mazurov (fox.cpp) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | go-sieve 2 | ==================== 3 | 4 | Sieve email filtering language ([RFC 5228]) 5 | implementation in Go. 6 | 7 | ## Supported extensions 8 | 9 | - envelope ([RFC 5228]) 10 | - fileinto ([RFC 5228]) 11 | - encoded-character ([RFC 5228]) 12 | - imap4flags ([RFC 5232]) 13 | - variables ([RFC 5229]) 14 | - relational ([RFC 5231]) 15 | 16 | ## Example 17 | 18 | See ./cmd/sieve-run. 19 | 20 | ## Known issues 21 | 22 | - Some invalid scripts are accepted as valid (see tests/compile_test.go) 23 | - Comments in addresses are not ignored when testing equality, etc. 24 | - Source routes in addresses are not ignored when testing equality, etc. 25 | 26 | [RFC 5228]: https://datatracker.ietf.org/doc/html/rfc5228 27 | [RFC 5229]: https://datatracker.ietf.org/doc/html/rfc5229 28 | [RFC 5232]: https://datatracker.ietf.org/doc/html/rfc5232 29 | [RFC 5231]: https://datatracker.ietf.org/doc/html/rfc5231 30 | 31 | -------------------------------------------------------------------------------- /cmd/sieve-run/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "net/textproto" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/foxcpp/go-sieve" 15 | "github.com/foxcpp/go-sieve/interp" 16 | ) 17 | 18 | func main() { 19 | msgPath := flag.String("eml", "", "msgPath message to process") 20 | scriptPath := flag.String("scriptPath", "", "scriptPath to run") 21 | envFrom := flag.String("from", "", "envelope from") 22 | envTo := flag.String("to", "", "envelope to") 23 | flag.Parse() 24 | 25 | msg, err := os.Open(*msgPath) 26 | if err != nil { 27 | log.Fatalln(err) 28 | } 29 | defer msg.Close() 30 | fileInfo, err := msg.Stat() 31 | if err != nil { 32 | log.Fatalln(err) 33 | } 34 | msgHdr, err := textproto.NewReader(bufio.NewReader(msg)).ReadMIMEHeader() 35 | if err != nil { 36 | log.Fatalln(err) 37 | } 38 | 39 | script, err := os.Open(*scriptPath) 40 | if err != nil { 41 | log.Fatalln(err) 42 | } 43 | defer script.Close() 44 | 45 | start := time.Now() 46 | loadedScript, err := sieve.Load(script, sieve.DefaultOptions()) 47 | end := time.Now() 48 | if err != nil { 49 | log.Fatalln(err) 50 | } 51 | log.Println("script loaded in", end.Sub(start)) 52 | 53 | envData := interp.EnvelopeStatic{ 54 | From: *envFrom, 55 | To: *envTo, 56 | } 57 | msgData := interp.MessageStatic{ 58 | Size: int(fileInfo.Size()), 59 | Header: msgHdr, 60 | } 61 | data := sieve.NewRuntimeData(loadedScript, interp.DummyPolicy{}, 62 | envData, msgData) 63 | 64 | ctx := context.Background() 65 | start = time.Now() 66 | if err := loadedScript.Execute(ctx, data); err != nil { 67 | log.Fatalln(err) 68 | } 69 | end = time.Now() 70 | log.Println("script executed in", end.Sub(start)) 71 | 72 | fmt.Println("redirect:", data.RedirectAddr) 73 | fmt.Println("fileinfo:", data.Mailboxes) 74 | fmt.Println("keep:", data.ImplicitKeep || data.Keep) 75 | fmt.Printf("flags: %s\n", strings.Join(data.Flags, " ")) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/sieve-run/msg.eml: -------------------------------------------------------------------------------- 1 | Date: Tue, 1 Apr 1997 09:06:31 -0800 (PST) 2 | From: coyote@desert.example.org 3 | To: roadrunner@acme.example.com 4 | Subject: I have a present for you 5 | 6 | Look, I'm sorry about the whole anvil thing, and I really 7 | didn't mean to try and drop it on you from the top of the 8 | cliff. I want to try to make it up to you. I've got some 9 | great birdseed over here at my place--top of the line 10 | stuff--and if you come by, I'll have it all wrapped up 11 | for you. I'm really sorry for all the problems I've caused 12 | for you over the years, but I know we can work this out. 13 | -- 14 | Wile E. Coyote "Super Genius" coyote@desert.example.org 15 | -------------------------------------------------------------------------------- /cmd/sieve-run/msgB.eml: -------------------------------------------------------------------------------- 1 | From: youcouldberich!@reply-by-postal-mail.invalid 2 | Sender: b1ff@de.res.example.com 3 | To: rube@landru.example.com 4 | Date: Mon, 31 Mar 1997 18:26:10 -0800 5 | Subject: $$$ YOU, TOO, CAN BE A MILLIONAIRE! $$$ 6 | 7 | YOU MAY HAVE ALREADY WON TEN MILLION DOLLARS, BUT I DOUBT 8 | IT! SO JUST POST THIS TO SIX HUNDRED NEWSGROUPS! IT WILL 9 | GUARANTEE THAT YOU GET AT LEAST FIVE RESPONSES WITH MONEY! 10 | MONEY! MONEY! COLD HARD CASH! YOU WILL RECEIVE OVER 11 | $20,000 IN LESS THAN TWO MONTHS! AND IT'S LEGAL!!!!!!!!! 12 | !!!!!!!!!!!!!!!!!!111111111!!!!!!!11111111111!!1 JUST 13 | SEND $5 IN SMALL, UNMARKED BILLS TO THE ADDRESSES BELOW! 14 | -------------------------------------------------------------------------------- /cmd/sieve-run/sieve-run: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foxcpp/go-sieve/72d6b002882a99f7c12d47a5f4a7533b302e51c0/cmd/sieve-run/sieve-run -------------------------------------------------------------------------------- /cmd/sieve-run/test.sieve: -------------------------------------------------------------------------------- 1 | # 2 | # Example Sieve Filter 3 | # Declare any optional features or extension used by the script 4 | # 5 | require ["fileinto"]; 6 | 7 | # 8 | # Handle messages from known mailing lists 9 | # Move messages from IETF filter discussion list to filter mailbox 10 | # 11 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 12 | { 13 | fileinto "filter"; # move to "filter" mailbox 14 | } 15 | # 16 | # Keep all messages to or from people in my company 17 | # 18 | elsif address :DOMAIN :is ["From", "To"] "example.com" 19 | { 20 | keep; # keep in "In" mailbox 21 | } 22 | 23 | # 24 | # Try and catch unsolicited email. If a message is not to me, 25 | # or it contains a subject known to be spam, file it away. 26 | # 27 | elsif anyof (NOT address :all :contains 28 | ["To", "Cc", "Bcc"] "me@example.com", 29 | header :matches "subject" 30 | ["*make*money*fast*", "*university*dipl*mas*"]) 31 | { 32 | fileinto "spam"; # move to "spam" mailbox 33 | } 34 | else 35 | { 36 | # Move all other (non-company) mail to "personal" 37 | # mailbox. 38 | fileinto "personal"; 39 | } 40 | -------------------------------------------------------------------------------- /execute_test.go: -------------------------------------------------------------------------------- 1 | package sieve 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "net/textproto" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/foxcpp/go-sieve/interp" 12 | ) 13 | 14 | var eml string = `Date: Tue, 1 Apr 1997 09:06:31 -0800 (PST) 15 | From: coyote@desert.example.org 16 | To: roadrunner@acme.example.com 17 | Subject: I have a present for you 18 | 19 | Look, I'm sorry about the whole anvil thing, and I really 20 | didn't mean to try and drop it on you from the top of the 21 | cliff. I want to try to make it up to you. I've got some 22 | great birdseed over here at my place--top of the line 23 | stuff--and if you come by, I'll have it all wrapped up 24 | for you. I'm really sorry for all the problems I've caused 25 | for you over the years, but I know we can work this out. 26 | -- 27 | Wile E. Coyote "Super Genius" coyote@desert.example.org 28 | ` 29 | 30 | type result struct { 31 | redirect []string 32 | fileinto []string 33 | implicitKeep bool 34 | keep bool 35 | flags []string 36 | } 37 | 38 | func testExecute(t *testing.T, in string, eml string, intendedResult result) { 39 | t.Run("case", func(t *testing.T) { 40 | 41 | msgHdr, err := textproto.NewReader(bufio.NewReader(strings.NewReader(eml))).ReadMIMEHeader() 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | script := bufio.NewReader(strings.NewReader(in)) 47 | 48 | loadedScript, err := Load(script, DefaultOptions()) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | env := interp.EnvelopeStatic{ 53 | From: "from@test.com", 54 | To: "to@test.com", 55 | } 56 | msg := interp.MessageStatic{ 57 | Size: len(eml), 58 | Header: msgHdr, 59 | } 60 | data := interp.NewRuntimeData(loadedScript, interp.DummyPolicy{}, 61 | env, msg) 62 | 63 | ctx := context.Background() 64 | if err := loadedScript.Execute(ctx, data); err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | r := result{ 69 | redirect: data.RedirectAddr, 70 | fileinto: data.Mailboxes, 71 | keep: data.Keep, 72 | implicitKeep: data.ImplicitKeep, 73 | flags: data.Flags, 74 | } 75 | 76 | if !reflect.DeepEqual(r, intendedResult) { 77 | t.Log("Wrong Execute output") 78 | t.Log("Actual: ", r) 79 | t.Log("Expected:", intendedResult) 80 | t.Fail() 81 | } 82 | }) 83 | } 84 | 85 | func TestFileinto(t *testing.T) { 86 | testExecute(t, `require ["fileinto"]; 87 | fileinto "test"; 88 | `, eml, 89 | result{ 90 | fileinto: []string{"test"}, 91 | }) 92 | testExecute(t, `require ["fileinto"]; 93 | fileinto "test"; 94 | fileinto "test2"; 95 | `, eml, 96 | result{ 97 | fileinto: []string{"test", "test2"}, 98 | }) 99 | } 100 | 101 | func TestFlags(t *testing.T) { 102 | testExecute(t, `require ["fileinto", "imap4flags"]; 103 | setflag ["flag1", "flag2"]; 104 | addflag ["flag2", "flag3"]; 105 | removeflag ["flag1"]; 106 | fileinto "test"; 107 | `, eml, 108 | result{ 109 | fileinto: []string{"test"}, 110 | flags: []string{"flag2", "flag3"}, 111 | }) 112 | testExecute(t, `require ["fileinto", "imap4flags"]; 113 | addflag ["flag2", "flag3"]; 114 | removeflag ["flag3", "flag4"]; 115 | fileinto "test"; 116 | `, eml, 117 | result{ 118 | fileinto: []string{"test"}, 119 | flags: []string{"flag2"}, 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/foxcpp/go-sieve 2 | 3 | go 1.20 4 | 5 | require github.com/davecgh/go-spew v1.1.1 6 | 7 | require ( 8 | github.com/emersion/go-message v0.18.0 // indirect 9 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect 10 | rsc.io/binaryregexp v0.2.0 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/emersion/go-message v0.18.0 h1:7LxAXHRpSeoO/Wom3ZApVZYG7c3d17yCScYce8WiXA8= 4 | github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A= 5 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= 6 | github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= 7 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 8 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 9 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 10 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 11 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 12 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 13 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 14 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 15 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 16 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 17 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 18 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 19 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 20 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 22 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 26 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 27 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 30 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 31 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 32 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 33 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 34 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 35 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 36 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 37 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 38 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= 40 | rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= 41 | -------------------------------------------------------------------------------- /interp/action.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type CmdStop struct{} 9 | 10 | func (c CmdStop) Execute(_ context.Context, _ *RuntimeData) error { 11 | return ErrStop 12 | } 13 | 14 | type CmdFileInto struct { 15 | Mailbox string 16 | Flags Flags 17 | } 18 | 19 | func (c CmdFileInto) Execute(_ context.Context, d *RuntimeData) error { 20 | mailbox := expandVars(d, c.Mailbox) 21 | found := false 22 | for _, m := range d.Mailboxes { 23 | if m == mailbox { 24 | found = true 25 | } 26 | } 27 | if found { 28 | return nil 29 | } 30 | d.Mailboxes = append(d.Mailboxes, mailbox) 31 | d.ImplicitKeep = false 32 | if c.Flags != nil { 33 | d.Flags = canonicalFlags(expandVarsList(d, c.Flags), nil, d.FlagAliases) 34 | } 35 | return nil 36 | } 37 | 38 | type CmdRedirect struct { 39 | Addr string 40 | } 41 | 42 | func (c CmdRedirect) Execute(ctx context.Context, d *RuntimeData) error { 43 | addr := expandVars(d, c.Addr) 44 | 45 | ok, err := d.Policy.RedirectAllowed(ctx, d, addr) 46 | if err != nil { 47 | return err 48 | } 49 | if !ok { 50 | return nil 51 | } 52 | d.RedirectAddr = append(d.RedirectAddr, addr) 53 | d.ImplicitKeep = false 54 | 55 | if len(d.RedirectAddr) > d.Script.opts.MaxRedirects { 56 | return fmt.Errorf("too many actions") 57 | } 58 | return nil 59 | } 60 | 61 | type CmdKeep struct { 62 | Flags Flags 63 | } 64 | 65 | func (c CmdKeep) Execute(_ context.Context, d *RuntimeData) error { 66 | d.Keep = true 67 | if c.Flags != nil { 68 | d.Flags = canonicalFlags(expandVarsList(d, c.Flags), nil, d.FlagAliases) 69 | } 70 | return nil 71 | } 72 | 73 | type CmdDiscard struct{} 74 | 75 | func (c CmdDiscard) Execute(_ context.Context, d *RuntimeData) error { 76 | d.ImplicitKeep = false 77 | d.Flags = make([]string, 0) 78 | return nil 79 | } 80 | 81 | type CmdSetFlag struct { 82 | Flags Flags 83 | } 84 | 85 | func (c CmdSetFlag) Execute(_ context.Context, d *RuntimeData) error { 86 | if c.Flags != nil { 87 | d.Flags = canonicalFlags(expandVarsList(d, c.Flags), nil, d.FlagAliases) 88 | } 89 | return nil 90 | } 91 | 92 | type CmdAddFlag struct { 93 | Flags Flags 94 | } 95 | 96 | func (c CmdAddFlag) Execute(_ context.Context, d *RuntimeData) error { 97 | if c.Flags != nil { 98 | flags := expandVarsList(d, c.Flags) 99 | 100 | if d.Flags == nil { 101 | d.Flags = make([]string, len(flags)) 102 | copy(d.Flags, flags) 103 | } else { 104 | // Use canonicalFlags to remove duplicates 105 | d.Flags = canonicalFlags(append(d.Flags, flags...), nil, d.FlagAliases) 106 | } 107 | } 108 | return nil 109 | } 110 | 111 | type CmdRemoveFlag struct { 112 | Flags Flags 113 | } 114 | 115 | func (c CmdRemoveFlag) Execute(_ context.Context, d *RuntimeData) error { 116 | if c.Flags != nil { 117 | // Use canonicalFlags to remove duplicates 118 | d.Flags = canonicalFlags(d.Flags, expandVarsList(d, c.Flags), d.FlagAliases) 119 | } 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /interp/control.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type CmdIf struct { 8 | Test Test 9 | Block []Cmd 10 | } 11 | 12 | func (c CmdIf) Execute(ctx context.Context, d *RuntimeData) error { 13 | res, err := c.Test.Check(ctx, d) 14 | if err != nil { 15 | return err 16 | } 17 | if res { 18 | for _, c := range c.Block { 19 | if err := c.Execute(ctx, d); err != nil { 20 | return err 21 | } 22 | } 23 | } 24 | d.ifResult = res 25 | return nil 26 | } 27 | 28 | type CmdElsif struct { 29 | Test Test 30 | Block []Cmd 31 | } 32 | 33 | func (c CmdElsif) Execute(ctx context.Context, d *RuntimeData) error { 34 | if d.ifResult { 35 | return nil 36 | } 37 | res, err := c.Test.Check(ctx, d) 38 | if err != nil { 39 | return err 40 | } 41 | if res { 42 | for _, c := range c.Block { 43 | if err := c.Execute(ctx, d); err != nil { 44 | return err 45 | } 46 | } 47 | } 48 | d.ifResult = res 49 | return nil 50 | } 51 | 52 | type CmdElse struct { 53 | Block []Cmd 54 | } 55 | 56 | func (c CmdElse) Execute(ctx context.Context, d *RuntimeData) error { 57 | if d.ifResult { 58 | return nil 59 | } 60 | for _, c := range c.Block { 61 | if err := c.Execute(ctx, d); err != nil { 62 | return err 63 | } 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /interp/dovecot_testsuite.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io/fs" 10 | "net/textproto" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/foxcpp/go-sieve/lexer" 16 | "github.com/foxcpp/go-sieve/parser" 17 | ) 18 | 19 | const DovecotTestExtension = "vnd.dovecot.testsuite" 20 | 21 | type CmdDovecotTest struct { 22 | TestName string 23 | Cmds []Cmd 24 | } 25 | 26 | func (c CmdDovecotTest) Execute(ctx context.Context, d *RuntimeData) error { 27 | testData := d.Copy() 28 | testData.testName = c.TestName 29 | testData.testFailMessage = "" 30 | 31 | d.Script.opts.T.Run(c.TestName, func(t *testing.T) { 32 | for _, testName := range testData.Script.opts.DisabledTests { 33 | if c.TestName == testName { 34 | t.Skip("test is disabled by DisabledTests") 35 | } 36 | } 37 | 38 | for _, cmd := range c.Cmds { 39 | if err := cmd.Execute(ctx, testData); err != nil { 40 | if errors.Is(err, ErrStop) { 41 | if testData.testFailMessage != "" { 42 | t.Errorf("test_fail at %v called: %v", testData.testFailAt, testData.testFailMessage) 43 | } 44 | return 45 | } 46 | t.Fatal("Test execution error:", err) 47 | } 48 | } 49 | }) 50 | 51 | return nil 52 | } 53 | 54 | type CmdDovecotTestFail struct { 55 | At lexer.Position 56 | Message string 57 | } 58 | 59 | func (c CmdDovecotTestFail) Execute(_ context.Context, d *RuntimeData) error { 60 | d.testFailMessage = expandVars(d, c.Message) 61 | d.testFailAt = c.At 62 | return ErrStop 63 | } 64 | 65 | type CmdDovecotConfigSet struct { 66 | Unset bool 67 | Key string 68 | Value string 69 | } 70 | 71 | func (c CmdDovecotConfigSet) Execute(_ context.Context, d *RuntimeData) error { 72 | switch c.Key { 73 | case "sieve_variables_max_variable_size": 74 | if c.Unset { 75 | d.Script.opts.MaxVariableLen = 4000 76 | } else { 77 | val, err := strconv.Atoi(c.Value) 78 | if err != nil { 79 | return err 80 | } 81 | d.Script.opts.MaxVariableLen = val 82 | } 83 | default: 84 | return fmt.Errorf("unknown test_config_set key: %v", c.Key) 85 | } 86 | return nil 87 | } 88 | 89 | type CmdDovecotTestSet struct { 90 | VariableName string 91 | VariableValue string 92 | } 93 | 94 | func (c CmdDovecotTestSet) Execute(_ context.Context, d *RuntimeData) error { 95 | value := expandVars(d, c.VariableValue) 96 | 97 | switch c.VariableName { 98 | case "message": 99 | r := textproto.NewReader(bufio.NewReader(strings.NewReader(c.VariableValue))) 100 | msgHdr, err := r.ReadMIMEHeader() 101 | if err != nil { 102 | return fmt.Errorf("failed to parse test message: %v", err) 103 | } 104 | 105 | d.Msg = MessageStatic{ 106 | Size: len(c.VariableValue), 107 | Header: msgHdr, 108 | } 109 | case "envelope.from": 110 | value = strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">") 111 | 112 | d.Envelope = EnvelopeStatic{ 113 | From: value, 114 | To: d.Envelope.EnvelopeTo(), 115 | Auth: d.Envelope.AuthUsername(), 116 | } 117 | case "envelope.to": 118 | value = strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">") 119 | 120 | d.Envelope = EnvelopeStatic{ 121 | From: d.Envelope.EnvelopeFrom(), 122 | To: value, 123 | Auth: d.Envelope.AuthUsername(), 124 | } 125 | case "envelope.auth": 126 | d.Envelope = EnvelopeStatic{ 127 | From: d.Envelope.EnvelopeFrom(), 128 | To: d.Envelope.EnvelopeTo(), 129 | Auth: value, 130 | } 131 | default: 132 | d.Variables[c.VariableName] = c.VariableValue 133 | } 134 | 135 | return nil 136 | } 137 | 138 | type TestDovecotCompile struct { 139 | ScriptPath string 140 | } 141 | 142 | func (t TestDovecotCompile) Check(_ context.Context, d *RuntimeData) (bool, error) { 143 | if d.Namespace == nil { 144 | return false, fmt.Errorf("RuntimeData.Namespace is not set, cannot load scripts") 145 | } 146 | 147 | svScript, err := fs.ReadFile(d.Namespace, t.ScriptPath) 148 | if err != nil { 149 | return false, nil 150 | } 151 | 152 | toks, err := lexer.Lex(bytes.NewReader(svScript), &lexer.Options{ 153 | Filename: t.ScriptPath, 154 | MaxTokens: 5000, 155 | }) 156 | if err != nil { 157 | return false, nil 158 | } 159 | 160 | cmds, err := parser.Parse(lexer.NewStream(toks), &parser.Options{ 161 | MaxBlockNesting: d.testMaxNesting, 162 | MaxTestNesting: d.testMaxNesting, 163 | }) 164 | if err != nil { 165 | return false, nil 166 | } 167 | 168 | script, err := LoadScript(cmds, &Options{ 169 | MaxRedirects: d.Script.opts.MaxRedirects, 170 | }) 171 | if err != nil { 172 | return false, nil 173 | } 174 | 175 | d.testScript = script 176 | return true, nil 177 | } 178 | 179 | type TestDovecotRun struct { 180 | } 181 | 182 | func (t TestDovecotRun) Check(ctx context.Context, d *RuntimeData) (bool, error) { 183 | if d.testScript == nil { 184 | return false, nil 185 | } 186 | 187 | testD := d.Copy() 188 | testD.Script = d.testScript 189 | // Note: Loaded script has no test environment available - 190 | // it is a regular Sieve script. 191 | 192 | err := d.testScript.Execute(ctx, testD) 193 | if err != nil { 194 | return false, nil 195 | } 196 | 197 | return true, nil 198 | } 199 | 200 | type TestDovecotTestError struct { 201 | matcherTest 202 | } 203 | 204 | func (t TestDovecotTestError) Check(_ context.Context, _ *RuntimeData) (bool, error) { 205 | // go-sieve has a very different error formatting and stops lexing/parsing/loading 206 | // on first error, therefore we skip all test_errors checks as they are 207 | // Pigeonhole-specific. 208 | return true, nil 209 | } 210 | -------------------------------------------------------------------------------- /interp/load.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/foxcpp/go-sieve/lexer" 8 | "github.com/foxcpp/go-sieve/parser" 9 | ) 10 | 11 | var supportedRequires = map[string]struct{}{ 12 | "fileinto": {}, 13 | "envelope": {}, 14 | "encoded-character": {}, 15 | 16 | "comparator-i;octet": {}, 17 | "comparator-i;ascii-casemap": {}, 18 | "comparator-i;ascii-numeric": {}, 19 | "comparator-i;unicode-casemap": {}, 20 | 21 | "imap4flags": {}, 22 | "variables": {}, 23 | "relational": {}, 24 | } 25 | 26 | var ( 27 | commands map[string]func(*Script, parser.Cmd) (Cmd, error) 28 | tests map[string]func(*Script, parser.Test) (Test, error) 29 | ) 30 | 31 | func init() { 32 | // break initialization loop 33 | 34 | commands = map[string]func(*Script, parser.Cmd) (Cmd, error){ 35 | // RFC 5228 36 | "require": loadRequire, 37 | "if": loadIf, 38 | "elsif": loadElsif, 39 | "else": loadElse, 40 | "stop": loadStop, 41 | "fileinto": loadFileInto, // fileinto extension 42 | "redirect": loadRedirect, 43 | "keep": loadKeep, 44 | "discard": loadDiscard, 45 | // RFC 5232 (imap4flags extension) 46 | "setflag": loadSetFlag, 47 | "addflag": loadAddFlag, 48 | "removeflag": loadRemoveFlag, 49 | // RFC 5229 (variables extension) 50 | "set": loadSet, 51 | // vnd.dovecot.testsuite 52 | "test": loadDovecotTest, 53 | "test_set": loadDovecotTestSet, 54 | "test_fail": loadDovecotTestFail, 55 | "test_binary_load": loadNoop, // go-sieve has no intermediate binary representation 56 | "test_binary_save": loadNoop, // go-sieve has no intermediate binary representation 57 | // "test_result_execute" // apply script results (validated using test_message) 58 | // "test_mailbox_create" 59 | // "test_imap_metadata_set" 60 | "test_config_reload": loadNoop, // go-sieve applies changes immediately 61 | "test_config_set": loadDovecotConfigSet, 62 | "test_config_unset": loadDovecotConfigUnset, 63 | // "test_result_reset" 64 | // "test_message" 65 | 66 | } 67 | tests = map[string]func(*Script, parser.Test) (Test, error){ 68 | // RFC 5228 69 | "address": loadAddressTest, 70 | "allof": loadAllOfTest, 71 | "anyof": loadAnyOfTest, 72 | "envelope": loadEnvelopeTest, // envelope extension 73 | "exists": loadExistsTest, 74 | "false": loadFalseTest, 75 | "true": loadTrueTest, 76 | "header": loadHeaderTest, 77 | "not": loadNotTest, 78 | "size": loadSizeTest, 79 | // RFC 5229 (variables extension) 80 | "string": loadStringTest, 81 | // vnd.dovecot.testsuite 82 | "test_script_compile": loadDovecotCompile, // compile script (to test for compile errors) 83 | "test_script_run": loadDovecotRun, // run script (to test for run-time errors) 84 | "test_error": loadDovecotError, // check detailed results of test_script_compile or test_script_run 85 | // "test_message" // check results of test_result_execute - where messages are 86 | // "test_result_action" // check results of test_result_execute - what actions are executed 87 | // "test_result_reset" // clean results as observed by test_result_action 88 | } 89 | } 90 | 91 | func LoadScript(cmdStream []parser.Cmd, opts *Options) (*Script, error) { 92 | s := &Script{ 93 | extensions: map[string]struct{}{}, 94 | opts: opts, 95 | } 96 | 97 | loadedCmds, err := LoadBlock(s, cmdStream) 98 | if err != nil { 99 | return nil, err 100 | } 101 | s.cmd = loadedCmds 102 | 103 | return s, nil 104 | } 105 | 106 | func LoadBlock(s *Script, cmds []parser.Cmd) ([]Cmd, error) { 107 | loaded := make([]Cmd, 0, len(cmds)) 108 | for _, c := range cmds { 109 | cmd, err := LoadCmd(s, c) 110 | if err != nil { 111 | return nil, err 112 | } 113 | if cmd == nil { 114 | continue 115 | } 116 | loaded = append(loaded, cmd) 117 | } 118 | return loaded, nil 119 | } 120 | 121 | func LoadCmd(s *Script, cmd parser.Cmd) (Cmd, error) { 122 | cmdName := strings.ToLower(cmd.Id) 123 | factory := commands[cmdName] 124 | if factory == nil { 125 | return nil, lexer.ErrorAt(cmd, "LoadBlock: unsupported command: %v", cmdName) 126 | } 127 | return factory(s, cmd) 128 | 129 | } 130 | 131 | func LoadTest(s *Script, t parser.Test) (Test, error) { 132 | testName := strings.ToLower(t.Id) 133 | factory := tests[testName] 134 | if factory == nil { 135 | return nil, lexer.ErrorAt(t, "LoadTest: unsupported test: %v", testName) 136 | } 137 | return factory(s, t) 138 | } 139 | 140 | type CmdNoop struct{} 141 | 142 | func (c CmdNoop) Execute(_ context.Context, _ *RuntimeData) error { 143 | return nil 144 | } 145 | 146 | func loadNoop(_ *Script, _ parser.Cmd) (Cmd, error) { 147 | return CmdNoop{}, nil 148 | } 149 | -------------------------------------------------------------------------------- /interp/load_action.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/foxcpp/go-sieve/parser" 8 | ) 9 | 10 | type Flags []string 11 | 12 | func canonicalFlags(src []string, remove Flags, aliases map[string]string) Flags { 13 | // This does four things 14 | // * Translate space delimited lists of flags into separate flags 15 | // * Handle flag aliases 16 | // * Deduplicate 17 | // * Sort 18 | // * (optionally) remove flags 19 | c := make(Flags, 0, len(src)) 20 | fm := make(map[string]struct{}) 21 | for _, fl := range src { 22 | for _, f := range strings.Split(fl, " ") { 23 | if fc, ok := aliases[f]; ok { 24 | fm[fc] = struct{}{} 25 | } else { 26 | fm[f] = struct{}{} 27 | } 28 | } 29 | } 30 | if remove != nil { 31 | for _, fl := range remove { 32 | for _, f := range strings.Split(fl, " ") { 33 | if fc, ok := aliases[f]; ok { 34 | delete(fm, fc) 35 | } else { 36 | delete(fm, f) 37 | } 38 | } 39 | } 40 | } 41 | for f := range fm { 42 | c = append(c, f) 43 | } 44 | sort.Strings(c) 45 | return c 46 | } 47 | 48 | func loadFileInto(s *Script, pcmd parser.Cmd) (Cmd, error) { 49 | if !s.RequiresExtension("fileinto") { 50 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'fileinto") 51 | } 52 | cmd := CmdFileInto{} 53 | err := LoadSpec(s, &Spec{ 54 | Tags: map[string]SpecTag{ 55 | "flags": { 56 | NeedsValue: true, 57 | MinStrCount: 1, 58 | MatchStr: func(val []string) { 59 | cmd.Flags = canonicalFlags(val, nil, nil) 60 | }, 61 | }, 62 | }, 63 | Pos: []SpecPosArg{ 64 | { 65 | MinStrCount: 1, 66 | MaxStrCount: 1, 67 | MatchStr: func(val []string) { 68 | cmd.Mailbox = val[0] 69 | }, 70 | }, 71 | }, 72 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if !s.RequiresExtension("imap4flags") && cmd.Flags != nil { 78 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'imap4flags") 79 | } 80 | 81 | return cmd, nil 82 | } 83 | 84 | func loadRedirect(s *Script, pcmd parser.Cmd) (Cmd, error) { 85 | cmd := CmdRedirect{} 86 | err := LoadSpec(s, &Spec{ 87 | Pos: []SpecPosArg{ 88 | { 89 | MinStrCount: 1, 90 | MaxStrCount: 1, 91 | MatchStr: func(val []string) { 92 | cmd.Addr = val[0] 93 | }, 94 | }, 95 | }, 96 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return cmd, nil 102 | } 103 | 104 | func loadKeep(s *Script, pcmd parser.Cmd) (Cmd, error) { 105 | cmd := CmdKeep{} 106 | err := LoadSpec(s, &Spec{ 107 | Tags: map[string]SpecTag{ 108 | "flags": { 109 | NeedsValue: true, 110 | MinStrCount: 1, 111 | MatchStr: func(val []string) { 112 | cmd.Flags = canonicalFlags(val, nil, nil) 113 | }, 114 | }, 115 | }, 116 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | if !s.RequiresExtension("imap4flags") && cmd.Flags != nil { 122 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'imap4flags") 123 | } 124 | 125 | return cmd, nil 126 | } 127 | 128 | func loadDiscard(s *Script, pcmd parser.Cmd) (Cmd, error) { 129 | cmd := CmdDiscard{} 130 | err := LoadSpec(s, &Spec{}, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 131 | return cmd, err 132 | } 133 | 134 | func loadSetFlag(s *Script, pcmd parser.Cmd) (Cmd, error) { 135 | if !s.RequiresExtension("imap4flags") { 136 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'imap4flags") 137 | } 138 | cmd := CmdSetFlag{} 139 | err := LoadSpec(s, &Spec{ 140 | Pos: []SpecPosArg{ 141 | { 142 | MinStrCount: 1, 143 | MatchStr: func(val []string) { 144 | cmd.Flags = canonicalFlags(val, nil, nil) 145 | }, 146 | }, 147 | }, 148 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | return cmd, nil 154 | } 155 | 156 | func loadAddFlag(s *Script, pcmd parser.Cmd) (Cmd, error) { 157 | if !s.RequiresExtension("imap4flags") { 158 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'imap4flags") 159 | } 160 | cmd := CmdAddFlag{} 161 | err := LoadSpec(s, &Spec{ 162 | Pos: []SpecPosArg{ 163 | { 164 | MinStrCount: 1, 165 | MatchStr: func(val []string) { 166 | cmd.Flags = canonicalFlags(val, nil, nil) 167 | }, 168 | }, 169 | }, 170 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | return cmd, nil 176 | } 177 | 178 | func loadRemoveFlag(s *Script, pcmd parser.Cmd) (Cmd, error) { 179 | if !s.RequiresExtension("imap4flags") { 180 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'imap4flags") 181 | } 182 | cmd := CmdRemoveFlag{} 183 | err := LoadSpec(s, &Spec{ 184 | Pos: []SpecPosArg{ 185 | { 186 | MinStrCount: 1, 187 | MatchStr: func(val []string) { 188 | cmd.Flags = canonicalFlags(val, nil, nil) 189 | }, 190 | }, 191 | }, 192 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | return cmd, nil 198 | } 199 | -------------------------------------------------------------------------------- /interp/load_control.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/foxcpp/go-sieve/parser" 7 | ) 8 | 9 | func loadRequire(s *Script, pcmd parser.Cmd) (Cmd, error) { 10 | var exts []string 11 | err := LoadSpec(s, &Spec{ 12 | Pos: []SpecPosArg{ 13 | { 14 | Optional: false, 15 | MatchStr: func(val []string) { 16 | exts = val 17 | }, 18 | MinStrCount: 1, 19 | }, 20 | }, 21 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | for _, ext := range exts { 27 | if ext == DovecotTestExtension { 28 | if s.opts.T == nil { 29 | return nil, fmt.Errorf("testing environment is not available, cannot use vnd.dovecot.testsuite") 30 | } 31 | s.extensions[DovecotTestExtension] = struct{}{} 32 | continue 33 | } 34 | 35 | if _, ok := supportedRequires[ext]; !ok { 36 | return nil, fmt.Errorf("loadRequire: unsupported extension: %v", ext) 37 | } 38 | s.extensions[ext] = struct{}{} 39 | } 40 | return nil, nil 41 | } 42 | 43 | func loadIf(s *Script, pcmd parser.Cmd) (Cmd, error) { 44 | cmd := CmdIf{} 45 | err := LoadSpec(s, &Spec{ 46 | AddTest: func(t Test) { 47 | cmd.Test = t 48 | }, 49 | AddBlock: func(cmds []Cmd) { 50 | cmd.Block = cmds 51 | }, 52 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 53 | return cmd, err 54 | } 55 | 56 | func loadElsif(s *Script, pcmd parser.Cmd) (Cmd, error) { 57 | cmd := CmdElsif{} 58 | err := LoadSpec(s, &Spec{ 59 | AddTest: func(t Test) { 60 | cmd.Test = t 61 | }, 62 | AddBlock: func(cmds []Cmd) { 63 | cmd.Block = cmds 64 | }, 65 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 66 | return cmd, err 67 | } 68 | 69 | func loadElse(s *Script, pcmd parser.Cmd) (Cmd, error) { 70 | cmd := CmdElse{} 71 | err := LoadSpec(s, &Spec{ 72 | AddBlock: func(cmds []Cmd) { 73 | cmd.Block = cmds 74 | }, 75 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 76 | return cmd, err 77 | } 78 | 79 | func loadStop(s *Script, pcmd parser.Cmd) (Cmd, error) { 80 | cmd := CmdStop{} 81 | err := LoadSpec(s, &Spec{}, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 82 | return cmd, err 83 | } 84 | -------------------------------------------------------------------------------- /interp/load_dovecot.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/foxcpp/go-sieve/parser" 7 | ) 8 | 9 | func loadDovecotTestSet(s *Script, pcmd parser.Cmd) (Cmd, error) { 10 | if !s.RequiresExtension(DovecotTestExtension) || s.opts.T == nil { 11 | return nil, fmt.Errorf("testing environment is not enabled") 12 | } 13 | cmd := CmdDovecotTestSet{} 14 | err := LoadSpec(s, &Spec{ 15 | Pos: []SpecPosArg{ 16 | { 17 | MinStrCount: 1, 18 | MaxStrCount: 1, 19 | MatchStr: func(val []string) { 20 | cmd.VariableName = val[0] 21 | }, 22 | NoVariables: true, 23 | }, 24 | { 25 | MinStrCount: 1, 26 | MaxStrCount: 1, 27 | MatchStr: func(val []string) { 28 | cmd.VariableValue = val[0] 29 | }, 30 | }, 31 | }, 32 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return cmd, nil 38 | } 39 | 40 | func loadDovecotTestFail(s *Script, pcmd parser.Cmd) (Cmd, error) { 41 | if !s.RequiresExtension(DovecotTestExtension) || s.opts.T == nil { 42 | return nil, fmt.Errorf("testing environment is not enabled") 43 | } 44 | cmd := CmdDovecotTestFail{} 45 | err := LoadSpec(s, &Spec{ 46 | Pos: []SpecPosArg{ 47 | { 48 | MinStrCount: 1, 49 | MaxStrCount: 1, 50 | MatchStr: func(val []string) { 51 | cmd.Message = val[0] 52 | }, 53 | }, 54 | }, 55 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 56 | cmd.At = pcmd.Position 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | if !usedVarsAreValid(s, cmd.Message) { 62 | return nil, parser.ErrorAt(pcmd.Position, "invalid variable used: %v", cmd.Message) 63 | } 64 | 65 | return cmd, nil 66 | } 67 | 68 | func loadDovecotTest(s *Script, pcmd parser.Cmd) (Cmd, error) { 69 | if !s.RequiresExtension(DovecotTestExtension) || s.opts.T == nil { 70 | return nil, fmt.Errorf("testing environment is not enabled") 71 | } 72 | cmd := CmdDovecotTest{} 73 | err := LoadSpec(s, &Spec{ 74 | Pos: []SpecPosArg{ 75 | { 76 | MinStrCount: 1, 77 | MaxStrCount: 1, 78 | MatchStr: func(val []string) { 79 | cmd.TestName = val[0] 80 | }, 81 | }, 82 | }, 83 | AddBlock: func(cmds []Cmd) { 84 | cmd.Cmds = cmds 85 | }, 86 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 87 | return cmd, err 88 | } 89 | 90 | func loadDovecotCompile(s *Script, test parser.Test) (Test, error) { 91 | loaded := TestDovecotCompile{} 92 | err := LoadSpec(s, &Spec{ 93 | Pos: []SpecPosArg{ 94 | { 95 | MatchStr: func(val []string) { 96 | loaded.ScriptPath = val[0] 97 | }, 98 | MinStrCount: 1, 99 | MaxStrCount: 1, 100 | }, 101 | }, 102 | }, test.Position, test.Args, test.Tests, nil) 103 | return loaded, err 104 | } 105 | 106 | func loadDovecotConfigSet(s *Script, pcmd parser.Cmd) (Cmd, error) { 107 | loaded := CmdDovecotConfigSet{} 108 | err := LoadSpec(s, &Spec{ 109 | Pos: []SpecPosArg{ 110 | { 111 | MatchStr: func(val []string) { 112 | loaded.Key = val[0] 113 | }, 114 | MinStrCount: 1, 115 | MaxStrCount: 1, 116 | }, 117 | { 118 | MatchStr: func(val []string) { 119 | loaded.Value = val[0] 120 | }, 121 | MinStrCount: 1, 122 | MaxStrCount: 1, 123 | }, 124 | }, 125 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 126 | return loaded, err 127 | } 128 | 129 | func loadDovecotConfigUnset(s *Script, pcmd parser.Cmd) (Cmd, error) { 130 | loaded := CmdDovecotConfigSet{ 131 | Unset: true, 132 | } 133 | err := LoadSpec(s, &Spec{ 134 | Pos: []SpecPosArg{ 135 | { 136 | MatchStr: func(val []string) { 137 | loaded.Key = val[0] 138 | }, 139 | MinStrCount: 1, 140 | MaxStrCount: 1, 141 | }, 142 | { 143 | MatchStr: func(val []string) { 144 | loaded.Value = val[0] 145 | }, 146 | MinStrCount: 1, 147 | MaxStrCount: 1, 148 | }, 149 | }, 150 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 151 | return loaded, err 152 | } 153 | 154 | func loadDovecotRun(s *Script, test parser.Test) (Test, error) { 155 | loaded := TestDovecotRun{} 156 | err := LoadSpec(s, &Spec{}, test.Position, test.Args, test.Tests, nil) 157 | return loaded, err 158 | } 159 | 160 | func loadDovecotError(s *Script, test parser.Test) (Test, error) { 161 | loaded := TestDovecotTestError{matcherTest: newMatcherTest()} 162 | err := LoadSpec(s, loaded.addSpecTags(&Spec{ 163 | Tags: map[string]SpecTag{ 164 | "index": { 165 | NeedsValue: true, 166 | MinStrCount: 1, 167 | MaxStrCount: 1, 168 | NoVariables: true, 169 | MatchNum: func(val int) {}, 170 | }, 171 | }, 172 | Pos: []SpecPosArg{ 173 | { 174 | MatchStr: func(val []string) {}, 175 | MinStrCount: 1, 176 | }, 177 | }, 178 | }), test.Position, test.Args, test.Tests, nil) 179 | return loaded, err 180 | } 181 | -------------------------------------------------------------------------------- /interp/load_generic.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/foxcpp/go-sieve/lexer" 7 | "github.com/foxcpp/go-sieve/parser" 8 | ) 9 | 10 | type SpecTag struct { 11 | NeedsValue bool 12 | MatchStr func(val []string) 13 | MatchNum func(val int) 14 | MatchBool func() 15 | 16 | // Checks for used string list. 17 | MinStrCount int 18 | MaxStrCount int 19 | 20 | // Toggle checks for valid variable names. 21 | NoVariables bool 22 | } 23 | 24 | type SpecPosArg struct { 25 | Optional bool 26 | MatchStr func(val []string) 27 | MatchNum func(i int) 28 | 29 | // Checks for used string list. 30 | MinStrCount int 31 | MaxStrCount int 32 | 33 | // Toggle checks for valid variable names. 34 | NoVariables bool 35 | } 36 | 37 | type Spec struct { 38 | Tags map[string]SpecTag 39 | Pos []SpecPosArg 40 | AddBlock func([]Cmd) 41 | BlockOptional bool 42 | AddTest func(Test) 43 | TestOptional bool 44 | MultipleTests bool 45 | } 46 | 47 | func LoadSpec(s *Script, spec *Spec, position lexer.Position, args []parser.Arg, tests []parser.Test, block []parser.Cmd) error { 48 | var lastTag *SpecTag 49 | nextPosArg := 0 50 | for _, a := range args { 51 | switch a := a.(type) { 52 | case parser.StringArg: 53 | if lastTag != nil && lastTag.NeedsValue { 54 | if lastTag.MatchNum != nil { 55 | return lexer.ErrorAt(a, "LoadSpec: tagged argument requires a number, got string-list") 56 | } else if lastTag.MatchStr != nil { 57 | value := a.Value 58 | if s.RequiresExtension("encoded-character") { 59 | var err error 60 | value, err = decodeEncodedChars(value) 61 | if err != nil { 62 | return lexer.ErrorAt(position, "LoadSpec: malformed encoded character sequence: %v", err) 63 | } 64 | } 65 | if s.RequiresExtension("variables") && !lastTag.NoVariables { 66 | 67 | } 68 | 69 | lastTag.MatchStr([]string{value}) 70 | } else { 71 | panic("missing matcher for SpecTag") 72 | } 73 | lastTag = nil 74 | continue 75 | } 76 | if nextPosArg >= len(spec.Pos) { 77 | return lexer.ErrorAt(a, "LoadSpec: too many arguments") 78 | } 79 | pos := spec.Pos[nextPosArg] 80 | if pos.MinStrCount > 1 { 81 | return lexer.ErrorAt(a, "LoadSpec: string-list required, got single string") 82 | } 83 | if pos.MatchNum != nil { 84 | return lexer.ErrorAt(a, "LoadSpec: argument requires a number, got string-list") 85 | } else if pos.MatchStr != nil { 86 | value := a.Value 87 | if s.RequiresExtension("encoded-character") { 88 | var err error 89 | value, err = decodeEncodedChars(value) 90 | if err != nil { 91 | return lexer.ErrorAt(position, "LoadSpec: malformed encoded character sequence: %v", err) 92 | } 93 | } 94 | 95 | pos.MatchStr([]string{value}) 96 | } else { 97 | panic("no pos matcher") 98 | } 99 | nextPosArg++ 100 | case parser.StringListArg: 101 | if lastTag != nil && lastTag.NeedsValue { 102 | if lastTag.MatchNum != nil { 103 | return lexer.ErrorAt(a, "LoadSpec: tagged argument requires a number, got string-list") 104 | } else if lastTag.MatchStr != nil { 105 | if (lastTag.MinStrCount != 0 && len(a.Value) < lastTag.MinStrCount) || (lastTag.MaxStrCount != 0 && len(a.Value) > lastTag.MaxStrCount) { 106 | return lexer.ErrorAt(a, "LoadSpec: wrong amount of string arguments") 107 | } 108 | 109 | value := a.Value 110 | if s.RequiresExtension("encoded-character") { 111 | for i := range value { 112 | var err error 113 | value[i], err = decodeEncodedChars(value[i]) 114 | if err != nil { 115 | return lexer.ErrorAt(position, "LoadSpec: malformed encoded character sequence: %v", err) 116 | } 117 | } 118 | } 119 | 120 | lastTag.MatchStr(value) 121 | } else { 122 | panic("missing matcher for SpecTag") 123 | } 124 | lastTag = nil 125 | continue 126 | } 127 | 128 | if nextPosArg >= len(spec.Pos) { 129 | return lexer.ErrorAt(a, "LoadSpec: too many arguments") 130 | } 131 | pos := spec.Pos[nextPosArg] 132 | if (pos.MinStrCount != 0 && len(a.Value) < pos.MinStrCount) || (pos.MaxStrCount != 0 && len(a.Value) > pos.MaxStrCount) { 133 | return lexer.ErrorAt(a, "LoadSpec: wrong amount of string arguments") 134 | } 135 | if pos.MatchNum != nil { 136 | return lexer.ErrorAt(a, "LoadSpec: argument requires a number, got string-list") 137 | } else if pos.MatchStr != nil { 138 | value := a.Value 139 | if s.RequiresExtension("encoded-character") { 140 | for i := range value { 141 | var err error 142 | value[i], err = decodeEncodedChars(value[i]) 143 | if err != nil { 144 | return lexer.ErrorAt(position, "LoadSpec: malformed encoded character sequence: %v", err) 145 | } 146 | } 147 | } 148 | 149 | pos.MatchStr(value) 150 | } else { 151 | panic("no pos matcher") 152 | } 153 | nextPosArg++ 154 | case parser.NumberArg: 155 | if lastTag != nil && lastTag.NeedsValue { 156 | if lastTag.MatchStr != nil { 157 | return lexer.ErrorAt(a, "LoadSpec: tagged argument requires a string-list, got number") 158 | } else if lastTag.MatchNum != nil { 159 | lastTag.MatchNum(a.Value) 160 | } else { 161 | panic("missing matcher for SpecTag") 162 | } 163 | lastTag = nil 164 | continue 165 | } 166 | 167 | if nextPosArg >= len(spec.Pos) { 168 | return lexer.ErrorAt(a, "LoadSpec: too many arguments") 169 | } 170 | pos := spec.Pos[nextPosArg] 171 | if pos.MatchStr != nil { 172 | return lexer.ErrorAt(a, "LoadSpec: argument requires a string-list, got number") 173 | } else if pos.MatchNum != nil { 174 | pos.MatchNum(a.Value) 175 | } else { 176 | panic("no pos matcher") 177 | } 178 | nextPosArg++ 179 | case parser.TagArg: 180 | if lastTag != nil && lastTag.NeedsValue { 181 | return lexer.ErrorAt(a, "LoadSpec: tagged argument requires a value") 182 | } 183 | tag, ok := spec.Tags[strings.ToLower(a.Value)] 184 | if !ok { 185 | return lexer.ErrorAt(a, "LoadSpec: unknown tagged argument: %v", a.Value) 186 | } 187 | if tag.NeedsValue { 188 | lastTag = &tag 189 | } else { 190 | tag.MatchBool() 191 | } 192 | } 193 | } 194 | for i := nextPosArg; i < len(spec.Pos); i++ { 195 | if !spec.Pos[i].Optional { 196 | return lexer.ErrorAt(position, "LoadSpec: %d argument is required", i+1) 197 | } 198 | } 199 | 200 | if spec.AddTest == nil { 201 | if len(tests) != 0 { 202 | return lexer.ErrorAt(position, "LoadSpec: no tests allowed") 203 | } 204 | } else { 205 | if len(tests) == 0 && !spec.TestOptional { 206 | return lexer.ErrorAt(position, "LoadSpec: at least one test required") 207 | } 208 | if len(tests) > 1 && !spec.MultipleTests { 209 | return lexer.ErrorAt(position, "LoadSpec: only one test allowed") 210 | } 211 | for _, t := range tests { 212 | loadedTest, err := LoadTest(s, t) 213 | if err != nil { 214 | return err 215 | } 216 | spec.AddTest(loadedTest) 217 | } 218 | } 219 | if spec.AddBlock != nil { 220 | if block != nil { 221 | loadedCmds, err := LoadBlock(s, block) 222 | if err != nil { 223 | return err 224 | } 225 | spec.AddBlock(loadedCmds) 226 | } else if !spec.BlockOptional { 227 | return lexer.ErrorAt(position, "LoadSpec: block is required") 228 | } 229 | } else if block != nil { 230 | return lexer.ErrorAt(position, "LoadSpec: no block allowed") 231 | } 232 | return nil 233 | } 234 | -------------------------------------------------------------------------------- /interp/load_test.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/davecgh/go-spew/spew" 9 | "github.com/foxcpp/go-sieve/lexer" 10 | "github.com/foxcpp/go-sieve/parser" 11 | ) 12 | 13 | func testCmdLoader(t *testing.T, s *Script, in string, out []Cmd) { 14 | t.Run("case", func(t *testing.T) { 15 | toks, err := lexer.Lex(strings.NewReader(in), &lexer.Options{}) 16 | if err != nil { 17 | t.Fatal("Lexer failed:", err) 18 | } 19 | inCmds, err := parser.Parse(lexer.NewStream(toks), &parser.Options{}) 20 | if err != nil { 21 | t.Fatal("Parser failed:", err) 22 | } 23 | 24 | if testing.Verbose() { 25 | t.Log("Parse tree:") 26 | t.Log(spew.Sdump(inCmds)) 27 | } 28 | 29 | actualCmd, err := LoadBlock(s, inCmds) 30 | if err != nil { 31 | if out != nil { 32 | t.Error("Unexpected error:", err) 33 | } 34 | return 35 | } 36 | if out == nil { 37 | t.Error("Unexpected success:", actualCmd) 38 | return 39 | } 40 | if !reflect.DeepEqual(actualCmd, out) { 41 | t.Log("Wrong LoadBlock output") 42 | t.Log("Actual: ", actualCmd) 43 | t.Log("Expected:", out) 44 | t.Fail() 45 | } 46 | }) 47 | } 48 | 49 | func TestLoadBlock(t *testing.T) { 50 | s := &Script{ 51 | extensions: supportedRequires, 52 | } 53 | testCmdLoader(t, s, `require ["envelope"];`, []Cmd{}) 54 | testCmdLoader(t, s, `if true { }`, []Cmd{CmdIf{ 55 | Test: TrueTest{}, 56 | Block: []Cmd{}, 57 | }}) 58 | testCmdLoader(t, s, `require "envelope"; 59 | require "fileinto"; 60 | if envelope :is "from" "test@example.org" { 61 | fileinto "hell"; 62 | } 63 | `, []Cmd{ 64 | CmdIf{ 65 | Test: EnvelopeTest{ 66 | matcherTest: matcherTest{ 67 | comparator: ComparatorASCIICaseMap, 68 | match: MatchIs, 69 | key: []string{"test@example.org"}, 70 | }, 71 | AddressPart: All, 72 | Field: []string{"from"}, 73 | }, 74 | Block: []Cmd{ 75 | CmdFileInto{Mailbox: "hell"}, 76 | }, 77 | }, 78 | }) 79 | testCmdLoader(t, s, `require "imap4flags"; 80 | require "fileinto"; 81 | fileinto :flags "flag1 flag2" "hell"; 82 | keep :flags ["flag1", "flag2"]; 83 | setflag ["flag2", "flag1", "flag2"]; 84 | addflag ["flag2", "flag1"]; 85 | removeflag "flag2"; 86 | `, []Cmd{ 87 | CmdFileInto{ 88 | Mailbox: "hell", 89 | Flags: Flags{"flag1", "flag2"}, 90 | }, 91 | CmdKeep{ 92 | Flags: Flags{"flag1", "flag2"}, 93 | }, 94 | CmdSetFlag{ 95 | Flags: Flags{"flag1", "flag2"}, 96 | }, 97 | CmdAddFlag{ 98 | Flags: Flags{"flag1", "flag2"}, 99 | }, 100 | CmdRemoveFlag{ 101 | Flags: Flags{"flag2"}, 102 | }, 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /interp/load_tests.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/foxcpp/go-sieve/parser" 7 | ) 8 | 9 | func loadAddressTest(s *Script, test parser.Test) (Test, error) { 10 | loaded := AddressTest{ 11 | matcherTest: newMatcherTest(), 12 | AddressPart: All, 13 | } 14 | var key []string 15 | err := LoadSpec(s, loaded.addSpecTags(&Spec{ 16 | Tags: map[string]SpecTag{ 17 | "all": { 18 | MatchBool: func() { 19 | loaded.AddressPart = All 20 | }, 21 | }, 22 | "localpart": { 23 | MatchBool: func() { 24 | loaded.AddressPart = LocalPart 25 | }, 26 | }, 27 | "domain": { 28 | MatchBool: func() { 29 | loaded.AddressPart = Domain 30 | }, 31 | }, 32 | }, 33 | Pos: []SpecPosArg{ 34 | { 35 | MatchStr: func(val []string) { 36 | loaded.Header = val 37 | }, 38 | MinStrCount: 1, 39 | }, 40 | { 41 | MatchStr: func(val []string) { 42 | key = val 43 | }, 44 | MinStrCount: 1, 45 | }, 46 | }, 47 | }), test.Position, test.Args, test.Tests, nil) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | if err := loaded.setKey(s, key); err != nil { 53 | return nil, err 54 | } 55 | 56 | return loaded, nil 57 | } 58 | 59 | func loadAllOfTest(s *Script, test parser.Test) (Test, error) { 60 | loaded := AllOfTest{} 61 | err := LoadSpec(s, &Spec{ 62 | AddTest: func(t Test) { 63 | loaded.Tests = append(loaded.Tests, t) 64 | }, 65 | MultipleTests: true, 66 | }, test.Position, test.Args, test.Tests, nil) 67 | return loaded, err 68 | } 69 | 70 | func loadAnyOfTest(s *Script, test parser.Test) (Test, error) { 71 | loaded := AnyOfTest{} 72 | err := LoadSpec(s, &Spec{ 73 | AddTest: func(t Test) { 74 | loaded.Tests = append(loaded.Tests, t) 75 | }, 76 | MultipleTests: true, 77 | }, test.Position, test.Args, test.Tests, nil) 78 | return loaded, err 79 | } 80 | 81 | func loadEnvelopeTest(s *Script, test parser.Test) (Test, error) { 82 | if !s.RequiresExtension("envelope") { 83 | return nil, fmt.Errorf("missing require 'envelope'") 84 | } 85 | 86 | loaded := EnvelopeTest{ 87 | matcherTest: newMatcherTest(), 88 | AddressPart: All, 89 | } 90 | var key []string 91 | err := LoadSpec(s, loaded.addSpecTags(&Spec{ 92 | Tags: map[string]SpecTag{ 93 | "all": { 94 | MatchBool: func() { 95 | loaded.AddressPart = All 96 | }, 97 | }, 98 | "localpart": { 99 | MatchBool: func() { 100 | loaded.AddressPart = LocalPart 101 | }, 102 | }, 103 | "domain": { 104 | MatchBool: func() { 105 | loaded.AddressPart = Domain 106 | }, 107 | }, 108 | }, 109 | Pos: []SpecPosArg{ 110 | { 111 | MatchStr: func(val []string) { 112 | loaded.Field = val 113 | }, 114 | MinStrCount: 1, 115 | }, 116 | { 117 | MatchStr: func(val []string) { 118 | key = val 119 | }, 120 | MinStrCount: 1, 121 | }, 122 | }, 123 | }), test.Position, test.Args, test.Tests, nil) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | if err := loaded.setKey(s, key); err != nil { 129 | return nil, err 130 | } 131 | 132 | return loaded, nil 133 | } 134 | 135 | func loadExistsTest(s *Script, test parser.Test) (Test, error) { 136 | loaded := ExistsTest{} 137 | err := LoadSpec(s, &Spec{ 138 | Pos: []SpecPosArg{ 139 | { 140 | MatchStr: func(val []string) { 141 | loaded.Fields = val 142 | }, 143 | MinStrCount: 1, 144 | }, 145 | }, 146 | }, test.Position, test.Args, test.Tests, nil) 147 | return loaded, err 148 | } 149 | 150 | func loadFalseTest(s *Script, test parser.Test) (Test, error) { 151 | loaded := FalseTest{} 152 | err := LoadSpec(s, &Spec{}, test.Position, test.Args, test.Tests, nil) 153 | return loaded, err 154 | } 155 | 156 | func loadTrueTest(s *Script, test parser.Test) (Test, error) { 157 | loaded := TrueTest{} 158 | err := LoadSpec(s, &Spec{}, test.Position, test.Args, test.Tests, nil) 159 | return loaded, err 160 | } 161 | 162 | func loadHeaderTest(s *Script, test parser.Test) (Test, error) { 163 | loaded := HeaderTest{matcherTest: newMatcherTest()} 164 | var key []string 165 | err := LoadSpec(s, loaded.addSpecTags(&Spec{ 166 | Pos: []SpecPosArg{ 167 | { 168 | MatchStr: func(val []string) { 169 | loaded.Header = val 170 | }, 171 | MinStrCount: 1, 172 | }, 173 | { 174 | MatchStr: func(val []string) { 175 | key = val 176 | }, 177 | MinStrCount: 1, 178 | }, 179 | }, 180 | }), test.Position, test.Args, test.Tests, nil) 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | if err := loaded.setKey(s, key); err != nil { 186 | return nil, err 187 | } 188 | 189 | return loaded, nil 190 | } 191 | 192 | func loadNotTest(s *Script, test parser.Test) (Test, error) { 193 | loaded := NotTest{} 194 | err := LoadSpec(s, &Spec{ 195 | AddTest: func(t Test) { 196 | loaded.Test = t 197 | }, 198 | }, test.Position, test.Args, test.Tests, nil) 199 | return loaded, err 200 | } 201 | 202 | func loadSizeTest(s *Script, test parser.Test) (Test, error) { 203 | loaded := SizeTest{} 204 | err := LoadSpec(s, &Spec{ 205 | Tags: map[string]SpecTag{ 206 | "under": { 207 | MatchBool: func() { loaded.Under = true }, 208 | }, 209 | "over": { 210 | MatchBool: func() { loaded.Over = true }, 211 | }, 212 | }, 213 | Pos: []SpecPosArg{ 214 | { 215 | MatchNum: func(i int) { 216 | loaded.Size = i 217 | }, 218 | }, 219 | }, 220 | }, test.Position, test.Args, test.Tests, nil) 221 | if loaded.Under == loaded.Over { 222 | return nil, fmt.Errorf("loadSizeTest: either under or over is required") 223 | } 224 | return loaded, err 225 | } 226 | -------------------------------------------------------------------------------- /interp/load_variables.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/foxcpp/go-sieve/parser" 9 | ) 10 | 11 | func loadSet(script *Script, pcmd parser.Cmd) (Cmd, error) { 12 | if !script.RequiresExtension("variables") { 13 | return nil, parser.ErrorAt(pcmd.Position, "missing require 'variables'") 14 | } 15 | cmd := CmdSet{} 16 | 17 | // by precedence 18 | var modifiers = map[int]func(string) string{} 19 | var conflictingMods bool 20 | 21 | err := LoadSpec(script, &Spec{ 22 | Tags: map[string]SpecTag{ 23 | "length": { 24 | MatchBool: func() { 25 | if modifiers[10] != nil { 26 | conflictingMods = true 27 | } 28 | modifiers[10] = func(s string) string { 29 | // RFC mentions `characters' and not octets 30 | return strconv.Itoa(len([]rune(s))) 31 | } 32 | }, 33 | }, 34 | "quotewildcard": { 35 | MatchBool: func() { 36 | if modifiers[20] != nil { 37 | conflictingMods = true 38 | } 39 | modifiers[20] = func(s string) string { 40 | escaped := strings.Builder{} 41 | escaped.Grow(len(s)) 42 | for _, chr := range s { 43 | switch chr { 44 | case '\\', '*', '?': 45 | escaped.WriteByte('\\') 46 | escaped.WriteRune(chr) 47 | default: 48 | escaped.WriteRune(chr) 49 | } 50 | } 51 | return escaped.String() 52 | } 53 | }, 54 | }, 55 | "upper": { 56 | MatchBool: func() { 57 | if modifiers[40] != nil { 58 | conflictingMods = true 59 | } 60 | modifiers[40] = func(s string) string { 61 | return strings.ToUpper(s) 62 | } 63 | }, 64 | }, 65 | "lower": { 66 | MatchBool: func() { 67 | if modifiers[40] != nil { 68 | conflictingMods = true 69 | } 70 | modifiers[40] = func(s string) string { 71 | return strings.ToLower(s) 72 | } 73 | }, 74 | }, 75 | "upperfirst": { 76 | MatchBool: func() { 77 | if modifiers[30] != nil { 78 | conflictingMods = true 79 | } 80 | modifiers[30] = func(s string) string { 81 | if len(s) == 0 { 82 | return s 83 | } 84 | first := s[0] 85 | if first >= 'a' && first <= 'z' { 86 | first -= 'a' - 'A' 87 | } 88 | return string(first) + s[1:] 89 | } 90 | }, 91 | }, 92 | "lowerfirst": { 93 | MatchBool: func() { 94 | if modifiers[30] != nil { 95 | conflictingMods = true 96 | } 97 | modifiers[30] = func(s string) string { 98 | if len(s) == 0 { 99 | return s 100 | } 101 | first := s[0] 102 | if first >= 'A' && first <= 'Z' { 103 | first += 'a' - 'A' 104 | } 105 | return string(first) + s[1:] 106 | } 107 | }, 108 | }, 109 | }, 110 | Pos: []SpecPosArg{ 111 | { 112 | MinStrCount: 1, 113 | MaxStrCount: 1, 114 | MatchStr: func(val []string) { 115 | cmd.Name = strings.ToLower(val[0]) 116 | }, 117 | }, 118 | { 119 | MinStrCount: 1, 120 | MaxStrCount: 1, 121 | MatchStr: func(val []string) { 122 | cmd.Value = val[0] 123 | }, 124 | }, 125 | }, 126 | }, pcmd.Position, pcmd.Args, pcmd.Tests, pcmd.Block) 127 | 128 | if conflictingMods { 129 | return nil, parser.ErrorAt(pcmd.Position, "conflicting value modifiers") 130 | } 131 | 132 | settable, _ := script.IsVarUsable(cmd.Name) 133 | if !settable { 134 | return nil, parser.ErrorAt(pcmd.Position, "cannot set this variable") 135 | } 136 | 137 | cmd.ModifyValue = func(s string) string { 138 | lastPrec := 9999 139 | for _, prec := range [4]int{40, 30, 20, 10} { 140 | fun := modifiers[prec] 141 | if fun != nil { 142 | s = fun(s) 143 | lastPrec = prec 144 | } 145 | } 146 | 147 | // If last run modifier was quotewildcard - check 148 | // whether created value would remain valid 149 | // if truncated to MaxVariableLen. If so, truncate 150 | // here and remove dangling backslashes (if any). 151 | if lastPrec == 20 { 152 | if len(s) > script.opts.MaxVariableLen { 153 | until := script.opts.MaxVariableLen 154 | 155 | // (Copy-pasted from RuntimeData.SetVar) 156 | // If this truncated an otherwise valid Unicode character, 157 | // remove the character altogether. 158 | for until > 0 && s[until] >= 128 && s[until] < 192 /* second or further octet of UTF-8 encoding */ { 159 | until-- 160 | } 161 | 162 | if s[until-1] == '\\' { 163 | until-- 164 | } 165 | 166 | s = s[:until] 167 | } 168 | } 169 | 170 | return s 171 | } 172 | 173 | return cmd, err 174 | } 175 | 176 | func loadStringTest(s *Script, test parser.Test) (Test, error) { 177 | if !s.RequiresExtension("variables") { 178 | return nil, fmt.Errorf("missing require 'variables'") 179 | } 180 | 181 | loaded := TestString{matcherTest: newMatcherTest()} 182 | var key []string 183 | err := LoadSpec(s, loaded.addSpecTags(&Spec{ 184 | Pos: []SpecPosArg{ 185 | { 186 | MatchStr: func(val []string) { 187 | loaded.Source = val 188 | }, 189 | MinStrCount: 1, 190 | }, 191 | { 192 | MatchStr: func(val []string) { 193 | key = val 194 | }, 195 | MinStrCount: 1, 196 | }, 197 | }, 198 | }), test.Position, test.Args, test.Tests, nil) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | if err := loaded.setKey(s, key); err != nil { 204 | return nil, err 205 | } 206 | 207 | return loaded, nil 208 | } 209 | -------------------------------------------------------------------------------- /interp/match.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "rsc.io/binaryregexp" 8 | ) 9 | 10 | func foldASCII(b byte) byte { 11 | if b >= 'A' && b <= 'Z' { 12 | return b + 32 13 | } 14 | return b 15 | } 16 | 17 | func patternToRegex(pattern string, caseFold bool) string { 18 | result := strings.Builder{} 19 | if caseFold { 20 | result.WriteString(`(?i)`) 21 | } 22 | result.WriteRune('^') 23 | escaped := false 24 | for _, chr := range pattern { 25 | if !escaped { 26 | switch chr { 27 | case '\\': 28 | escaped = true 29 | case '?': 30 | result.WriteString(`(.)`) 31 | case '*': 32 | result.WriteString(`(.*?)`) 33 | case '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$': 34 | result.WriteRune('\\') 35 | fallthrough 36 | default: 37 | result.WriteRune(chr) 38 | } 39 | } else { 40 | switch chr { 41 | case '\\', '?', '*', '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$': 42 | result.WriteRune('\\') 43 | fallthrough 44 | default: 45 | result.WriteRune(chr) 46 | } 47 | 48 | escaped = false 49 | } 50 | } 51 | 52 | // Such regex won't compile. 53 | if escaped { 54 | return result.String() 55 | } 56 | 57 | result.WriteRune('$') 58 | 59 | return result.String() 60 | } 61 | 62 | type CompiledMatcher func(value string) (bool, []string, error) 63 | 64 | // compileMatcher returns a function that will check whether pre-defined pattern matches the passed 65 | // value. It is preferable to use compileMatcher over matchOctet, matchUnicode if 66 | // pattern does not change often (e.g. does not depend on any variables). 67 | func compileMatcher(pattern string, octet bool, caseFold bool) (CompiledMatcher, error) { 68 | if octet { 69 | regex, err := binaryregexp.Compile(patternToRegex(pattern, caseFold)) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return func(value string) (bool, []string, error) { 75 | matches := regex.FindStringSubmatch(value) 76 | return len(matches) != 0, matches, nil 77 | }, nil 78 | } 79 | 80 | regex, err := regexp.Compile(patternToRegex(pattern, caseFold)) 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | return func(value string) (bool, []string, error) { 86 | matches := regex.FindStringSubmatch(value) 87 | return len(matches) != 0, matches, nil 88 | }, nil 89 | } 90 | 91 | func matchOctet(pattern, value string, caseFold bool) (bool, []string, error) { 92 | regex, err := binaryregexp.Compile(patternToRegex(pattern, caseFold)) 93 | if err != nil { 94 | return false, nil, err 95 | } 96 | 97 | matches := regex.FindStringSubmatch(value) 98 | return len(matches) != 0, matches, nil 99 | } 100 | 101 | func matchUnicode(pattern, value string, caseFold bool) (bool, []string, error) { 102 | regex, err := regexp.Compile(patternToRegex(pattern, caseFold)) 103 | if err != nil { 104 | return false, nil, err 105 | } 106 | 107 | matches := regex.FindStringSubmatch(value) 108 | return len(matches) != 0, matches, nil 109 | } 110 | -------------------------------------------------------------------------------- /interp/matchertest.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // matcherTest contains code shared between tests 9 | // such as 'header', 'address', 'envelope', 'string' - 10 | // all tests that compare some values from message 11 | // with pre-defined "key" 12 | type matcherTest struct { 13 | comparator Comparator 14 | match Match 15 | relational Relational 16 | key []string 17 | 18 | // Used for keys without variables. 19 | keyCompiled []CompiledMatcher 20 | 21 | matchCnt int 22 | } 23 | 24 | func newMatcherTest() matcherTest { 25 | return matcherTest{ 26 | comparator: DefaultComparator, 27 | match: MatchIs, 28 | } 29 | } 30 | 31 | func (t *matcherTest) addSpecTags(s *Spec) *Spec { 32 | if s.Tags == nil { 33 | s.Tags = make(map[string]SpecTag, 4) 34 | } 35 | s.Tags["comparator"] = SpecTag{ 36 | NeedsValue: true, 37 | MinStrCount: 1, 38 | MaxStrCount: 1, 39 | MatchStr: func(val []string) { 40 | t.comparator = Comparator(val[0]) 41 | }, 42 | NoVariables: true, 43 | } 44 | s.Tags["is"] = SpecTag{ 45 | MatchBool: func() { 46 | t.match = MatchIs 47 | t.matchCnt++ 48 | }, 49 | } 50 | s.Tags["contains"] = SpecTag{ 51 | MatchBool: func() { 52 | t.match = MatchContains 53 | t.matchCnt++ 54 | }, 55 | } 56 | s.Tags["matches"] = SpecTag{ 57 | MatchBool: func() { 58 | t.match = MatchMatches 59 | t.matchCnt++ 60 | }, 61 | } 62 | s.Tags["value"] = SpecTag{ 63 | NeedsValue: true, 64 | MinStrCount: 1, 65 | MaxStrCount: 1, 66 | NoVariables: true, 67 | MatchStr: func(val []string) { 68 | t.match = MatchValue 69 | t.matchCnt++ 70 | t.relational = Relational(val[0]) 71 | }, 72 | } 73 | s.Tags["count"] = SpecTag{ 74 | NeedsValue: true, 75 | MinStrCount: 1, 76 | MaxStrCount: 1, 77 | NoVariables: true, 78 | MatchStr: func(val []string) { 79 | t.match = MatchCount 80 | t.matchCnt++ 81 | t.relational = Relational(val[0]) 82 | }, 83 | } 84 | return s 85 | } 86 | 87 | func (t *matcherTest) setKey(s *Script, k []string) error { 88 | t.key = k 89 | 90 | if t.matchCnt > 1 { 91 | return fmt.Errorf("multiple match-types are not allowed") 92 | } 93 | 94 | if t.match == MatchCount || t.match == MatchValue { 95 | if !s.RequiresExtension("relational") { 96 | return fmt.Errorf("missing require 'relational'") 97 | } 98 | switch t.relational { 99 | case RelGreaterThan, RelGreaterOrEqual, 100 | RelLessThan, RelLessOrEqual, RelEqual, 101 | RelNotEqual: 102 | default: 103 | return fmt.Errorf("unknown relational operator: %v", t.relational) 104 | } 105 | } 106 | 107 | caseFold := false 108 | octet := false 109 | switch t.comparator { 110 | case ComparatorOctet: 111 | octet = true 112 | case ComparatorUnicodeCaseMap: 113 | caseFold = true 114 | case ComparatorASCIICaseMap: 115 | octet = true 116 | caseFold = true 117 | case ComparatorASCIINumeric: 118 | default: 119 | return fmt.Errorf("unsupported comparator: %v", t.comparator) 120 | } 121 | 122 | if t.match == MatchMatches { 123 | t.keyCompiled = make([]CompiledMatcher, len(t.key)) 124 | for i := range t.key { 125 | if len(usedVars(s, t.key[i])) > 0 { 126 | continue 127 | } 128 | 129 | var err error 130 | t.keyCompiled[i], err = compileMatcher(t.key[i], octet, caseFold) 131 | if err != nil { 132 | return fmt.Errorf("malformed pattern (%v): %v", t.key[i], err) 133 | } 134 | } 135 | } 136 | 137 | if t.match == MatchCount && t.comparator != ComparatorASCIINumeric { 138 | return fmt.Errorf("non-numeric comparators cannot be used with :count") 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (t *matcherTest) isCount() bool { 145 | return t.match == MatchCount 146 | } 147 | 148 | func (t *matcherTest) countMatches(d *RuntimeData, value uint64) bool { 149 | if !t.isCount() { 150 | panic("countMatches can be called only with MatchCount matcher") 151 | } 152 | 153 | for _, k := range t.key { 154 | kNum, err := strconv.ParseUint(expandVars(d, k), 10, 64) 155 | if err != nil { 156 | continue 157 | } 158 | 159 | if t.relational.CompareUint64(value, kNum) { 160 | return true 161 | } 162 | } 163 | 164 | return false 165 | } 166 | 167 | func (t *matcherTest) tryMatch(d *RuntimeData, source string) (bool, error) { 168 | for i, key := range t.key { 169 | var ( 170 | ok bool 171 | matches []string 172 | err error 173 | ) 174 | if t.keyCompiled != nil && t.keyCompiled[i] != nil { 175 | ok, matches, err = t.keyCompiled[i](source) 176 | } else { 177 | key = expandVars(d, key) 178 | ok, matches, err = testString(t.comparator, t.match, t.relational, source, expandVars(d, key)) 179 | } 180 | if err != nil { 181 | return false, err 182 | } 183 | if ok { 184 | if t.match == MatchMatches { 185 | d.MatchVariables = matches 186 | } 187 | return true, nil 188 | } 189 | } 190 | return false, nil 191 | } 192 | -------------------------------------------------------------------------------- /interp/message_static.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "net/textproto" 6 | ) 7 | 8 | type DummyPolicy struct { 9 | } 10 | 11 | func (d DummyPolicy) RedirectAllowed(_ context.Context, _ *RuntimeData, _ string) (bool, error) { 12 | return true, nil 13 | } 14 | 15 | type MessageHeader interface { 16 | Values(key string) []string 17 | Set(key, value string) 18 | Del(key string) 19 | } 20 | 21 | var ( 22 | _ MessageHeader = textproto.MIMEHeader{} 23 | ) 24 | 25 | type EnvelopeStatic struct { 26 | From string 27 | To string 28 | Auth string 29 | } 30 | 31 | func (m EnvelopeStatic) EnvelopeFrom() string { 32 | return m.From 33 | } 34 | 35 | func (m EnvelopeStatic) EnvelopeTo() string { 36 | return m.To 37 | } 38 | 39 | func (m EnvelopeStatic) AuthUsername() string { 40 | return m.Auth 41 | } 42 | 43 | // MessageStatic is a simple Message interface implementation 44 | // that just keeps all data in memory in a Go struct. 45 | type MessageStatic struct { 46 | Size int 47 | Header MessageHeader 48 | } 49 | 50 | func (m MessageStatic) HeaderGet(key string) ([]string, error) { 51 | return m.Header.Values(key), nil 52 | } 53 | 54 | func (m MessageStatic) MessageSize() int { 55 | return m.Size 56 | } 57 | -------------------------------------------------------------------------------- /interp/relational.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | type Relational string 4 | 5 | const ( 6 | RelGreaterThan Relational = "gt" 7 | RelGreaterOrEqual Relational = "ge" 8 | RelLessThan Relational = "lt" 9 | RelLessOrEqual Relational = "le" 10 | RelEqual Relational = "eq" 11 | RelNotEqual Relational = "ne" 12 | ) 13 | 14 | func (r Relational) CompareString(lhs, rhs string) bool { 15 | switch r { 16 | case RelGreaterThan: 17 | return lhs > rhs 18 | case RelGreaterOrEqual: 19 | return lhs >= rhs 20 | case RelLessThan: 21 | return lhs < rhs 22 | case RelLessOrEqual: 23 | return lhs <= rhs 24 | case RelEqual: 25 | return lhs == rhs 26 | case RelNotEqual: 27 | return lhs != rhs 28 | } 29 | return false 30 | } 31 | 32 | func (r Relational) CompareUint64(lhs, rhs uint64) bool { 33 | switch r { 34 | case RelGreaterThan: 35 | return lhs > rhs 36 | case RelGreaterOrEqual: 37 | return lhs >= rhs 38 | case RelLessThan: 39 | return lhs < rhs 40 | case RelLessOrEqual: 41 | return lhs <= rhs 42 | case RelEqual: 43 | return lhs == rhs 44 | case RelNotEqual: 45 | return lhs != rhs 46 | } 47 | return false 48 | } 49 | 50 | func (r Relational) CompareNumericValue(lhs, rhs *uint64) bool { 51 | // https://www.rfc-editor.org/rfc/rfc4790.html#section-9.1 52 | // nil (string not starting with a digit) 53 | // represents positive infinity. inf == inf. inf > any integer. 54 | 55 | switch r { 56 | case RelGreaterThan: 57 | if lhs == nil { 58 | if rhs == nil { 59 | return false 60 | } 61 | return true 62 | } 63 | if rhs == nil { 64 | return false 65 | } 66 | return *lhs > *rhs 67 | case RelGreaterOrEqual: 68 | return !RelLessThan.CompareNumericValue(lhs, rhs) 69 | case RelLessThan: 70 | if rhs == nil { 71 | if lhs == nil { 72 | return false 73 | } 74 | return true 75 | } 76 | if lhs == nil { 77 | return false 78 | } 79 | return *lhs < *rhs 80 | case RelLessOrEqual: 81 | return !RelGreaterThan.CompareNumericValue(lhs, rhs) 82 | case RelEqual: 83 | if lhs == nil && rhs == nil { 84 | return true 85 | } 86 | if lhs != nil && rhs != nil { 87 | return *lhs == *rhs 88 | } 89 | return false 90 | case RelNotEqual: 91 | return !RelEqual.CompareNumericValue(lhs, rhs) 92 | } 93 | return false 94 | } 95 | -------------------------------------------------------------------------------- /interp/runtime.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/fs" 7 | "strings" 8 | 9 | "github.com/foxcpp/go-sieve/lexer" 10 | ) 11 | 12 | type PolicyReader interface { 13 | RedirectAllowed(ctx context.Context, d *RuntimeData, addr string) (bool, error) 14 | } 15 | 16 | type Envelope interface { 17 | EnvelopeFrom() string 18 | EnvelopeTo() string 19 | AuthUsername() string 20 | } 21 | 22 | type Message interface { 23 | /* 24 | HeaderGet returns the header field value. 25 | 26 | RFC requires the following handling for encoded fields: 27 | 28 | Comparisons are performed on octets. Implementations convert text 29 | from header fields in all charsets [MIME3] to Unicode, encoded as 30 | UTF-8, as input to the comparator (see section 2.7.3). 31 | Implementations MUST be capable of converting US-ASCII, ISO-8859- 32 | 1, the US-ASCII subset of ISO-8859-* character sets, and UTF-8. 33 | Text that the implementation cannot convert to Unicode for any 34 | reason MAY be treated as plain US-ASCII (including any [MIME3] 35 | syntax) or processed according to local conventions. An encoded 36 | NUL octet (character zero) SHOULD NOT cause early termination of 37 | the header content being compared against. 38 | */ 39 | HeaderGet(key string) ([]string, error) 40 | MessageSize() int 41 | } 42 | 43 | type RuntimeData struct { 44 | Policy PolicyReader 45 | Envelope Envelope 46 | Msg Message 47 | Script *Script 48 | // For files accessible vis "include", "test_script_compile", etc. 49 | Namespace fs.FS 50 | 51 | ifResult bool 52 | 53 | RedirectAddr []string 54 | Mailboxes []string 55 | Flags []string 56 | Keep bool 57 | ImplicitKeep bool 58 | 59 | FlagAliases map[string]string 60 | 61 | MatchVariables []string 62 | Variables map[string]string 63 | 64 | // vnd.dovecot.testsuit state 65 | testName string 66 | testFailMessage string // if set - test failed. 67 | testFailAt lexer.Position 68 | testScript *Script // script loaded using test_script_compile 69 | testMaxNesting int // max nesting for scripts loaded using test_script_compile 70 | } 71 | 72 | func (d *RuntimeData) Copy() *RuntimeData { 73 | newData := &RuntimeData{ 74 | Policy: d.Policy, 75 | Envelope: d.Envelope, 76 | Msg: d.Msg, 77 | Script: d.Script, 78 | Namespace: d.Namespace, 79 | RedirectAddr: make([]string, len(d.RedirectAddr)), 80 | Mailboxes: make([]string, len(d.Mailboxes)), 81 | Flags: make([]string, len(d.Flags)), 82 | Keep: d.Keep, 83 | ImplicitKeep: d.ImplicitKeep, 84 | FlagAliases: make(map[string]string, len(d.FlagAliases)), 85 | MatchVariables: make([]string, len(d.MatchVariables)), 86 | Variables: make(map[string]string, len(d.Variables)), 87 | testName: d.testName, 88 | testFailMessage: d.testFailMessage, 89 | testFailAt: d.testFailAt, 90 | testScript: d.testScript, 91 | testMaxNesting: d.testMaxNesting, 92 | } 93 | 94 | copy(newData.RedirectAddr, d.RedirectAddr) 95 | copy(newData.Mailboxes, d.Mailboxes) 96 | copy(newData.Flags, d.Flags) 97 | copy(newData.MatchVariables, d.MatchVariables) 98 | 99 | for k, v := range d.FlagAliases { 100 | newData.FlagAliases[k] = v 101 | } 102 | for k, v := range d.Variables { 103 | newData.Variables[k] = v 104 | } 105 | 106 | return newData 107 | } 108 | 109 | func (d *RuntimeData) MatchVariable(i int) string { 110 | if i >= len(d.MatchVariables) { 111 | return "" 112 | } 113 | return d.MatchVariables[i] 114 | } 115 | 116 | func (d *RuntimeData) Var(name string) (string, error) { 117 | namespace, name, ok := strings.Cut(strings.ToLower(name), ".") 118 | if !ok { 119 | name = namespace 120 | namespace = "" 121 | } 122 | 123 | switch namespace { 124 | case "envelope": 125 | // > References to namespaces without a prior require statement for the 126 | // > relevant extension MUST cause an error. 127 | if !d.Script.RequiresExtension("envelope") { 128 | return "", fmt.Errorf("require 'envelope' to use corresponding variables") 129 | } 130 | switch name { 131 | case "from": 132 | return d.Envelope.EnvelopeFrom(), nil 133 | case "to": 134 | return d.Envelope.EnvelopeTo(), nil 135 | case "auth": 136 | return d.Envelope.AuthUsername(), nil 137 | default: 138 | return "", nil 139 | } 140 | case "": 141 | // User variables. 142 | return d.Variables[name], nil 143 | default: 144 | return "", fmt.Errorf("unknown extension variable: %v", name) 145 | } 146 | } 147 | 148 | func (d *RuntimeData) SetVar(name, value string) error { 149 | if len(name) > d.Script.opts.MaxVariableNameLen { 150 | return fmt.Errorf("attempting to use a too long variable name: %v", name) 151 | } 152 | if len(value) > d.Script.opts.MaxVariableLen { 153 | until := d.Script.opts.MaxVariableLen 154 | // If this truncated an otherwise valid Unicode character, 155 | // remove the character altogether. 156 | for until > 0 && value[until] >= 128 && value[until] < 192 /* second or further octet of UTF-8 encoding */ { 157 | until-- 158 | } 159 | 160 | value = value[:until] 161 | 162 | } 163 | 164 | namespace, name, ok := strings.Cut(strings.ToLower(name), ".") 165 | if !ok { 166 | name = namespace 167 | namespace = "" 168 | } 169 | 170 | switch namespace { 171 | case "envelope": 172 | return fmt.Errorf("cannot modify envelope. variables") 173 | case "": 174 | // User variables. 175 | d.Variables[name] = value 176 | return nil 177 | default: 178 | return fmt.Errorf("unknown extension variable: %v", name) 179 | } 180 | } 181 | 182 | func NewRuntimeData(s *Script, p PolicyReader, e Envelope, m Message) *RuntimeData { 183 | return &RuntimeData{ 184 | Script: s, 185 | Policy: p, 186 | Envelope: e, 187 | Msg: m, 188 | ImplicitKeep: true, 189 | FlagAliases: make(map[string]string), 190 | Variables: map[string]string{}, 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /interp/script.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/foxcpp/go-sieve/lexer" 10 | ) 11 | 12 | type Cmd interface { 13 | Execute(ctx context.Context, d *RuntimeData) error 14 | } 15 | 16 | type Options struct { 17 | MaxRedirects int 18 | 19 | MaxVariableCount int 20 | MaxVariableNameLen int 21 | MaxVariableLen int 22 | 23 | // If specified - enables vnd.dovecot.testsuite extension 24 | // and will execute tests. 25 | T *testing.T 26 | DisabledTests []string 27 | } 28 | 29 | type Script struct { 30 | extensions map[string]struct{} 31 | cmd []Cmd 32 | 33 | opts *Options 34 | } 35 | 36 | var ErrStop = errors.New("interpreter: stop called") 37 | 38 | func (s Script) Extensions() []string { 39 | exts := make([]string, 0, len(s.extensions)) 40 | for ext := range s.extensions { 41 | exts = append(exts, ext) 42 | } 43 | return exts 44 | } 45 | 46 | func (s Script) RequiresExtension(name string) bool { 47 | _, ok := s.extensions[name] 48 | return ok 49 | } 50 | 51 | func (s *Script) IsVarUsable(variableName string) (settable, gettable bool) { 52 | if len(variableName) > s.opts.MaxVariableNameLen { 53 | return false, false 54 | } 55 | 56 | namespace, name, ok := strings.Cut(strings.ToLower(variableName), ".") 57 | if !ok { 58 | name = namespace 59 | namespace = "" 60 | } 61 | 62 | if !lexer.IsValidIdentifier(name) { 63 | return false, false 64 | } 65 | 66 | switch namespace { 67 | case "envelope": 68 | if !s.RequiresExtension("envelope") { 69 | return false, false 70 | } 71 | return false, true 72 | case "": 73 | return true, true 74 | default: 75 | return false, false 76 | } 77 | } 78 | 79 | func (s Script) Execute(ctx context.Context, d *RuntimeData) error { 80 | for _, c := range s.cmd { 81 | if err := c.Execute(ctx, d); err != nil { 82 | if errors.Is(err, ErrStop) { 83 | return nil 84 | } 85 | return err 86 | } 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /interp/strings.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | /* 12 | blank = WSP / CRLF 13 | encoded-arb-octets = "${hex:" hex-pair-seq "}" 14 | hex-pair-seq = *blank hex-pair *(1*blank hex-pair) *blank 15 | hex-pair = 1*2HEXDIG 16 | 17 | This whitespace handling is giving me headaches. [ \t\r\n]* is "blank" above. 18 | */ 19 | var encodedHexRegex = regexp.MustCompile( 20 | `(?i)\${(` + 21 | `hex:[ \t\r\n]*([0-9a-f]{1,2}(?:[ \t\r\n]+[0-9a-f]{1,2})*)[ \t\r\n]*|` + 22 | `unicode:[ \t\r\n]*([0-9a-f]+(?:[ \t\r\n]+[0-9a-f]+)*)[ \t\r\n]*)}`) 23 | 24 | var removeWSP = strings.NewReplacer( 25 | "\t", "", "\r", "", "\n", "", " ", "", 26 | ) 27 | 28 | var normalizeWSP = strings.NewReplacer( 29 | "\t", "", "\r", "", "\n", "", 30 | ) 31 | 32 | func decodeEncodedChars(s string) (string, error) { 33 | var lastErr error 34 | decoded := encodedHexRegex.ReplaceAllStringFunc(s, func(match string) string { 35 | if strings.HasPrefix(strings.ToLower(match), "${hex:") { 36 | hexString := removeWSP.Replace(match[6 : len(match)-1]) 37 | decoded, err := hex.DecodeString(hexString) 38 | if err != nil { 39 | lastErr = err 40 | return "" 41 | } 42 | return string(decoded) 43 | } 44 | 45 | cpString := strings.Split(normalizeWSP.Replace(match[10:len(match)-1]), " ") 46 | replacement := strings.Builder{} 47 | replacement.Grow(len(cpString)) 48 | for _, part := range cpString { 49 | if part != "" { 50 | value, err := strconv.ParseInt(part, 16, 32) 51 | if err != nil { 52 | lastErr = err 53 | return "" 54 | } 55 | /* 56 | RFC 5228 Section 2.4.2.4: 57 | It is an error for a script to use a hexadecimal value that isn't in 58 | either the range 0 to D7FF or the range E000 to 10FFFF. (The range 59 | D800 to DFFF is excluded as those character numbers are only used as 60 | part of the UTF-16 encoding form and are not applicable to the UTF-8 61 | encoding that the syntax here represents.) 62 | */ 63 | if !(value >= 0 && value <= 0xD7FF) && !(value >= 0xE000 && value <= 0x10FFFF) { 64 | lastErr = fmt.Errorf("encoded unicode keypoint is out of range") 65 | return "" 66 | } 67 | replacement.WriteRune(rune(value)) 68 | } 69 | } 70 | return replacement.String() 71 | }) 72 | if lastErr != nil { 73 | return "", lastErr 74 | } 75 | return decoded, nil 76 | } 77 | -------------------------------------------------------------------------------- /interp/test.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/emersion/go-message/mail" 9 | ) 10 | 11 | type Test interface { 12 | Check(ctx context.Context, d *RuntimeData) (bool, error) 13 | } 14 | 15 | type AddressTest struct { 16 | matcherTest 17 | 18 | AddressPart AddressPart 19 | Header []string 20 | } 21 | 22 | var allowedAddrHeaders = map[string]struct{}{ 23 | // Required by Sieve. 24 | "from": {}, 25 | "to": {}, 26 | "cc": {}, 27 | "bcc": {}, 28 | "sender": {}, 29 | "resent-from": {}, 30 | "resent-to": {}, 31 | // Misc (RFC 2822) 32 | "reply-to": {}, 33 | "resent-reply-to": {}, 34 | "resent-sender": {}, 35 | "resent-cc": {}, 36 | "resent-bcc": {}, 37 | // Non-standard (RFC 2076, draft-palme-mailext-headers-08.txt) 38 | "for-approval": {}, 39 | "for-handling": {}, 40 | "for-comment": {}, 41 | "apparently-to": {}, 42 | "errors-to": {}, 43 | "delivered-to": {}, 44 | "return-receipt-to": {}, 45 | "x-admin": {}, 46 | "read-receipt-to": {}, 47 | "x-confirm-reading-to": {}, 48 | "return-receipt-requested": {}, 49 | "registered-mail-reply-requested-by": {}, 50 | "mail-followup-to": {}, 51 | "mail-reply-to": {}, 52 | "abuse-reports-to": {}, 53 | "x-complaints-to": {}, 54 | "x-report-abuse-to": {}, 55 | "x-beenthere": {}, 56 | "x-original-from": {}, 57 | "x-original-to": {}, 58 | } 59 | 60 | func (a AddressTest) Check(_ context.Context, d *RuntimeData) (bool, error) { 61 | entryCount := uint64(0) 62 | for _, hdr := range a.Header { 63 | hdr = strings.ToLower(hdr) 64 | hdr = expandVars(d, hdr) 65 | 66 | if _, ok := allowedAddrHeaders[hdr]; !ok { 67 | continue 68 | } 69 | 70 | values, err := d.Msg.HeaderGet(hdr) 71 | if err != nil { 72 | return false, err 73 | } 74 | 75 | for _, value := range values { 76 | addrList, err := mail.ParseAddressList(value) 77 | if err != nil { 78 | return false, nil 79 | } 80 | 81 | for _, addr := range addrList { 82 | if a.isCount() { 83 | entryCount++ 84 | continue 85 | } 86 | 87 | ok, err := testAddress(d, a.matcherTest, a.AddressPart, addr.Address) 88 | if err != nil { 89 | return false, err 90 | } 91 | if ok { 92 | return true, nil 93 | } 94 | } 95 | } 96 | } 97 | 98 | if a.isCount() { 99 | return a.countMatches(d, entryCount), nil 100 | } 101 | 102 | return false, nil 103 | } 104 | 105 | type AllOfTest struct { 106 | Tests []Test 107 | } 108 | 109 | func (a AllOfTest) Check(ctx context.Context, d *RuntimeData) (bool, error) { 110 | for _, t := range a.Tests { 111 | ok, err := t.Check(ctx, d) 112 | if err != nil { 113 | return false, err 114 | } 115 | if !ok { 116 | return false, nil 117 | } 118 | } 119 | return true, nil 120 | } 121 | 122 | type AnyOfTest struct { 123 | Tests []Test 124 | } 125 | 126 | func (a AnyOfTest) Check(ctx context.Context, d *RuntimeData) (bool, error) { 127 | for _, t := range a.Tests { 128 | ok, err := t.Check(ctx, d) 129 | if err != nil { 130 | return false, err 131 | } 132 | if ok { 133 | return true, nil 134 | } 135 | } 136 | return false, nil 137 | } 138 | 139 | type EnvelopeTest struct { 140 | matcherTest 141 | 142 | AddressPart AddressPart 143 | Field []string 144 | } 145 | 146 | func (e EnvelopeTest) Check(_ context.Context, d *RuntimeData) (bool, error) { 147 | entryCount := uint64(0) 148 | for _, field := range e.Field { 149 | var value string 150 | switch strings.ToLower(expandVars(d, field)) { 151 | case "from": 152 | value = d.Envelope.EnvelopeFrom() 153 | case "to": 154 | value = d.Envelope.EnvelopeTo() 155 | case "auth": 156 | value = d.Envelope.AuthUsername() 157 | default: 158 | return false, fmt.Errorf("envelope: unsupported envelope-part: %v", field) 159 | } 160 | if e.isCount() { 161 | if value != "" { 162 | entryCount++ 163 | } 164 | continue 165 | } 166 | 167 | ok, err := testAddress(d, e.matcherTest, e.AddressPart, value) 168 | if err != nil { 169 | return false, err 170 | } 171 | if ok { 172 | return true, nil 173 | } 174 | } 175 | if e.isCount() { 176 | return e.countMatches(d, entryCount), nil 177 | } 178 | return false, nil 179 | } 180 | 181 | type ExistsTest struct { 182 | Fields []string 183 | } 184 | 185 | func (e ExistsTest) Check(_ context.Context, d *RuntimeData) (bool, error) { 186 | for _, field := range e.Fields { 187 | values, err := d.Msg.HeaderGet(expandVars(d, field)) 188 | if err != nil { 189 | return false, err 190 | } 191 | if len(values) == 0 { 192 | return false, nil 193 | } 194 | } 195 | return true, nil 196 | } 197 | 198 | type FalseTest struct{} 199 | 200 | func (f FalseTest) Check(context.Context, *RuntimeData) (bool, error) { 201 | return false, nil 202 | } 203 | 204 | type TrueTest struct{} 205 | 206 | func (t TrueTest) Check(context.Context, *RuntimeData) (bool, error) { 207 | return true, nil 208 | } 209 | 210 | type HeaderTest struct { 211 | matcherTest 212 | 213 | Header []string 214 | } 215 | 216 | func (h HeaderTest) Check(_ context.Context, d *RuntimeData) (bool, error) { 217 | entryCount := uint64(0) 218 | for _, hdr := range h.Header { 219 | values, err := d.Msg.HeaderGet(expandVars(d, hdr)) 220 | if err != nil { 221 | return false, err 222 | } 223 | 224 | for _, value := range values { 225 | if h.isCount() { 226 | entryCount++ 227 | continue 228 | } 229 | 230 | ok, err := h.matcherTest.tryMatch(d, value) 231 | if err != nil { 232 | return false, err 233 | } 234 | if ok { 235 | return true, nil 236 | } 237 | } 238 | } 239 | 240 | if h.isCount() { 241 | return h.countMatches(d, entryCount), nil 242 | } 243 | 244 | return false, nil 245 | } 246 | 247 | type NotTest struct { 248 | Test Test 249 | } 250 | 251 | func (n NotTest) Check(ctx context.Context, d *RuntimeData) (bool, error) { 252 | ok, err := n.Test.Check(ctx, d) 253 | if err != nil { 254 | return false, err 255 | } 256 | return !ok, nil 257 | } 258 | 259 | type SizeTest struct { 260 | Size int 261 | Over bool 262 | Under bool 263 | } 264 | 265 | func (s SizeTest) Check(_ context.Context, d *RuntimeData) (bool, error) { 266 | if s.Over && d.Msg.MessageSize() > s.Size { 267 | return true, nil 268 | } 269 | if s.Under && d.Msg.MessageSize() < s.Size { 270 | return true, nil 271 | } 272 | return false, nil 273 | } 274 | -------------------------------------------------------------------------------- /interp/test_string.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "unicode" 9 | ) 10 | 11 | type Match string 12 | 13 | const ( 14 | MatchContains Match = "contains" 15 | MatchIs Match = "is" 16 | MatchMatches Match = "matches" 17 | MatchValue Match = "value" 18 | MatchCount Match = "count" 19 | ) 20 | 21 | type Comparator string 22 | 23 | const ( 24 | ComparatorOctet Comparator = "i;octet" 25 | ComparatorASCIICaseMap Comparator = "i;ascii-casemap" 26 | ComparatorASCIINumeric Comparator = "i;ascii-numeric" 27 | ComparatorUnicodeCaseMap Comparator = "i;unicode-casemap" 28 | 29 | DefaultComparator = ComparatorASCIICaseMap 30 | ) 31 | 32 | type AddressPart string 33 | 34 | const ( 35 | LocalPart AddressPart = "localpart" 36 | Domain AddressPart = "domain" 37 | All AddressPart = "all" 38 | ) 39 | 40 | func split(addr string) (mailbox, domain string, err error) { 41 | if strings.EqualFold(addr, "postmaster") { 42 | return addr, "", nil 43 | } 44 | 45 | indx := strings.LastIndexByte(addr, '@') 46 | if indx == -1 { 47 | return "", "", errors.New("address: missing at-sign") 48 | } 49 | mailbox = addr[:indx] 50 | domain = addr[indx+1:] 51 | if mailbox == "" { 52 | return "", "", errors.New("address: empty local-part") 53 | } 54 | if domain == "" { 55 | return "", "", errors.New("address: empty domain") 56 | } 57 | return 58 | } 59 | 60 | var ErrComparatorMatchUnsupported = fmt.Errorf("match-comparator combination not supported") 61 | 62 | func numericValue(s string) *uint64 { 63 | // https://www.rfc-editor.org/rfc/rfc4790.html#section-9.1 64 | 65 | if len(s) == 0 { 66 | return nil 67 | } 68 | runes := []rune(s) 69 | if !unicode.IsDigit(runes[0]) { 70 | return nil 71 | } 72 | var sl string 73 | for i, r := range runes { 74 | if !unicode.IsDigit(r) { 75 | sl = string(runes[:i]) 76 | break 77 | } 78 | } 79 | if sl == "" { 80 | sl = s 81 | } 82 | digit, err := strconv.ParseUint(sl, 10, 64) 83 | if err != nil { 84 | return nil 85 | } 86 | return &digit 87 | } 88 | 89 | func testString(comparator Comparator, match Match, rel Relational, value, key string) (bool, []string, error) { 90 | switch comparator { 91 | case ComparatorOctet: 92 | switch match { 93 | case MatchContains: 94 | return strings.Contains(value, key), nil, nil 95 | case MatchIs: 96 | return value == key, nil, nil 97 | case MatchMatches: 98 | return matchOctet(key, value, false) 99 | case MatchValue: 100 | return rel.CompareString(value, key), nil, nil 101 | case MatchCount: 102 | panic("testString should not be used with MatchCount") 103 | } 104 | case ComparatorASCIINumeric: 105 | switch match { 106 | case MatchContains: 107 | return false, nil, ErrComparatorMatchUnsupported 108 | case MatchIs: 109 | lhsNum := numericValue(value) 110 | rhsNum := numericValue(key) 111 | return RelEqual.CompareNumericValue(lhsNum, rhsNum), nil, nil 112 | case MatchMatches: 113 | return false, nil, ErrComparatorMatchUnsupported 114 | case MatchValue: 115 | lhsNum := numericValue(value) 116 | rhsNum := numericValue(key) 117 | return rel.CompareNumericValue(lhsNum, rhsNum), nil, nil 118 | case MatchCount: 119 | panic("testString should not be used with MatchCount") 120 | } 121 | case ComparatorASCIICaseMap: 122 | switch match { 123 | case MatchContains: 124 | value = toLowerASCII(value) 125 | key = toLowerASCII(key) 126 | return strings.Contains(value, key), nil, nil 127 | case MatchIs: 128 | value = toLowerASCII(value) 129 | key = toLowerASCII(key) 130 | return value == key, nil, nil 131 | case MatchMatches: 132 | return matchOctet(key, value, true) 133 | case MatchValue: 134 | value = toLowerASCII(value) 135 | key = toLowerASCII(key) 136 | return rel.CompareString(value, key), nil, nil 137 | case MatchCount: 138 | panic("testString should not be used with MatchCount") 139 | } 140 | case ComparatorUnicodeCaseMap: 141 | switch match { 142 | case MatchContains: 143 | value = strings.ToLower(value) 144 | key = strings.ToLower(key) 145 | return strings.Contains(value, key), nil, nil 146 | case MatchIs: 147 | return strings.EqualFold(value, key), nil, nil 148 | case MatchMatches: 149 | return matchUnicode(key, value, true) 150 | case MatchValue: 151 | value = toLowerASCII(value) 152 | key = toLowerASCII(key) 153 | return rel.CompareString(value, key), nil, nil 154 | case MatchCount: 155 | panic("testString should not be used with MatchCount") 156 | } 157 | } 158 | return false, nil, nil 159 | } 160 | 161 | func testAddress(d *RuntimeData, matcher matcherTest, part AddressPart, address string) (bool, error) { 162 | if address == "<>" { 163 | address = "" 164 | } 165 | 166 | var valueToCompare string 167 | if address != "" { 168 | switch part { 169 | case LocalPart: 170 | localPart, _, err := split(address) 171 | if err != nil { 172 | return false, nil 173 | } 174 | valueToCompare = localPart 175 | case Domain: 176 | _, domain, err := split(address) 177 | if err != nil { 178 | return false, nil 179 | } 180 | valueToCompare = domain 181 | case All: 182 | valueToCompare = address 183 | } 184 | } 185 | 186 | ok, err := matcher.tryMatch(d, valueToCompare) 187 | if err != nil { 188 | return false, err 189 | } 190 | return ok, nil 191 | } 192 | 193 | func toLowerASCII(s string) string { 194 | hasUpper := false 195 | for i := 0; i < len(s); i++ { 196 | c := s[i] 197 | hasUpper = hasUpper || ('A' <= c && c <= 'Z') 198 | } 199 | if !hasUpper { 200 | return s 201 | } 202 | var ( 203 | b strings.Builder 204 | pos int 205 | ) 206 | b.Grow(len(s)) 207 | for i := 0; i < len(s); i++ { 208 | c := s[i] 209 | if 'A' <= c && c <= 'Z' { 210 | c += 'a' - 'A' 211 | if pos < i { 212 | b.WriteString(s[pos:i]) 213 | } 214 | b.WriteByte(c) 215 | pos = i + 1 216 | } 217 | } 218 | if pos < len(s) { 219 | b.WriteString(s[pos:]) 220 | } 221 | return b.String() 222 | } 223 | -------------------------------------------------------------------------------- /interp/variables.go: -------------------------------------------------------------------------------- 1 | package interp 2 | 3 | import ( 4 | "context" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | /* 11 | variable-ref = "${" [namespace] variable-name "}" 12 | namespace = identifier "." *sub-namespace 13 | sub-namespace = variable-name "." 14 | variable-name = num-variable / identifier 15 | num-variable = 1*DIGIT 16 | */ 17 | var variableRegexp = regexp.MustCompile(`\${(?:[a-zA-Z_][a-zA-Z0-9_]*\.(?:(?:[a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)\.)*)?(?:[a-zA-Z_][a-zA-Z0-9_]*|[0-9]+)}`) 18 | 19 | func usedVars(script *Script, s string) []string { 20 | if !script.RequiresExtension("variables") { 21 | return nil 22 | } 23 | 24 | variables := variableRegexp.FindAllString(s, -1) 25 | for i := range variables { 26 | // Cut ${} and case-fold. 27 | variables[i] = strings.ToLower(variables[i][2 : len(variables[i])-1]) 28 | } 29 | 30 | return variables 31 | } 32 | 33 | func usedVarsAreValid(script *Script, s string) bool { 34 | for _, v := range usedVars(script, s) { 35 | matchNum, err := strconv.Atoi(v) 36 | if err == nil && matchNum >= 0 { 37 | continue 38 | } 39 | 40 | _, gettable := script.IsVarUsable(v) 41 | if !gettable { 42 | return false 43 | } 44 | } 45 | return true 46 | } 47 | 48 | func expandVarsList(d *RuntimeData, list []string) []string { 49 | if !d.Script.RequiresExtension("variables") { 50 | return list 51 | } 52 | 53 | listCpy := make([]string, len(list)) 54 | for i, val := range list { 55 | listCpy[i] = expandVars(d, val) 56 | } 57 | return listCpy 58 | } 59 | 60 | func expandVars(d *RuntimeData, s string) string { 61 | if !d.Script.RequiresExtension("variables") { 62 | return s 63 | } 64 | 65 | expanded := variableRegexp.ReplaceAllStringFunc(s, func(match string) string { 66 | name := match[2 : len(match)-1] 67 | 68 | if matchNum, err := strconv.Atoi(name); err == nil && matchNum >= 0 { 69 | return d.MatchVariable(matchNum) 70 | } 71 | 72 | value, err := d.Var(name) 73 | if err != nil { 74 | panic("attempt to use an unusable variable: " + name) 75 | } 76 | return value 77 | }) 78 | return expanded 79 | } 80 | 81 | type CmdSet struct { 82 | Name string 83 | Value string 84 | 85 | ModifyValue func(string) string 86 | } 87 | 88 | func (c CmdSet) Execute(_ context.Context, d *RuntimeData) error { 89 | return d.SetVar(c.Name, c.ModifyValue(expandVars(d, c.Value))) 90 | } 91 | 92 | type TestString struct { 93 | matcherTest 94 | 95 | Source []string 96 | } 97 | 98 | func (t TestString) Check(_ context.Context, d *RuntimeData) (bool, error) { 99 | entryCount := uint64(0) 100 | for _, source := range t.Source { 101 | source = expandVars(d, source) 102 | 103 | if t.isCount() { 104 | if source != "" { 105 | entryCount++ 106 | } 107 | continue 108 | } 109 | 110 | ok, err := t.matcherTest.tryMatch(d, source) 111 | if err != nil { 112 | return false, err 113 | } 114 | if ok { 115 | return true, nil 116 | } 117 | } 118 | 119 | if t.isCount() { 120 | return t.countMatches(d, entryCount), nil 121 | } 122 | 123 | return false, nil 124 | } 125 | -------------------------------------------------------------------------------- /lexer/lex.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type Options struct { 13 | Filename string 14 | NoPosition bool 15 | MaxTokens int 16 | } 17 | 18 | func consumeCRLF(r *bufio.Reader, state *lexerState) error { 19 | b, err := r.ReadByte() 20 | if err != nil { 21 | return err 22 | } 23 | switch b { 24 | case '\r': 25 | b, err = r.ReadByte() 26 | if err != nil { 27 | return err 28 | } 29 | if b != '\n' { 30 | return fmt.Errorf("CR is not followed by LF") 31 | } 32 | fallthrough 33 | case '\n': 34 | state.Line++ 35 | state.Col = 0 36 | return nil 37 | default: 38 | panic("consumeCRLF should not be called not on CR/LF") 39 | } 40 | } 41 | 42 | func Lex(r io.Reader, opts *Options) ([]Token, error) { 43 | if opts == nil { 44 | opts = &Options{} 45 | } 46 | toks, err := tokenStream(bufio.NewReader(r), opts) 47 | if err != nil { 48 | if err == io.EOF { 49 | return nil, io.ErrUnexpectedEOF 50 | } 51 | return nil, err 52 | } 53 | return toks, nil 54 | } 55 | 56 | type lexerState struct { 57 | Position 58 | } 59 | 60 | func tokenStream(r *bufio.Reader, opts *Options) ([]Token, error) { 61 | res := []Token{} 62 | state := &lexerState{} 63 | state.File = opts.Filename 64 | state.Line = 1 65 | for { 66 | b, err := r.ReadByte() 67 | if err != nil { 68 | if err == io.EOF { 69 | break 70 | } 71 | return nil, err 72 | } 73 | if opts.NoPosition { 74 | state.Line = 0 75 | state.Col = 0 76 | } else { 77 | state.Col++ 78 | } 79 | switch b { 80 | case 0: 81 | return nil, fmt.Errorf("go-sieve/lexer: NUL is not allowed in input stream") 82 | case '[': 83 | res = append(res, ListStart{state.Position}) 84 | case ']': 85 | res = append(res, ListEnd{state.Position}) 86 | case '{': 87 | res = append(res, BlockStart{state.Position}) 88 | case '}': 89 | res = append(res, BlockEnd{state.Position}) 90 | case '(': 91 | res = append(res, TestListStart{state.Position}) 92 | case ')': 93 | res = append(res, TestListEnd{state.Position}) 94 | case ',': 95 | res = append(res, Comma{state.Position}) 96 | case ':': 97 | res = append(res, Colon{state.Position}) 98 | case ';': 99 | res = append(res, Semicolon{state.Position}) 100 | case ' ', '\t': 101 | continue 102 | case '\r', '\n': 103 | if err := r.UnreadByte(); err != nil { 104 | return nil, err 105 | } 106 | if err := consumeCRLF(r, state); err != nil { 107 | return nil, err 108 | } 109 | case '"': 110 | lineCol := state.Position 111 | str, err := quotedString(r, state) 112 | if err != nil { 113 | return nil, err 114 | } 115 | res = append(res, String{Position: lineCol, Text: str}) 116 | case '#': 117 | if err := hashComment(r, state); err != nil { 118 | return nil, err 119 | } 120 | case '/': 121 | b2, err := r.ReadByte() 122 | if err != nil { 123 | return nil, err 124 | } 125 | state.Col++ 126 | if b2 != '*' { 127 | return nil, fmt.Errorf("unexpected forward slash") 128 | } 129 | if err := multilineComment(r, state); err != nil { 130 | return nil, err 131 | } 132 | case 't': 133 | // "text:" 134 | lineCol := state.Position 135 | ext, err := r.Peek(4) 136 | if err != nil { 137 | return nil, err 138 | } 139 | if bytes.Equal(ext, []byte("ext:")) { 140 | if _, err := r.Discard(4); err != nil { 141 | return nil, err 142 | } 143 | state.Col += 4 144 | // we consume whitespace and then build the multiline string 145 | wsLoop: 146 | for { 147 | b, err := r.ReadByte() 148 | if err != nil { 149 | return nil, err 150 | } 151 | state.Col++ 152 | switch b { 153 | case ' ', '\t': 154 | continue 155 | case '#': 156 | if err := hashComment(r, state); err != nil { 157 | return nil, err 158 | } 159 | break wsLoop 160 | case '\r', '\n': 161 | if err := r.UnreadByte(); err != nil { 162 | return nil, err 163 | } 164 | if err := consumeCRLF(r, state); err != nil { 165 | return nil, err 166 | } 167 | break wsLoop 168 | default: 169 | return nil, fmt.Errorf("unexpected character: %v", b) 170 | } 171 | } 172 | mlString, err := multilineString(r, state) 173 | if err != nil { 174 | return nil, err 175 | } 176 | res = append(res, String{Position: lineCol, Text: mlString}) 177 | continue 178 | } 179 | // if that's not text: but something else 180 | fallthrough 181 | default: 182 | lineCol := state.Position 183 | 184 | if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') { 185 | str, err := identifier(r, string(b), state) 186 | if err != nil { 187 | return nil, err 188 | } 189 | res = append(res, Identifier{Position: lineCol, Text: str}) 190 | } else if b >= '0' && b <= '9' { 191 | num, err := number(r, string(b), state) 192 | if err != nil { 193 | return nil, err 194 | } 195 | num.Position = lineCol 196 | res = append(res, num) 197 | } else { 198 | return nil, fmt.Errorf("unexpected character: %v", b) 199 | } 200 | } 201 | if opts.MaxTokens != 0 && len(res) > opts.MaxTokens { 202 | return nil, fmt.Errorf("too many tokens") 203 | } 204 | } 205 | return res, nil 206 | } 207 | 208 | func IsValidIdentifier(s string) bool { 209 | if len(s) == 0 { 210 | return false 211 | } 212 | 213 | first := s[0] 214 | if !(first >= 'a' && first <= 'z') && !(first >= 'A' && first <= 'Z') { 215 | return false 216 | } 217 | 218 | for _, chr := range s[1:] { 219 | switch { 220 | case chr >= 'a' && chr <= 'z': 221 | case chr >= 'A' && chr <= 'Z': 222 | case chr >= '0' && chr <= '9': 223 | case chr == '_': 224 | default: 225 | return false 226 | } 227 | } 228 | return true 229 | } 230 | 231 | func identifier(r *bufio.Reader, startWith string, state *lexerState) (string, error) { 232 | id := strings.Builder{} 233 | id.WriteString(startWith) 234 | for { 235 | b, err := r.ReadByte() 236 | if err != nil { 237 | if err == io.EOF { 238 | break 239 | } 240 | return "", err 241 | } 242 | state.Col++ 243 | // identifier = (ALPHA / "_") *(ALPHA / DIGIT / "_") 244 | if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' { 245 | id.WriteByte(b) 246 | } else { 247 | if err := r.UnreadByte(); err != nil { 248 | return "", err 249 | } 250 | state.Col-- 251 | break 252 | } 253 | } 254 | return id.String(), nil 255 | } 256 | 257 | func number(r *bufio.Reader, startWith string, state *lexerState) (Number, error) { 258 | num := strings.Builder{} 259 | num.WriteString(startWith) 260 | q := None 261 | readLoop: 262 | for { 263 | b, err := r.ReadByte() 264 | if err != nil { 265 | if err == io.EOF { 266 | break 267 | } 268 | return Number{}, err 269 | } 270 | state.Col++ 271 | switch b { 272 | case 'K', 'G', 'M': 273 | q = Quantifier(b) 274 | break readLoop 275 | case 'k', 'g', 'm': 276 | q = Quantifier(b - 32 /* to upper */) 277 | break readLoop 278 | } 279 | if b >= '0' && b <= '9' { 280 | num.WriteByte(b) 281 | } else { 282 | if err := r.UnreadByte(); err != nil { 283 | return Number{}, err 284 | } 285 | state.Col-- 286 | break readLoop 287 | } 288 | } 289 | 290 | numParsed, err := strconv.Atoi(num.String()) 291 | if err != nil { 292 | return Number{}, err 293 | } 294 | return Number{Value: numParsed, Quantifier: q}, nil 295 | } 296 | 297 | func hashComment(r *bufio.Reader, state *lexerState) error { 298 | for { 299 | b, err := r.ReadByte() 300 | if err != nil { 301 | if err == io.EOF { 302 | break 303 | } 304 | return err 305 | } 306 | state.Col++ 307 | if b == '\r' || b == '\n' { 308 | if err := r.UnreadByte(); err != nil { 309 | return err 310 | } 311 | if err := consumeCRLF(r, state); err != nil { 312 | return err 313 | } 314 | break 315 | } 316 | } 317 | return nil 318 | } 319 | 320 | func multilineComment(r *bufio.Reader, state *lexerState) error { 321 | wasStar := false 322 | for { 323 | b, err := r.ReadByte() 324 | if err != nil { 325 | return err 326 | } 327 | state.Col++ 328 | if b == '\n' { 329 | state.Line++ 330 | state.Col = 0 331 | } 332 | if wasStar && b == '/' { 333 | return nil 334 | } 335 | wasStar = b == '*' 336 | } 337 | } 338 | 339 | func quotedString(r *bufio.Reader, state *lexerState) (string, error) { 340 | str := strings.Builder{} 341 | atBackslash := false 342 | for { 343 | b, err := r.ReadByte() 344 | if err != nil { 345 | return "", err 346 | } 347 | state.Col++ 348 | switch b { 349 | case '\r', '\n': 350 | if err := r.UnreadByte(); err != nil { 351 | return "", err 352 | } 353 | if err := consumeCRLF(r, state); err != nil { 354 | return "", err 355 | } 356 | 357 | str.WriteByte('\r') 358 | str.WriteByte('\n') 359 | case '\\': 360 | if !atBackslash { 361 | atBackslash = true 362 | continue 363 | } 364 | str.WriteByte(b) 365 | case '"': 366 | if !atBackslash { 367 | return str.String(), nil 368 | } 369 | str.WriteByte(b) 370 | default: 371 | str.WriteByte(b) 372 | } 373 | atBackslash = false 374 | } 375 | } 376 | 377 | func multilineString(r *bufio.Reader, state *lexerState) (string, error) { 378 | atLF := false 379 | atLFHadDot := false 380 | var data strings.Builder 381 | for { 382 | b, err := r.ReadByte() 383 | if err != nil { 384 | return "", err 385 | } 386 | state.Col++ 387 | // We also normalize LF into CRLF while reading multiline strings. 388 | switch b { 389 | case '.': 390 | if atLF { 391 | atLFHadDot = true 392 | } else { 393 | data.WriteByte('.') 394 | atLFHadDot = false 395 | } 396 | 397 | atLF = false 398 | case '\r', '\n': 399 | if err := r.UnreadByte(); err != nil { 400 | return "", err 401 | } 402 | if err := consumeCRLF(r, state); err != nil { 403 | return "", err 404 | } 405 | if atLFHadDot { 406 | return data.String(), nil 407 | } 408 | data.WriteByte('\r') 409 | data.WriteByte('\n') 410 | atLF = true 411 | default: 412 | if atLFHadDot { 413 | data.WriteByte('.') 414 | } 415 | atLF = false 416 | atLFHadDot = false 417 | data.WriteByte(b) 418 | } 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /lexer/lex_fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package lexer 5 | 6 | import ( 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func FuzzLex(f *testing.F) { 13 | f.Add(``) 14 | f.Add(`"hello"`) 15 | f.Add(`[ "hello", "there"]`) 16 | f.Add(`{ id1 id2 }`) 17 | f.Add(`{ id1 # comment id2 }`) 18 | f.Add(`{}`) 19 | f.Add(`"multi 20 | line 21 | string"`) 22 | f.Add(` 23 | there are 24 | also 25 | /* multi 26 | line 27 | comments */`) 28 | f.Add(`[ "hello" ] # comment parsing should also work 29 | /* also a comment 30 | whatever # aaaa 31 | "still a comment" 32 | {} 33 | */ 34 | { identifier :size 123K } `) 35 | f.Fuzz(func(t *testing.T, script string) { 36 | toks, err := Lex(strings.NewReader(script), &Options{NoPosition: true}) 37 | if err != nil { 38 | t.Skip(err) 39 | } 40 | out := strings.Builder{} 41 | if err := Write(&out, toks); err != nil { 42 | t.Fatal("Write should succeed for any Lex output:", err) 43 | } 44 | toks2, err := Lex(strings.NewReader(out.String()), &Options{NoPosition: true}) 45 | if err != nil { 46 | t.Fatal("Lex should succeed for any Write output:", err) 47 | } 48 | if !reflect.DeepEqual(toks, toks2) { 49 | t.Log("Two Lex calls produced inconsistent output") 50 | t.Log("First: ", toks) 51 | t.Log("Second:", toks2) 52 | t.FailNow() 53 | } 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /lexer/lex_test.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func testLexer(t *testing.T, script string, tokens []Token) { 10 | t.Run("case", func(t *testing.T) { 11 | actualTokens, err := Lex(strings.NewReader(script), &Options{}) 12 | if err != nil { 13 | if tokens == nil { 14 | return 15 | } 16 | t.Error("Unexpected error:", err) 17 | return 18 | } 19 | if tokens == nil { 20 | t.Error("Unexpected success:", actualTokens) 21 | return 22 | } 23 | if !reflect.DeepEqual(tokens, actualTokens) { 24 | t.Log("Wrong lexer output:") 25 | t.Logf("Actual: %#v", actualTokens) 26 | t.Logf("Expected: %#v", tokens) 27 | t.Fail() 28 | } 29 | }) 30 | } 31 | 32 | func TestLex(t *testing.T) { 33 | testLexer(t, ``, []Token{}) 34 | testLexer(t, `[]`, []Token{ListStart{Position: LineCol(1, 1)}, ListEnd{Position: LineCol(1, 2)}}) 35 | testLexer(t, `[ "hello1" , "hello2" ]`, []Token{ 36 | ListStart{Position: LineCol(1, 1)}, 37 | String{Text: "hello1", Position: LineCol(1, 3)}, 38 | Comma{Position: LineCol(1, 12)}, 39 | String{Text: "hello2", Position: LineCol(1, 14)}, 40 | ListEnd{LineCol(1, 23)}, 41 | }) 42 | testLexer(t, `"multi 43 | line 44 | string"`, []Token{String{Text: "multi\r\nline\r\nstring", Position: LineCol(1, 1)}}) 45 | testLexer(t, `" and so it goes... `, nil) // lexer error 46 | testLexer(t, `[ "hello" ] id`, []Token{ 47 | ListStart{Position: LineCol(1, 1)}, 48 | String{Text: "hello", Position: LineCol(1, 3)}, 49 | ListEnd{Position: LineCol(1, 11)}, 50 | Identifier{Text: "id", Position: LineCol(1, 13)}, 51 | }) 52 | testLexer(t, `[ "hello" ] 53 | /* also a comment 54 | whatever # aaaa 55 | "still a comment" 56 | {} 57 | */ 58 | { identifier :size 123K }`, []Token{ 59 | ListStart{Position: LineCol(1, 1)}, 60 | String{Text: "hello", Position: LineCol(1, 3)}, 61 | ListEnd{Position: LineCol(1, 11)}, 62 | BlockStart{Position: LineCol(7, 1)}, 63 | Identifier{Text: "identifier", Position: LineCol(7, 3)}, 64 | Colon{Position: LineCol(7, 14)}, 65 | Identifier{Text: "size", Position: LineCol(7, 15)}, 66 | Number{Value: 123, Quantifier: Kilo, Position: LineCol(7, 20)}, 67 | BlockEnd{Position: LineCol(7, 25)}, 68 | }) 69 | testLexer(t, `set "message" text: 70 | From: sirius@example.org 71 | To: nico@frop.example.com 72 | Subject: Frop! 73 | 74 | Frop! 75 | . 76 | `, []Token{ 77 | Identifier{Text: "set", Position: LineCol(1, 1)}, 78 | String{Text: "message", Position: LineCol(1, 5)}, 79 | String{Text: "From: sirius@example.org\r\n" + 80 | "To: nico@frop.example.com\r\n" + 81 | "Subject: Frop!\r\n" + 82 | "\r\n" + 83 | "Frop!\r\n", Position: LineCol(1, 15)}, 84 | }) 85 | testLexer(t, `set "message" text: 86 | From: sirius@example.org 87 | To: nico@frop.example.com 88 | Subject: Frop! 89 | 90 | .. 91 | Frop! 92 | . 93 | `, []Token{ 94 | Identifier{Text: "set", Position: LineCol(1, 1)}, 95 | String{Text: "message", Position: LineCol(1, 5)}, 96 | String{Text: "From: sirius@example.org\r\n" + 97 | "To: nico@frop.example.com\r\n" + 98 | "Subject: Frop!\r\n" + 99 | "\r\n" + 100 | ".\r\n" + 101 | "Frop!\r\n", Position: LineCol(1, 15)}, 102 | }) 103 | testLexer(t, `set "text" text: # Comment 104 | Line 1 105 | .Line 2 106 | ..Line 3 107 | .Line 4 108 | Line 5 109 | . 110 | ;`, []Token{ 111 | Identifier{Text: "set", Position: LineCol(1, 1)}, 112 | String{Text: "text", Position: LineCol(1, 5)}, 113 | String{Text: "Line 1\r\n.Line 2\r\n.Line 3\r\n.Line 4\r\nLine 5\r\n", Position: LineCol(1, 12)}, 114 | Semicolon{Position: LineCol(8, 1)}, 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /lexer/stream.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Stream struct { 8 | cursor int 9 | toks []Token 10 | } 11 | 12 | func (s *Stream) Last() Token { 13 | if s.cursor >= len(s.toks) { 14 | return nil 15 | } 16 | return s.toks[s.cursor] 17 | } 18 | 19 | func (s *Stream) Pop() Token { 20 | s.cursor++ 21 | if s.cursor >= len(s.toks) { 22 | return nil 23 | } 24 | return s.toks[s.cursor] 25 | } 26 | 27 | func (s *Stream) Peek() Token { 28 | cur := s.cursor + 1 29 | if cur >= len(s.toks) { 30 | return nil 31 | } 32 | return s.toks[cur] 33 | } 34 | 35 | func (s *Stream) Err(format string, args ...interface{}) error { 36 | last := s.Last() 37 | if last == nil { 38 | return fmt.Errorf(format, args...) 39 | } 40 | return ErrorAt(last, format, args...) 41 | } 42 | 43 | func NewStream(toks []Token) *Stream { 44 | return &Stream{cursor: -1, toks: toks} 45 | } 46 | -------------------------------------------------------------------------------- /lexer/token.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type Position struct { 9 | File string 10 | Line int 11 | Col int 12 | } 13 | 14 | func (l Position) String() string { 15 | if l.File != "" { 16 | return l.File + ":" + strconv.Itoa(l.Line) + ":" + strconv.Itoa(l.Col) 17 | } 18 | return strconv.Itoa(l.Line) + ":" + strconv.Itoa(l.Col) 19 | } 20 | 21 | func (l Position) LineCol() (int, int) { 22 | return l.Line, l.Col 23 | } 24 | 25 | func LineCol(line, col int) Position { 26 | return Position{Line: line, Col: col} 27 | } 28 | 29 | type Token interface { 30 | LineCol() (int, int) 31 | String() string 32 | } 33 | 34 | type Identifier struct { 35 | Position 36 | Text string 37 | } 38 | 39 | func (t Identifier) String() string { return fmt.Sprintf(`Identifiner("%s")`, t.Text) } 40 | 41 | type Quantifier byte 42 | 43 | const ( 44 | None Quantifier = '\x00' 45 | Kilo Quantifier = 'K' 46 | Mega Quantifier = 'M' 47 | Giga Quantifier = 'G' 48 | ) 49 | 50 | func (q Quantifier) Multiplier() int { 51 | switch q { 52 | case None: 53 | return 1 54 | case Kilo: 55 | return 1024 56 | case Mega: 57 | return 1024 * 1024 58 | case Giga: 59 | return 1024 * 1024 * 1024 60 | default: 61 | panic("unknown quantifier") 62 | } 63 | } 64 | 65 | type Number struct { 66 | Position 67 | Value int 68 | Quantifier Quantifier 69 | } 70 | 71 | func (t Number) String() string { 72 | if t.Quantifier != None { 73 | return fmt.Sprintf("Number(%d, %v)", t.Value, string(t.Quantifier)) 74 | } 75 | return fmt.Sprintf("Number(%d)", t.Value) 76 | 77 | } 78 | 79 | type String struct { 80 | Position 81 | Text string 82 | } 83 | 84 | func (t String) String() string { return fmt.Sprintf(`String("%s")`, t.Text) } 85 | 86 | type ListStart struct{ Position } 87 | 88 | func (ListStart) String() string { return "ListStart()" } 89 | 90 | type ListEnd struct{ Position } 91 | 92 | func (ListEnd) String() string { return "ListEnd()" } 93 | 94 | type BlockStart struct{ Position } 95 | 96 | func (BlockStart) String() string { return "BlockStart()" } 97 | 98 | type BlockEnd struct{ Position } 99 | 100 | func (BlockEnd) String() string { return "BlockEnd()" } 101 | 102 | type TestListStart struct{ Position } 103 | 104 | func (TestListStart) String() string { return "TestListStart()" } 105 | 106 | type TestListEnd struct{ Position } 107 | 108 | func (TestListEnd) String() string { return "TestListEnd()" } 109 | 110 | type Comma struct{ Position } 111 | 112 | func (Comma) String() string { return "Comma()" } 113 | 114 | type Semicolon struct{ Position } 115 | 116 | func (Semicolon) String() string { return "Semicolon()" } 117 | 118 | type Colon struct{ Position } 119 | 120 | func (Colon) String() string { return "Colon()" } 121 | 122 | type position interface { 123 | LineCol() (int, int) 124 | } 125 | 126 | type tokError struct { 127 | t position 128 | text string 129 | } 130 | 131 | func (e tokError) Error() string { 132 | if e.t == nil { 133 | return fmt.Sprintf("unknown-position: %s", e.text) 134 | } 135 | line, col := e.t.LineCol() 136 | if line == 0 || col == 0 { 137 | return fmt.Sprintf("invalid-position: %s", e.text) 138 | } 139 | return fmt.Sprintf("%d:%d: %s", line, col, e.text) 140 | } 141 | 142 | func ErrorAt(t position, format string, args ...interface{}) error { 143 | return tokError{t: t, text: fmt.Sprintf(format, args...)} 144 | } 145 | -------------------------------------------------------------------------------- /lexer/write.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | func Write(w io.Writer, toks []Token) error { 11 | bw := bufio.NewWriter(w) 12 | for _, t := range toks { 13 | var err error 14 | switch t := t.(type) { 15 | case Identifier: 16 | _, err = bw.WriteString(t.Text) 17 | case Number: 18 | if t.Quantifier != None { 19 | _, err = fmt.Fprintf(bw, "%d%s", t.Value, string(t.Quantifier)) 20 | } else { 21 | _, err = fmt.Fprintf(bw, "%d", t.Value) 22 | } 23 | case String: 24 | _, err = bw.WriteString(formatString(t.Text)) 25 | case ListStart: 26 | err = bw.WriteByte('[') 27 | case ListEnd: 28 | err = bw.WriteByte(']') 29 | case TestListStart: 30 | err = bw.WriteByte('(') 31 | case TestListEnd: 32 | err = bw.WriteByte(')') 33 | case BlockStart: 34 | err = bw.WriteByte('{') 35 | case BlockEnd: 36 | err = bw.WriteByte('}') 37 | case Comma: 38 | err = bw.WriteByte(',') 39 | case Semicolon: 40 | err = bw.WriteByte(';') 41 | case Colon: 42 | err = bw.WriteByte(':') 43 | default: 44 | panic("unexpected token type") 45 | } 46 | if err != nil { 47 | return err 48 | } 49 | 50 | // TODO: Preserve whitespace properly instead? 51 | if err := bw.WriteByte(' '); err != nil { 52 | return err 53 | } 54 | } 55 | if err := bw.Flush(); err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func formatString(s string) string { 62 | esc := strings.Builder{} 63 | esc.WriteByte('"') 64 | for _, r := range []byte(s) { 65 | switch r { 66 | case '"': 67 | esc.WriteString(`\"`) 68 | case '\\': 69 | esc.WriteString(`\\`) 70 | default: 71 | esc.WriteByte(r) 72 | } 73 | } 74 | esc.WriteByte('"') 75 | return esc.String() 76 | } 77 | -------------------------------------------------------------------------------- /parser/command.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "github.com/foxcpp/go-sieve/lexer" 4 | 5 | type Arg interface { 6 | LineCol() (int, int) 7 | arg() 8 | } 9 | 10 | type NumberArg struct { 11 | Value int 12 | lexer.Position 13 | } 14 | 15 | func (NumberArg) arg() {} 16 | 17 | type StringArg struct { 18 | Value string 19 | lexer.Position 20 | } 21 | 22 | func (StringArg) arg() {} 23 | 24 | type StringListArg struct { 25 | Value []string 26 | lexer.Position 27 | } 28 | 29 | func (StringListArg) arg() {} 30 | 31 | type TagArg struct { 32 | Value string 33 | lexer.Position 34 | } 35 | 36 | func (TagArg) arg() {} 37 | 38 | type Test struct { 39 | lexer.Position 40 | Id string 41 | Args []Arg 42 | Tests []Test 43 | } 44 | 45 | type Cmd struct { 46 | lexer.Position 47 | Id string 48 | Args []Arg 49 | Tests []Test 50 | Block []Cmd 51 | } 52 | -------------------------------------------------------------------------------- /parser/parse.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "github.com/foxcpp/go-sieve/lexer" 5 | ) 6 | 7 | type Options struct { 8 | MaxBlockNesting int 9 | MaxTestNesting int 10 | } 11 | 12 | func Parse(stream *lexer.Stream, opts *Options) ([]Cmd, error) { 13 | return parse(stream, 0, opts) 14 | } 15 | 16 | // parse is a low-level parsing function, it creates 17 | // AST with very little interpretation of values. 18 | func parse(stream *lexer.Stream, nesting int, opts *Options) ([]Cmd, error) { 19 | if opts.MaxBlockNesting != 0 && nesting > opts.MaxBlockNesting { 20 | return nil, stream.Err("block nesting limit exceeded") 21 | } 22 | res := []Cmd{} 23 | for { 24 | curCmd := Cmd{} 25 | 26 | idT := stream.Pop() 27 | if idT == nil { 28 | return res, nil 29 | } 30 | switch id := idT.(type) { 31 | case lexer.Identifier: 32 | curCmd.Id = id.Text 33 | curCmd.Position = id.Position 34 | case lexer.BlockEnd: 35 | return res, nil 36 | default: 37 | return nil, stream.Err("reading command: expected an identifier or closing brace") 38 | } 39 | 40 | args, tests, err := readArguments(stream, false, 0, opts) 41 | if err != nil { 42 | return nil, err 43 | } 44 | curCmd.Args = args 45 | curCmd.Tests = tests 46 | 47 | cmdEnd := stream.Pop() 48 | if cmdEnd == nil { 49 | return nil, stream.Err("reading command: expected semicolon or block") 50 | } 51 | switch cmdEnd.(type) { 52 | case lexer.Semicolon: 53 | // Ok. 54 | case lexer.BlockStart: 55 | cmds, err := parse(stream, nesting+1, opts) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | // EOF vs } check 61 | last := stream.Last() 62 | if last == nil { 63 | return nil, stream.Err("reading command: expected a closing brace") 64 | } 65 | 66 | curCmd.Block = cmds 67 | default: 68 | return nil, stream.Err("reading command: unexpected token") 69 | } 70 | 71 | res = append(res, curCmd) 72 | } 73 | } 74 | 75 | func readArguments(s *lexer.Stream, forTest bool, nesting int, opts *Options) ([]Arg, []Test, error) { 76 | if opts.MaxTestNesting != 0 && nesting > opts.MaxTestNesting { 77 | return nil, nil, s.Err("reading arguments: nesting limit exceeded") 78 | } 79 | var args []Arg 80 | var tests []Test 81 | 82 | for { 83 | tok := s.Peek() 84 | if tok == nil { 85 | return nil, nil, s.Err("reading arguments: expected semicolon or arguments or block, got EOF") 86 | } 87 | switch tok := tok.(type) { 88 | case lexer.Semicolon, lexer.BlockStart: 89 | return args, tests, nil 90 | case lexer.Comma, lexer.TestListEnd: 91 | if !forTest { 92 | return nil, nil, s.Err("reading arguments: expected semicolon or arguments or block, got %v", tok) 93 | } 94 | return args, tests, nil 95 | case lexer.String: 96 | s.Pop() 97 | args = append(args, StringArg{Value: tok.Text, Position: tok.Position}) 98 | case lexer.ListStart: 99 | s.Pop() 100 | list, err := readStringList(s) 101 | if err != nil { 102 | return nil, nil, err 103 | } 104 | args = append(args, StringListArg{Value: list, Position: tok.Position}) 105 | case lexer.Number: 106 | s.Pop() 107 | args = append(args, NumberArg{Value: tok.Value * tok.Quantifier.Multiplier(), Position: tok.Position}) 108 | case lexer.Colon: 109 | s.Pop() // colon 110 | idT := s.Pop() 111 | if idT == nil { 112 | return nil, nil, s.Err("reading arguments: expected identifier, got EOF") 113 | } 114 | id, ok := idT.(lexer.Identifier) 115 | if !ok { 116 | return nil, nil, s.Err("reading arguments: expected identifier") 117 | } 118 | args = append(args, TagArg{Value: id.Text, Position: tok.Position}) 119 | case lexer.Identifier: 120 | // a single test, at the end of arguments. 121 | s.Pop() 122 | t := Test{ 123 | Position: tok.Position, 124 | Id: tok.Text, 125 | } 126 | tArgs, tTests, err := readArguments(s, true, nesting+1, opts) 127 | if err != nil { 128 | return nil, nil, err 129 | } 130 | 131 | t.Args = tArgs 132 | t.Tests = tTests 133 | tests = append(tests, t) 134 | case lexer.TestListStart: 135 | s.Pop() 136 | var err error 137 | tests, err = readTestList(s, nesting, opts) 138 | if err != nil { 139 | return nil, nil, err 140 | } 141 | return args, tests, nil 142 | default: 143 | return nil, nil, s.Err("reading arguments: expected semicolon or arguments or block. got %v", tok) 144 | } 145 | } 146 | } 147 | 148 | func readTestList(s *lexer.Stream, nesting int, opts *Options) ([]Test, error) { 149 | needTest := true 150 | res := []Test{} 151 | for { 152 | tok := s.Pop() 153 | if tok == nil { 154 | return nil, s.Err("reading test list: expected identifier, got EOF") 155 | } 156 | switch tok := tok.(type) { 157 | case lexer.Identifier: 158 | if !needTest { 159 | return nil, s.Err("reading test list: expected comma or closing brace, got identifier") 160 | } 161 | t := Test{ 162 | Position: tok.Position, 163 | Id: tok.Text, 164 | } 165 | args, tests, err := readArguments(s, true, nesting+1, opts) 166 | if err != nil { 167 | return nil, err 168 | } 169 | t.Args = args 170 | t.Tests = tests 171 | res = append(res, t) 172 | needTest = false 173 | case lexer.Comma: 174 | if needTest { 175 | return nil, s.Err("reading test list: expected identifier or list end, got comma") 176 | } 177 | needTest = true 178 | case lexer.TestListEnd: 179 | return res, nil 180 | default: 181 | return nil, s.Err("reading test list: expected identifier or comma or closing brace, got %v", tok) 182 | } 183 | } 184 | } 185 | 186 | func readStringList(s *lexer.Stream) ([]string, error) { 187 | res := []string{} 188 | needString := true 189 | for { 190 | tok := s.Pop() 191 | if tok == nil { 192 | return nil, s.Err("reading string list: expected string or closing brace, got EOF") 193 | } 194 | switch tok := tok.(type) { 195 | case lexer.String: 196 | if !needString { 197 | return nil, s.Err("reading string list: expected comma or closing brace, got string") 198 | } 199 | res = append(res, tok.Text) 200 | needString = false 201 | case lexer.Comma: 202 | if needString { 203 | return nil, s.Err("reading string list: expected string, got comma") 204 | } 205 | needString = true 206 | case lexer.ListEnd: 207 | return res, nil 208 | default: 209 | return nil, s.Err("reading string list: expected string, comma or closing brace") 210 | } 211 | } 212 | } 213 | 214 | func ErrorAt(pos lexer.Position, fmt string, args ...interface{}) error { 215 | return lexer.ErrorAt(pos, fmt, args...) 216 | } 217 | -------------------------------------------------------------------------------- /parser/parse_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/davecgh/go-spew/spew" 9 | "github.com/foxcpp/go-sieve/lexer" 10 | ) 11 | 12 | const exampleScript = ` # 13 | # Example Sieve Filter 14 | # Declare any optional features or extension used by the script 15 | # 16 | require ["fileinto"]; 17 | 18 | # 19 | # Handle messages from known mailing lists 20 | # Move messages from IETF filter discussion list to filter mailbox 21 | # 22 | if header :is "Sender" "owner-ietf-mta-filters@imc.org" 23 | { 24 | fileinto "filter"; # move to "filter" mailbox 25 | } 26 | # 27 | # Keep all messages to or from people in my company 28 | # 29 | elsif address :DOMAIN :is ["From", "To"] "example.com" 30 | { 31 | keep; # keep in "In" mailbox 32 | } 33 | 34 | # 35 | # Try and catch unsolicited email. If a message is not to me, 36 | # or it contains a subject known to be spam, file it away. 37 | # 38 | elsif anyof (NOT address :all :contains 39 | ["To", "Cc", "Bcc"] "me@example.com", 40 | header :matches "subject" 41 | ["*make*money*fast*", "*university*dipl*mas*"]) 42 | { 43 | fileinto "spam"; # move to "spam" mailbox 44 | } 45 | else 46 | { 47 | # Move all other (non-company) mail to "personal" 48 | # mailbox. 49 | fileinto "personal"; 50 | } 51 | ` 52 | 53 | func testParse(t *testing.T, script string, cmds []Cmd) { 54 | toks, err := lexer.Lex(strings.NewReader(script), &lexer.Options{ 55 | NoPosition: true, 56 | }) 57 | if err != nil { 58 | t.Fatal("Lexer failed:", err) 59 | } 60 | s := lexer.NewStream(toks) 61 | actualCmds, err := parse(s, 0, &Options{}) 62 | if err != nil { 63 | t.Error("parse failed:", err) 64 | return 65 | } 66 | if err != nil { 67 | if cmds == nil { 68 | return 69 | } 70 | t.Error("Unexpected failure:", err) 71 | return 72 | } 73 | if cmds == nil { 74 | t.Error("Unexpected success:", actualCmds) 75 | return 76 | } 77 | if !reflect.DeepEqual(cmds, actualCmds) { 78 | t.Log("Wrong parse result") 79 | t.Log("Expected:") 80 | t.Log(spew.Sdump(cmds)) 81 | t.Log("Actual:") 82 | t.Log(spew.Sdump(actualCmds)) 83 | t.Fail() 84 | } 85 | } 86 | 87 | func TestParser(t *testing.T) { 88 | testParse(t, exampleScript, []Cmd{ 89 | { 90 | Id: "require", 91 | Args: []Arg{ 92 | StringListArg{Value: []string{"fileinto"}}, 93 | }, 94 | }, 95 | { 96 | Id: "if", 97 | Tests: []Test{ 98 | { 99 | Id: "header", 100 | Args: []Arg{ 101 | TagArg{Value: "is"}, 102 | StringArg{Value: "Sender"}, 103 | StringArg{Value: "owner-ietf-mta-filters@imc.org"}, 104 | }, 105 | }, 106 | }, 107 | Block: []Cmd{ 108 | { 109 | Id: "fileinto", 110 | Args: []Arg{ 111 | StringArg{Value: "filter"}, 112 | }, 113 | }, 114 | }, 115 | }, 116 | { 117 | Id: "elsif", 118 | Tests: []Test{ 119 | { 120 | Id: "address", 121 | Args: []Arg{ 122 | TagArg{Value: "DOMAIN"}, 123 | TagArg{Value: "is"}, 124 | StringListArg{Value: []string{"From", "To"}}, 125 | StringArg{Value: "example.com"}, 126 | }, 127 | }, 128 | }, 129 | Block: []Cmd{ 130 | { 131 | Id: "keep", 132 | }, 133 | }, 134 | }, 135 | { 136 | Id: "elsif", 137 | Tests: []Test{ 138 | { 139 | Id: "anyof", 140 | Tests: []Test{ 141 | { 142 | Id: "NOT", 143 | Tests: []Test{ 144 | { 145 | Id: "address", 146 | Args: []Arg{ 147 | TagArg{Value: "all"}, 148 | TagArg{Value: "contains"}, 149 | StringListArg{Value: []string{"To", "Cc", "Bcc"}}, 150 | StringArg{Value: "me@example.com"}, 151 | }, 152 | }, 153 | }, 154 | }, 155 | { 156 | Id: "header", 157 | Args: []Arg{ 158 | TagArg{Value: "matches"}, 159 | StringArg{Value: "subject"}, 160 | StringListArg{Value: []string{"*make*money*fast*", "*university*dipl*mas*"}}, 161 | }, 162 | }, 163 | }, 164 | }, 165 | }, 166 | Block: []Cmd{ 167 | { 168 | Id: "fileinto", 169 | Args: []Arg{ 170 | StringArg{Value: "spam"}, 171 | }, 172 | }, 173 | }, 174 | }, 175 | { 176 | Id: "else", 177 | Block: []Cmd{ 178 | { 179 | Id: "fileinto", 180 | Args: []Arg{ 181 | StringArg{Value: "personal"}, 182 | }, 183 | }, 184 | }, 185 | }, 186 | }) 187 | } 188 | -------------------------------------------------------------------------------- /sieve.go: -------------------------------------------------------------------------------- 1 | package sieve 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/foxcpp/go-sieve/interp" 7 | "github.com/foxcpp/go-sieve/lexer" 8 | "github.com/foxcpp/go-sieve/parser" 9 | ) 10 | 11 | type ( 12 | Script = interp.Script 13 | RuntimeData = interp.RuntimeData 14 | 15 | PolicyReader = interp.PolicyReader 16 | Message = interp.Message 17 | Envelope = interp.Envelope 18 | 19 | Options struct { 20 | Lexer lexer.Options 21 | Parser parser.Options 22 | Interp interp.Options 23 | } 24 | ) 25 | 26 | func DefaultOptions() Options { 27 | return Options{ 28 | Lexer: lexer.Options{ 29 | MaxTokens: 5000, 30 | }, 31 | Parser: parser.Options{ 32 | MaxBlockNesting: 15, 33 | MaxTestNesting: 15, 34 | }, 35 | Interp: interp.Options{ 36 | MaxRedirects: 5, 37 | MaxVariableCount: 128, 38 | MaxVariableNameLen: 32, 39 | MaxVariableLen: 4000, 40 | }, 41 | } 42 | } 43 | 44 | func Load(r io.Reader, opts Options) (*Script, error) { 45 | toks, err := lexer.Lex(r, &opts.Lexer) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | cmds, err := parser.Parse(lexer.NewStream(toks), &opts.Parser) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return interp.LoadScript(cmds, &opts.Interp) 56 | } 57 | 58 | func NewRuntimeData(s *Script, p interp.PolicyReader, e interp.Envelope, msg interp.Message) *interp.RuntimeData { 59 | return interp.NewRuntimeData(s, p, e, msg) 60 | } 61 | -------------------------------------------------------------------------------- /tests/base_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestTestsuite(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "testsuite.svtest")) 10 | } 11 | 12 | func TestLexer(t *testing.T) { 13 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "lexer.svtest")) 14 | } 15 | 16 | func TestControlIf(t *testing.T) { 17 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "control-if.svtest")) 18 | } 19 | 20 | func TestControlStop(t *testing.T) { 21 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "control-stop.svtest")) 22 | } 23 | 24 | func TestTestAddress(t *testing.T) { 25 | RunDovecotTestWithout(t, filepath.Join("pigeonhole", "tests", "test-address.svtest"), 26 | []string{ 27 | // test_fail at 85:3 called: failed to ignore comment in address 28 | // go-sieve address parser does not remove comments. 29 | "Basic functionality", 30 | // test_fail at 458:3 called: :localpart matched invalid UTF-8 address 31 | // FIXME: Not sure what is wrong here. UTF-8 looks valid? 32 | "Invalid addresses", 33 | }) 34 | } 35 | 36 | func TestTestAllof(t *testing.T) { 37 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "test-allof.svtest")) 38 | } 39 | 40 | func TestTestAnyof(t *testing.T) { 41 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "test-anyof.svtest")) 42 | } 43 | 44 | func TestTestExists(t *testing.T) { 45 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "test-exists.svtest")) 46 | } 47 | 48 | func TestTestHeader(t *testing.T) { 49 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "test-header.svtest")) 50 | } 51 | 52 | func TestTestSize(t *testing.T) { 53 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "test-size.svtest")) 54 | } 55 | -------------------------------------------------------------------------------- /tests/comparators_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestComparatorsOctet(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "comparators", "i-octet.svtest")) 10 | } 11 | 12 | func TestComparatorsASCIICasemap(t *testing.T) { 13 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "comparators", "i-ascii-casemap.svtest")) 14 | } 15 | -------------------------------------------------------------------------------- /tests/compile_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestCompile(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "compile", "compile.svtest")) 10 | } 11 | 12 | // go-sieve has more simple error handling, but we still run 13 | // tests to check whether any invalid scripts are not loaded as valid. 14 | 15 | func TestCompileErrors(t *testing.T) { 16 | t.Skip("FIXME: Non-conforming compilation") 17 | // Stripped test_error calls from errors.svtest. 18 | RunDovecotTestInline(t, filepath.Join("pigeonhole", "tests", "compile"), ` 19 | require "vnd.dovecot.testsuite"; 20 | test "Lexer errors (FIXME: count only)" { 21 | if test_script_compile "errors/lexer.sieve" { 22 | test_fail "compile should have failed."; 23 | } 24 | } 25 | test "Parser errors (FIXME: count only)" { 26 | if test_script_compile "errors/parser.sieve" { 27 | test_fail "compile should have failed."; 28 | } 29 | } 30 | test "Header errors" { 31 | if test_script_compile "errors/header.sieve" { 32 | test_fail "compile should have failed."; 33 | } 34 | } 35 | test "Address errors" { 36 | if test_script_compile "errors/address.sieve" { 37 | test_fail "compile should have failed."; 38 | } 39 | } 40 | test "If errors (FIXME: count only)" { 41 | if test_script_compile "errors/if.sieve" { 42 | test_fail "compile should have failed."; 43 | } 44 | } 45 | test "Require errors (FIXME: count only)" { 46 | if test_script_compile "errors/require.sieve" { 47 | test_fail "compile should have failed."; 48 | } 49 | } 50 | test "Size errors (FIXME: count only)" { 51 | if test_script_compile "errors/size.sieve" { 52 | test_fail "compile should have failed."; 53 | } 54 | } 55 | test "Envelope errors (FIXME: count only)" { 56 | if test_script_compile "errors/envelope.sieve" { 57 | test_fail "compile should have failed."; 58 | } 59 | } 60 | test "Stop errors (FIXME: count only)" { 61 | if test_script_compile "errors/stop.sieve" { 62 | test_fail "compile should have failed."; 63 | } 64 | } 65 | test "Keep errors (FIXME: count only)" { 66 | if test_script_compile "errors/keep.sieve" { 67 | test_fail "compile should have failed."; 68 | } 69 | } 70 | test "Fileinto errors (FIXME: count only)" { 71 | if test_script_compile "errors/fileinto.sieve" { 72 | test_fail "compile should have failed."; 73 | } 74 | } 75 | test "COMPARATOR errors (FIXME: count only)" { 76 | if test_script_compile "errors/comparator.sieve" { 77 | test_fail "compile should have failed."; 78 | } 79 | } 80 | test "ADDRESS-PART errors (FIXME: count only)" { 81 | if test_script_compile "errors/address-part.sieve" { 82 | test_fail "compile should have failed."; 83 | } 84 | } 85 | test "MATCH-TYPE errors (FIXME: count only)" { 86 | if test_script_compile "errors/match-type.sieve" { 87 | test_fail "compile should have failed."; 88 | } 89 | } 90 | test "Encoded-character errors (FIXME: count only)" { 91 | if test_script_compile "errors/encoded-character.sieve" { 92 | test_fail "compile should have failed."; 93 | } 94 | } 95 | test "Outgoing address errors (FIXME: count only)" { 96 | if test_script_compile "errors/out-address.sieve" { 97 | test_fail "compile should have failed."; 98 | } 99 | } 100 | test "Tagged argument errors (FIXME: count only)" { 101 | if test_script_compile "errors/tag.sieve" { 102 | test_fail "compile should have failed."; 103 | } 104 | } 105 | test "Typos" { 106 | if test_script_compile "errors/typos.sieve" { 107 | test_fail "compile should have failed."; 108 | } 109 | } 110 | test "Unsupported language features (FIXME: count only)" { 111 | if test_script_compile "errors/unsupported.sieve" { 112 | test_fail "compile should have failed."; 113 | } 114 | } 115 | `) 116 | //RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "compile", "errors.svtest")) 117 | } 118 | 119 | func TestCompileRecover(t *testing.T) { 120 | t.Skip("FIXME: Non-conforming compilation") 121 | RunDovecotTestInline(t, filepath.Join("pigeonhole", "tests", "compile"), ` 122 | require "vnd.dovecot.testsuite"; 123 | test "Missing semicolons" { 124 | if test_script_compile "recover/commands-semicolon.sieve" { 125 | test_fail "compile should have failed."; 126 | } 127 | } 128 | test "Missing semicolon at end of block" { 129 | if test_script_compile "recover/commands-endblock.sieve" { 130 | test_fail "compile should have failed."; 131 | } 132 | } 133 | test "Spurious comma at end of test list" { 134 | if test_script_compile "recover/tests-endcomma.sieve" { 135 | test_fail "compile should have failed."; 136 | } 137 | } 138 | `) 139 | } 140 | 141 | func TestCompileWarnings(t *testing.T) { 142 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "compile", "warnings.svtest")) 143 | } 144 | -------------------------------------------------------------------------------- /tests/encodedcharacter_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestExtensionsEncodedCharacters(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "encoded-character.svtest")) 10 | } 11 | -------------------------------------------------------------------------------- /tests/envelope_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestExtensionsEnvelope(t *testing.T) { 9 | RunDovecotTestWithout(t, filepath.Join("pigeonhole", "tests", "extensions", "envelope.svtest"), 10 | []string{ 11 | // Parser does not understand source routes 12 | "Envelope - source route", 13 | "Envelope - source route errors", 14 | // Envelope address validation is left to the library user e.g. SMTP server. 15 | "Envelope - invalid paths", 16 | "Envelope - syntax errors", 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /tests/match_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestMatchContains(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "match-types", "contains.svtest")) 10 | } 11 | 12 | func TestMatchIs(t *testing.T) { 13 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "match-types", "is.svtest")) 14 | } 15 | 16 | func TestMatchMatches(t *testing.T) { 17 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "match-types", "matches.svtest")) 18 | } 19 | -------------------------------------------------------------------------------- /tests/relational_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestRelationalBasic(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "relational", "basic.svtest")) 10 | } 11 | 12 | func TestRelationalComparators(t *testing.T) { 13 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "relational", "comparators.svtest")) 14 | } 15 | 16 | func TestRelationalErrors(t *testing.T) { 17 | // Stripped test_error calls. 18 | RunDovecotTestInline(t, filepath.Join("pigeonhole", "tests", "extensions", "relational"), ` 19 | require "vnd.dovecot.testsuite"; 20 | test "Syntax errors" { 21 | if test_script_compile "errors/syntax.sieve" { 22 | test_fail "compile should have failed"; 23 | } 24 | } 25 | test "Validation errors" { 26 | if test_script_compile "errors/validation.sieve" { 27 | test_fail "compile should have failed"; 28 | } 29 | } 30 | `) 31 | 32 | //RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "relational", "errors.svtest")) 33 | } 34 | 35 | func TestRelationalRFC(t *testing.T) { 36 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "relational", "rfc.svtest")) 37 | } 38 | -------------------------------------------------------------------------------- /tests/run.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/foxcpp/go-sieve" 12 | "github.com/foxcpp/go-sieve/interp" 13 | ) 14 | 15 | func RunDovecotTestInline(t *testing.T, baseDir string, scriptText string) { 16 | opts := sieve.DefaultOptions() 17 | opts.Lexer.Filename = "inline" 18 | opts.Interp.T = t 19 | 20 | script, err := sieve.Load(strings.NewReader(scriptText), opts) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | ctx := context.Background() 26 | 27 | // Empty data. 28 | data := sieve.NewRuntimeData(script, interp.DummyPolicy{}, 29 | interp.EnvelopeStatic{}, interp.MessageStatic{}) 30 | 31 | if baseDir == "" { 32 | wd, err := os.Getwd() 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | data.Namespace = os.DirFS(wd) 37 | } else { 38 | data.Namespace = os.DirFS(baseDir) 39 | } 40 | 41 | err = script.Execute(ctx, data) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | } 46 | 47 | func RunDovecotTestWithout(t *testing.T, path string, disabledTests []string) { 48 | svScript, err := os.ReadFile(path) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | opts := sieve.DefaultOptions() 54 | opts.Lexer.Filename = filepath.Base(path) 55 | opts.Interp.T = t 56 | opts.Interp.DisabledTests = disabledTests 57 | 58 | script, err := sieve.Load(bytes.NewReader(svScript), opts) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | 63 | ctx := context.Background() 64 | 65 | // Empty data. 66 | data := sieve.NewRuntimeData(script, interp.DummyPolicy{}, 67 | interp.EnvelopeStatic{}, interp.MessageStatic{}) 68 | data.Namespace = os.DirFS(filepath.Dir(path)) 69 | 70 | err = script.Execute(ctx, data) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | } 75 | 76 | func RunDovecotTest(t *testing.T, path string) { 77 | RunDovecotTestWithout(t, path, nil) 78 | } 79 | -------------------------------------------------------------------------------- /tests/variables_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestExtensionsVariablesBasic(t *testing.T) { 9 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "basic.svtest")) 10 | } 11 | 12 | func TestExtensionsVariablesErrors(t *testing.T) { 13 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "errors.svtest")) 14 | } 15 | 16 | func TestExtensionsVariablesLimits(t *testing.T) { 17 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "limits.svtest")) 18 | } 19 | 20 | func TestExtensionsVariablesMatch(t *testing.T) { 21 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "match.svtest")) 22 | } 23 | 24 | func TestExtensionsVariablesModifiers(t *testing.T) { 25 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "modifiers.svtest")) 26 | } 27 | 28 | func TestExtensionsVariablesQuoting(t *testing.T) { 29 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "quoting.svtest")) 30 | } 31 | 32 | func TestExtensionsVariablesRegex(t *testing.T) { 33 | t.Skip("requires regex extension") 34 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "regex.svtest")) 35 | } 36 | 37 | func TestExtensionsVariablesString(t *testing.T) { 38 | RunDovecotTest(t, filepath.Join("pigeonhole", "tests", "extensions", "variables", "string.svtest")) 39 | } 40 | --------------------------------------------------------------------------------