Skip to content

Commit f254a1b

Browse files
authored
Sas time encoding roundtrip (Azure#25524)
* Preserved date-time format when parsing values from a query string for better round tripping * Updated test string constant * Ci fixes * Changelogs and ci fix * PR feedback * Moved TimeAndFormat to implementation
1 parent f4ee974 commit f254a1b

File tree

17 files changed

+230
-73
lines changed

17 files changed

+230
-73
lines changed

sdk/storage/azure-storage-blob/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
### Breaking Changes
1313

1414
### Bugs Fixed
15+
- Fixed a bug that would cause authenticating with a sas token to fail if the timestamps in the token were formatted differently.
1516

1617
### Other Changes
1718
- Deprecated BlobClient.uploadWithResponse that does not return a response.

sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSasImplUtil.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.azure.storage.common.StorageSharedKeyCredential;
1616
import com.azure.storage.common.implementation.Constants;
1717
import com.azure.storage.common.implementation.StorageImplUtils;
18+
import com.azure.storage.common.implementation.TimeAndFormat;
1819
import com.azure.storage.common.sas.SasIpRange;
1920
import com.azure.storage.common.sas.SasProtocol;
2021

@@ -199,8 +200,10 @@ private String encode(UserDelegationKey userDelegationKey, String signature) {
199200

200201
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SERVICE_VERSION, VERSION);
201202
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_PROTOCOL, this.protocol);
202-
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_START_TIME, formatQueryParameterDate(this.startTime));
203-
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_EXPIRY_TIME, formatQueryParameterDate(this.expiryTime));
203+
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_START_TIME, formatQueryParameterDate(
204+
new TimeAndFormat(this.startTime, null)));
205+
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_EXPIRY_TIME, formatQueryParameterDate(
206+
new TimeAndFormat(this.expiryTime, null)));
204207
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_IP_RANGE, this.sasIpRange);
205208
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_IDENTIFIER, this.identifier);
206209
if (userDelegationKey != null) {
@@ -209,9 +212,9 @@ private String encode(UserDelegationKey userDelegationKey, String signature) {
209212
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_TENANT_ID,
210213
userDelegationKey.getSignedTenantId());
211214
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_KEY_START,
212-
formatQueryParameterDate(userDelegationKey.getSignedStart()));
215+
formatQueryParameterDate(new TimeAndFormat(userDelegationKey.getSignedStart(), null)));
213216
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_KEY_EXPIRY,
214-
formatQueryParameterDate(userDelegationKey.getSignedExpiry()));
217+
formatQueryParameterDate(new TimeAndFormat(userDelegationKey.getSignedExpiry(), null)));
215218
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_KEY_SERVICE,
216219
userDelegationKey.getSignedService());
217220
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_KEY_VERSION,

sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/sas/BlobServiceSasQueryParameters.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55

66
import com.azure.storage.blob.models.UserDelegationKey;
77
import com.azure.storage.blob.BlobClientBuilder;
8+
import com.azure.storage.common.implementation.StorageImplUtils;
89
import com.azure.storage.common.sas.BaseSasQueryParameters;
910
import com.azure.storage.common.sas.SasProtocol;
1011
import com.azure.storage.common.implementation.Constants;
1112
import com.azure.storage.common.sas.SasIpRange;
12-
import com.azure.storage.common.Utility;
1313

