Skip to content

Commit

Permalink
feature: control randomness of boundary headers (#276)
Browse files Browse the repository at this point in the history
Adds a RandSeed func to mail builder, allowing for reproducible output.
  • Loading branch information
xoba committed Feb 10, 2023
1 parent 7c7bb37 commit 2161503
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 15 deletions.
21 changes: 21 additions & 0 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"io/ioutil"
"math/rand"
"mime"
"net/mail"
"net/textproto"
Expand All @@ -28,13 +29,20 @@ type MailBuilder struct {
text, html []byte
inlines, attachments []*Part
err error
randSource rand.Source
}

// Builder returns an empty MailBuilder struct.
func Builder() MailBuilder {
return MailBuilder{}
}

// RandSeed sets the seed for random uuid boundary strings.
func (p MailBuilder) RandSeed(seed int64) MailBuilder {
p.randSource = stringutil.NewLockedSource(seed)
return p
}

// Error returns the stored error from a file attachment/inline read or nil.
func (p MailBuilder) Error() error {
return p.err
Expand Down Expand Up @@ -391,9 +399,22 @@ func (p MailBuilder) Build() (*Part, error) {
h.Add(k, s)
}
}
if r := p.randSource; r != nil {
root.propagateRand(r)
}
return root, nil
}

func (p *Part) propagateRand(rand rand.Source) {
p.randSource = rand
for _, x := range []*Part{p.FirstChild, p.NextSibling} {
if x == nil {
continue
}
x.propagateRand(rand)
}
}

