first commit
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// Package vcard implements a minimal RFC 6350 vCard parser/builder — just
|
||||
// the fields CardDAV needs to round-trip contacts: UID, FN, N, EMAIL, TEL,
|
||||
// ORG, NOTE. Not a full vCard 4.0 implementation (no PHOTO, no groups, no
|
||||
// extended params) — enough for real mail/contacts clients to store and
|
||||
// retrieve a usable contact, with more fields added as client compatibility
|
||||
// testing surfaces the need.
|
||||
package vcard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Card struct {
|
||||
UID string
|
||||
FN string // formatted name
|
||||
N string // structured name: Family;Given;Middle;Prefix;Suffix
|
||||
Email []string
|
||||
Tel []string
|
||||
Org string
|
||||
Note string
|
||||
}
|
||||
|
||||
// Parse reads a single vCard (BEGIN:VCARD...END:VCARD) into a Card.
|
||||
func Parse(data string) (*Card, error) {
|
||||
lines := unfold(data)
|
||||
c := &Card{}
|
||||
inCard := false
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
upper := strings.ToUpper(line)
|
||||
switch {
|
||||
case upper == "BEGIN:VCARD":
|
||||
inCard = true
|
||||
continue
|
||||
case upper == "END:VCARD":
|
||||
inCard = false
|
||||
continue
|
||||
}
|
||||
if !inCard {
|
||||
continue
|
||||
}
|
||||
|
||||
name, value, found := splitProperty(line)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.ToUpper(name) {
|
||||
case "UID":
|
||||
c.UID = value
|
||||
case "FN":
|
||||
c.FN = value
|
||||
case "N":
|
||||
c.N = value
|
||||
case "EMAIL":
|
||||
c.Email = append(c.Email, value)
|
||||
case "TEL":
|
||||
c.Tel = append(c.Tel, value)
|
||||
case "ORG":
|
||||
c.Org = value
|
||||
case "NOTE":
|
||||
c.Note = unescape(value)
|
||||
}
|
||||
}
|
||||
|
||||
if c.UID == "" {
|
||||
return nil, fmt.Errorf("vcard missing required UID property")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Build renders a Card back into vCard 4.0 text, CRLF line endings per spec.
|
||||
func (c *Card) Build() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("BEGIN:VCARD\r\n")
|
||||
b.WriteString("VERSION:4.0\r\n")
|
||||
b.WriteString("UID:" + c.UID + "\r\n")
|
||||
if c.FN != "" {
|
||||
b.WriteString("FN:" + escape(c.FN) + "\r\n")
|
||||
}
|
||||
if c.N != "" {
|
||||
b.WriteString("N:" + c.N + "\r\n")
|
||||
}
|
||||
for _, e := range c.Email {
|
||||
b.WriteString("EMAIL:" + e + "\r\n")
|
||||
}
|
||||
for _, t := range c.Tel {
|
||||
b.WriteString("TEL:" + t + "\r\n")
|
||||
}
|
||||
if c.Org != "" {
|
||||
b.WriteString("ORG:" + escape(c.Org) + "\r\n")
|
||||
}
|
||||
if c.Note != "" {
|
||||
b.WriteString("NOTE:" + escape(c.Note) + "\r\n")
|
||||
}
|
||||
b.WriteString("END:VCARD\r\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitProperty splits "NAME;PARAM=x:value" into (name, value) — parameters
|
||||
// are discarded in this minimal pass (deferred: TYPE=work/home distinction).
|
||||
func splitProperty(line string) (name, value string, found bool) {
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx == -1 {
|
||||
return "", "", false
|
||||
}
|
||||
namePart := line[:colonIdx]
|
||||
value = line[colonIdx+1:]
|
||||
if semiIdx := strings.Index(namePart, ";"); semiIdx != -1 {
|
||||
namePart = namePart[:semiIdx]
|
||||
}
|
||||
return namePart, value, true
|
||||
}
|
||||
|
||||
// unfold reverses RFC 6350 §3.2 line folding (a line starting with a single
|
||||
// space or tab is a continuation of the previous line).
|
||||
func unfold(data string) []string {
|
||||
raw := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
|
||||
var out []string
|
||||
for _, line := range raw {
|
||||
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
|
||||
out[len(out)-1] += line[1:]
|
||||
} else {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, ",", "\\,")
|
||||
s = strings.ReplaceAll(s, ";", "\\;")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
return s
|
||||
}
|
||||
|
||||
func unescape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\\,", ",")
|
||||
s = strings.ReplaceAll(s, "\\;", ";")
|
||||
s = strings.ReplaceAll(s, "\\\\", "\\")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package vcard
|
||||
|
||||
import "testing"
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
f.Add("BEGIN:VCARD\r\nVERSION:4.0\r\nUID:test-1\r\nFN:Test Person\r\nEND:VCARD\r\n")
|
||||
f.Add("BEGIN:VCARD\nUID:no-crlf\nEND:VCARD\n")
|
||||
f.Add("BEGIN:VCARD\r\nUID:folded\r\nNOTE:line one\r\n continued\r\nEND:VCARD\r\n")
|
||||
f.Add("BEGIN:VCARD\r\nUID:escaped\r\nNOTE:a\\,b\\;c\\\\d\\ne\r\nEND:VCARD\r\n")
|
||||
f.Add("")
|
||||
f.Add("BEGIN:VCARD\r\nEND:VCARD\r\n")
|
||||
f.Add("not a vcard at all")
|
||||
f.Add("BEGIN:VCARD\r\n:\r\nEND:VCARD\r\n")
|
||||
f.Add("BEGIN:VCARD\r\nUID\r\nEND:VCARD\r\n")
|
||||
f.Add("BEGIN:VCARD\r\n;;;:;;;\r\nUID:x\r\nEND:VCARD\r\n")
|
||||
|
||||
f.Fuzz(func(t *testing.T, data string) {
|
||||
// The only contract checked here: Parse must never panic on any
|
||||
// input, malformed or not — a returned error is fine, a crash is
|
||||
// not, since this parser runs on untrusted client-supplied CardDAV
|
||||
// PUT bodies.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Parse panicked on input %q: %v", data, r)
|
||||
}
|
||||
}()
|
||||
Parse(data)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user