Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions v3/postaladdress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package ldap

import (
"errors"
"fmt"
"strings"
)

var ErrEmptyPostalAddress = errors.New("ldap: postal address cannot be empty")

// PostalAddress represents an RFC 4517 Postal Address
// A postal address is a sequence of strings of one or more arbitrary UCS
// characters, which form the lines of the address.
type PostalAddress struct {
lines []string
}

// NewPostalAddress creates a new PostalAddress by copying non-empty lines from the provided slice of strings.
func NewPostalAddress(lines []string) (*PostalAddress, error) {
copiedLines := make([]string, 0, len(lines))
for _, line := range lines {
if line == "" {
continue
}
copiedLines = append(copiedLines, line)
}

if len(copiedLines) == 0 {
return nil, ErrEmptyPostalAddress
}

return &PostalAddress{lines: copiedLines}, nil
}

// Lines returns a copy of the address lines as a slice of strings.
func (p *PostalAddress) Lines() []string {
copiedLines := make([]string, len(p.lines))
copy(copiedLines, p.lines)
return copiedLines
}

// String returns the postal address as a single string, with lines joined by newline characters.
func (p *PostalAddress) String() string {
return strings.Join(p.lines, "\n")
}

// Escape encodes special characters in the PostalAddress lines as per RFC 4517 and appends a `$` at the end of each line.
func (p *PostalAddress) Escape() string {
builder := &strings.Builder{}

for _, line := range p.lines {
for _, char := range line {
switch char {
case '\\':
builder.WriteString("\\5C")
case '$':
builder.WriteString("\\24")
default:
builder.WriteRune(char)
}
}

builder.WriteRune('$')
}

return builder.String()
}

// ParsePostalAddress parses an RFC 4517 escaped postal address string into a PostalAddress object or returns an error.
func ParsePostalAddress(escaped string) (*PostalAddress, error) {
lines := strings.Split(escaped, "$")
parsedLines := make([]string, 0, len(lines))
const totalEscapeLen = 3

for _, line := range lines {
if line == "" {
// Skip empty lines
continue
}

builder := &strings.Builder{}
for i := 0; i < len(line); i++ {
char := line[i]
if char == '\\' && i+totalEscapeLen <= len(line) {
escapeSeq := line[i+1 : i+totalEscapeLen]
switch escapeSeq {
case "5C", "5c":
builder.WriteRune('\\')
i += 2
case "24":
builder.WriteRune('$')
i += 2
default:
return nil, fmt.Errorf("invalid escape sequence: \\%s at position %d", escapeSeq, i)
}
} else if char == '\\' {
return nil, fmt.Errorf("incomplete escape sequence at position %d", i)
} else {
builder.WriteByte(char)
}
}
parsedLines = append(parsedLines, builder.String())
}

if len(parsedLines) == 0 {
return nil, ErrEmptyPostalAddress
}

return &PostalAddress{lines: parsedLines}, nil
}

// Equal compares the current PostalAddress with another PostalAddress and returns true if they are identical.
func (p *PostalAddress) Equal(other *PostalAddress) bool {
if p == other {
return true
}
if p == nil || other == nil {
return false
}

if len(p.lines) != len(other.lines) {
return false
}
for i := range p.lines {
if p.lines[i] != other.lines[i] {
return false
}
}
return true
}
222 changes: 222 additions & 0 deletions v3/postaladdress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package ldap

import (
"testing"

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

func TestPostalAddressRoundTrip(t *testing.T) {
testStrings := []struct {
Escaped string
Expected string
}{
{
Escaped: "AAAAA\\5cBBBBB$",
Expected: "AAAAA\\BBBBB",
},
{
Escaped: `line\5C`,
Expected: "line\\",
},
{
Escaped: "1234 Main St.$Anytown, CA 12345$USA",
Expected: "1234 Main St.\nAnytown, CA 12345\nUSA",
},
{
Escaped: `\241,000,000 Sweepstakes$PO Box 1000000$Anytown, CA 12345$USA`,
Expected: "$1,000,000 Sweepstakes\nPO Box 1000000\nAnytown, CA 12345\nUSA",
},
}
for _, str := range testStrings {
t.Run(str.Escaped, func(t *testing.T) {
escaped, err := ParsePostalAddress(str.Escaped)
assert.NoError(t, err)
assert.Equal(t, str.Expected, escaped.String())

addr, err := NewPostalAddress([]string{str.Expected})
assert.NoError(t, err)
assert.Equal(t, str.Expected, addr.String(), "PostalAddress.String() should round-trip")
})
}
}

func TestPostalAddressEmptyLines(t *testing.T) {
_, err := NewPostalAddress([]string{""})
assert.Equal(t, err, ErrEmptyPostalAddress)
}

func TestPostalAddressUTF8Handling(t *testing.T) {
testCases := []struct {
name string
lines []string
expected string
}{
{
name: "emoji characters",
lines: []string{"123 Main St 🏠", "Tokyo 🗾", "Japan 🇯🇵"},
expected: "123 Main St 🏠$Tokyo 🗾$Japan 🇯🇵$",
},
{
name: "cyrillic characters",
lines: []string{"Красная площадь", "Москва 101000", "Россия"},
expected: "Красная площадь$Москва 101000$Россия$",
},
{
name: "chinese characters",
lines: []string{"北京市东城区", "天安门广场", "中国"},
expected: "北京市东城区$天安门广场$中国$",
},
{
name: "arabic characters",
lines: []string{"شارع الملك فهد", "الرياض", "المملكة العربية السعودية"},
expected: "شارع الملك فهد$الرياض$المملكة العربية السعودية$",
},
{
name: "mixed scripts with special chars",
lines: []string{"Café René ☕", "Zürich $1000\\month", "Schweiz 🇨🇭"},
expected: "Café René ☕$Zürich \\241000\\5Cmonth$Schweiz 🇨🇭$",
},
{
name: "mathematical symbols",
lines: []string{"∑ ∫ ∂", "π ≈ 3.14159", "∞ ≠ 0"},
expected: "∑ ∫ ∂$π ≈ 3.14159$∞ ≠ 0$",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
addr, err := NewPostalAddress(tc.lines)
assert.NoError(t, err)
escaped := addr.Escape()
assert.Equal(t, tc.expected, escaped, "UTF-8 characters should be preserved in escaped output")

// Round-trip test
parsed, err := ParsePostalAddress(escaped)
assert.NoError(t, err)
assert.Equal(t, tc.lines, parsed.Lines(), "UTF-8 characters should survive round-trip")
})
}
}

