blob: 7dd783fa728fc25f0f69f3f3ee760bf042687cf9 [file] [log] [blame] [edit]
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
part of "dart:_http";
class _HttpHeaders implements HttpHeaders {
final Map<String, List<String>> _headers;
// The original header names keyed by the lowercase header names.
Map<String, String>? _originalHeaderNames;
final String protocolVersion;
bool _mutable = true; // Are the headers currently mutable?
List<String>? _noFoldingHeaders;
int _contentLength = -1;
bool _persistentConnection = true;
bool _chunkedTransferEncoding = false;
String? _host;
int? _port;
final int _defaultPortForScheme;
_HttpHeaders(
this.protocolVersion, {
int defaultPortForScheme = HttpClient.defaultHttpPort,
_HttpHeaders? initialHeaders,
}) : _headers = HashMap<String, List<String>>(),
_defaultPortForScheme = defaultPortForScheme {
if (initialHeaders != null) {
initialHeaders._headers.forEach((name, value) => _headers[name] = value);
_contentLength = initialHeaders._contentLength;
_persistentConnection = initialHeaders._persistentConnection;
_chunkedTransferEncoding = initialHeaders._chunkedTransferEncoding;
_host = initialHeaders._host;
_port = initialHeaders._port;
}
if (protocolVersion == "1.0") {
_persistentConnection = false;
_chunkedTransferEncoding = false;
}
}
List<String>? operator [](String name) => _headers[_validateField(name)];
String? value(String name) {
name = _validateField(name);
List<String>? values = _headers[name];
if (values == null) return null;
assert(values.isNotEmpty);
if (values.length > 1) {
throw HttpException("More than one value for header $name");
}
return values[0];
}
void add(String name, Object value, {bool preserveHeaderCase = false}) {
_checkMutable();
String lowercaseName = _validateField(name);
if (preserveHeaderCase && name != lowercaseName) {
(_originalHeaderNames ??= {})[lowercaseName] = name;
} else {
_originalHeaderNames?.remove(lowercaseName);
}
_addAll(lowercaseName, value);
}
void _addAll(String name, Object value) {
if (value is Iterable) {
for (var v in value) {
_add(name, _validateValue(v));
}
} else {
_add(name, _validateValue(value));
}
}
void set(String name, Object value, {bool preserveHeaderCase = false}) {
_checkMutable();
String lowercaseName = _validateField(name);
_headers.remove(lowercaseName);
_originalHeaderNames?.remove(lowercaseName);
if (lowercaseName == HttpHeaders.contentLengthHeader) {
_contentLength = -1;
}
if (lowercaseName == HttpHeaders.transferEncodingHeader) {
_chunkedTransferEncoding = false;
}
if (preserveHeaderCase && name != lowercaseName) {
(_originalHeaderNames ??= {})[lowercaseName] = name;
}
_addAll(lowercaseName, value);
}
void remove(String name, Object value) {
_checkMutable();
name = _validateField(name);
value = _validateValue(value);
List<String>? values = _headers[name];
if (values != null) {
values.remove(_valueToString(value));
if (values.isEmpty) {
_headers.remove(name);
_originalHeaderNames?.remove(name);
}
}
if (name == HttpHeaders.transferEncodingHeader && value == "chunked") {
_chunkedTransferEncoding = false;
}
}
void removeAll(String name) {
_checkMutable();
name = _validateField(name);
_headers.remove(name);
_originalHeaderNames?.remove(name);
}
void forEach(void Function(String name, List<String> values) action) {
_headers.forEach((String name, List<String> values) {
String originalName = _originalHeaderName(name);
action(originalName, values);
});
}
void noFolding(String name) {
name = _validateField(name);
(_noFoldingHeaders ??= <String>[]).add(name);
}
bool get persistentConnection => _persistentConnection;
void set persistentConnection(bool persistentConnection) {
_checkMutable();
if (persistentConnection == _persistentConnection) return;
final originalName = _originalHeaderName(HttpHeaders.connectionHeader);
if (persistentConnection) {
if (protocolVersion == "1.1") {
remove(HttpHeaders.connectionHeader, "close");
} else {
if (_contentLength < 0) {
throw HttpException(
"Trying to set 'Connection: Keep-Alive' on HTTP 1.0 headers with "
"no ContentLength",
);
}
add(originalName, "keep-alive", preserveHeaderCase: true);
}
} else {
if (protocolVersion == "1.1") {
add(originalName, "close", preserveHeaderCase: true);
} else {
remove(HttpHeaders.connectionHeader, "keep-alive");
}
}
_persistentConnection = persistentConnection;
}
int get contentLength => _contentLength;
void set contentLength(int contentLength) {
_checkMutable();
if (protocolVersion == "1.0" &&
persistentConnection &&
contentLength == -1) {
throw HttpException(
"Trying to clear ContentLength on HTTP 1.0 headers with "
"'Connection: Keep-Alive' set",
);
}
if (_contentLength == contentLength) return;
_contentLength = contentLength;
if (_contentLength >= 0) {
if (chunkedTransferEncoding) chunkedTransferEncoding = false;
_set(HttpHeaders.contentLengthHeader, contentLength.toString());
} else {
_headers.remove(HttpHeaders.contentLengthHeader);
if (protocolVersion == "1.1") {
chunkedTransferEncoding = true;
}
}
}
bool get chunkedTransferEncoding => _chunkedTransferEncoding;
void set chunkedTransferEncoding(bool chunkedTransferEncoding) {
_checkMutable();
if (chunkedTransferEncoding && protocolVersion == "1.0") {
throw HttpException(
"Trying to set 'Transfer-Encoding: Chunked' on HTTP 1.0 headers",
);
}
if (chunkedTransferEncoding == _chunkedTransferEncoding) return;
if (chunkedTransferEncoding) {
List<String>? values = _headers[HttpHeaders.transferEncodingHeader];
if (values == null || !values.contains("chunked")) {
// Headers does not specify chunked encoding - add it if set.
_addValue(HttpHeaders.transferEncodingHeader, "chunked");
}
contentLength = -1;
} else {
// Headers does specify chunked encoding - remove it if not set.
remove(HttpHeaders.transferEncodingHeader, "chunked");
}
_chunkedTransferEncoding = chunkedTransferEncoding;
}
String? get host => _host;
void set host(String? host) {
_checkMutable();
_host = host;
_updateHostHeader();
}
int? get port => _port;
void set port(int? port) {
_checkMutable();
_port = port;
_updateHostHeader();
}
DateTime? get ifModifiedSince {
List<String>? values = _headers[HttpHeaders.ifModifiedSinceHeader];
if (values != null) {
assert(values.isNotEmpty);
return HttpDate._tryParse(values[0]);
}
return null;
}
void set ifModifiedSince(DateTime? ifModifiedSince) {
_checkMutable();
if (ifModifiedSince == null) {
_headers.remove(HttpHeaders.ifModifiedSinceHeader);
} else {
// Format "ifModifiedSince" header with date in Greenwich Mean Time (GMT).
String formatted = HttpDate.format(ifModifiedSince.toUtc());
_set(HttpHeaders.ifModifiedSinceHeader, formatted);
}
}
DateTime? get date {
List<String>? values = _headers[HttpHeaders.dateHeader];
if (values != null) {
assert(values.isNotEmpty);
return HttpDate._tryParse(values[0]);
}
return null;
}
void set date(DateTime? date) {
_checkMutable();
if (date == null) {
_headers.remove(HttpHeaders.dateHeader);
} else {
// Format "DateTime" header with date in Greenwich Mean Time (GMT).
String formatted = HttpDate.format(date.toUtc());
_set(HttpHeaders.dateHeader, formatted);
}
}
DateTime? get expires {
List<String>? values = _headers[HttpHeaders.expiresHeader];
if (values != null) {
assert(values.isNotEmpty);
return HttpDate._tryParse(values[0]);
}
return null;
}
void set expires(DateTime? expires) {
_checkMutable();
if (expires == null) {
_headers.remove(HttpHeaders.expiresHeader);
} else {
// Format "Expires" header with date in Greenwich Mean Time (GMT).
String formatted = HttpDate.format(expires.toUtc());
_set(HttpHeaders.expiresHeader, formatted);
}
}
ContentType? get contentType {
var values = _headers[HttpHeaders.contentTypeHeader];
if (values != null) {
return ContentType.parse(values[0]);
} else {
return null;
}
}
void set contentType(ContentType? contentType) {
_checkMutable();
if (contentType == null) {
_headers.remove(HttpHeaders.contentTypeHeader);
} else {
_set(HttpHeaders.contentTypeHeader, contentType.toString());
}
}
void clear() {
_checkMutable();
_headers.clear();
_contentLength = -1;
_persistentConnection = true;
_chunkedTransferEncoding = false;
_host = null;
_port = null;
}
// [name] must be a lower-case version of the name.
void _add(String name, Object value) {
assert(name == _validateField(name));
// Use the length as index on what method to call. This is notable
// faster than computing hash and looking up in a hash-map.
switch (name.length) {
case 4:
if (HttpHeaders.dateHeader == name) {
_addDate(name, value);
return;
}
if (HttpHeaders.hostHeader == name) {
_addHost(name, _checkString(name, value));
return;
}
break;
case 7:
if (HttpHeaders.expiresHeader == name) {
_addExpires(name, value);
return;
}
break;
case 10:
if (HttpHeaders.connectionHeader == name) {
_addConnection(name, _checkString(name, value));
return;
}
break;
case 12:
if (HttpHeaders.contentTypeHeader == name) {
_addContentType(name, _checkString(name, value));
return;
}
break;
case 14:
if (HttpHeaders.contentLengthHeader == name) {
_addContentLength(name, value);
return;
}
break;
case 17:
if (HttpHeaders.transferEncodingHeader == name) {
_addTransferEncoding(name, value);
return;
}
if (HttpHeaders.ifModifiedSinceHeader == name) {
_addIfModifiedSince(name, value);
return;
}
}
_addValue(name, value);
}
static String _checkString(String name, Object value) {
if (value is String) return value;
throw HttpException("Unexpected type for header named $name");
}
void _addContentLength(String name, Object value) {
if (value is int) {
if (value >= 0) {
contentLength = value;
return;
}
throw HttpException("Content-Length must contain only digits");
}
if (value is String && value.isNotEmpty) {
var length = 0;
var number = 0;
var incrementLength = 0;
for (var i = 0; i < value.length; i++) {
var digit = value.codeUnitAt(i) ^ _CharCode.ZERO;
if (digit <= 9) {
number = number * 10 + digit;
if (number != 0) incrementLength = 1;
length += incrementLength;
continue;
}
throw HttpException("Content-Length must contain only digits");
}
if (length >= 16) {
throw HttpException("Content-Length too large");
}
contentLength = number;
return;
}
throw HttpException("Unexpected type for header named $name");
}
void _addTransferEncoding(String name, Object value) {
if (value == "chunked") {
chunkedTransferEncoding = true;
} else {
_addValue(HttpHeaders.transferEncodingHeader, value);
}
}
void _addDate(String name, Object value) {
if (value is DateTime) {
date = value;
} else if (value is String) {
_set(HttpHeaders.dateHeader, value);
} else {
throw HttpException("Unexpected type for header named $name");
}
}
void _addExpires(String name, Object value) {
if (value is DateTime) {
expires = value;
} else if (value is String) {
_set(HttpHeaders.expiresHeader, value);
} else {
throw HttpException("Unexpected type for header named $name");
}
}
void _addIfModifiedSince(String name, Object value) {
if (value is DateTime) {
ifModifiedSince = value;
} else if (value is String) {
_set(HttpHeaders.ifModifiedSinceHeader, value);
} else {
throw HttpException("Unexpected type for header named $name");
}
}
void _addHost(String name, String value) {
// value.indexOf will only work for ipv4, ipv6 which has multiple : in its
// host part needs lastIndexOf
int pos = value.lastIndexOf(":");
// According to RFC 3986, section 3.2.2, host part of ipv6 address must be
// enclosed by square brackets.
// https://serverfault.com/questions/205793/how-can-one-distinguish-the-host-and-the-port-in-an-ipv6-url
if (pos < 0 || value.startsWith("[") && value.endsWith("]")) {
_host = value;
_port = HttpClient.defaultHttpPort;
} else {
if (pos > 0) {
_host = value.substring(0, pos);
} else {
_host = null;
}
if (pos + 1 == value.length) {
_port = HttpClient.defaultHttpPort;
} else {
_port = int.tryParse(value.substring(pos + 1), radix: 10);
}
}
_set(HttpHeaders.hostHeader, value);
}
void _addConnection(String name, String value) {
if (_isTextNoCase(value, 0, value.length, 'close')) {
_persistentConnection = false;
} else if (_isTextNoCase(value, 0, value.length, 'keep-alive')) {
_persistentConnection = true;
}
_addValue(name, value);
}
void _addContentType(String name, String value) {
_set(HttpHeaders.contentTypeHeader, value);
}
void _addValue(String name, Object value) {
List<String> values = (_headers[name] ??= <String>[]);
values.add(_valueToString(value));
}
String _valueToString(Object value) {
if (value is DateTime) {
return HttpDate.format(value);
} else if (value is String) {
return value; // TODO(39784): no _validateValue?
} else {
var stringValue = value.toString();
_validateValue(stringValue);
return stringValue;
}
}
void _set(String name, String value) {
assert(name == _validateField(name));
_headers[name] = <String>[value];
}
void _checkMutable() {
if (!_mutable) throw HttpException("HTTP headers are not mutable");
}
void _updateHostHeader() {
var host = _host;
if (host != null) {
bool defaultPort = _port == null || _port == _defaultPortForScheme;
_set("host", defaultPort ? host : "$host:$_port");
}
}
bool _shouldFoldMultiValueHeader(String name) {
if (name == HttpHeaders.setCookieHeader) return false;
var noFoldingHeaders = _noFoldingHeaders;
return noFoldingHeaders == null || !noFoldingHeaders.contains(name);
}
void _finalize() {
_mutable = false;
}
void _build(BytesBuilder builder, {bool skipZeroContentLength = false}) {
// per https://tools.ietf.org/html/rfc7230#section-3.3.2
// A user agent SHOULD NOT send a
// Content-Length header field when the request message does not
// contain a payload body and the method semantics do not anticipate
// such a body.
String? ignoreHeader = _contentLength == 0 && skipZeroContentLength
? HttpHeaders.contentLengthHeader
: null;
_headers.forEach((String name, List<String> values) {
if (ignoreHeader == name) {
return;
}
String originalName = _originalHeaderName(name);
bool fold = _shouldFoldMultiValueHeader(name);
var nameData = originalName.codeUnits;
builder.add(nameData);
builder.addByte(_CharCode.COLON);
builder.addByte(_CharCode.SP);
for (int i = 0; i < values.length; i++) {
if (i > 0) {
if (fold) {
builder.addByte(_CharCode.COMMA);
builder.addByte(_CharCode.SP);
} else {
builder.addByte(_CharCode.CR);
builder.addByte(_CharCode.LF);
builder.add(nameData);
builder.addByte(_CharCode.COLON);
builder.addByte(_CharCode.SP);
}
}
builder.add(values[i].codeUnits);
}
builder.addByte(_CharCode.CR);
builder.addByte(_CharCode.LF);
});
}
String toString() {
StringBuffer sb = StringBuffer();
_headers.forEach((String name, List<String> values) {
String originalName = _originalHeaderName(name);
sb
..write(originalName)
..write(": ");
bool fold = _shouldFoldMultiValueHeader(name);
for (int i = 0; i < values.length; i++) {
if (i > 0) {
if (fold) {
sb.write(", ");
} else {
sb
..write("\n")
..write(originalName)
..write(": ");
}
}
sb.write(values[i]);
}
sb.write("\n");
});
return sb.toString();
}
List<Cookie> _parseCookies() {
// Parse a Cookie header value according to the rules in RFC 6265.
var cookies = <Cookie>[];
void parseCookieString(String source) {
int index = 0;
String parseName() {
int start = index;
while (index < source.length) {
if (source.codeUnitAt(index)
case == _CharCode.SP || == _CharCode.HT || == _CharCode.EQUALS)
break;
index++;
}
return source.substring(start, index);
}
String parseValue() {
int start = index;
while (index < source.length) {
if (source.codeUnitAt(index)
case == _CharCode.SP ||
== _CharCode.HT ||
== _CharCode.SEMI_COLON)
break;
index++;
}
return source.substring(start, index);
}
bool expect(int charCode) {
if (index < source.length && source.codeUnitAt(index) == charCode) {
index++;
return true;
}
return false;
}
while (index < source.length) {
index = _skipWhitespace(source, index);
if (index >= source.length) break;
String name = parseName();
index = _skipWhitespace(source, index);
if (expect(_CharCode.EQUALS)) {
index = _skipWhitespace(source, index);
String value = parseValue();
try {
cookies.add(_Cookie(name, value));
} catch (_) {
// Skip it, invalid cookie data.
}
index = _skipWhitespace(source, index);
}
if (index >= source.length) return;
index = source.indexOf(';', index);
if (index < 0) break;
index++;
}
}
List<String>? values = _headers[HttpHeaders.cookieHeader];
if (values != null) {
for (var headerValue in values) {
parseCookieString(headerValue);
}
}
return cookies;
}
// Returns negative if valid, positive position of an error if not.
static int _isValidFieldString(String field) {
for (var i = 0; i < field.length; i++) {
if (!_HttpParser._isTokenChar(field.codeUnitAt(i))) {
return i;
}
}
return -1;
}
// Returns negative if valid, positive position of an error if not.
static int _isValidValueString(String value) {
for (var i = 0; i < value.length; i++) {
if (!_HttpParser._isValueChar(value.codeUnitAt(i))) {
return i;
}
}
return -1;
}
static String _validateField(String field) {
var errorAt = _isValidFieldString(field);
if (errorAt >= 0) {
throw FormatException(
"Invalid HTTP header field name: ${json.encode(field)}",
field,
errorAt,
);
}
return field.toLowerCase();
}
static Object _validateValue(Object value) {
if (value is String) {
var errorAt = _isValidValueString(value);
if (errorAt >= 0) {
throw FormatException(
"Invalid HTTP header field value: ${json.encode(value)}",
value,
errorAt,
);
}
}
return value;
}
String _originalHeaderName(String name) {
return _originalHeaderNames?[name] ?? name;
}
}
class _HeaderValue implements HeaderValue {
String _value;
final Map<String, String?> _parameters;
Map<String, String?>? _unmodifiableParameters;
_HeaderValue([this._value = "", this._parameters = const {}]);
static _HeaderValue parse(
String value, {
required int parameterSeparator,
int valueSeparator = _CharCode.NONE,
bool preserveBackslash = false,
}) {
// Parse the string.
var result = _HeaderValue('', {});
result._parse(value, parameterSeparator, valueSeparator, preserveBackslash);
return result;
}
String get value => _value;
Map<String, String?> get parameters =>
_unmodifiableParameters ??= UnmodifiableMapView(_parameters);
static bool _isToken(String token) {
if (token.isEmpty) {
return false;
}
const delimiters = '"(),/:;<=>?@[]{}';
var delimiterCodeUnits = delimiters.codeUnits;
for (int i = 0; i < token.length; i++) {
int codeUnit = token.codeUnitAt(i);
if (codeUnit <= 32 ||
codeUnit >= 127 ||
delimiterCodeUnits.contains(codeUnit)) {
return false;
}
}
return true;
}
String toString() {
StringBuffer sb = StringBuffer();
sb.write(_value);
_parameters.forEach((String name, String? value) {
sb
..write("; ")
..write(name);
if (value != null) {
sb.write("=");
if (_isToken(value)) {
sb.write(value);
} else {
sb.writeCharCode(_CharCode.QUOTE);
for (int i = 0; i < value.length; i++) {
int codeUnit = value.codeUnitAt(i);
if (codeUnit == _CharCode.BACKSLASH ||
codeUnit == _CharCode.QUOTE) {
// Escape embedded `"` or `\`.
sb.writeCharCode(_CharCode.BACKSLASH);
}
sb.writeCharCode(codeUnit);
}
sb.writeCharCode(_CharCode.QUOTE);
}
}
});
return sb.toString();
}
void _parse(
String source,
int parameterSeparator,
int valueSeparator, // Use negative value for `none`.
bool preserveBackslash,
) {
int index = 0;
bool done() => index == source.length;
String parseValue() {
int start = index;
while (index < source.length) {
var char = source.codeUnitAt(index);
if (char != _CharCode.SP &&
char != _CharCode.HT &&
char != valueSeparator &&
char != parameterSeparator) {
index++;
} else {
break;
}
}
return source.substring(start, index);
}
bool maybeExpect(int codeUnit) {
if (index < source.length && source.codeUnitAt(index) == codeUnit) {
index++;
return true;
}
return false;
}
void expect(int codeUnit) {
if (!maybeExpect(codeUnit)) {
throw HttpException("Failed to parse header value");
}
}
void parseParameters() {
String parseParameterName() {
int start = index;
while (index < source.length) {
var char = source.codeUnitAt(index);
if (char != _CharCode.SP &&
char != _CharCode.HT &&
char != _CharCode.EQUALS &&
char != parameterSeparator &&
char != valueSeparator) {
index++;
} else {
break;
}
}
return source.substring(start, index).toLowerCase();
}
String parseParameterValue() {
if (maybeExpect(_CharCode.QUOTE)) {
// Parse quoted value.
StringBuffer sb = StringBuffer();
while (index < source.length) {
var char = source.codeUnitAt(index);
index++;
if (char != _CharCode.QUOTE) {
if (char != _CharCode.BACKSLASH) {
sb.writeCharCode(char);
continue;
}
// If `preserveBackslash` is true, retain backslashes
// except those escaping a backslash.
// Otherwise remove backslash.
// Then retain the next char verbatim.
if (index < source.length) {
char = source.codeUnitAt(index);
index++;
if (preserveBackslash && char != _CharCode.QUOTE) {
sb.writeCharCode(_CharCode.BACKSLASH);
}
sb.writeCharCode(char);
} else {
// No char after a `\`, and also no end quote.
break;
}
} else {
// Char is end quote.
return sb.toString();
}
}
throw HttpException("Failed to parse header value");
} else {
// Parse non-quoted value.
return parseValue();
}
}
while (index < source.length) {
index = _skipWhitespace(source, index);
if (index >= source.length) return;
String name = parseParameterName();
index = _skipWhitespace(source, index);
if (maybeExpect(_CharCode.EQUALS)) {
index = _skipWhitespace(source, index);
String value = parseParameterValue();
if (name == 'charset' && this is _ContentType) {
// Charset parameter of ContentTypes are always lower-case.
value = value.toLowerCase();
}
_parameters[name] = value;
} else if (name.isNotEmpty) {
_parameters[name] = null;
}
index = _skipWhitespace(source, index);
if (index >= source.length) return;
// TODO: Implement support for multi-valued parameters.
if (source.codeUnitAt(index) == valueSeparator) return;
expect(parameterSeparator);
}
}
index = _skipWhitespace(source, index);
_value = parseValue();
index = _skipWhitespace(source, index);
if (index >= source.length) return;
// TODO: Implement support for multi-valued parameters.
if (source.codeUnitAt(index) == valueSeparator) return;
maybeExpect(parameterSeparator); // Separator is optional.
parseParameters();
}
}
class _ContentType extends _HeaderValue implements ContentType {
String _primaryType = "";
String _subType = "";
_ContentType(
this._primaryType,
this._subType,
String? charset,
Map<String, String?> parameters,
) : super("$_primaryType/$_subType", _createParams(parameters, charset));
static Map<String, String?> _createParams(
Map<String, String?> parameters,
String? charset,
) {
var result = <String, String?>{};
parameters.forEach((String key, String? value) {
String lowerCaseKey = key.toLowerCase();
if (lowerCaseKey == "charset") {
if (value != null) value = value.toLowerCase();
}
result[lowerCaseKey] = value;
});
if (charset != null) {
result["charset"] = charset.toLowerCase();
}
return result;
}
_ContentType._() : super('', {});
static _ContentType parse(String source) {
var result = _ContentType._();
result._parse(source, _CharCode.SEMI_COLON, _CharCode.NONE, false);
String value = result.value;
int index = value.indexOf("/");
if (index < 0 || index == (value.length - 1)) {
result._primaryType = value.trim().toLowerCase();
} else {
result._primaryType = value.substring(0, index).trim().toLowerCase();
result._subType = value.substring(index + 1).trim().toLowerCase();
}
return result;
}
String get mimeType => '$primaryType/$subType';
String get primaryType => _primaryType;
String get subType => _subType;
String? get charset => parameters["charset"];
}
class _Cookie implements Cookie {
String _name;
String _value;
DateTime? expires;
int? maxAge;
String? domain;
String? _path;
bool httpOnly = false;
bool secure = false;
SameSite? sameSite;
_Cookie(String name, String value)
: _name = _validateName(name),
_value = _validateValue(value),
httpOnly = true;
String get name => _name;
String get value => _value;
String? get path => _path;
set path(String? newPath) {
_validatePath(newPath);
_path = newPath;
}
set name(String newName) {
_validateName(newName);
_name = newName;
}
set value(String newValue) {
_validateValue(newValue);
_value = newValue;
}
_Cookie.fromSetCookieValue(String value) : _name = "", _value = "" {
// Parse the 'set-cookie' header value.
_parseSetCookieValue(value);
}
// Parse a 'set-cookie-string' value according to the rules in RFC 6265,
// and update this cookie with the result.
//
// Is more permissive about whitespace than the grammar,
// by allowing spaces or horizontal tabs around any `;` or `=`.
// Is case-insensitive about attribute names and known values.
//
// set-cookie-header = "Set-Cookie:" SP set-cookie-string
// set-cookie-string = cookie-pair *( ";" SP cookie-av )
// cookie-pair = cookie-name "=" cookie-value
// cookie-name = token
// cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
// cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
// ; US-ASCII characters excluding CTLs,
// ; whitespace DQUOTE, comma, semicolon,
// ; and backslash
// token = <token, defined in [RFC2616], Section 2.2>
//
// cookie-av = expires-av / max-age-av / domain-av /
// path-av / secure-av / httponly-av /
// extension-av
// expires-av = "Expires=" sane-cookie-date
// sane-cookie-date = <rfc1123-date, defined in [RFC2616], Section 3.3.1>
// max-age-av = "Max-Age=" non-zero-digit *DIGIT
// ; In practice, both expires-av and max-age-av
// ; are limited to dates representable by the
// ; user agent.
// non-zero-digit = %x31-39
// ; digits 1 through 9
// domain-av = "Domain=" domain-value
// domain-value = <subdomain>
// ; defined in [RFC1034], Section 3.5, as
// ; enhanced by [RFC1123], Section 2.1
// path-av = "Path=" path-value
// path-value = <any CHAR except CTLs or ";">
// secure-av = "Secure"
// httponly-av = "HttpOnly"
// extension-av = <any CHAR except CTLs or ";">
void _parseSetCookieValue(String source) {
int index = 0;
// Skips until after next [charCode] or to end of input, whichever is first.
//
// The [charCode] must not be space or tab.
//
// Returns the position after the last non-whitespace character before
// [charCode].
int parseUntil(int charCode) {
int start = index;
int afterLastNonWhitespace = index;
while (index < source.length) {
var char = source.codeUnitAt(index);
index++;
if (char != charCode) {
if (char != _CharCode.SP && char != _CharCode.HT) {
afterLastNonWhitespace = index;
}
} else {
break;
}
}
return afterLastNonWhitespace;
}
index = _skipWhitespace(source, index);
var nameStart = index;
var nameEnd = parseUntil(_CharCode.EQUALS);
if (index >= source.length || nameEnd == nameStart) {
// Missing `=` after name or no name at all.
throw HttpException("Failed to parse header value [$source]");
}
var name = source.substring(nameStart, nameEnd);
_name = _validateName(name);
index = _skipWhitespace(source, index);
var valueStart = index;
var valueEnd = parseUntil(_CharCode.SEMI_COLON);
var value = source.substring(valueStart, valueEnd);
_value = _validateValue(value);
// Parse attributes. After `;` of cookie or previous attribute, or at end.
while (index < source.length) {
index = _skipWhitespace(source, index);
if (index >= source.length) break;
int nameStart = index;
int nameEnd = index;
int char = 0;
// Name is until `=` or `;`, ignore trailing whitespace.
// (Can't use `parseUntil` since that only accepts one end-character.)
do {
char = source.codeUnitAt(index);
index++;
if (char != _CharCode.EQUALS && char != _CharCode.SEMI_COLON) {
if (char != _CharCode.SP && char != _CharCode.HT) {
nameEnd = index;
}
} else {
break;
}
} while (index < source.length);
int nameLength = nameEnd - nameStart;
int valueStart = 0;
int valueEnd = 0;
if (char == _CharCode.EQUALS) {
index = _skipWhitespace(source, index);
valueStart = index;
valueEnd = parseUntil(_CharCode.SEMI_COLON);
}
if (_isTextNoCase(source, nameStart, nameLength, "Expires")) {
if (valueStart > 0) {
expires = HttpDate._parseCookieDate(source, valueStart, valueEnd);
} else {
throw HttpException("Missing value for 'Expires'");
}
} else if (_isTextNoCase(source, nameStart, nameLength, "Max-Age")) {
if (valueStart > 0) {
maxAge = int.parse(source.substring(valueStart, valueEnd));
} else {
throw HttpException("Missing value for 'Max-Age'");
}
} else if (_isTextNoCase(source, nameStart, nameLength, "Domain")) {
domain = valueStart > 0 ? source.substring(valueStart, valueEnd) : "";
} else if (_isTextNoCase(source, nameStart, nameLength, "Path")) {
path = valueStart > 0 ? source.substring(valueStart, valueEnd) : "";
} else if (_isTextNoCase(source, nameStart, nameLength, "HttpOnly")) {
if (valueStart > 0) {
throw HttpException("Value given for 'HttpOnly'");
}
httpOnly = true;
} else if (_isTextNoCase(source, nameStart, nameLength, "Secure")) {
if (valueStart > 0) {
throw HttpException("Value given for 'Secure'");
}
secure = true;
} else if (_isTextNoCase(source, nameStart, nameLength, "SameSite")) {
var valueLength = valueEnd - valueStart; // Is 0 if no value.
sameSite = switch (valueLength) {
"Lax".length
when _isTextNoCase(source, valueStart, valueLength, "Lax") =>
SameSite.lax,
"None".length
when _isTextNoCase(source, valueStart, valueLength, "None") =>
SameSite.none,
"Strict".length
when _isTextNoCase(source, valueStart, valueLength, "Strict") =>
SameSite.strict,
_ => throw HttpException(
"'SameSite' value should be one of 'Lax', 'Strict' or 'None'.",
),
};
} else {
// An extension-av, which is not validated or processed.
}
}
}
String toString() {
StringBuffer out = StringBuffer();
out
..write(_name)
..write("=")
..write(_value);
void writeParameter(String name, Object? value) {
out
..write('; ')
..write(name);
if (value != null) {
out
..write('=')
..write(value);
}
}
var expires = this.expires;
if (expires != null) {
writeParameter('Expires', ''); // Writes empty value.
HttpDate._formatTo(expires, out);
}
if (maxAge != null) {
writeParameter('Max-Age', maxAge);
}
var domain = this.domain;
if (domain != null) {
writeParameter('Domain', domain.trim());
}
var path = this.path;
if (path != null) {
writeParameter('Path', path.trim());
}
if (secure) writeParameter('Secure', null);
if (httpOnly) writeParameter("HttpOnly", null);
var sameSite = this.sameSite;
if (sameSite != null) {
writeParameter('SameSite', sameSite.name);
}
return out.toString();
}
static int _isValidName(String newName) {
const separators = r"""()<>@,;:\"/[]?={}""";
var separatorCodeUnits = separators.codeUnits;
for (int i = 0; i < newName.length; i++) {
int codeUnit = newName.codeUnitAt(i);
if (codeUnit <= _CharCode.SP ||
codeUnit >= 0x7F ||
separatorCodeUnits.contains(codeUnit)) {
return i;
}
}
return -1;
}
static String _validateName(String newName) {
var errorAt = _isValidName(newName);
if (errorAt >= 0) {
throw FormatException(
"Invalid character in cookie name, code unit: '${newName.codeUnitAt(errorAt)}'",
newName,
errorAt,
);
}
return newName;
}
static int _isValidValueString(String newValue) {
var start = 0;
var end = newValue.length;
// Per RFC 6265, consider surrounding "" as part of the value, but otherwise
// double quotes are not allowed.
if (end >= start + 2 &&
newValue.codeUnitAt(start) == _CharCode.QUOTE &&
newValue.codeUnitAt(end - 1) == _CharCode.QUOTE) {
start++;
end--;
}
for (int i = start; i < end; i++) {
int codeUnit = newValue.codeUnitAt(i);
if (!((codeUnit >= 0x21 && codeUnit <= 0x7E) &&
codeUnit != _CharCode.QUOTE &&
codeUnit != _CharCode.COMMA &&
codeUnit != _CharCode.SEMI_COLON &&
codeUnit != _CharCode.BACKSLASH)) {
return i;
}
}
return -1;
}
static String _validateValue(String source) {
var errorAt = _isValidValueString(source);
if (errorAt >= 0) {
throw FormatException(
"Invalid character in cookie value, code unit: '${source.codeUnitAt(errorAt)}'",
source,
errorAt,
);
}
return source;
}
static int _isValidPath(String path, int start, int end) {
for (int i = start; i < end; i++) {
int codeUnit = path.codeUnitAt(i);
// According to RFC 6265, ';' and controls should not occur in the
// path.
// path-value = <any CHAR except CTLs or ";">
// CTLs = %x00-1F / %x7F
if (codeUnit < _CharCode.SP ||
codeUnit >= _CharCode.DEL ||
codeUnit == _CharCode.SEMI_COLON) {
return i;
}
}
return -1;
}
static void _validatePath(String? path) {
if (path == null) return;
var errorAt = _isValidPath(path, 0, path.length);
if (errorAt >= 0) {
throw FormatException(
"Invalid character in cookie path, code unit: '${path.codeUnitAt(errorAt)}'",
path,
errorAt,
);
}
}
}
/// Checks if `source.substring(at, at + length)` is the same as [text].
///
/// If [offset] is non-zero, only checks against `text.substring(offset)`.
/// Starts by checking that [text] has length [length] - [offset].
///
/// Ignores case of ASCII letters.
///
/// The [text] should match the casing of the expected input to make
/// checking faster.
bool _isTextNoCase(
String source,
int at,
int length,
String text, [
int offset = 0,
]) {
if (text.length - offset != length) return false;
for (var i = 0; i < length; i++) {
int testChar = text.codeUnitAt(offset + i);
int actualChar = source.codeUnitAt(at + i);
var delta = testChar ^ actualChar;
if (delta == 0) continue;
if (delta == 0x20) {
testChar |= 0x20; // To lower case if ASCII letter.
if (testChar >= _CharCode.LETTER_a && testChar <= _CharCode.LETTER_z) {
continue;
}
}
return false;
}
return true;
}
int _skipWhitespace(String source, int index) {
while (index < source.length) {
int charCode = source.codeUnitAt(index);
if (charCode == _CharCode.SP || charCode == _CharCode.HT) {
index++;
continue;
}
break;
}
return index;
}