Skip to content

Commit 422e05b

Browse files
authored
Cosmos DB: Fixes whitespace and supported special characters handling (Azure#18579)
* Using Path.Escape * emulator tests * Adding more tests * changelog entry * Fixing test * supporting ASCII
1 parent 71c6535 commit 422e05b

File tree

7 files changed

+146
-12
lines changed

7 files changed

+146
-12
lines changed

sdk/data/azcosmos/CHANGELOG.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
# Release History
22

3-
## 0.3.2 (Unreleased)
3+
## 0.3.2 (2022-08-09)
44

55
### Features Added
66
* Added `NewClientFromConnectionString` function to create client from connection string
77
* Added support for parametrized queries through `QueryOptions.QueryParameters`
88

9-
### Breaking Changes
10-
119
### Bugs Fixed
12-
13-
### Other Changes
10+
* Fixed handling of ids with whitespaces and special supported characters
1411

1512
## 0.3.1 (2022-05-12)
1613

sdk/data/azcosmos/cosmos_paths.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,6 @@ func createLink(parentPath string, pathSegment string, id string) string {
168168
}
169169
completePath.WriteString(pathSegment)
170170
completePath.WriteString("/")
171-
completePath.WriteString(url.QueryEscape(id))
171+
completePath.WriteString(url.PathEscape(id))
172172
return completePath.String()
173173
}

sdk/data/azcosmos/cosmos_paths_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ func TestPathCreateLink(t *testing.T) {
2121
t.Errorf("Expected %s, got %s", expected, actual)
2222
}
2323

24-
expected = "dbs/esc%40ped"
25-
actual = createLink("", pathSegmentDatabase, "esc@ped")
24+
expected = "dbs/with%20space"
25+
actual = createLink("", pathSegmentDatabase, "with space")
2626
if actual != expected {
2727
t.Errorf("Expected %s, got %s", expected, actual)
2828
}

sdk/data/azcosmos/emulator_cosmos_item_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ package azcosmos
66
import (
77
"context"
88
"encoding/json"
9+
"errors"
10+
"net/http"
11+
"strings"
912
"testing"
13+
14+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1015
)
1116