// SendWithReversePath encodes the message and sends it via the specified Sender.
func (p MailBuilder) SendWithReversePath(sender Sender, from string) error {
buf := &bytes.Buffer{}
Expand Down
2 changes: 1 addition & 1 deletion encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func (p *Part) setupMIMEHeaders() transferEncoding {
// Setup headers.
if p.FirstChild != nil && p.Boundary == "" {
// Multipart, generate random boundary marker.
p.Boundary = "enmime-" + stringutil.UUID()
p.Boundary = "enmime-" + stringutil.UUID(p.randSource)
}
if p.ContentID != "" {
p.Header.Set(hnContentID, coding.ToIDHeader(p.ContentID))
Expand Down
44 changes: 44 additions & 0 deletions internal/stringutil/rand_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package stringutil

import (
"math/rand"
"sync"
"time"
)

var globalRandSource rand.Source

func init() {
globalRandSource = NewLockedSource(time.Now().UTC().UnixNano())
}

// NewLockedSource creates a source of randomness using the given seed.
func NewLockedSource(seed int64) rand.Source64 {
return &lockedSource{
lock: new(sync.Mutex),
s: rand.NewSource(seed).(rand.Source64),
}
}

type lockedSource struct {
lock sync.Locker
s rand.Source64
}

func (x *lockedSource) Int63() int64 {
x.lock.Lock()
defer x.lock.Unlock()
return x.s.Int63()
}

func (x *lockedSource) Uint64() uint64 {
x.lock.Lock()
defer x.lock.Unlock()
return x.s.Uint64()
}

func (x *lockedSource) Seed(seed int64) {
x.lock.Lock()
defer x.lock.Unlock()
x.s.Seed(seed)
}
16 changes: 6 additions & 10 deletions internal/stringutil/uuid.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ package stringutil
import (
"fmt"
"math/rand"
"sync"
"time"
)

var uuidRand = rand.New(rand.NewSource(time.Now().UnixNano()))
var uuidMutex = &sync.Mutex{}

// UUID generates a random UUID according to RFC 4122.
func UUID() string {
// UUID generates a random UUID according to RFC 4122, using optional rand if supplied
func UUID(rs rand.Source) string {
uuid := make([]byte, 16)
uuidMutex.Lock()
_, _ = uuidRand.Read(uuid)
uuidMutex.Unlock()
if rs == nil {
rs = globalRandSource
}
_, _ = rand.New(rs).Read(uuid)
// variant bits; see section 4.1.1
uuid[8] = uuid[8]&^0xc0 | 0x80
// version 4 (pseudo-random); see section 4.1.3
Expand Down
4 changes: 2 additions & 2 deletions internal/stringutil/uuid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
)

func TestUUID(t *testing.T) {
id1 := stringutil.UUID()
id2 := stringutil.UUID()
id1 := stringutil.UUID(nil)
id2 := stringutil.UUID(nil)

if id1 == id2 {
t.Errorf("Random UUID should not equal another random UUID")
Expand Down
7 changes: 5 additions & 2 deletions part.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"io"
"io/ioutil"
"math/rand"
"mime/quotedprintable"
"net/textproto"
"strconv"
Expand All @@ -26,11 +27,11 @@ const (
// Part represents a node in the MIME multipart tree. The Content-Type, Disposition and File Name
// are parsed out of the header for easier access.
type Part struct {
PartID string // PartID labels this parts position within the tree.
PartID string // PartID labels this part's position within the tree.
Parent *Part // Parent of this part (can be nil.)
FirstChild *Part // FirstChild is the top most child of this part.
NextSibling *Part // NextSibling of this part.
Header textproto.MIMEHeader // Header for this Part.
Header textproto.MIMEHeader // Header for this part.

Boundary string // Boundary marker used within this part.
ContentID string // ContentID header for cid URL scheme.
Expand All @@ -47,6 +48,8 @@ type Part struct {
Epilogue []byte // Epilogue contains data following the closing boundary marker.

parser *Parser // Provides access to parsing options.

randSource rand.Source // optional rand for uuid boundary generation
}

// NewPart creates a new Part object.
Expand Down
93 changes: 93 additions & 0 deletions randoption_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package enmime_test

import (
"bytes"
"fmt"
"testing"
"time"

"github.com/jhillyerd/enmime"
"github.com/stretchr/testify/assert"
)

// TestRandOption checks that different randomness modes behave as expected, relative to one another.
func TestRandOption(t *testing.T) {
types := []ReproducibilityMode{ZeroSource, OneSource, DefaultSource, TimestampSource}
for _, a := range types {
for _, b := range types {
ha, hb := buildEmail(t, a), buildEmail(t, b)
if a == b && a.IsReproducible() {
assert.Equal(t, ha, hb)
} else {
assert.NotEqual(t, ha, hb)
}
}
}
}

type ReproducibilityMode int

const (
ZeroSource ReproducibilityMode = iota
OneSource
DefaultSource
TimestampSource
)

func (mode ReproducibilityMode) IsReproducible() bool {
switch mode {
case ZeroSource:
return true
case OneSource:
return true
case DefaultSource:
return false
case TimestampSource:
return false
default:
panic(fmt.Errorf("illegal mode: %d", mode))
}
}

func (mode ReproducibilityMode) String() string {
switch mode {
case ZeroSource:
return "ZeroSource"
case OneSource:
return "OneSource"
case DefaultSource:
return "DefaultSource"
case TimestampSource:
return "TimestampSource"
default:
panic(fmt.Errorf("illegal mode: %d", mode))
}
}

// buildEmail creates a string email, according to the given Reproducibilitymode.
func buildEmail(t *testing.T, mode ReproducibilityMode) string {
t.Helper()
var b enmime.MailBuilder
switch mode {
case ZeroSource:
b = enmime.Builder().RandSeed(0)
case OneSource:
b = enmime.Builder().RandSeed(1)
case DefaultSource:
b = enmime.Builder()
case TimestampSource:
b = enmime.Builder().RandSeed(time.Now().UTC().UnixNano())
default:
panic(fmt.Errorf("illegal mode: %d", mode))
}
b = b.From("name", "same").To("anon", "anon@example.com").AddAttachment([]byte("testing"), "text/plain", "test.txt")
p, err := b.Build()
if err != nil {
t.Fatalf("can't build email: %v", err)
}
w := new(bytes.Buffer)
if err := p.Encode(w); err != nil {
t.Fatalf("can't encode part: %v", err)
}
return w.String()
}

0 comments on commit 2161503

Please sign in to comment.