diff --git a/card.go b/card.go index ee30c00..4449b3b 100644 --- a/card.go +++ b/card.go @@ -146,7 +146,7 @@ func (c Card) Preferred(k string) *Field { // Value returns the first field value of the card for the given property. If // there is no such field, it returns an empty string. -func (c Card) Value(k string) string { +func (c Card) Value(k string) FieldValue { f := c.Get(k) if f == nil { return "" @@ -156,18 +156,18 @@ func (c Card) Value(k string) string { // AddValue adds the k, v pair to the list of field values. It appends to any // existing values. -func (c Card) AddValue(k, v string) { +func (c Card) AddValue(k string, v FieldValue) { c.Add(k, &Field{Value: v}) } // SetValue sets the field k to the single value v. It replaces any existing // value. -func (c Card) SetValue(k, v string) { +func (c Card) SetValue(k string, v FieldValue) { c.Set(k, &Field{Value: v}) } // PreferredValue returns the preferred field value of the card. -func (c Card) PreferredValue(k string) string { +func (c Card) PreferredValue(k string) FieldValue { f := c.Preferred(k) if f == nil { return "" @@ -176,13 +176,13 @@ func (c Card) PreferredValue(k string) string { } // Values returns a list of values for a given property. -func (c Card) Values(k string) []string { +func (c Card) Values(k string) []FieldValue { fields := c[k] if fields == nil { return nil } - values := make([]string, len(fields)) + values := make([]FieldValue, len(fields)) for i, f := range fields { values[i] = f.Value } @@ -192,7 +192,7 @@ func (c Card) Values(k string) []string { // Kind returns the kind of the object represented by this card. If it isn't // specified, it returns the default: KindIndividual. func (c Card) Kind() Kind { - kind := strings.ToLower(c.Value(FieldKind)) + kind := strings.ToLower(c.Value(FieldKind).String()) if kind == "" { return KindIndividual } @@ -201,7 +201,7 @@ func (c Card) Kind() Kind { // SetKind sets the kind of the object represented by this card. func (c Card) SetKind(kind Kind) { - c.SetValue(FieldKind, string(kind)) + c.SetValue(FieldKind, NewFieldValue(string(kind))) } // FormattedNames returns formatted names of the card. The length of the result @@ -251,7 +251,7 @@ func (c Card) SetName(name *Name) { // Gender returns this card's gender. func (c Card) Gender() (sex Sex, identity string) { v := c.Value(FieldGender) - parts := strings.SplitN(v, ";", 2) + parts := strings.SplitN(v.String(), ";", 2) return Sex(strings.ToUpper(parts[0])), maybeGet(parts, 1) } @@ -261,7 +261,7 @@ func (c Card) SetGender(sex Sex, identity string) { if identity != "" { v += ";" + identity } - c.SetValue(FieldGender, v) + c.SetValue(FieldGender, NewFieldValue(v)) } // Addresses returns addresses of the card. @@ -300,17 +300,17 @@ func (c Card) SetAddress(address *Address) { // Categories returns category information about the card, also known as "tags". func (c Card) Categories() []string { - return strings.Split(c.PreferredValue(FieldCategories), ",") + return c.PreferredValue(FieldCategories).Values() } // SetCategories sets category information about the card. func (c Card) SetCategories(categories []string) { - c.SetValue(FieldCategories, strings.Join(categories, ",")) + c.SetValue(FieldCategories, NewFieldValue(categories...)) } // Revision returns revision information about the current card. func (c Card) Revision() (time.Time, error) { - rev := c.Value(FieldRevision) + rev := c.Value(FieldRevision).String() if rev == "" { return time.Time{}, nil } @@ -319,16 +319,74 @@ func (c Card) Revision() (time.Time, error) { // SetRevision sets revision information about the current card. func (c Card) SetRevision(t time.Time) { - c.SetValue(FieldRevision, t.Format(timestampLayout)) + c.SetValue(FieldRevision, NewFieldValue(t.Format(timestampLayout))) } // A field contains a value and some parameters. type Field struct { - Value string + Value FieldValue Params Params Group string } +// FieldValue contains the raw field value. +// Use [NewFieldValue] to construct a properly escaped field value. +type FieldValue string + +var valueFormatter = strings.NewReplacer("\\", "\\\\", "\n", "\\n", ",", "\\,") +var valueParser = strings.NewReplacer("\\\\", "\\", "\\n", "\n", "\\,", ",") + +// NewFieldValue creates a new FieldValue after +// escaping each part (backslash, newline, comma) +func NewFieldValue(parts ...string) FieldValue { + for i := range parts { + parts[i] = valueFormatter.Replace(parts[i]) + } + return FieldValue(strings.Join(parts, ",")) +} + +// String returns the raw value of the field +func (fv FieldValue) String() string { + return string(fv) +} + +// Values returns the field value parts, split by comma (each part being unescaped). +// Guaranteed to have a len > 0 +func (fv FieldValue) Values() []string { + parts := stringsSplitUnescaped(string(fv), ',') + for i := range parts { + parts[i] = valueParser.Replace(parts[i]) + } + return parts +} + +// stringsSplitUnescaped splits the string when "sep" is NOT escaped +// (i.e. not precedeed by a[n unescaped] backslash) +func stringsSplitUnescaped(s string, sep rune) []string { + n := strings.Count(s, string(sep)) + if n <= 0 { + return []string{s} + } + ss := make([]string, 0, n) // might not be full if some sep are escaped + escaping := false + i := 0 + for j, c := range s { + if escaping { + escaping = false + continue + } + switch c { + case '\\': + escaping = true + case sep: + ss = append(ss, s[i:j]) + i = j + 1 + } + } + ss = append(ss, s[i:]) + return ss[:len(ss):len(ss)] // clip the slice +} + // Params is a set of field parameters. type Params map[string][]string @@ -435,7 +493,7 @@ type Name struct { } func newName(field *Field) *Name { - components := strings.Split(field.Value, ";") + components := strings.Split(field.Value.String(), ";") return &Name{ field, maybeGet(components, 0), @@ -450,13 +508,13 @@ func (n *Name) field() *Field { if n.Field == nil { n.Field = new(Field) } - n.Field.Value = strings.Join([]string{ + n.Field.Value = NewFieldValue(strings.Join([]string{ n.FamilyName, n.GivenName, n.AdditionalName, n.HonorificPrefix, n.HonorificSuffix, - }, ";") + }, ";")) return n.Field } @@ -486,7 +544,7 @@ type Address struct { } func newAddress(field *Field) *Address { - components := strings.Split(field.Value, ";") + components := strings.Split(field.Value.String(), ";") return &Address{ field, maybeGet(components, 0), @@ -503,7 +561,7 @@ func (a *Address) field() *Field { if a.Field == nil { a.Field = new(Field) } - a.Field.Value = strings.Join([]string{ + a.Field.Value = NewFieldValue(strings.Join([]string{ a.PostOfficeBox, a.ExtendedAddress, a.StreetAddress, @@ -511,6 +569,6 @@ func (a *Address) field() *Field { a.Region, a.PostalCode, a.Country, - }, ";") + }, ";")) return a.Field } diff --git a/card_test.go b/card_test.go index a40fea5..554614e 100644 --- a/card_test.go +++ b/card_test.go @@ -78,6 +78,60 @@ var testCardGoogle = Card{ }, } +var testCardGoogleMultiEmail = Card{ + "VERSION": []*Field{{Value: "3.0"}}, + "N": []*Field{{Value: "Bloggs;Joe;;;"}}, + "FN": []*Field{{Value: "Joe Bloggs"}}, + "EMAIL": []*Field{{ + Value: `me@joebloggs.com, joe@joebloggs.com`, + Params: Params{"TYPE": {"INTERNET", "HOME"}}, + }}, + "TEL": []*Field{{ + Value: "+44 20 1234 5678", + Params: Params{"TYPE": {"CELL"}}, + }}, + "ADR": []*Field{{ + Value: ";;1 Trafalgar Square;London;;WC2N;United Kingdom", + Params: Params{"TYPE": {"HOME"}}, + }}, + "URL": []*Field{ + {Value: "http\\://joebloggs.com", Group: "item1"}, + {Value: "http\\://twitter.com/test", Group: "item2"}, + }, + "X-SKYPE": []*Field{{Value: "joe.bloggs"}}, + "X-ABLABEL": []*Field{ + {Value: "_$!!$_", Group: "item1"}, + {Value: "Twitter", Group: "item2"}, + }, +} + +var testCardGoogleMultiEmailComma = Card{ + "VERSION": []*Field{{Value: "3.0"}}, + "N": []*Field{{Value: "Bloggs;Joe;;;"}}, + "FN": []*Field{{Value: "Joe Bloggs"}}, + "EMAIL": []*Field{{ + Value: `me@joebloggs.com, joe@joebloggs\,com`, + Params: Params{"TYPE": {"INTERNET", "HOME"}}, + }}, + "TEL": []*Field{{ + Value: "+44 20 1234 5678", + Params: Params{"TYPE": {"CELL"}}, + }}, + "ADR": []*Field{{ + Value: ";;1 Trafalgar Square;London;;WC2N;United Kingdom", + Params: Params{"TYPE": {"HOME"}}, + }}, + "URL": []*Field{ + {Value: "http\\://joebloggs.com", Group: "item1"}, + {Value: "http\\://twitter.com/test", Group: "item2"}, + }, + "X-SKYPE": []*Field{{Value: "joe.bloggs"}}, + "X-ABLABEL": []*Field{ + {Value: "_$!!$_", Group: "item1"}, + {Value: "Twitter", Group: "item2"}, + }, +} + var testCardApple = Card{ "VERSION": []*Field{{Value: "3.0"}}, "N": []*Field{{Value: "Bloggs;Joe;;;"}}, @@ -145,7 +199,7 @@ func TestCard(t *testing.T) { {Value: "me@example.com", Params: Params{"TYPE": {"work"}}}, }, } - expected := []string{"me@example.org", "me@example.com"} + expected := []FieldValue{"me@example.org", "me@example.com"} if values := cardMultipleValues.Values(FieldEmail); !reflect.DeepEqual(expected, values) { t.Errorf("Expected card emails to be %+v but got %+v", expected, values) } @@ -157,13 +211,13 @@ func TestCard(t *testing.T) { func TestCard_AddValue(t *testing.T) { card := make(Card) - name1 := "Akiyama Mio" + name1 := FieldValue("Akiyama Mio") card.AddValue("FN", name1) if values := card.Values("FN"); len(values) != 1 || values[0] != name1 { t.Errorf("Expected one FN value, got %v", values) } - name2 := "Mio Akiyama" + name2 := FieldValue("Mio Akiyama") card.AddValue("FN", name2) if values := card.Values("FN"); len(values) != 2 || values[0] != name1 || values[1] != name2 { t.Errorf("Expected two FN values, got %v", values) @@ -335,3 +389,75 @@ func TestCard_Revision(t *testing.T) { t.Errorf("Expected revision to be %v but got %v", expected, rev) } } + +func TestCard_FieldValue(t *testing.T) { + fieldValues := map[FieldValue][]string{ + "": {""}, + ",": {"", ""}, + "\\,": {","}, + ",,\\,,": {"", "", ",", ""}, + "geo:1.23\\,4.56": {"geo:1.23,4.56"}, + "geo:1.23,4.56": {"geo:1.23", "4.56"}, + "geo:1\\,23,4\\,56": {"geo:1,23", "4,56"}, + } + for fv, parts := range fieldValues { + t.Run(fv.String(), func(t *testing.T) { + gotParts := fv.Values() + if !reflect.DeepEqual(parts, gotParts) { + t.Errorf("Expected parts to be %+v but got %+v", parts, gotParts) + } + + gotFV := NewFieldValue(parts...) + if gotFV != fv { + t.Errorf("Expected FieldValue to be %+v but got %+v", fv, gotFV) + } + }) + } +} + +// go test -fuzztime=10s -fuzz=Card_FieldValueRaw +func FuzzCard_FieldValueRaw(f *testing.F) { + f.Add(``) + f.Add(`123`) + f.Add(`1,2,3`) + f.Add(`1\abc`) // missing escaping of "\" + f.Add(`1\,2,3`) + f.Add(`1\\,2,3`) + f.Fuzz(func(t *testing.T, raw string) { + fv := FieldValue(raw) + parts := fv.Values() + got1 := NewFieldValue(parts...) + if got1 != fv { + // the raw value was wrongly escaped: + // "got" should now be correctly escaped + if len(got1) <= len(fv) { + t.Errorf("Expected a larger (escaped) string than %q, got %q", fv, got1) + } + // encode again and check that we get back the same (correctly escaped) raw value + got2 := NewFieldValue(got1.Values()...) + if got1 != got2 { + t.Errorf("Expected %q, got %q", got1, got2) + } + } + }) +} + +func FuzzCard_FieldValueParts(f *testing.F) { + f.Add("p0", "p1") + f.Add("1,2", "3") + f.Add("1\\,2", "3") + f.Add("1\\\\,2", "3") + f.Fuzz(func(t *testing.T, part0, part1 string) { + fv := NewFieldValue(part0, part1) + got := fv.Values() + if len(got) != 2 { + t.Fatalf("Expected 2 values, got %d: %v", len(got), got) + } + if got[0] != part0 { + t.Fatalf("Expected first value %q, got %q", part0, got[0]) + } + if got[1] != part1 { + t.Fatalf("Expected first value %q, got %q", part1, got[1]) + } + }) +} diff --git a/decoder.go b/decoder.go index 7d3bdb4..78892e2 100644 --- a/decoder.go +++ b/decoder.go @@ -73,7 +73,7 @@ func (dec *Decoder) Decode() (Card, error) { if !hasBegin { if k == "BEGIN" { - if strings.ToUpper(f.Value) != "VCARD" { + if strings.ToUpper(f.Value.String()) != "VCARD" { return card, errors.New("vcard: invalid BEGIN value") } hasBegin = true @@ -82,7 +82,7 @@ func (dec *Decoder) Decode() (Card, error) { return card, errors.New("vcard: no BEGIN field found") } } else if k == "END" { - if strings.ToUpper(f.Value) != "VCARD" { + if strings.ToUpper(f.Value.String()) != "VCARD" { return card, errors.New("vcard: invalid END value") } hasEnd = true @@ -116,7 +116,7 @@ func parseLine(l string) (key string, field *Field, err error) { } } - field.Value = parseValue(l) + field.Value = FieldValue(l) // take the raw field value return } @@ -193,10 +193,7 @@ func parseParamValues(s string) (values []string, more bool, tail string, err er } } - values = strings.Split(vs, ",") - for i, value := range values { - values[i] = parseValue(value) - } + values = FieldValue(vs).Values() // split from the raw value return } @@ -219,9 +216,3 @@ func parseQuoted(s string, quote byte) (value, tail string, err error) { value = string(buf) return } - -var valueParser = strings.NewReplacer("\\\\", "\\", "\\n", "\n", "\\,", ",") - -func parseValue(s string) string { - return valueParser.Replace(s) -} diff --git a/decoder_test.go b/decoder_test.go index 4af77e1..2d5c711 100644 --- a/decoder_test.go +++ b/decoder_test.go @@ -1,6 +1,7 @@ package vcard import ( + "bytes" "reflect" "strings" "testing" @@ -43,6 +44,35 @@ item2.URL:http\://twitter.com/test item2.X-ABLabel:Twitter END:VCARD` +// Google Contacts (15 November 2012) +var testCardGoogleMultiValueString = `BEGIN:VCARD +VERSION:3.0 +N:Bloggs;Joe;;; +FN:Joe Bloggs +EMAIL;TYPE=INTERNET;TYPE=HOME:me@joebloggs.com, joe@joebloggs.com +TEL;TYPE=CELL:+44 20 1234 5678 +ADR;TYPE=HOME:;;1 Trafalgar Square;London;;WC2N;United Kingdom +item1.URL:http\://joebloggs.com +item1.X-ABLabel:_$!!$_ +X-SKYPE:joe.bloggs +item2.URL:http\://twitter.com/test +item2.X-ABLabel:Twitter +END:VCARD` + +var testCardGoogleMultiValueWithCommaString = `BEGIN:VCARD +VERSION:3.0 +N:Bloggs;Joe;;; +FN:Joe Bloggs +EMAIL;TYPE=INTERNET;TYPE=HOME:me@joebloggs.com, joe@joebloggs\,com +TEL;TYPE=CELL:+44 20 1234 5678 +ADR;TYPE=HOME:;;1 Trafalgar Square;London;;WC2N;United Kingdom +item1.URL:http\://joebloggs.com +item1.X-ABLabel:_$!!$_ +X-SKYPE:joe.bloggs +item2.URL:http\://twitter.com/test +item2.X-ABLabel:Twitter +END:VCARD` + // Apple Contacts (version 7.1) var testCardAppleString = `BEGIN:VCARD VERSION:3.0 @@ -81,6 +111,8 @@ var decoderTests = []struct { {testCardGoogleString, testCardGoogle}, {testCardAppleString, testCardApple}, {testCardLineFoldingString, testCardLineFolding}, + {testCardGoogleMultiValueString, testCardGoogleMultiEmail}, + {testCardGoogleMultiValueWithCommaString, testCardGoogleMultiEmailComma}, } func TestDecoder(t *testing.T) { @@ -94,7 +126,35 @@ func TestDecoder(t *testing.T) { if !reflect.DeepEqual(card, test.card) { t.Errorf("Invalid parsed card: expected \n%+v\n but got \n%+v", test.card, card) for k, fields := range test.card { - t.Log(k, reflect.DeepEqual(fields, card[k]), fields[0], card[k][0]) + if reflect.DeepEqual(fields, card[k]) { + continue + } + for i := range fields { + t.Log(k, i, fields[i], card[k][i]) + } + } + } + + var buf bytes.Buffer + err = NewEncoder(&buf).Encode(card) + if err != nil { + t.Fatal("Expected no error when encoding card, got:", err) + } + // the encoding is not canonical, so we can't compare it + // we re-decode the encoded part instead + cardReEncoded, err := NewDecoder(&buf).Decode() + if err != nil { + t.Fatal("Expected no error when re-decoding card, got:", err) + } + if !reflect.DeepEqual(cardReEncoded, test.card) { + t.Errorf("Invalid re-parsed card: expected \n%+v\n but got \n%+v", test.card, cardReEncoded) + for k, fields := range test.card { + if reflect.DeepEqual(fields, cardReEncoded[k]) { + continue + } + for i := range fields { + t.Log(k, i, fields[i], cardReEncoded[k][i]) + } } } } @@ -132,7 +192,7 @@ func TestDecoder_invalid(t *testing.T) { func TestParseLine_escaped(t *testing.T) { l := "NOTE:Mythical Manager\\nHyjinx Software Division\\nBabsCo\\, Inc.\\n" expectedKey := "NOTE" - expectedValue := "Mythical Manager\nHyjinx Software Division\nBabsCo, Inc.\n" + expectedValue := NewFieldValue("Mythical Manager\nHyjinx Software Division\nBabsCo, Inc.\n") if key, field, err := parseLine(l); err != nil { t.Fatal("Expected no error while parsing line, got:", err) diff --git a/encoder.go b/encoder.go index 974cefc..303985b 100644 --- a/encoder.go +++ b/encoder.go @@ -68,16 +68,10 @@ func formatLine(key string, field *Field) string { } } - s += ":" + formatValue(field.Value) + s += ":" + field.Value.String() return s } func formatParam(k, v string) string { - return k + "=" + formatValue(v) -} - -var valueFormatter = strings.NewReplacer("\\", "\\\\", "\n", "\\n", ",", "\\,") - -func formatValue(v string) string { - return valueFormatter.Replace(v) + return k + "=" + NewFieldValue(v).String() } diff --git a/encoder_test.go b/encoder_test.go index 6cfff47..f997ba5 100644 --- a/encoder_test.go +++ b/encoder_test.go @@ -51,7 +51,7 @@ var testValue = []struct { func TestFormatValue(t *testing.T) { for _, test := range testValue { - if formatted := formatValue(test.v); formatted != test.formatted { + if formatted := NewFieldValue(test.v).String(); formatted != test.formatted { t.Errorf("formatValue(%q): expected %q, got %q", test.v, test.formatted, formatted) } } diff --git a/example_test.go b/example_test.go index 0b5a00b..10ad66e 100644 --- a/example_test.go +++ b/example_test.go @@ -57,14 +57,14 @@ func ExampleNewEncoder() { for _, entry := range contacts { // set only the value of a field by using card.SetValue. // This does not set parameters - card.SetValue(vcard.FieldFormattedName, strings.Join(entry[:3], " ")) - card.SetValue(vcard.FieldTelephone, entry[3]) + card.SetValue(vcard.FieldFormattedName, vcard.NewFieldValue(strings.Join(entry[:3], " "))) + card.SetValue(vcard.FieldTelephone, vcard.NewFieldValue(entry[3])) // set the value of a field and other parameters by using card.Set card.Set(vcard.FieldName, &vcard.Field{ - Value: strings.Join(entry[:3], ";"), + Value: vcard.NewFieldValue(strings.Join(entry[:3], ";")), Params: map[string][]string{ - vcard.ParamSortAs: []string{ + vcard.ParamSortAs: { entry[0] + " " + entry[2], }, }, diff --git a/v4.go b/v4.go index d8ae511..fd1299a 100644 --- a/v4.go +++ b/v4.go @@ -9,7 +9,7 @@ import ( // ToV4 converts a card to vCard version 4. func ToV4(card Card) { version := card.Value(FieldVersion) - if strings.HasPrefix(version, "4.") { + if strings.HasPrefix(version.String(), "4.") { return }