http-fields 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. http_fields/__init__.py +193 -0
  2. http_fields/accept.py +37 -0
  3. http_fields/acceptcharset.py +48 -0
  4. http_fields/acceptencoding.py +42 -0
  5. http_fields/acceptlanguage.py +47 -0
  6. http_fields/acceptranges.py +35 -0
  7. http_fields/accesscontrol.py +197 -0
  8. http_fields/age.py +34 -0
  9. http_fields/allow.py +46 -0
  10. http_fields/altsvc.py +70 -0
  11. http_fields/authbases.py +79 -0
  12. http_fields/authenticationinfo.py +19 -0
  13. http_fields/authorization.py +23 -0
  14. http_fields/cachecontrol.py +119 -0
  15. http_fields/cachestatus.py +12 -0
  16. http_fields/connection.py +35 -0
  17. http_fields/contentdigest.py +19 -0
  18. http_fields/contentdisposition.py +66 -0
  19. http_fields/contentencoding.py +45 -0
  20. http_fields/contentlanguage.py +38 -0
  21. http_fields/contentlength.py +34 -0
  22. http_fields/contentlocation.py +24 -0
  23. http_fields/contentrange.py +74 -0
  24. http_fields/contenttype.py +104 -0
  25. http_fields/cookie.py +38 -0
  26. http_fields/date.py +17 -0
  27. http_fields/dateheader.py +32 -0
  28. http_fields/entitytaglist.py +39 -0
  29. http_fields/etag.py +46 -0
  30. http_fields/expect.py +52 -0
  31. http_fields/expires.py +17 -0
  32. http_fields/forwarded.py +36 -0
  33. http_fields/fromheader.py +42 -0
  34. http_fields/header.py +250 -0
  35. http_fields/host.py +44 -0
  36. http_fields/ifmatch.py +24 -0
  37. http_fields/ifmodifiedsince.py +17 -0
  38. http_fields/ifnonematch.py +24 -0
  39. http_fields/ifrange.py +49 -0
  40. http_fields/ifunmodifiedsince.py +17 -0
  41. http_fields/lastmodified.py +17 -0
  42. http_fields/link.py +35 -0
  43. http_fields/location.py +24 -0
  44. http_fields/maxforwards.py +32 -0
  45. http_fields/origin.py +42 -0
  46. http_fields/parsedobjs.py +62 -0
  47. http_fields/prefer.py +64 -0
  48. http_fields/priority.py +55 -0
  49. http_fields/productlistheader.py +33 -0
  50. http_fields/proxyauthenticate.py +17 -0
  51. http_fields/proxyauthenticationinfo.py +17 -0
  52. http_fields/proxyauthorization.py +17 -0
  53. http_fields/proxystatus.py +12 -0
  54. http_fields/py.typed +0 -0
  55. http_fields/range.py +37 -0
  56. http_fields/referer.py +24 -0
  57. http_fields/retryafter.py +45 -0
  58. http_fields/server.py +18 -0
  59. http_fields/setcookie.py +419 -0
  60. http_fields/stricttransportsecurity.py +96 -0
  61. http_fields/structuredfields.py +356 -0
  62. http_fields/structuredheaders.py +90 -0
  63. http_fields/te.py +36 -0
  64. http_fields/trailer.py +44 -0
  65. http_fields/upgrade.py +35 -0
  66. http_fields/uriheader.py +36 -0
  67. http_fields/useragent.py +18 -0
  68. http_fields/vary.py +40 -0
  69. http_fields/via.py +35 -0
  70. http_fields/visitors/__init__.py +1 -0
  71. http_fields/visitors/rfc3986.py +195 -0
  72. http_fields/visitors/rfc6265.py +62 -0
  73. http_fields/visitors/rfc6266.py +234 -0
  74. http_fields/visitors/rfc7230.py +80 -0
  75. http_fields/visitors/rfc7239.py +43 -0
  76. http_fields/visitors/rfc7240.py +77 -0
  77. http_fields/visitors/rfc7838.py +77 -0
  78. http_fields/visitors/rfc8288.py +56 -0
  79. http_fields/visitors/rfc9110/__init__.py +117 -0
  80. http_fields/visitors/rfc9110/_base.py +71 -0
  81. http_fields/visitors/rfc9110/accept.py +135 -0
  82. http_fields/visitors/rfc9110/acceptcharset.py +26 -0
  83. http_fields/visitors/rfc9110/acceptencoding.py +76 -0
  84. http_fields/visitors/rfc9110/acceptlanguage.py +55 -0
  85. http_fields/visitors/rfc9110/acceptranges.py +13 -0
  86. http_fields/visitors/rfc9110/allow.py +13 -0
  87. http_fields/visitors/rfc9110/authenticationinfo.py +19 -0
  88. http_fields/visitors/rfc9110/authorization.py +77 -0
  89. http_fields/visitors/rfc9110/authparam.py +40 -0
  90. http_fields/visitors/rfc9110/authscheme.py +17 -0
  91. http_fields/visitors/rfc9110/challenge.py +57 -0
  92. http_fields/visitors/rfc9110/comment.py +99 -0
  93. http_fields/visitors/rfc9110/connection.py +13 -0
  94. http_fields/visitors/rfc9110/contentlanguage.py +21 -0
  95. http_fields/visitors/rfc9110/contentlength.py +9 -0
  96. http_fields/visitors/rfc9110/contentrange.py +70 -0
  97. http_fields/visitors/rfc9110/contenttype.py +52 -0
  98. http_fields/visitors/rfc9110/date.py +12 -0
  99. http_fields/visitors/rfc9110/entitytag.py +66 -0
  100. http_fields/visitors/rfc9110/etag.py +12 -0
  101. http_fields/visitors/rfc9110/field.py +15 -0
  102. http_fields/visitors/rfc9110/host.py +32 -0
  103. http_fields/visitors/rfc9110/httpdate.py +167 -0
  104. http_fields/visitors/rfc9110/ifmatch.py +24 -0
  105. http_fields/visitors/rfc9110/ifmodifiedsince.py +12 -0
  106. http_fields/visitors/rfc9110/ifnonematch.py +26 -0
  107. http_fields/visitors/rfc9110/ifunmodifiedsince.py +12 -0
  108. http_fields/visitors/rfc9110/lastmodified.py +14 -0
  109. http_fields/visitors/rfc9110/location.py +9 -0
  110. http_fields/visitors/rfc9110/parameters.py +124 -0
  111. http_fields/visitors/rfc9110/product.py +39 -0
  112. http_fields/visitors/rfc9110/quotedstring.py +66 -0
  113. http_fields/visitors/rfc9110/range.py +100 -0
  114. http_fields/visitors/rfc9110/rangeunit.py +17 -0
  115. http_fields/visitors/rfc9110/retryafter.py +20 -0
  116. http_fields/visitors/rfc9110/server.py +16 -0
  117. http_fields/visitors/rfc9110/te.py +83 -0
  118. http_fields/visitors/rfc9110/token.py +18 -0
  119. http_fields/visitors/rfc9110/token68.py +18 -0
  120. http_fields/visitors/rfc9110/type.py +17 -0
  121. http_fields/visitors/rfc9110/upgrade.py +57 -0
  122. http_fields/visitors/rfc9110/useragent.py +16 -0
  123. http_fields/visitors/rfc9110/vary.py +10 -0
  124. http_fields/visitors/rfc9110/via.py +86 -0
  125. http_fields/visitors/rfc9110/weight.py +40 -0
  126. http_fields/visitors/rfc9110/wwwauthenticate.py +21 -0
  127. http_fields/visitors/rfc9111.py +92 -0
  128. http_fields/wwwauthenticate.py +24 -0
  129. http_fields-0.1.1.dist-info/METADATA +88 -0
  130. http_fields-0.1.1.dist-info/RECORD +132 -0
  131. http_fields-0.1.1.dist-info/WHEEL +4 -0
  132. http_fields-0.1.1.dist-info/licenses/LICENSE.txt +16 -0
