diff --git a/candidate.go b/candidate.go index e2548d95..5c5bcddd 100644 --- a/candidate.go +++ b/candidate.go @@ -56,7 +56,14 @@ type Candidate interface { // In the order of insertion, *(key value). // Extension attributes are defined in RFC 5245, Section 15.1: // https://datatracker.ietf.org/doc/html/rfc5245#section-15.1 - Extensions() CandidateExtensions + //. + Extensions() []CandidateExtension + + // GetExtension returns the value of the extension attribute associated with the ICECandidate. + // Extension attributes are defined in RFC 5245, Section 15.1: + // https://datatracker.ietf.org/doc/html/rfc5245#section-15.1 + //. + GetExtension(key string) (value string, ok bool) String() string Type() CandidateType diff --git a/candidate_base.go b/candidate_base.go index c187a5d1..63efb26d 100644 --- a/candidate_base.go +++ b/candidate_base.go @@ -45,7 +45,7 @@ type candidateBase struct { remoteCandidateCaches map[AddrPort]Candidate isLocationTracked bool - extensions *CandidateExtensions + extensions []CandidateExtension } // Done implements context.Context @@ -418,7 +418,7 @@ func (c *candidateBase) Equal(other Candidate) bool { // DeepEqual is same as Equal but also compares the extensions func (c *candidateBase) DeepEqual(other Candidate) bool { - return c.Equal(other) && c.Extensions().Equal(other.Extensions()) + return c.Equal(other) && c.extensionsEqual(other.Extensions()) } // String makes the candidateBase printable @@ -510,7 +510,7 @@ func (c *candidateBase) Marshal() string { r.Port) } - extensions := c.Extensions().Marshal() + extensions := c.marshalExtensions() if extensions != "" { val = fmt.Sprintf("%s %s", val, extensions) @@ -519,27 +519,94 @@ func (c *candidateBase) Marshal() string { return val } -func (c *candidateBase) Extensions() CandidateExtensions { - if c.extensions == nil { - ext := CandidateExtensions{} +// CandidateExtension represents a single candidate extension +// as defined in https://tools.ietf.org/html/rfc5245#section-15.1 +// . +type CandidateExtension struct { + key string + value string +} + +func (c *candidateBase) Extensions() []CandidateExtension { + // IF Extensions were not parsed using UnmarshalCandidate + // For backwards compatibility when the TCPType is set manually + if len(c.extensions) == 0 && c.TCPType() != TCPTypeUnspecified { + return []CandidateExtension{{ + key: "tcptype", + value: c.TCPType().String(), + }} + } + + extensions := make([]CandidateExtension, len(c.extensions)) + copy(extensions, c.extensions) + + return extensions +} + +// Get returns the value of the given key if it exists. +func (c candidateBase) GetExtension(key string) (string, bool) { + for i := range c.extensions { + if c.extensions[i].key == key { + return c.extensions[i].value, true + } + } + + // TCPType was manually set. + if key == "tcptype" && c.TCPType() != TCPTypeUnspecified { + return c.TCPType().String(), true + } - // Extensions were not parsed using UnmarshalCandidate - // For backwards compatibility we set TCPType value + return "", false +} + +// marshalExtensions returns the string representation of the candidate extensions. +func (c candidateBase) marshalExtensions() string { + value := "" + exts := c.Extensions() - if c.TCPType() != TCPTypeUnspecified { - ext.extensions = append(ext.extensions, CandidateExtension{ - key: "tcptype", - value: c.TCPType().String(), - }) + for i := range exts { + if value != "" { + value += " " } - return ext + value += exts[i].key + " " + exts[i].value } - return *c.extensions + return value } -func (c *candidateBase) setExtensions(extensions *CandidateExtensions) { +// Equal returns true if the candidate extensions are equal. +func (c candidateBase) extensionsEqual(other []CandidateExtension) bool { + freq1 := make(map[CandidateExtension]int) + freq2 := make(map[CandidateExtension]int) + + if len(c.extensions) != len(other) { + return false + } + + if len(c.extensions) == 0 { + return true + } + + if len(c.extensions) == 1 { + return c.extensions[0] == other[0] + } + + for i := range c.extensions { + freq1[c.extensions[i]]++ + freq2[other[i]]++ + } + + for k, v := range freq1 { + if freq2[k] != v { + return false + } + } + + return true +} + +func (c *candidateBase) setExtensions(extensions []CandidateExtension) { c.extensions = extensions } @@ -627,17 +694,16 @@ func UnmarshalCandidate(raw string) (Candidate, error) { } tcpType := TCPTypeUnspecified - var extensions *CandidateExtensions + var extensions []CandidateExtension + var tcpTypeRaw string if pos < len(raw) { - extensions, err = UnmarshalCandidateExtensions(raw[pos:]) + extensions, tcpTypeRaw, err = unmarshalCandidateExtensions(raw[pos:]) if err != nil { return nil, fmt.Errorf("%w: %v", errParseExtension, err) //nolint:errorlint // we wrap the error } - tcpTypeRaw, ok := extensions.Get("tcptype") - - if ok { + if tcpTypeRaw != "" { tcpType = NewTCPType(tcpTypeRaw) if tcpType == TCPTypeUnspecified { return nil, fmt.Errorf("%w: invalid or unsupported TCPtype %s", errParseTCPType, tcpTypeRaw) @@ -804,6 +870,26 @@ func readCandidatePort(raw string, start int) (int, int, error) { return port, pos, nil } +// Read a byte-string token from the raw string +// As defined in RFC 4566 1*(%x01-09/%x0B-0C/%x0E-FF) ;any byte except NUL, CR, or LF +// we imply that extensions byte-string are UTF-8 encoded +func readCandidateByteString(raw string, start int) (string, int, error) { + for i, char := range raw[start:] { + if char == 0x20 { // SP + return raw[start : start+i], start + i + 1, nil + } + + // 1*(%x01-09/%x0B-0C/%x0E-FF) + if !(char >= 0x01 && char <= 0x09 || + char >= 0x0B && char <= 0x0C || + char >= 0x0E && char <= 0xFF) { + return "", 0, fmt.Errorf("invalid byte-string character: %c", char) //nolint: err113 // handled by caller + } + } + + return raw[start:], len(raw), nil +} + // Read and validate raddr and rport from the raw string // [SP rel-addr] [SP rel-port] // defined in https://datatracker.ietf.org/doc/html/rfc5245#section-15.1 @@ -841,3 +927,45 @@ func tryReadRelativeAddrs(raw string, start int) (raddr string, rport, pos int, return raddr, rport, pos, nil } + +// UnmarshalCandidateExtensions parses the candidate extensions from the raw string. +// *(SP extension-att-name SP extension-att-value) +// Where extension-att-name, and extension-att-value are byte-strings +// as defined in https://tools.ietf.org/html/rfc5245#section-15.1 +func unmarshalCandidateExtensions(raw string) (extensions []CandidateExtension, rawTCPTypeRaw string, err error) { + extensions = make([]CandidateExtension, 0) + + if raw == "" { + return extensions, "", nil + } + + if raw[0] == 0x20 { // SP + return extensions, "", fmt.Errorf("%w: unexpected space %s", errParseExtension, raw) + } + + for i := 0; i < len(raw); { + key, next, err := readCandidateByteString(raw, i) + if err != nil { + return extensions, "", fmt.Errorf("%w: failed to read key %v", errParseExtension, err) //nolint: errorlint // we wrap the error + } + i = next + + if i >= len(raw) { + return extensions, "", fmt.Errorf("%w: missing value for %s in %s", errParseExtension, key, raw) + } + + value, next, err := readCandidateByteString(raw, i) + if err != nil { + return extensions, "", fmt.Errorf("%w: failed to read value %v", errParseExtension, err) //nolint: errorlint // we are wrapping the error + } + i = next + + if key == "tcptype" { + rawTCPTypeRaw = value + } + + extensions = append(extensions, CandidateExtension{key, value}) + } + + return +} diff --git a/candidate_test.go b/candidate_test.go index 4fc4e904..8285d6eb 100644 --- a/candidate_test.go +++ b/candidate_test.go @@ -274,7 +274,7 @@ func mustCandidateHost(conf *CandidateHostConfig) Candidate { return cand } -func mustCandidateHostWithExtensions(t *testing.T, conf *CandidateHostConfig, extensions *CandidateExtensions) Candidate { +func mustCandidateHostWithExtensions(t *testing.T, conf *CandidateHostConfig, extensions []CandidateExtension) Candidate { t.Helper() cand, err := NewCandidateHost(conf) @@ -295,7 +295,7 @@ func mustCandidateRelay(conf *CandidateRelayConfig) Candidate { return cand } -func mustCandidateRelayWithExtensions(t *testing.T, conf *CandidateRelayConfig, extensions *CandidateExtensions) Candidate { +func mustCandidateRelayWithExtensions(t *testing.T, conf *CandidateRelayConfig, extensions []CandidateExtension) Candidate { t.Helper() cand, err := NewCandidateRelay(conf) @@ -316,7 +316,7 @@ func mustCandidateServerReflexive(conf *CandidateServerReflexiveConfig) Candidat return cand } -func mustCandidateServerReflexiveWithExtensions(t *testing.T, conf *CandidateServerReflexiveConfig, extensions *CandidateExtensions) Candidate { +func mustCandidateServerReflexiveWithExtensions(t *testing.T, conf *CandidateServerReflexiveConfig, extensions []CandidateExtension) Candidate { t.Helper() cand, err := NewCandidateServerReflexive(conf) @@ -329,7 +329,7 @@ func mustCandidateServerReflexiveWithExtensions(t *testing.T, conf *CandidateSer return cand } -func mustCandidatePeerReflexiveWithExtensions(t *testing.T, conf *CandidatePeerReflexiveConfig, extensions *CandidateExtensions) Candidate { +func mustCandidatePeerReflexiveWithExtensions(t *testing.T, conf *CandidatePeerReflexiveConfig, extensions []CandidateExtension) Candidate { t.Helper() cand, err := NewCandidatePeerReflexive(conf) @@ -389,11 +389,11 @@ func TestCandidateMarshal(t *testing.T) { RelAddr: "10.0.0.1", RelPort: 12345, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "0"}, {"network-id", "2"}, {"network-cost", "10"}, - }}, + }, ), "4207374052 1 tcp 1685790463 192.0.2.15 50000 typ prflx raddr 10.0.0.1 rport 12345 generation 0 network-id 2 network-cost 10", false, @@ -603,75 +603,63 @@ func TestMarshalUnmarshalCandidateWithZoneID(t *testing.T) { func TestCandidateExtensionsMarshal(t *testing.T) { testCases := []struct { - Extensions CandidateExtensions + Extensions []CandidateExtension candidate string }{ { - CandidateExtensions{ - []CandidateExtension{ - {"generation", "0"}, - {"ufrag", "QNvE"}, - {"network-id", "4"}, - }, + []CandidateExtension{ + {"generation", "0"}, + {"ufrag", "QNvE"}, + {"network-id", "4"}, }, "1299692247 1 udp 2122134271 fdc8:cc8:c835:e400:343c:feb:32c8:17b9 58240 typ host generation 0 ufrag QNvE network-id 4", }, { - CandidateExtensions{ - []CandidateExtension{ - {"generation", "1"}, - {"network-id", "2"}, - {"network-cost", "50"}, - }, + []CandidateExtension{ + {"generation", "1"}, + {"network-id", "2"}, + {"network-cost", "50"}, }, "647372371 1 udp 1694498815 191.228.238.68 53991 typ srflx raddr 192.168.0.274 rport 53991 generation 1 network-id 2 network-cost 50", }, { - CandidateExtensions{ - []CandidateExtension{ - {"generation", "0"}, - {"network-id", "2"}, - {"network-cost", "10"}, - }, + []CandidateExtension{ + {"generation", "0"}, + {"network-id", "2"}, + {"network-cost", "10"}, }, "4207374052 1 tcp 1685790463 192.0.2.15 50000 typ prflx raddr 10.0.0.1 rport 12345 generation 0 network-id 2 network-cost 10", }, { - CandidateExtensions{ - []CandidateExtension{ - {"generation", "0"}, - {"network-id", "1"}, - {"network-cost", "20"}, - {"ufrag", "frag42abcdef"}, - {"password", "abc123exp123"}, - }, + []CandidateExtension{ + {"generation", "0"}, + {"network-id", "1"}, + {"network-cost", "20"}, + {"ufrag", "frag42abcdef"}, + {"password", "abc123exp123"}, }, "848194626 1 udp 16777215 50.0.0.1 5000 typ relay raddr 192.168.0.1 rport 5001 generation 0 network-id 1 network-cost 20 ufrag frag42abcdef password abc123exp123", }, { - CandidateExtensions{ - []CandidateExtension{ - {"tcptype", "active"}, - {"generation", "0"}, - }, + []CandidateExtension{ + {"tcptype", "active"}, + {"generation", "0"}, }, "1052353102 1 tcp 2128609279 192.168.0.196 0 typ host tcptype active generation 0", }, { - CandidateExtensions{ - []CandidateExtension{ - {"tcptype", "active"}, - {"generation", "0"}, - }, + []CandidateExtension{ + {"tcptype", "active"}, + {"generation", "0"}, }, "1052353102 1 tcp 2128609279 192.168.0.196 0 typ host tcptype active generation 0", }, { - CandidateExtensions{}, + []CandidateExtension{}, "1052353102 1 tcp 2128609279 192.168.0.196 0 typ host", }, { - CandidateExtensions{}, + []CandidateExtension{}, "1052353102 1 tcp 2128609279 192.168.0.196 0 typ host", }, } @@ -679,13 +667,13 @@ func TestCandidateExtensionsMarshal(t *testing.T) { for _, tc := range testCases { candidate, err := UnmarshalCandidate(tc.candidate) require.NoError(t, err) - require.Equal(t, tc.Extensions.Equal(candidate.Extensions()), true, "Extensions should be equal", tc.candidate) + require.Equal(t, tc.Extensions, candidate.Extensions(), "Extensions should be equal", tc.candidate) valueStr := candidate.Marshal() candidate2, err := UnmarshalCandidate(valueStr) require.NoError(t, err) - require.Equal(t, tc.Extensions.Equal(candidate2.Extensions()), true, "Marshal() should preserve extensions") + require.Equal(t, tc.Extensions, candidate2.Extensions(), "Marshal() should preserve extensions") } } @@ -697,12 +685,10 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { ufrag := "QNvE" networkID := "4" - extensions := &CandidateExtensions{ - []CandidateExtension{ - {"generation", generation}, - {"ufrag", ufrag}, - {"network-id", networkID}, - }, + extensions := []CandidateExtension{ + {"generation", generation}, + {"ufrag", ufrag}, + {"network-id", networkID}, } candidate, err := UnmarshalCandidate( @@ -711,11 +697,6 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { ) require.NoError(t, err) - candidate2, err := UnmarshalCandidate( - "1052353102 1 tcp 2128609279 192.168.0.196 0 typ host tcptype active generation 0", - ) - require.NoError(t, err) - testCases := []struct { a Candidate b Candidate @@ -742,7 +723,7 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { Priority: 500, Foundation: "750", }, - &CandidateExtensions{[]CandidateExtension{}}, + []CandidateExtension{}, ), noExt, true, @@ -762,44 +743,6 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { candidate, true, }, - { - mustCandidateHostWithExtensions( - t, - &CandidateHostConfig{ - Network: NetworkTypeTCP4.String(), - Address: "192.168.0.196", - Port: 0, - Priority: 2128609279, - Foundation: "1052353102", - TCPType: TCPTypeActive, - }, - &CandidateExtensions{[]CandidateExtension{ - {"tcptype", TCPTypeActive.String()}, - {"generation", "0"}, - }}, - ), - candidate2, - true, - }, - { - mustCandidateHostWithExtensions( - t, - &CandidateHostConfig{ - Network: NetworkTypeTCP4.String(), - Address: "192.168.0.196", - Port: 0, - Priority: 2128609279, - Foundation: "1052353102", - TCPType: TCPTypeActive, - }, - &CandidateExtensions{[]CandidateExtension{ - {"tcptype", TCPTypeActive.String()}, - {"generation", "0"}, - }}, - ), - candidate2, - true, - }, { mustCandidateRelayWithExtensions( t, @@ -810,10 +753,10 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { RelAddr: "10.0.0.2", RelPort: 5001, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "0"}, {"network-id", "1"}, - }}, + }, ), mustCandidateRelayWithExtensions( t, @@ -824,10 +767,10 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { RelAddr: "10.0.0.2", RelPort: 5001, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"network-id", "1"}, {"generation", "0"}, - }}, + }, ), true, }, @@ -841,11 +784,11 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { RelAddr: "10.0.0.1", RelPort: 12345, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "0"}, {"network-id", "2"}, {"network-cost", "10"}, - }}, + }, ), mustCandidatePeerReflexiveWithExtensions( t, @@ -856,11 +799,11 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { RelAddr: "10.0.0.1", RelPort: 12345, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "0"}, {"network-id", "2"}, {"network-cost", "10"}, - }}, + }, ), true, }, @@ -874,11 +817,11 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { RelAddr: "192.168.0.274", RelPort: 53991, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "0"}, {"network-id", "2"}, {"network-cost", "10"}, - }}, + }, ), mustCandidateServerReflexiveWithExtensions( t, @@ -889,11 +832,11 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { RelAddr: "192.168.0.274", RelPort: 53991, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "0"}, {"network-id", "2"}, {"network-cost", "10"}, - }}, + }, ), true, }, @@ -907,11 +850,11 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { Priority: 500, Foundation: "750", }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"generation", "5"}, {"ufrag", ufrag}, {"network-id", networkID}, - }}, + }, ), candidate, false, @@ -927,10 +870,10 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { Foundation: "1052353102", TCPType: TCPTypeActive, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"tcptype", TCPTypeActive.String()}, {"generation", "0"}, - }}, + }, ), mustCandidateHostWithExtensions( t, @@ -942,10 +885,10 @@ func TestCandidateExtensionsDeepEqual(t *testing.T) { Foundation: "1052353102", TCPType: TCPTypeActive, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"tcptype", TCPTypeActive.String()}, {"generation", "0"}, - }}, + }, ), false, }, @@ -968,21 +911,21 @@ func TestCandidateGetExtension(t *testing.T) { Foundation: "1052353102", TCPType: TCPTypeActive, }, - &CandidateExtensions{[]CandidateExtension{ + []CandidateExtension{ {"tcptype", TCPTypeActive.String()}, {"generation", "0"}, - }}, + }, ) - value, ok := candidate.Extensions().Get("tcptype") + value, ok := candidate.GetExtension("tcptype") require.Equal(t, TCPTypeActive.String(), value) require.True(t, ok) - value, ok = candidate.Extensions().Get("generation") + value, ok = candidate.GetExtension("generation") require.Equal(t, "0", value) require.True(t, ok) - value, ok = candidate.Extensions().Get("INVALID") + value, ok = candidate.GetExtension("INVALID") require.Equal(t, "", value) require.False(t, ok) }) @@ -999,8 +942,289 @@ func TestCandidateGetExtension(t *testing.T) { }, ) - value, ok := candidate.Extensions().Get("tcptype") + value, ok := candidate.GetExtension("tcptype") require.Equal(t, TCPTypeActive.String(), value) require.True(t, ok) }) } + +func TestUnmarshalCandidateExtensions(t *testing.T) { + testCases := []struct { + name string + value string + expected []CandidateExtension + fail bool + }{ + { + name: "empty string", + value: "", + expected: []CandidateExtension{}, + fail: false, + }, + { + name: "valid extension string", + value: "a b c d", + expected: []CandidateExtension{{"a", "b"}, {"c", "d"}}, + fail: false, + }, + { + name: "valid extension string", + value: "a b empty c d", + expected: []CandidateExtension{ + {"a", "b"}, + {"empty", ""}, + {"c", "d"}, + }, + fail: false, + }, + { + name: "invalid extension string", + value: "invalid", + expected: []CandidateExtension{}, + fail: true, + }, + { + name: "invalid extension", + value: " a b", + expected: []CandidateExtension{{"a", "b"}, {"c", "d"}}, + fail: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + req := require.New(t) + + actual, _, err := unmarshalCandidateExtensions(testCase.value) + if testCase.fail { + req.Error(err) + } else { + req.NoError(err) + req.EqualValuesf( + testCase.expected, + actual, + "UnmarshalCandidateExtensions() did not return the expected value %v", + testCase.value, + ) + } + }) + } +} + +func TestExtensionGet(t *testing.T) { + t.Run("Get extension", func(t *testing.T) { + extensions := []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + } + candidate, err := NewCandidateHost(&CandidateHostConfig{ + Network: NetworkTypeUDP4.String(), + Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a", + Port: 53987, + Priority: 500, + Foundation: "750", + }) + if err != nil { + t.Error(err) + } + + candidate.setExtensions(extensions) + + value, ok := candidate.GetExtension("c") + require.True(t, ok) + require.Equal(t, "d", value) + + value, ok = candidate.GetExtension("a") + require.True(t, ok) + require.Equal(t, "b", value) + + value, ok = candidate.GetExtension("b") + require.False(t, ok) + require.Equal(t, "", value) + }) + + // This is undefined behavior in the spec; extension-att-name is not unique + // but it implied that it's unique in the implementation + t.Run("Extension with multiple values", func(t *testing.T) { + extensions := []CandidateExtension{ + {"a", "1"}, + {"a", "2"}, + } + + candidate, err := NewCandidateHost(&CandidateHostConfig{ + Network: NetworkTypeUDP4.String(), + Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a", + Port: 53987, + Priority: 500, + Foundation: "750", + }) + if err != nil { + t.Error(err) + } + + candidate.setExtensions(extensions) + + value, ok := candidate.GetExtension("a") + require.True(t, ok) + require.Equal(t, "1", value) + }) +} + +func TestExtensionMarhshal(t *testing.T) { + t.Run("Marshal extension", func(t *testing.T) { + extensions := []CandidateExtension{ + {"generation", "0"}, + {"ValuE", "KeE"}, + {"empty", ""}, + {"another", "value"}, + } + candidate, err := NewCandidateHost(&CandidateHostConfig{ + Network: NetworkTypeUDP4.String(), + Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a", + Port: 53987, + Priority: 500, + Foundation: "750", + }) + if err != nil { + t.Error(err) + } + + candidate.setExtensions(extensions) + + value := candidate.marshalExtensions() + require.Equal(t, "generation 0 ValuE KeE empty another value", value) + }) + + t.Run("Marshal Empty", func(t *testing.T) { + candidate, err := NewCandidateHost(&CandidateHostConfig{ + Network: NetworkTypeUDP4.String(), + Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a", + Port: 53987, + Priority: 500, + Foundation: "750", + }) + if err != nil { + t.Error(err) + } + + value := candidate.marshalExtensions() + require.Equal(t, "", value) + }) + + t.Run("Marshal TCPType no extension", func(t *testing.T) { + candidate, err := NewCandidateHost(&CandidateHostConfig{ + Network: NetworkTypeUDP4.String(), + Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a", + Port: 53987, + Priority: 500, + Foundation: "750", + TCPType: TCPTypeActive, + }) + if err != nil { + t.Error(err) + } + + value := candidate.marshalExtensions() + require.Equal(t, "tcptype active", value) + }) +} + +func TestExtensionEqual(t *testing.T) { + testCases := []struct { + name string + extensions1 []CandidateExtension + extensions2 []CandidateExtension + expected bool + }{ + { + name: "Empty extensions", + extensions1: []CandidateExtension{}, + extensions2: []CandidateExtension{}, + expected: true, + }, + { + name: "Single value extensions", + extensions1: []CandidateExtension{{"a", "b"}}, + extensions2: []CandidateExtension{{"a", "b"}}, + expected: true, + }, + { + name: "multiple value extensions", + extensions1: []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + }, + extensions2: []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + }, + expected: true, + }, + { + name: "unsorted extensions", + extensions1: []CandidateExtension{ + {"c", "d"}, + {"a", "b"}, + }, + extensions2: []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + }, + expected: true, + }, + { + name: "different values", + extensions1: []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + }, + extensions2: []CandidateExtension{ + {"a", "b"}, + {"c", "e"}, + }, + expected: false, + }, + { + name: "different size", + extensions1: []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + }, + extensions2: []CandidateExtension{ + {"a", "b"}, + }, + expected: false, + }, + { + name: "different keys", + extensions1: []CandidateExtension{ + {"a", "b"}, + {"c", "d"}, + }, + extensions2: []CandidateExtension{ + {"a", "b"}, + {"e", "d"}, + }, + expected: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + cand, err := NewCandidateHost(&CandidateHostConfig{ + Network: NetworkTypeUDP4.String(), + Address: "fcd9:e3b8:12ce:9fc5:74a5:c6bb:d8b:e08a", + Port: 53987, + Priority: 500, + Foundation: "750", + }) + if err != nil { + t.Error(err) + } + + cand.setExtensions(testCase.extensions1) + + require.Equal(t, testCase.expected, cand.extensionsEqual(testCase.extensions2)) + }) + } +} diff --git a/candidateextensions.go b/candidateextensions.go deleted file mode 100644 index 1162ceac..00000000 --- a/candidateextensions.go +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-FileCopyrightText: 2023 The Pion community -// SPDX-License-Identifier: MIT - -package ice - -import ( - "fmt" -) - -// CandidateExtensions represents the ICE candidate extensions -// as defined in https://tools.ietf.org/html/rfc5245#section-15.1 -// . -type CandidateExtensions struct { - // *(key value) pairs - extensions []CandidateExtension -} - -// CandidateExtension represents a single candidate extension -// as defined in https://tools.ietf.org/html/rfc5245#section-15.1 -// . -type CandidateExtension struct { - key string - value string -} - -// UnmarshalCandidateExtensions parses the candidate extensions from the raw string. -// *(SP extension-att-name SP extension-att-value) -// Where extension-att-name, and extension-att-value are byte-strings -// as defined in https://tools.ietf.org/html/rfc5245#section-15.1 -func UnmarshalCandidateExtensions(raw string) (*CandidateExtensions, error) { - extensions := CandidateExtensions{ - extensions: make([]CandidateExtension, 0), - } - - if raw == "" { - return &extensions, nil - } - - if raw[0] == 0x20 { // SP - return nil, fmt.Errorf("%w: unexpected space %s", errParseExtension, raw) - } - - for i := 0; i < len(raw); { - key, next, err := readCandidateByteString(raw, i) - if err != nil { - return nil, fmt.Errorf("%w: failed to read key %v", errParseExtension, err) //nolint: errorlint // we wrap the error - } - i = next - - if i >= len(raw) { - return nil, fmt.Errorf("%w: missing value for %s in %s", errParseExtension, key, raw) - } - - value, next, err := readCandidateByteString(raw, i) - if err != nil { - return nil, fmt.Errorf("%w: failed to read value %v", errParseExtension, err) //nolint: errorlint // we are wrapping the error - } - i = next - - extensions.extensions = append(extensions.extensions, CandidateExtension{key, value}) - } - - return &extensions, nil -} - -// Read a byte-string token from the raw string -// As defined in RFC 4566 1*(%x01-09/%x0B-0C/%x0E-FF) ;any byte except NUL, CR, or LF -// we imply that extensions byte-string are UTF-8 encoded -func readCandidateByteString(raw string, start int) (string, int, error) { - for i, char := range raw[start:] { - if char == 0x20 { // SP - return raw[start : start+i], start + i + 1, nil - } - - // 1*(%x01-09/%x0B-0C/%x0E-FF) - if !(char >= 0x01 && char <= 0x09 || - char >= 0x0B && char <= 0x0C || - char >= 0x0E && char <= 0xFF) { - return "", 0, fmt.Errorf("invalid byte-string character: %c", char) //nolint: err113 // handled by caller - } - } - - return raw[start:], len(raw), nil -} - -// Get returns the value of the given key if it exists. -func (c CandidateExtensions) Get(key string) (string, bool) { - for i := range c.extensions { - if c.extensions[i].key == key { - return c.extensions[i].value, true - } - } - - return "", false -} - -// Marshal returns the string representation of the candidate extensions. -func (c CandidateExtensions) Marshal() string { - value := "" - - for i := range c.extensions { - if value != "" { - value += " " - } - - value += c.extensions[i].key + " " + c.extensions[i].value - } - - return value -} - -// Equal returns true if the candidate extensions are equal. -func (c CandidateExtensions) Equal(other CandidateExtensions) bool { - freq1 := make(map[CandidateExtension]int) - freq2 := make(map[CandidateExtension]int) - - if len(c.extensions) != len(other.extensions) { - return false - } - - if len(c.extensions) == 0 { - return true - } - - if len(c.extensions) == 1 { - return c.extensions[0] == other.extensions[0] - } - - for i := range c.extensions { - freq1[c.extensions[i]]++ - freq2[other.extensions[i]]++ - } - - for k, v := range freq1 { - if freq2[k] != v { - return false - } - } - - return true -} diff --git a/candidateextensions_test.go b/candidateextensions_test.go deleted file mode 100644 index 9d696b7a..00000000 --- a/candidateextensions_test.go +++ /dev/null @@ -1,208 +0,0 @@ -// SPDX-FileCopyrightText: 2023 The Pion community -// SPDX-License-Identifier: MIT - -package ice - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestUnmarshalCandidateExtensions(t *testing.T) { - testCases := []struct { - name string - value string - expected *CandidateExtensions - fail bool - }{ - { - name: "empty string", - value: "", - expected: &CandidateExtensions{extensions: []CandidateExtension{}}, - fail: false, - }, - { - name: "valid extension string", - value: "a b c d", - expected: &CandidateExtensions{extensions: []CandidateExtension{{"a", "b"}, {"c", "d"}}}, - fail: false, - }, - { - name: "valid extension string", - value: "a b empty c d", - expected: &CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"empty", ""}, - {"c", "d"}, - }}, - fail: false, - }, - { - name: "invalid extension string", - value: "invalid", - expected: &CandidateExtensions{extensions: []CandidateExtension{}}, - fail: true, - }, - { - name: "invalid extension", - value: " a b", - expected: &CandidateExtensions{extensions: []CandidateExtension{{"a", "b"}, {"c", "d"}}}, - fail: true, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - req := require.New(t) - - actual, err := UnmarshalCandidateExtensions(testCase.value) - if testCase.fail { - req.Error(err) - } else { - req.NoError(err) - req.EqualValuesf( - testCase.expected, - actual, - "UnmarshalCandidateExtensions() did not return the expected value %v", - testCase.value, - ) - } - }) - } -} - -func TestExtensionGet(t *testing.T) { - t.Run("Get extension", func(t *testing.T) { - extensions := CandidateExtensions{[]CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }} - - value, ok := extensions.Get("c") - require.True(t, ok) - require.Equal(t, "d", value) - - value, ok = extensions.Get("a") - require.True(t, ok) - require.Equal(t, "b", value) - - value, ok = extensions.Get("b") - require.False(t, ok) - require.Equal(t, "", value) - }) - - // This is undefined behavior in the spec; extension-att-name is not unique - // but it implied that it's unique in the implementation - t.Run("Extension with multiple values", func(t *testing.T) { - extensions := CandidateExtensions{extensions: []CandidateExtension{ - {"a", "1"}, - {"a", "2"}, - }} - - value, ok := extensions.Get("a") - require.True(t, ok) - require.Equal(t, "1", value) - }) -} - -func TestExtensionMarhshal(t *testing.T) { - t.Run("Marshal extension", func(t *testing.T) { - extensions := CandidateExtensions{extensions: []CandidateExtension{ - {"generation", "0"}, - {"ValuE", "KeE"}, - {"empty", ""}, - {"another", "value"}, - }} - - value := extensions.Marshal() - require.Equal(t, "generation 0 ValuE KeE empty another value", value) - }) -} - -func TestExtensionEqual(t *testing.T) { - testCases := []struct { - name string - extensions1 CandidateExtensions - extensions2 CandidateExtensions - expected bool - }{ - { - name: "Empty extensions", - extensions1: CandidateExtensions{extensions: []CandidateExtension{}}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{}}, - expected: true, - }, - { - name: "Single value extensions", - extensions1: CandidateExtensions{extensions: []CandidateExtension{{"a", "b"}}}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{{"a", "b"}}}, - expected: true, - }, - { - name: "multiple value extensions", - extensions1: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }}, - expected: true, - }, - { - name: "unsorted extensions", - extensions1: CandidateExtensions{extensions: []CandidateExtension{ - {"c", "d"}, - {"a", "b"}, - }}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }}, - expected: true, - }, - { - name: "different values", - extensions1: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "e"}, - }}, - expected: false, - }, - { - name: "different size", - extensions1: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - }}, - expected: false, - }, - { - name: "different keys", - extensions1: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"c", "d"}, - }}, - extensions2: CandidateExtensions{extensions: []CandidateExtension{ - {"a", "b"}, - {"e", "d"}, - }}, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - actual := testCase.extensions1.Equal(testCase.extensions2) - require.Equal(t, testCase.expected, actual) - }) - } -}