1414
import java.time.OffsetDateTime;
1515
import java.util.Map;
@@ -71,9 +71,9 @@ public BlobServiceSasQueryParameters(Map<String, String[]> queryParamsMap, boole
7171
this.keyTenantId = getQueryParameter(queryParamsMap, Constants.UrlConstants.SAS_SIGNED_TENANT_ID,
7272
removeSasParametersFromMap);
7373
this.keyStart = getQueryParameter(queryParamsMap, Constants.UrlConstants.SAS_SIGNED_KEY_START,
74-
removeSasParametersFromMap, Utility::parseDate);
74+
removeSasParametersFromMap, StorageImplUtils::parseDateAndFormat).getDateTime();
7575
this.keyExpiry = getQueryParameter(queryParamsMap, Constants.UrlConstants.SAS_SIGNED_KEY_EXPIRY,
76-
removeSasParametersFromMap, Utility::parseDate);
76+
removeSasParametersFromMap, StorageImplUtils::parseDateAndFormat).getDateTime();
7777
this.keyService = getQueryParameter(queryParamsMap, Constants.UrlConstants.SAS_SIGNED_KEY_SERVICE,
7878
removeSasParametersFromMap);
7979
this.keyVersion = getQueryParameter(queryParamsMap, Constants.UrlConstants.SAS_SIGNED_KEY_VERSION,

sdk/storage/azure-storage-common/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
### Breaking Changes
88

99
### Bugs Fixed
10+
- Fixed a bug that would cause authenticating with a sas token to fail if the timestamps in the token were formatted differently.
1011

1112
### Other Changes
1213

sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/Utility.java

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.azure.core.util.CoreUtils;
88
import com.azure.core.util.UrlBuilder;
99
import com.azure.core.util.logging.ClientLogger;
10+
import com.azure.storage.common.implementation.StorageImplUtils;
1011
import reactor.core.publisher.Flux;
1112
import reactor.core.publisher.Mono;
1213

@@ -21,45 +22,20 @@
2122
import java.time.ZoneOffset;
2223
import java.time.format.DateTimeFormatter;
2324
import java.util.Locale;
24-
import java.util.regex.Pattern;
2525

2626
/**
2727
* Utility methods for storage client libraries.
2828
*/
2929
public final class Utility {
3030
private static final ClientLogger LOGGER = new ClientLogger(Utility.class);
3131
private static final String UTF8_CHARSET = "UTF-8";
32-
private static final String INVALID_DATE_STRING = "Invalid Date String: %s.";
3332

3433
/**
3534
* Please see <a href=https://docs.microsoft.com/en-us/azure/azure-resource-manager/management/azure-services-resource-providers>here</a>
3635
* for more information on Azure resource provider namespaces.
3736
*/
3837
public static final String STORAGE_TRACING_NAMESPACE_VALUE = "Microsoft.Storage";
3938

40-
/**
41-
* Stores a reference to the date/time pattern with the greatest precision Java.util.Date is capable of expressing.
42-
*/
43-
private static final String MAX_PRECISION_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS";
44-
/**
45-
* Stores a reference to the ISO8601 date/time pattern.
46-
*/
47-
private static final String ISO8601_PATTERN = "yyyy-MM-dd'T'HH:mm:ss'Z'";
48-
/**
49-
* Stores a reference to the ISO8601 date/time pattern.
50-
*/
51-
private static final String ISO8601_PATTERN_NO_SECONDS = "yyyy-MM-dd'T'HH:mm'Z'";
52-
/**
53-
* The length of a datestring that matches the MAX_PRECISION_PATTERN.
54-
*/
55-
private static final int MAX_PRECISION_DATESTRING_LENGTH = MAX_PRECISION_PATTERN.replaceAll("'", "")
56-
.length();
57-
/**
58-
* A compiled Pattern that finds 'Z'. This is used as Java 8's String.replace method uses Pattern.compile
59-
* internally without simple case opt-outs.
60-
*/
61-
private static final Pattern Z_PATTERN = Pattern.compile("Z");
62-
6339

6440
/**
6541
* Performs a safe decoding of the passed string, taking care to preserve each {@code +} character rather than
@@ -188,33 +164,35 @@ public static String encodeUrlPath(String url) {
188164
* @param dateString the {@code String} to be interpreted as a <code>Date</code>
189165
* @return the corresponding <code>Date</code> object
190166
* @throws IllegalArgumentException If {@code dateString} doesn't match an ISO8601 pattern
167+
* @deprecated Use {@link StorageImplUtils#parseDateAndFormat(String)}
191168
*/
169+
@Deprecated
192170
public static OffsetDateTime parseDate(String dateString) {
193-
String pattern = MAX_PRECISION_PATTERN;
171+
String pattern = StorageImplUtils.MAX_PRECISION_PATTERN;
194172
switch (dateString.length()) {
195173
case 28: // "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'"-> [2012-01-04T23:21:59.1234567Z] length = 28
196174
case 27: // "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"-> [2012-01-04T23:21:59.123456Z] length = 27
197175
case 26: // "yyyy-MM-dd'T'HH:mm:ss.SSSSS'Z'"-> [2012-01-04T23:21:59.12345Z] length = 26
198176
case 25: // "yyyy-MM-dd'T'HH:mm:ss.SSSS'Z'"-> [2012-01-04T23:21:59.1234Z] length = 25
199177
case 24: // "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"-> [2012-01-04T23:21:59.123Z] length = 24
200-
dateString = dateString.substring(0, MAX_PRECISION_DATESTRING_LENGTH);
178+
dateString = dateString.substring(0, StorageImplUtils.MAX_PRECISION_DATESTRING_LENGTH);
201179
break;
202180
case 23: // "yyyy-MM-dd'T'HH:mm:ss.SS'Z'"-> [2012-01-04T23:21:59.12Z] length = 23
203181
// SS is assumed to be milliseconds, so a trailing 0 is necessary
204-
dateString = Z_PATTERN.matcher(dateString).replaceAll("0");
182+
dateString = StorageImplUtils.Z_PATTERN.matcher(dateString).replaceAll("0");
205183
break;
206184
case 22: // "yyyy-MM-dd'T'HH:mm:ss.S'Z'"-> [2012-01-04T23:21:59.1Z] length = 22
207185
// S is assumed to be milliseconds, so trailing 0's are necessary
208-
dateString = Z_PATTERN.matcher(dateString).replaceAll("00");
186+
dateString = StorageImplUtils.Z_PATTERN.matcher(dateString).replaceAll("00");
209187
break;
210188
case 20: // "yyyy-MM-dd'T'HH:mm:ss'Z'"-> [2012-01-04T23:21:59Z] length = 20
211-
pattern = Utility.ISO8601_PATTERN;
189+
pattern = StorageImplUtils.ISO8601_PATTERN;
212190
break;
213191
case 17: // "yyyy-MM-dd'T'HH:mm'Z'"-> [2012-01-04T23:21Z] length = 17
214-
pattern = Utility.ISO8601_PATTERN_NO_SECONDS;
192+
pattern = StorageImplUtils.ISO8601_PATTERN_NO_SECONDS;
215193
break;
216194
default:
217-
throw new IllegalArgumentException(String.format(Locale.ROOT, INVALID_DATE_STRING, dateString));
195+
throw new IllegalArgumentException(String.format(Locale.ROOT, StorageImplUtils.INVALID_DATE_STRING, dateString));
218196
}
219197

220198
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);

sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/AccountSasImplUtil.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,10 @@ private String encode(String signature) {
125125
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SERVICES, this.services);
126126
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_RESOURCES_TYPES, this.resourceTypes);
127127
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_PROTOCOL, this.protocol);
128-
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_START_TIME, formatQueryParameterDate(this.startTime));
129-
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_EXPIRY_TIME, formatQueryParameterDate(this.expiryTime));
128+
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_START_TIME, formatQueryParameterDate(
129+
new TimeAndFormat(this.startTime, null)));
130+
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_EXPIRY_TIME, formatQueryParameterDate(
131+
new TimeAndFormat(this.expiryTime, null)));
130132
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_IP_RANGE, this.sasIpRange);
131133
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_SIGNED_PERMISSIONS, this.permissions);
132134
tryAppendQueryParameter(sb, Constants.UrlConstants.SAS_ENCRYPTION_SCOPE, this.encryptionScope);

sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/SasImplUtils.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import com.azure.storage.common.Utility;
1010
import com.azure.storage.common.policy.StorageSharedKeyCredentialPolicy;
1111

12-
import java.time.OffsetDateTime;
1312
import java.util.Comparator;
1413
import java.util.Locale;
1514
import java.util.Map;
@@ -55,14 +54,18 @@ public static void tryAppendQueryParameter(StringBuilder sb, String param, Objec
5554
/**
5655
* Formats date time SAS query parameters.
5756
*
58-
* @param dateTime The SAS date time.
57+
* @param timeAndFormat The SAS date time.
5958
* @return A String representing the SAS date time.
6059
*/
61-
public static String formatQueryParameterDate(OffsetDateTime dateTime) {
62-
if (dateTime == null) {
60+
public static String formatQueryParameterDate(TimeAndFormat timeAndFormat) {
61+
if (timeAndFormat == null || timeAndFormat.getDateTime() == null) {
6362
return null;
6463
} else {
65-
return Constants.ISO_8601_UTC_DATE_FORMATTER.format(dateTime);
64+
if (timeAndFormat.getFormatter() == null) {
65+
return Constants.ISO_8601_UTC_DATE_FORMATTER.format(timeAndFormat.getDateTime());
66+
} else {
67+
return timeAndFormat.getFormatter().format(timeAndFormat.getDateTime());
68+
}
6669
}
6770
}
6871

sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/StorageImplUtils.java

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@
1717
import java.security.InvalidKeyException;
1818
import java.security.NoSuchAlgorithmException;
1919
import java.time.Duration;
20+
import java.time.LocalDateTime;
21+
import java.time.ZoneOffset;
22+
import java.time.format.DateTimeFormatter;
2023
import java.util.Base64;
2124
import java.util.Locale;
2225
import java.util.Map;
2326
import java.util.TreeMap;
2427
import java.util.function.Function;
28+
import java.util.regex.Pattern;
2529

2630
import reactor.core.publisher.Flux;
2731
import reactor.core.publisher.Mono;
@@ -46,8 +50,10 @@ public class StorageImplUtils {
4650
private static final String NO_PATH_SEGMENTS = "URL %s does not contain path segments.";
4751

4852
private static final String STRING_TO_SIGN_LOG_INFO_MESSAGE = "The string to sign computed by the SDK is: {}{}";
53+
4954
private static final String STRING_TO_SIGN_LOG_WARNING_MESSAGE = "Please remember to disable '{}' before going "
5055
+ "to production as this string can potentially contain PII.";
56+
5157
private static final String STORAGE_EXCEPTION_LOG_STRING_TO_SIGN_MESSAGE = String.format(
5258
"If you are using a StorageSharedKeyCredential, and the server returned an "
5359
+ "error message that says 'Signature did not match', you can compare the string to sign with"
@@ -62,6 +68,49 @@ public class StorageImplUtils {
6268
Constants.STORAGE_LOG_STRING_TO_SIGN, Constants.STORAGE_LOG_STRING_TO_SIGN,
6369
Constants.STORAGE_LOG_STRING_TO_SIGN);
6470

71+
/**
72+
* @deprecated See value in {@link StorageImplUtils}
73+
*/
74+
@Deprecated
75+
public static final String INVALID_DATE_STRING = "Invalid Date String: %s.";
76+
77+
/**
78+
* Stores a reference to the date/time pattern with the greatest precision Java.util.Date is capable of expressing.
79+
* @deprecated See value in {@link StorageImplUtils}
80+
*/
81+
@Deprecated
82+
public static final String MAX_PRECISION_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS";
83+
84+
/**
85+
* The length of a datestring that matches the MAX_PRECISION_PATTERN.
86+
* @deprecated See value in {@link StorageImplUtils}
87+
*/
88+
@Deprecated
89+
public static final int MAX_PRECISION_DATESTRING_LENGTH = MAX_PRECISION_PATTERN.replaceAll("'", "")
90+
.length();
91+
92+
/**
93+
* Stores a reference to the ISO8601 date/time pattern.
94+
* @deprecated See value in {@link StorageImplUtils}
95+
*/
96+
@Deprecated
97+
public static final String ISO8601_PATTERN = "yyyy-MM-dd'T'HH:mm:ss'Z'";
98+
99+
/**
100+
* Stores a reference to the ISO8601 date/time pattern.
101+
* @deprecated See value in {@link StorageImplUtils}
102+
*/
103+
@Deprecated
104+
public static final String ISO8601_PATTERN_NO_SECONDS = "yyyy-MM-dd'T'HH:mm'Z'";
105+
106+
/**
107+
* A compiled Pattern that finds 'Z'. This is used as Java 8's String.replace method uses Pattern.compile
108+
* internally without simple case opt-outs.
109+
* @deprecated See value in {@link StorageImplUtils}
110+
*/
111+
@Deprecated
112+
public static final Pattern Z_PATTERN = Pattern.compile("Z");
113+
65114
/**
66115
* Parses the query string into a key-value pair map that maintains key, query parameter key, order. The value is
67116
* stored as a string (ex. key=val1,val2,val3 instead of key=[val1, val2, val3]).
@@ -327,4 +376,45 @@ public static String convertStorageExceptionMessage(String message, HttpResponse
327376
}
328377
return message;
329378
}
379+
380+
/**
381+
* Given a String representing a date in a form of the ISO8601 pattern, generates a Date representing it with up to
382+
* millisecond precision.
383+
*
384+
* @param dateString the {@code String} to be interpreted as a <code>Date</code>
385+
* @return the corresponding <code>Date</code> object
386+
* @throws IllegalArgumentException If {@code dateString} doesn't match an ISO8601 pattern
387+
*/
388+
public static TimeAndFormat parseDateAndFormat(String dateString) {
389+
String pattern = MAX_PRECISION_PATTERN;
390+
switch (dateString.length()) {
391+
case 28: // "yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'"-> [2012-01-04T23:21:59.1234567Z] length = 28
392+
case 27: // "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"-> [2012-01-04T23:21:59.123456Z] length = 27
393+
case 26: // "yyyy-MM-dd'T'HH:mm:ss.SSSSS'Z'"-> [2012-01-04T23:21:59.12345Z] length = 26
394+
case 25: // "yyyy-MM-dd'T'HH:mm:ss.SSSS'Z'"-> [2012-01-04T23:21:59.1234Z] length = 25
395+
case 24: // "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"-> [2012-01-04T23:21:59.123Z] length = 24
396+
dateString = dateString.substring(0, MAX_PRECISION_DATESTRING_LENGTH);
397+
break;
398+
case 23: // "yyyy-MM-dd'T'HH:mm:ss.SS'Z'"-> [2012-01-04T23:21:59.12Z] length = 23
399+
// SS is assumed to be milliseconds, so a trailing 0 is necessary
400+
dateString = Z_PATTERN.matcher(dateString).replaceAll("0");
401+
break;
402+
case 22: // "yyyy-MM-dd'T'HH:mm:ss.S'Z'"-> [2012-01-04T23:21:59.1Z] length = 22
403+
// S is assumed to be milliseconds, so trailing 0's are necessary
404+
dateString = Z_PATTERN.matcher(dateString).replaceAll("00");
405+
break;
406+
case 20: // "yyyy-MM-dd'T'HH:mm:ss'Z'"-> [2012-01-04T23:21:59Z] length = 20
407+
pattern = ISO8601_PATTERN;
408+
break;
409+
case 17: // "yyyy-MM-dd'T'HH:mm'Z'"-> [2012-01-04T23:21Z] length = 17
410+
pattern = ISO8601_PATTERN_NO_SECONDS;
411+
break;
412+
default:
413+
throw new IllegalArgumentException(String.format(Locale.ROOT, INVALID_DATE_STRING, dateString));
414+
}
415+
416+
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, Locale.ROOT);
417+
return new TimeAndFormat(LocalDateTime.parse(dateString, formatter).atZone(ZoneOffset.UTC).toOffsetDateTime(),
418+
formatter);
419+
}
330420
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.azure.storage.common.implementation;
5+
6+
import java.time.OffsetDateTime;
7+
import java.time.format.DateTimeFormatter;
8+
9+
/**
10+
* A class to hold an {@code OffsetDateTime} object and the formatter from which it was parsed for effective
11+
* roundtripping.
12+
* Without this, we always format date-times the same way, but if we parsed a value from a sas token in on an endpoint
13+
* or in a connection string, we have to preserve the formatting, especially the precision, so the signature still
14+
* matches.
15+
*/
16+
public final class TimeAndFormat {
17+
private final OffsetDateTime dateTime;
18+
private final DateTimeFormatter format;
19+
20+
/**
21+
* Constructs a new {@code TimeAndFormat object}.
22+
*
23+
* @param dateTime The date-time object.
24+
* @param formatter The format it was parsed from. Null if it was not parsed from a {@code DateTimeFormatter}.
25+
*/
26+
public TimeAndFormat(OffsetDateTime dateTime, DateTimeFormatter formatter) {
27+
// If the format is null, it means we didn't parse it and we can just use our default formatter
28+
this.dateTime = dateTime;
29+
this.format = formatter;
30+
}
31+
32+
/**
33+
* Gets the date-time.
34+
*
35+
* @return The date-time
36+
*/
37+
public OffsetDateTime getDateTime() {
38+
return dateTime;
39+
}
40+
41+
/**
42+
* Gets the format the time was parsed from.
43+
*
44+
* @return The format. May be null if time was not parsed.
45+
*/
46+
public DateTimeFormatter getFormatter() {
47+
return format;
48+
}
49+
}

0 commit comments

Comments
 (0)