http_fields/referer.py ADDED
@@ -0,0 +1,24 @@
1
+ """Referer header class."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from abnf import Rule
6
+ from abnf.grammars import rfc9110
7
+
8
+ from http_fields.parsedobjs import ParsedStr
9
+ from http_fields.uriheader import UriHeader
10
+
11
+
12
+ class RefererUri(ParsedStr):
13
+ """A Referer URI reference (RFC 9110). Self-validating."""
14
+
15
+ parser = rfc9110.Rule("Referer")
16
+
17
+
18
+ class Referer(UriHeader):
19
+ """Referer header, as defined by RFC 9110."""
20
+
21
+ name: ClassVar[str] = "referer"
22
+ rule: ClassVar[Rule] = rfc9110.Rule("Referer")
23
+ uri_type: ClassVar[type[ParsedStr]] = RefererUri
24
+ uri: RefererUri
@@ -0,0 +1,45 @@
1
+ """Retry-After header class."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import ClassVar
6
+
7
+ from abnf import Rule
8
+ from abnf.grammars import rfc9110
9
+ from typing_extensions import Self
10
+
11
+ from http_fields.header import Header
12
+ from http_fields.parsedobjs import NonNegativeInt
13
+ from http_fields.visitors.rfc9110 import RetryAfterVisitor, imf_fixdate
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RetryAfter(Header):
18
+ """Retry-After header, as defined by RFC 9110. The delay is either a number of seconds
19
+ (a NonNegativeInt) or an HTTP-date."""
20
+
21
+ name: ClassVar[str] = "Retry-After"
22
+ rule: ClassVar[Rule] = rfc9110.Rule("Retry-After")
23
+ visitor: ClassVar[RetryAfterVisitor] = RetryAfterVisitor()
24
+
25
+ delay: NonNegativeInt | datetime
26
+
27
+ def __init__(self, delay: int | datetime) -> None:
28
+ if isinstance(delay, datetime):
29
+ object.__setattr__(self, "delay", delay)
30
+ # Runtime guard against callers that ignore the type annotation; the int check
31
+ # reads as redundant to the type checker but rejects a stray non-int at runtime.
32
+ elif isinstance(delay, int) and not isinstance(delay, bool): # pyright: ignore[reportUnnecessaryIsInstance]
33
+ object.__setattr__(self, "delay", NonNegativeInt(delay))
34
+ else:
35
+ raise TypeError("delay must be an int or datetime.")
36
+
37
+ @classmethod
38
+ def parse(cls, value: str) -> Self:
39
+ return cls(cls.visitor.visit(cls._node(value)))
40
+
41
+ @property
42
+ def value(self) -> str:
43
+ if isinstance(self.delay, datetime):
44
+ return imf_fixdate(self.delay)
45
+ return str(self.delay)
http_fields/server.py ADDED
@@ -0,0 +1,18 @@
1
+ """Server header class."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from abnf import Rule
6
+ from abnf.grammars import rfc9110
7
+
8
+ from http_fields.productlistheader import ProductListHeader
9
+ from http_fields.visitors.rfc9110 import ServerVisitor
10
+
11
+
12
+ class Server(ProductListHeader):
13
+ """Server header, as defined by RFC 9110. ``items`` is a sequence of Product or
14
+ Comment values."""
15
+
16
+ name: ClassVar[str] = "Server"
17
+ rule: ClassVar[Rule] = rfc9110.Rule("Server")
18
+ visitor = ServerVisitor()
@@ -0,0 +1,419 @@
1
+ """SetCookie header class."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date, datetime, time, timedelta, timezone
5
+ from logging import Logger, getLogger
6
+ from typing import Any, ClassVar, Literal, cast
7
+
8
+ from abnf.grammars import rfc6265
9
+ from abnf.grammars.misc import load_grammar_rules
10
+ from abnf.parser import Node, NodeVisitor, ParseError, Rule
11
+ from typing_extensions import Self
12
+
13
+ from http_fields.header import Header
14
+ from http_fields.visitors.rfc9110 import imf_fixdate
15
+
16
+ _LOGGER = getLogger(__name__)
17
+
18
+ # Control characters disallowed in a Set-Cookie string: C0 except TAB, plus DEL.
19
+ # Blocking CR/LF/NUL here is what prevents header injection via cookie fields.
20
+ _CONTROL_CHARS = frozenset(chr(c) for c in range(0x20) if c != 0x09) | {"\x7f"}
21
+
22
+
23
+ @load_grammar_rules()
24
+ class CookieDate(Rule):
25
+ grammar = [
26
+ "cookie-date = *delimiter date-token-list *delimiter",
27
+ "date-token-list = date-token *( 1*delimiter date-token )",
28
+ "date-token = 1*non-delimiter",
29
+ "delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E",
30
+ 'non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA / %x7F-FF',
31
+ "non-digit = %x00-2F / %x3A-FF",
32
+ "day-of-month = 1*2DIGIT [ non-digit *OCTET ]",
33
+ 'month = ( "jan" / "feb" / "mar" / "apr" / "may" / "jun" / "jul" / "aug" / "sep" / "oct" / "nov" / "dec" ) *OCTET',
34
+ "year = 2*4DIGIT [ non-digit *OCTET ]",
35
+ "time = hms-time [ non-digit *OCTET ]",
36
+ 'hms-time = time-field ":" time-field ":" time-field',
37
+ "time-field = 1*2DIGIT",
38
+ ]
39
+
40
+
41
+ class CookieDateVisitor(NodeVisitor):
42
+ def visit_cookie_date(self, node: Node):
43
+ found_time: time | None = None
44
+ found_day_of_month: int | None = None
45
+ found_month: int | None = None
46
+ found_year: int | None = None
47
+ # The date-token-list child visits to list[str] (visit() returns Any from abnf).
48
+ date_tokens: list[str] = next(filter(None, map(self.visit, node.children)))
49
+ for date_token in date_tokens:
50
+ try:
51
+ node = CookieDate("time").parse_all(date_token)
52
+ except ParseError:
53
+ pass
54
+ else:
55
+ if not found_time:
56
+ found_time = self.visit_time(node)
57
+ continue
58
+
59
+ try:
60
+ node = CookieDate("day-of-month").parse_all(date_token)
61
+ except ParseError:
62
+ pass
63
+ else:
64
+ if not found_day_of_month:
65
+ found_day_of_month = self.visit_day_of_month(node)
66
+ continue
67
+ try:
68
+ node = CookieDate("month").parse_all(date_token)
69
+ except ParseError:
70
+ pass
71
+ else:
72
+ if not found_month:
73
+ found_month = self.visit_month(node)
74
+ continue
75
+
76
+ try:
77
+ node = CookieDate("year").parse_all(date_token)
78
+ except ParseError:
79
+ pass
80
+ else:
81
+ if not found_year:
82
+ found_year = self.visit_year(node)
83
+ continue
84
+
85
+ if found_time is None:
86
+ raise ValueError("No valid time found in cookie date.")
87
+ if found_day_of_month is None:
88
+ raise ValueError("No valid day of month found in cookie date.")
89
+ if found_month is None:
90
+ raise ValueError("No valid month found in cookie date.")
91
+ if found_year is None:
92
+ raise ValueError("No valid day of month found in cookie date.")
93
+
94
+ return datetime.combine(
95
+ date(found_year, found_month, found_day_of_month), found_time
96
+ )
97
+
98
+ def visit_date_token_list(self, node: Node) -> list[str]:
99
+ return list(filter(None, map(self.visit, node.children)))
100
+
101
+ @staticmethod
102
+ def visit_date_token(node: Node):
103
+ return node.value
104
+
105
+ def visit_time(self, node: Node) -> time:
106
+ return self.visit(node.children[0])
107
+
108
+ def visit_hms_time(self, node: Node):
109
+ h: int
110
+ m: int
111
+ s: int
112
+ # Select the time-field children by name; the ":" separators visit to
113
+ # None, and a zero-valued field (e.g. "00") would be lost by a
114
+ # truthiness filter.
115
+ h, m, s = (
116
+ self.visit(child) for child in node.children if child.name == "time-field"
117
+ )
118
+ # raises ValueError if h, m. s do not satisfy the same bounds as
119
+ # as specified by parsing algorithm.
120
+ return time(h, m, s, tzinfo=timezone.utc)
121
+
122
+ @staticmethod
123
+ def visit_time_field(node: Node):
124
+ return int(node.value)
125
+
126
+ @staticmethod
127
+ def visit_day_of_month(node: Node):
128
+ # grammar implies that int(node.value) should not throw.
129
+ # bounds checking as specified by parsing algorithm happens later.
130
+ day_of_month = int(node.value)
131
+ if 1 <= day_of_month <= 31:
132
+ return day_of_month
133
+ else:
134
+ raise ValueError("day of month is out of valid range [1, 31].")
135
+
136
+ @staticmethod
137
+ def visit_month(node: Node):
138
+ # grammar specifies case-insensitive match.
139
+ month_names = [
140
+ "jan",
141
+ "feb",
142
+ "mar",
143
+ "apr",
144
+ "may",
145
+ "jun",
146
+ "jul",
147
+ "aug",
148
+ "sep",
149
+ "oct",
150
+ "nov",
151
+ "dec",
152
+ ]
153
+ return 1 + month_names.index(node.value.lower())
154
+
155
+ @staticmethod
156
+ def visit_year(node: Node):
157
+ value = int(node.value)
158
+ if 70 <= value <= 99:
159
+ year = 1900 + value
160
+ elif 0 <= value <= 69:
161
+ year = 2000 + value
162
+ else:
163
+ year = value
164
+
165
+ if year >= 1601:
166
+ return year
167
+ else:
168
+ raise ValueError("year must be at least 1601.")
169
+
170
+
171
+ def parse_cookie_date(src: str) -> datetime:
172
+ """
173
+ RFC 6265 specifies an algorithm to parse a cookie date. The reason is to
174
+ support interoperablility with servers that may not quite follow the grammar
175
+ as specified in RFC 6265 section 4."""
176
+
177
+ node = CookieDate("cookie-date").parse_all(src)
178
+ visitor = CookieDateVisitor()
179
+ return visitor.visit(node)
180
+
181
+
182
+ @dataclass(frozen=True, kw_only=True)
183
+ class SetCookie(Header):
184
+ """Set-Cookie header, as defined by RFC 6265.
185
+
186
+ Use :meth:`build` to construct (and validate) from pieces, or :meth:`parse` to parse a
187
+ raw header value with the lenient RFC 6265 section 5 algorithm.
188
+ """
189
+
190
+ name: ClassVar[str] = "Set-Cookie"
191
+
192
+ cookie_name: str
193
+ cookie_value: str
194
+ domain: str | None = None
195
+ path: str | None = None
196
+ expires: datetime | None = None
197
+ max_age: int | None = None
198
+ secure: bool = False
199
+ http_only: bool = False
200
+ # None means the origin did not set a SameSite attribute; it is not serialized.
201
+ samesite: str | None = None
202
+ extension: tuple[str, ...] = ()
203
+
204
+ def __post_init__(self) -> None:
205
+ # Reject control characters (CR/LF/NUL, ...) in the string fields, so a direct
206
+ # constructor call cannot inject header content that the lenient parse() and
207
+ # build() paths also reject.
208
+ for value in (
209
+ self.cookie_name,
210
+ self.cookie_value,
211
+ self.domain,
212
+ self.path,
213
+ *self.extension,
214
+ ):
215
+ if value is not None and _CONTROL_CHARS.intersection(value):
216
+ raise ValueError("Set-Cookie field contains a control character.")
217
+
218
+ @classmethod
219
+ def build(
220
+ cls,
221
+ cookie_name: str,
222
+ cookie_value: str,
223
+ *,
224
+ domain: str | None = None,
225
+ path: str | None = None,
226
+ expires: datetime | None = None,
227
+ max_age: int | None = None,
228
+ secure: bool = False,
229
+ http_only: bool = False,
230
+ samesite: Literal["Strict", "Lax", "None"] | None = None,
231
+ extension: list[str] | None = None,
232
+ ) -> Self:
233
+ """Build a validated Set-Cookie from its pieces."""
234
+ # Runtime guards for callers that ignore the type annotations.
235
+ if not isinstance(cookie_name, str): # pyright: ignore[reportUnnecessaryIsInstance]
236
+ raise TypeError("cookie_name must be str.")
237
+ if not isinstance(cookie_value, str): # pyright: ignore[reportUnnecessaryIsInstance]
238
+ raise TypeError("cookie_value must be str.")
239
+ try:
240
+ cookie_name = rfc6265.Rule("cookie-name").parse_all(cookie_name).value
241
+ cookie_value = rfc6265.Rule("cookie-value").parse_all(cookie_value).value
242
+ except ParseError as exc:
243
+ raise ValueError("Invalid argument value.") from exc
244
+ if domain is not None:
245
+ try:
246
+ rfc6265.Rule("domain-value").parse_all(domain)
247
+ except ParseError as exc:
248
+ raise ValueError("Invalid domain value.") from exc
249
+ if path is not None:
250
+ try:
251
+ rfc6265.Rule("path-value").parse_all(path)
252
+ except ParseError as exc:
253
+ raise ValueError("Invalid path value.") from exc
254
+ if expires is not None and expires.year < 1601:
255
+ raise ValueError("Expires year must be at least 1601.")
256
+ # the RFC 6265 grammar requires max-age to be positive; this is almost universally
257
+ # ignored, so we go with the crowd and clamp negatives to 0.
258
+ normalized_max_age = 0 if max_age and max_age < 0 else max_age
259
+ try:
260
+ ext = (
261
+ tuple(
262
+ rfc6265.Rule("extension-av").parse_all(x).value for x in extension
263
+ )
264
+ if extension
265
+ else ()
266
+ )
267
+ except ParseError as exc:
268
+ raise ValueError("Invalid extension value.") from exc
269
+ return cls(
270
+ cookie_name=cookie_name,
271
+ cookie_value=cookie_value,
272
+ domain=domain,
273
+ path=path,
274
+ expires=expires,
275
+ max_age=normalized_max_age,
276
+ secure=secure,
277
+ http_only=http_only,
278
+ samesite=samesite,
279
+ extension=ext,
280
+ )
281
+
282
+ @classmethod
283
+ def parse(cls, value: str) -> Self:
284
+ """Parse a Set-Cookie value using the lenient RFC 6265 section 5 algorithm, which
285
+ is more forgiving than the section 4 grammar for interoperability."""
286
+ cls._check_length(value)
287
+ attrs = cls._parse_value(value)
288
+ attrs["extension"] = tuple(attrs["extension"])
289
+ return cls(**attrs)
290
+
291
+ @property
292
+ def value(self) -> str:
293
+ header_value = [f"{self.cookie_name}={self.cookie_value}"]
294
+ if self.domain is not None:
295
+ header_value.append(f"Domain={self.domain}")
296
+ if self.path is not None:
297
+ header_value.append(f"Path={self.path}")
298
+ if self.expires is not None:
299
+ header_value.append(f"Expires={imf_fixdate(self.expires)}")
300
+ if self.max_age is not None:
301
+ header_value.append(f"Max-Age={self.max_age}")
302
+ if self.samesite is not None:
303
+ header_value.append(f"SameSite={self.samesite}")
304
+ if self.secure:
305
+ header_value.append("Secure")
306
+ if self.http_only:
307
+ header_value.append("HttpOnly")
308
+ if self.extension:
309
+ header_value.append("; ".join(self.extension))
310
+
311
+ return "; ".join(header_value)
312
+
313
+ @staticmethod
314
+ def default_path(request_path: str):
315
+ if request_path:
316
+ if request_path[0] == "/":
317
+ cmps = request_path.split("/")[1:-1]
318
+ return "/" + "/".join(x for x in cmps)
319
+ else:
320
+ return "/"
321
+ else:
322
+ return "/"
323
+
324
+ def expiry_time(self, *, now: datetime | None = None):
325
+ """expiry time is the datetime at which the cookie expires. It is computed
326
+ as specified in RFC 6265."""
327
+ if self.max_age is not None:
328
+ if self.max_age > 0:
329
+ if now is None:
330
+ now = datetime.now(timezone.utc)
331
+ return now + timedelta(seconds=self.max_age)
332
+ else:
333
+ return datetime.fromtimestamp(0, timezone.utc)
334
+ elif self.expires is not None:
335
+ return self.expires
336
+ else:
337
+ return None
338
+
339
+ @staticmethod
340
+ def _parse_value(src: str, *, log: Logger = _LOGGER) -> dict[str, Any]:
341
+ cookie_attrs: dict[str, Any] = {}
342
+
343
+ for idx, x in enumerate(src):
344
+ if x in _CONTROL_CHARS:
345
+ raise ValueError(f"Illegal character in value at offset {idx}")
346
+
347
+ name_value_pair, attribute_src = (
348
+ _ if len(_ := src.split(";", 1)) == 2 else (_[0], "")
349
+ )
350
+ name_value_split = name_value_pair.split("=", 1)
351
+ if len(name_value_split) != 2:
352
+ # RFC 6265 section 5.2 step 2: a name-value pair lacking "=" causes
353
+ # the entire set-cookie-string to be ignored.
354
+ raise ValueError("Set-Cookie name-value pair lacks '='.")
355
+ cookie_name, cookie_value = (x.strip(" \t") for x in name_value_split)
356
+ if not cookie_name:
357
+ # RFC 6265 section 5.2 step 5: an empty cookie-name is ignored.
358
+ raise ValueError("Set-Cookie name is empty.")
359
+ # if len(cookie_name) + len(cookie_value) > 4096: this test is contained in RFC 6265bis, a new draft.
360
+ # raise ValueError("Cookie name-value pair exceeds 4096 characters.")
361
+
362
+ cookie_attrs["cookie_name"] = cookie_name
363
+ cookie_attrs["cookie_value"] = cookie_value
364
+ cookie_attrs["extension"] = cast(list[str], [])
365
+
366
+ for attr in [x for x in attribute_src.split(";") if x]:
367
+ attr_name, attr_value = (
368
+ _
369
+ if len(_ := [x.strip(" \t") for x in attr.split("=", 1)]) == 2
370
+ else (_[0], "")
371
+ )
372
+ # if len(attr_value) > 1024: this test is contained in RFC 6265bis, a new draft.
373
+ # ignore. log?
374
+ # continue
375
+ matchname = attr_name.lower()
376
+ if matchname == "expires":
377
+ try:
378
+ cookie_attrs["expires"] = parse_cookie_date(attr_value)
379
+ except ValueError:
380
+ log.info(f"Invalid Expires value {attr_value}.")
381
+ elif matchname == "max-age":
382
+ try:
383
+ cookie_attrs["max_age"] = int(attr_value)
384
+ except ValueError:
385
+ # ignore.
386
+ log.info(f"Invalid Max-Age value {attr_value}.")
387
+ elif matchname == "domain":
388
+ # An empty Domain attribute-value carries no cookie-domain; ignore it.
389
+ if attr_value:
390
+ cookie_attrs["domain"] = (
391
+ attr_value[1:] if attr_value.startswith(".") else attr_value
392
+ ).lower()
393
+ elif matchname == "path":
394
+ # when cookie path is '' or not an absolute path, the default path should be used for comparison. But computation
395
+ # of the default path depends on the request path. We don't want to entangle parsing with the request,
396
+ # so we leave it to the user to call default_path as needed.
397
+ cookie_attrs["path"] = (
398
+ attr_value if attr_value.startswith("/") else None
399
+ )
400
+ elif matchname == "secure":
401
+ cookie_attrs["secure"] = True
402
+ elif matchname == "httponly":
403
+ cookie_attrs["http_only"] = True
404
+ elif matchname == "samesite":
405
+ # Store the canonical spelling; an unrecognized value is treated
406
+ # as absent (None) so it is never serialized as "SameSite=Default".
407
+ cookie_attrs["samesite"] = {
408
+ "strict": "Strict",
409
+ "lax": "Lax",
410
+ "none": "None",
411
+ }.get(attr_value.lower())
412
+ else:
413
+ # extension-av
414
+ # RFC 6256 Section 5 explicitly ignores extension-av in its loose parsing algorithm.
415
+ # In the Set-Cookie grammar, attributes are delimited by ";" SP. This algorithm
416
+ # splits attributes on ";", then strips SP, TAB from attribute name, value. But "SP" is a valid
417
+ # attribute-av character, so we only strip the leading space, if present.
418
+ cookie_attrs["extension"].append(attr[1:] if attr[0] == " " else attr)
419
+ return cookie_attrs
@@ -0,0 +1,96 @@
1
+ """Strict-Transport-Security header class."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import ClassVar
5
+
6
+ from abnf import Rule
7
+ from abnf.grammars import rfc6797
8
+ from typing_extensions import Self
9
+
10
+ from http_fields.header import Header
11
+ from http_fields.parsedobjs import NonNegativeInt
12
+
13
+
14
+ def _unquote(value: str) -> str:
15
+ """Strip a surrounding quoted-string (RFC 6797 directive-value), unescaping
16
+ quoted-pairs; a bare token is returned unchanged."""
17
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
18
+ inner = value[1:-1]
19
+ out: list[str] = []
20
+ i = 0
21
+ while i < len(inner):
22
+ if inner[i] == "\\" and i + 1 < len(inner):
23
+ out.append(inner[i + 1])
24
+ i += 2
25
+ else:
26
+ out.append(inner[i])
27
+ i += 1
28
+ return "".join(out)
29
+ return value
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class StrictTransportSecurity(Header):
34
+ """Strict-Transport-Security (HSTS) header, as defined by RFC 6797."""
35
+
36
+ name: ClassVar[str] = "Strict-Transport-Security"
37
+ rule: ClassVar[Rule] = rfc6797.Rule("Strict-Transport-Security")
38
+
39
+ max_age: NonNegativeInt
40
+ include_subdomains: bool = False
41
+ preload: bool = False
42
+
43
+ def __init__(
44
+ self, max_age: int, include_subdomains: bool = False, preload: bool = False
45
+ ) -> None:
46
+ object.__setattr__(self, "max_age", NonNegativeInt(max_age))
47
+ object.__setattr__(self, "include_subdomains", include_subdomains)
48
+ object.__setattr__(self, "preload", preload)
49
+
50
+ @classmethod
51
+ def parse(cls, value: str) -> Self:
52
+ # the grammar rule matches the whole header line, so prepend the field name.
53
+ node = cls._prefixed_node(value)
54
+ max_age = 0
55
+ max_age_seen = False
56
+ include_subdomains = False
57
+ preload = False
58
+ for directive in (c for c in node.children if c.name == "directive"):
59
+ dname = next(
60
+ c.value for c in directive.children if c.name == "directive-name"
61
+ ).lower()
62
+ if dname == "max-age":
63
+ dvalue = _unquote(
64
+ next(
65
+ (
66
+ c.value
67
+ for c in directive.children
68
+ if c.name == "directive-value"
69
+ ),
70
+ "",
71
+ )
72
+ )
73
+ # RFC 6797 section 6.1: directive-value = token / quoted-string.
74
+ # A max-age directive with no value is malformed; leave max_age_seen
75
+ # False so the "requires a max-age directive" error below fires.
76
+ if dvalue:
77
+ max_age = int(dvalue)
78
+ max_age_seen = True
79
+ elif dname == "includesubdomains":
80
+ include_subdomains = True
81
+ elif dname == "preload":
82
+ preload = True
83
+ if not max_age_seen:
84
+ # RFC 6797 section 6.1.1: an STS header field without max-age is
85
+ # ignored; surface that as an error rather than a spurious max-age=0.
86
+ raise ValueError("Strict-Transport-Security requires a max-age directive.")
87
+ return cls(max_age, include_subdomains, preload)
88
+
89
+ @property
90
+ def value(self) -> str:
91
+ parts = [f"max-age={self.max_age}"]
92
+ if self.include_subdomains:
93
+ parts.append("includeSubDomains")
94
+ if self.preload:
95
+ parts.append("preload")
96
+ return "; ".join(parts)