1217
func TestItemCRUD(t *testing.T) {
@@ -136,3 +141,79 @@ func TestItemCRUD(t *testing.T) {
136141
t.Fatalf("Expected empty response, got %v", itemResponse.Value)
137142
}
138143
}
144+
145+
func TestItemIdEncoding(t *testing.T) {
146+
emulatorTests := newEmulatorTests(t)
147+
client := emulatorTests.getClient(t)
148+
149+
database := emulatorTests.createDatabase(t, context.TODO(), client, "itemCRUD")
150+
defer emulatorTests.deleteDatabase(t, context.TODO(), database)
151+
properties := ContainerProperties{
152+
ID: "aContainer",
153+
PartitionKeyDefinition: PartitionKeyDefinition{
154+
Paths: []string{"/pk"},
155+
},
156+
}
157+
158+
_, err := database.CreateContainer(context.TODO(), properties, nil)
159+
if err != nil {
160+
t.Fatalf("Failed to create container: %v", err)
161+
}
162+
163+
container, _ := database.NewContainer("aContainer")
164+
165+
verifyEncodingScenario(t, container, "PlainVanillaId", "Test", http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
166+
verifyEncodingScenario(t, container, "IdWithWhitespaces", "This is a test", http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
167+
verifyEncodingScenario(t, container, "IdStartingWithWhitespaces", " Test", http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
168+
verifyEncodingScenario(t, container, "IdEndingWithWhitespace", "Test ", http.StatusCreated, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized)
169+
verifyEncodingScenario(t, container, "IdEndingWithWhitespaces", "Test ", http.StatusCreated, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized)
170+
verifyEncodingScenario(t, container, "IdWithAllowedSpecialCharacters", "WithAllowedSpecial,=.:~+-@()^${}[]!_Chars", http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
171+
verifyEncodingScenario(t, container, "IdWithBase64EncodedIdCharacters", strings.Replace("BQE1D3PdG4N4bzU9TKaCIM3qc0TVcZ2/Y3jnsRfwdHC1ombkX3F1dot/SG0/UTq9AbgdX3kOWoP6qL6lJqWeKgV3zwWWPZO/t5X0ehJzv9LGkWld07LID2rhWhGT6huBM6Q=", "/", "-", -1), http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
172+
verifyEncodingScenario(t, container, "IdEndingWithPercentEncodedWhitespace", "IdEndingWithPercentEncodedWhitespace%20", http.StatusCreated, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized)
173+
verifyEncodingScenario(t, container, "IdWithPercentEncodedSpecialChar", "WithPercentEncodedSpecialChar%E9%B1%80", http.StatusCreated, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized)
174+
verifyEncodingScenario(t, container, "IdWithDisallowedCharQuestionMark", "Disallowed?Chars", http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
175+
verifyEncodingScenario(t, container, "IdWithDisallowedCharForwardSlash", "Disallowed/Chars", http.StatusCreated, http.StatusBadRequest, http.StatusBadRequest, http.StatusBadRequest)
176+
verifyEncodingScenario(t, container, "IdWithDisallowedCharBackSlash", "Disallowed\\Chars", http.StatusCreated, http.StatusBadRequest, http.StatusBadRequest, http.StatusBadRequest)
177+
verifyEncodingScenario(t, container, "IdWithDisallowedCharPoundSign", "Disallowed#Chars", http.StatusCreated, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized)
178+
verifyEncodingScenario(t, container, "IdWithCarriageReturn", "With\rCarriageReturn", http.StatusCreated, http.StatusBadRequest, http.StatusBadRequest, http.StatusBadRequest)
179+
verifyEncodingScenario(t, container, "IdWithTab", "With\tTab", http.StatusCreated, http.StatusBadRequest, http.StatusBadRequest, http.StatusBadRequest)
180+
verifyEncodingScenario(t, container, "IdWithLineFeed", "With\nLineFeed", http.StatusCreated, http.StatusBadRequest, http.StatusBadRequest, http.StatusBadRequest)
181+
verifyEncodingScenario(t, container, "IdWithUnicodeCharacters", "WithUnicode鱀", http.StatusCreated, http.StatusOK, http.StatusOK, http.StatusNoContent)
182+
}
183+
184+
func verifyEncodingScenario(t *testing.T, container *ContainerClient, name string, id string, expectedCreate int, expectedRead int, expectedReplace int, expectedDelete int) {
185+
item := map[string]interface{}{
186+
"id": id,
187+
"pk": id,
188+
}
189+
190+
pk := NewPartitionKeyString(id)
191+
192+
marshalled, err := json.Marshal(item)
193+
if err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
itemResponse, err := container.CreateItem(context.TODO(), pk, marshalled, nil)
198+
verifyEncodingScenarioResponse(t, name+"Create", itemResponse, err, expectedCreate)
199+
itemResponse, err = container.ReadItem(context.TODO(), pk, id, nil)
200+
verifyEncodingScenarioResponse(t, name+"Read", itemResponse, err, expectedRead)
201+
itemResponse, err = container.ReplaceItem(context.TODO(), pk, id, marshalled, nil)
202+
verifyEncodingScenarioResponse(t, name+"Replace", itemResponse, err, expectedReplace)
203+
itemResponse, err = container.DeleteItem(context.TODO(), pk, id, nil)
204+
verifyEncodingScenarioResponse(t, name+"Delete", itemResponse, err, expectedDelete)
205+
}
206+
207+
func verifyEncodingScenarioResponse(t *testing.T, name string, itemResponse ItemResponse, err error, expectedStatus int) {
208+
if err != nil {
209+
var responseErr *azcore.ResponseError
210+
errors.As(err, &responseErr)
211+
if responseErr.StatusCode != expectedStatus {
212+
t.Fatalf("[%s] Expected status code %d, got %d, %s", name, expectedStatus, responseErr.StatusCode, err)
213+
}
214+
} else {
215+
if itemResponse.RawResponse.StatusCode != expectedStatus {
216+
t.Fatalf("[%s] Expected status code %d, got %d", name, expectedStatus, itemResponse.RawResponse.StatusCode)
217+
}
218+
}
219+
}

sdk/data/azcosmos/partition_key.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package azcosmos
55

66
import (
77
"encoding/json"
8+
"strconv"
9+
"strings"
810
)
911

1012
// PartitionKey represents a logical partition key value.
@@ -37,9 +39,28 @@ func NewPartitionKeyNumber(value float64) PartitionKey {
3739
}
3840

3941
func (pk *PartitionKey) toJsonString() (string, error) {
40-
res, err := json.Marshal(pk.values)
41-
if err != nil {
42-
return "", err
42+
var completeJson strings.Builder
43+
completeJson.Grow(256)
44+
completeJson.WriteString("[")
45+
for index, i := range pk.values {
46+
switch v := i.(type) {
47+
case string:
48+
// json marshall does not support escaping ASCII as an option
49+
escaped := strconv.QuoteToASCII(v)
50+
completeJson.WriteString(escaped)
51+
default:
52+
res, err := json.Marshal(v)
53+
if err != nil {
54+
return "", err
55+
}
56+
completeJson.WriteString(string(res))
57+
}
58+
59+
if index < len(pk.values)-1 {
60+
completeJson.WriteString(",")
61+
}
4362
}
44-
return string(res), nil
63+
64+
completeJson.WriteString("]")
65+
return completeJson.String(), nil
4566
}

sdk/data/azcosmos/shared_key_credential.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ func (c *KeyCredential) buildCanonicalizedAuthHeader(method, resourceType, resou
8181
return ""
8282
}
8383

84+
resourceAddress, _ = url.PathUnescape(resourceAddress)
85+
8486
// https://docs.microsoft.com/en-us/rest/api/cosmos-db/access-control-on-cosmosdb-resources#constructkeytoken
8587
stringToSign := join(strings.ToLower(method), "\n", strings.ToLower(resourceType), "\n", resourceAddress, "\n", strings.ToLower(xmsDate), "\n", "", "\n")
8688
signature := c.computeHMACSHA256(stringToSign)

sdk/data/azcosmos/shared_key_credential_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,36 @@ func Test_buildCanonicalizedAuthHeaderFromRequestWithRid(t *testing.T) {
108108

109109
assert.Equal(t, expected, authHeader)
110110
}
111+
112+
func Test_buildCanonicalizedAuthHeaderFromRequestWithEscapedCharacters(t *testing.T) {
113+
key := "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
114+
115+
cred, err := NewKeyCredential(key)
116+
117+
assert.NoError(t, err)
118+
119+
method := "GET"
120+
resourceType := "dbs"
121+
originalResourceId := "dbs/name with spaces"
122+
resourceId := url.PathEscape(originalResourceId)
123+
xmsDate := "Thu, 27 Apr 2017 00:51:12 GMT"
124+
tokenType := "master"
125+
version := "1.0"
126+
127+
stringToSign := join(strings.ToLower(method), "\n", strings.ToLower(resourceType), "\n", originalResourceId, "\n", strings.ToLower(xmsDate), "\n", "", "\n")
128+
signature := cred.computeHMACSHA256(stringToSign)
129+
expected := url.QueryEscape(fmt.Sprintf("type=%s&ver=%s&sig=%s", tokenType, version, signature))
130+
131+
req, _ := azruntime.NewRequest(context.TODO(), http.MethodGet, "http://localhost")
132+
operationContext := pipelineRequestOptions{
133+
resourceType: resourceTypeDatabase,
134+
resourceAddress: resourceId,
135+
}
136+
137+
req.Raw().Header.Set(headerXmsDate, xmsDate)
138+
req.Raw().Header.Set(headerXmsVersion, "2020-11-05")
139+
req.SetOperationValue(operationContext)
140+
authHeader, _ := cred.buildCanonicalizedAuthHeaderFromRequest(req)
141+
142+
assert.Equal(t, expected, authHeader)
143+
}

0 commit comments

Comments
 (0)