func TestPostalAddressEqual(t *testing.T) {
testCases := []struct {
name string
addr1 *PostalAddress
addr2 *PostalAddress
expected bool
}{
{
name: "both nil",
addr1: nil,
addr2: nil,
expected: true,
},
{
name: "first nil",
addr1: nil,
addr2: mustNewPostalAddress(t, []string{"line1"}),
expected: false,
},
{
name: "second nil",
addr1: mustNewPostalAddress(t, []string{"line1"}),
addr2: nil,
expected: false,
},
{
name: "same single line",
addr1: mustNewPostalAddress(t, []string{"123 Main St"}),
addr2: mustNewPostalAddress(t, []string{"123 Main St"}),
expected: true,
},
{
name: "different single line",
addr1: mustNewPostalAddress(t, []string{"123 Main St"}),
addr2: mustNewPostalAddress(t, []string{"456 Oak Ave"}),
expected: false,
},
{
name: "same multi-line",
addr1: mustNewPostalAddress(t, []string{"123 Main St", "Anytown, CA", "USA"}),
addr2: mustNewPostalAddress(t, []string{"123 Main St", "Anytown, CA", "USA"}),
expected: true,
},
{
name: "different multi-line content",
addr1: mustNewPostalAddress(t, []string{"123 Main St", "Anytown, CA", "USA"}),
addr2: mustNewPostalAddress(t, []string{"123 Main St", "Othertown, CA", "USA"}),
expected: false,
},
{
name: "different line count",
addr1: mustNewPostalAddress(t, []string{"123 Main St", "Anytown, CA"}),
addr2: mustNewPostalAddress(t, []string{"123 Main St", "Anytown, CA", "USA"}),
expected: false,
},
{
name: "same order matters",
addr1: mustNewPostalAddress(t, []string{"line1", "line2"}),
addr2: mustNewPostalAddress(t, []string{"line2", "line1"}),
expected: false,
},
{
name: "whitespace differences",
addr1: mustNewPostalAddress(t, []string{"123 Main St"}),
addr2: mustNewPostalAddress(t, []string{"123 Main St"}),
expected: false,
},
{
name: "case sensitive",
addr1: mustNewPostalAddress(t, []string{"Main Street"}),
addr2: mustNewPostalAddress(t, []string{"main street"}),
expected: false,
},
{
name: "with special characters",
addr1: mustNewPostalAddress(t, []string{"Café René", "$1000\\month"}),
addr2: mustNewPostalAddress(t, []string{"Café René", "$1000\\month"}),
expected: true,
},
{
name: "with UTF-8 characters",
addr1: mustNewPostalAddress(t, []string{"北京市东城区", "中国 🇨🇳"}),
addr2: mustNewPostalAddress(t, []string{"北京市东城区", "中国 🇨🇳"}),
expected: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := tc.addr1.Equal(tc.addr2)
assert.Equal(t, tc.expected, result)

// Test symmetry (except for nil cases where calling on nil would panic)
if tc.addr1 != nil && tc.addr2 != nil {
reverseResult := tc.addr2.Equal(tc.addr1)
assert.Equal(t, tc.expected, reverseResult, "Equals should be symmetric")
}
})
}
}

func TestParsePostalAddress_Escape(t *testing.T) {
t.Run("incomplete escape", func(t *testing.T) {
_, err := ParsePostalAddress("AAAAAAAAAA\\")
assert.Error(t, err)
})

t.Run("invalid escape", func(t *testing.T) {
_, err := ParsePostalAddress("AAAAAAAAAA\\5XAAAAA")
assert.Error(t, err)
})
}

func mustNewPostalAddress(t *testing.T, lines []string) *PostalAddress {
t.Helper()
addr, err := NewPostalAddress(lines)
if err != nil {
t.Fatalf("NewPostalAddress failed: %v", err)
}
return addr
}
Loading