diff --git a/card.go b/card.go index ee30c00..9c7e2c9 100644 --- a/card.go +++ b/card.go @@ -94,6 +94,54 @@ func maybeGet(l []string, i int) string { return "" } +// isStructuredValue reports whether a property's value is a list of components +// separated by ';', each escaped per RFC 6350 section 3.4. +func isStructuredValue(key string) bool { + switch strings.ToUpper(key) { + case FieldName, FieldAddress: + return true + } + return false +} + +// structuredValueEscaper escapes a single structured-value component +// (RFC 6350 section 3.4): backslash first so it doesn't double-escape the rest. +var structuredValueEscaper = strings.NewReplacer("\\", "\\\\", "\n", "\\n", ",", "\\,", ";", "\\;") + +// formatStructuredValue escapes each component and joins them with ';'. +func formatStructuredValue(components []string) string { + escaped := make([]string, len(components)) + for i, c := range components { + escaped[i] = structuredValueEscaper.Replace(c) + } + return strings.Join(escaped, ";") +} + +// parseStructuredValue splits a structured value on unescaped ';' and unescapes +// each component. It reverses formatStructuredValue. +func parseStructuredValue(value string) []string { + var components []string + var b strings.Builder + for i := 0; i < len(value); i++ { + switch c := value[i]; { + case c == '\\' && i+1 < len(value): + if n := value[i+1]; n == 'n' || n == 'N' { + b.WriteByte('\n') + } else { + b.WriteByte(n) + } + i++ + case c == ';': + components = append(components, b.String()) + b.Reset() + default: + b.WriteByte(c) + } + } + components = append(components, b.String()) + return components +} + // A Card is an address book entry. type Card map[string][]*Field @@ -435,7 +483,7 @@ type Name struct { } func newName(field *Field) *Name { - components := strings.Split(field.Value, ";") + components := parseStructuredValue(field.Value) return &Name{ field, maybeGet(components, 0), @@ -450,13 +498,13 @@ func (n *Name) field() *Field { if n.Field == nil { n.Field = new(Field) } - n.Field.Value = strings.Join([]string{ + n.Field.Value = formatStructuredValue([]string{ n.FamilyName, n.GivenName, n.AdditionalName, n.HonorificPrefix, n.HonorificSuffix, - }, ";") + }) return n.Field } @@ -486,7 +534,7 @@ type Address struct { } func newAddress(field *Field) *Address { - components := strings.Split(field.Value, ";") + components := parseStructuredValue(field.Value) return &Address{ field, maybeGet(components, 0), @@ -503,7 +551,7 @@ func (a *Address) field() *Field { if a.Field == nil { a.Field = new(Field) } - a.Field.Value = strings.Join([]string{ + a.Field.Value = formatStructuredValue([]string{ a.PostOfficeBox, a.ExtendedAddress, a.StreetAddress, @@ -511,6 +559,6 @@ func (a *Address) field() *Field { a.Region, a.PostalCode, a.Country, - }, ";") + }) return a.Field } diff --git a/decoder.go b/decoder.go index 7d3bdb4..1f7048a 100644 --- a/decoder.go +++ b/decoder.go @@ -116,7 +116,13 @@ func parseLine(l string) (key string, field *Field, err error) { } } - field.Value = parseValue(l) + if isStructuredValue(key) { + // Keep the raw value; its components are unescaped on access, splitting + // on unescaped ';' (RFC 6350 section 3.4). + field.Value = l + } else { + field.Value = parseValue(l) + } return } diff --git a/encoder.go b/encoder.go index e2f0017..120e43f 100644 --- a/encoder.go +++ b/encoder.go @@ -73,7 +73,13 @@ func formatLine(key string, field *Field) string { } } - s += ":" + formatValue(field.Value) + if isStructuredValue(key) { + // The value is already escaped per component (RFC 6350 section 3.4); + // its ';' separators must stay literal. + s += ":" + field.Value + } else { + s += ":" + formatValue(field.Value) + } return s } diff --git a/structured_test.go b/structured_test.go new file mode 100644 index 0000000..bc0241e --- /dev/null +++ b/structured_test.go @@ -0,0 +1,101 @@ +package vcard + +import ( + "bytes" + "strings" + "testing" +) + +func roundtripCard(t *testing.T, c Card) Card { + t.Helper() + var buf bytes.Buffer + if err := NewEncoder(&buf).Encode(c); err != nil { + t.Fatal(err) + } + out, err := NewDecoder(strings.NewReader(buf.String())).Decode() + if err != nil { + t.Fatal(err) + } + return out +} + +func versionedCard() Card { + c := make(Card) + c.SetValue(FieldVersion, "4.0") + return c +} + +// A literal ';' inside a structured component must survive a round trip: per +// RFC 6350 section 3.4 a ';' separates components only when unescaped. +func TestStructuredName_Roundtrip(t *testing.T) { + tests := []Name{ + {FamilyName: "a;b", GivenName: "c"}, + {FamilyName: "de Groot", GivenName: "Rene;", AdditionalName: ";x;"}, + {FamilyName: `back\slash`, GivenName: "co,mma"}, + {FamilyName: `ends-with\`, GivenName: "b"}, + {FamilyName: `lit\;eral`, GivenName: "c"}, + {FamilyName: "line\nbreak"}, + {FamilyName: "Doe", GivenName: "J."}, + } + for _, want := range tests { + c := versionedCard() + c.SetName(&want) + got := roundtripCard(t, c).Name() + if got == nil { + t.Fatalf("%+v: Name() nil after round trip", want) + } + if got.FamilyName != want.FamilyName || got.GivenName != want.GivenName || + got.AdditionalName != want.AdditionalName { + t.Errorf("round trip: got (%q,%q,%q) want (%q,%q,%q)", + got.FamilyName, got.GivenName, got.AdditionalName, + want.FamilyName, want.GivenName, want.AdditionalName) + } + } +} + +func TestStructuredAddress_Roundtrip(t *testing.T) { + want := &Address{ + StreetAddress: "12 Main St; Apt 3", + Locality: "A,B", + Region: `C\D`, + Country: "x;y;z", + } + c := versionedCard() + c.SetAddress(want) + got := roundtripCard(t, c).Address() + if got == nil { + t.Fatal("Address() nil after round trip") + } + if got.StreetAddress != want.StreetAddress || got.Locality != want.Locality || + got.Region != want.Region || got.Country != want.Country { + t.Errorf("round trip: got %+v want %+v", got, want) + } +} + +// The encoded form escapes a literal ';' as '\;' and keeps separators literal. +func TestStructuredName_EncodedForm(t *testing.T) { + c := versionedCard() + c.SetName(&Name{FamilyName: "a;b", GivenName: "c"}) + var buf bytes.Buffer + if err := NewEncoder(&buf).Encode(c); err != nil { + t.Fatal(err) + } + const want = "N:a\\;b;c;;;" + if !strings.Contains(buf.String(), want) { + t.Errorf("encoded N line: want %q in\n%s", want, buf.String()) + } +} + +// Only property values are escaped, not parameter values (RFC 6350 section 3.4): +// a ';' in a parameter must not gain a structured-value backslash escape. +func TestStructuredParam_Unaffected(t *testing.T) { + c := versionedCard() + c.Set("TEL", &Field{Value: "123", Params: Params{"TYPE": {"a;b"}}}) + var buf bytes.Buffer + if err := NewEncoder(&buf).Encode(c); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), `\;`) { + t.Errorf("parameter value was structurally escaped:\n%s", buf.String()) + } +}