redbot 2.7.0__tar.gz → 2.7.1__tar.gz

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 (87) hide show
  1. {redbot-2.7.0/redbot.egg-info → redbot-2.7.1}/PKG-INFO +3 -3
  2. {redbot-2.7.0 → redbot-2.7.1}/pyproject.toml +2 -2
  3. {redbot-2.7.0 → redbot-2.7.1}/redbot/__init__.py +1 -1
  4. {redbot-2.7.0 → redbot-2.7.1}/redbot/daemon.py +1 -17
  5. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/html.py +2 -1
  6. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/response_finish.html +4 -2
  7. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/response_multi_finish.html +19 -7
  8. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/response_start.html +1 -1
  9. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/text.py +21 -16
  10. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/__init__.py +41 -8
  11. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/fetch.py +19 -4
  12. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/link_parse.py +68 -12
  13. {redbot-2.7.0 → redbot-2.7.1}/redbot/webbotauth.py +25 -10
  14. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/captcha.py +68 -23
  15. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/save.py +80 -36
  16. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/saved_tests.py +13 -7
  17. {redbot-2.7.0 → redbot-2.7.1/redbot.egg-info}/PKG-INFO +3 -3
  18. {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/requires.txt +2 -2
  19. {redbot-2.7.0 → redbot-2.7.1}/LICENSE.md +0 -0
  20. {redbot-2.7.0 → redbot-2.7.1}/MANIFEST.in +0 -0
  21. {redbot-2.7.0 → redbot-2.7.1}/README.md +0 -0
  22. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/__init__.py +0 -0
  23. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/__init__.py +0 -0
  24. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/check-circle.svg +0 -0
  25. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/exclamation-circle.svg +0 -0
  26. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/info-circle.svg +0 -0
  27. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/question-circle.svg +0 -0
  28. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/times-circle.svg +0 -0
  29. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/__init__.py +0 -0
  30. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/apple-touch-icon-144x144.png +0 -0
  31. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/apple-touch-icon-152x152.png +0 -0
  32. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/favicon-16x16.png +0 -0
  33. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/favicon-32x32.png +0 -0
  34. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/favicon.ico +0 -0
  35. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/mstile-144x144.png +0 -0
  36. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/redbot-sq.png +0 -0
  37. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/prettify.js +0 -0
  38. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/prettify.js.map +0 -0
  39. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/red_style.css +0 -0
  40. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/red_style.css.map +0 -0
  41. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/script.js +0 -0
  42. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/script.js.map +0 -0
  43. {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/style.css +0 -0
  44. {redbot-2.7.0 → redbot-2.7.1}/redbot/cli.py +0 -0
  45. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/__init__.py +0 -0
  46. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/har.py +0 -0
  47. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/html_base.py +0 -0
  48. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/null_links.py +0 -0
  49. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/__init__.py +0 -0
  50. {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/footer.html +0 -0
  51. {redbot-2.7.0 → redbot-2.7.1}/redbot/gc.py +0 -0
  52. {redbot-2.7.0 → redbot-2.7.1}/redbot/i18n.py +0 -0
  53. {redbot-2.7.0 → redbot-2.7.1}/redbot/note.py +0 -0
  54. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/__init__.py +0 -0
  55. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/base.py +0 -0
  56. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/conneg.py +0 -0
  57. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/etag_validate.py +0 -0
  58. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/lm_validate.py +0 -0
  59. {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/range.py +0 -0
  60. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/__init__.py +0 -0
  61. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/es/LC_MESSAGES/messages.mo +0 -0
  62. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/es/LC_MESSAGES/messages.po +0 -0
  63. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/extra_strings.py +0 -0
  64. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/fr/LC_MESSAGES/messages.mo +0 -0
  65. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/fr/LC_MESSAGES/messages.po +0 -0
  66. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/ja/LC_MESSAGES/messages.mo +0 -0
  67. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/ja/LC_MESSAGES/messages.po +0 -0
  68. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/messages.pot +0 -0
  69. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/zh/LC_MESSAGES/messages.mo +0 -0
  70. {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/zh/LC_MESSAGES/messages.po +0 -0
  71. {redbot-2.7.0 → redbot-2.7.1}/redbot/type.py +0 -0
  72. {redbot-2.7.0 → redbot-2.7.1}/redbot/utils.py +0 -0
  73. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/__init__.py +0 -0
  74. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/__init__.py +0 -0
  75. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/base.py +0 -0
  76. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/client_error.py +0 -0
  77. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/error.py +0 -0
  78. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/run_test.py +0 -0
  79. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/show.py +0 -0
  80. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/headers.py +0 -0
  81. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/links.py +0 -0
  82. {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/ratelimit.py +0 -0
  83. {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/SOURCES.txt +0 -0
  84. {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/dependency_links.txt +0 -0
  85. {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/entry_points.txt +0 -0
  86. {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/top_level.txt +0 -0
  87. {redbot-2.7.0 → redbot-2.7.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redbot
3
- Version: 2.7.0
3
+ Version: 2.7.1
4
4
  Summary: REDbot is lint for HTTP.
5
5
  Author-email: Mark Nottingham <mnot@mnot.net>
6
6
  License-Expression: MIT
@@ -18,12 +18,12 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE.md
19
19
  Requires-Dist: cryptography>=42.0.0
20
20
  Requires-Dist: http-sf>=1.4.0
21
- Requires-Dist: httplint>=2026.10.1
21
+ Requires-Dist: httplint>=2026.10.3
22
22
  Requires-Dist: Jinja2>=3.1.2
23
23
  Requires-Dist: markdown>=3.4.4
24
24
  Requires-Dist: MarkupSafe>=2.1.3
25
25
  Requires-Dist: netaddr>=1.2.1
26
- Requires-Dist: thor>=0.16.0
26
+ Requires-Dist: thor>=0.16.1
27
27
  Provides-Extra: systemd
28
28
  Requires-Dist: cysystemd; extra == "systemd"
29
29
  Provides-Extra: dev
@@ -22,12 +22,12 @@ license-files = ["LICENSE.md"]
22
22
  dependencies = [
23
23
  "cryptography >= 42.0.0",
24
24
  "http-sf >= 1.4.0",
25
- "httplint >= 2026.10.1",
25
+ "httplint >= 2026.10.3",
26
26
  "Jinja2 >= 3.1.2",
27
27
  "markdown >= 3.4.4",
28
28
  "MarkupSafe >= 2.1.3",
29
29
  "netaddr >= 1.2.1",
30
- "thor >= 0.16.0",
30
+ "thor >= 0.16.1",
31
31
  ]
32
32
 
33
33
  [project.urls]
@@ -2,7 +2,7 @@
2
2
  This is the Resource Expert Droid.
3
3
  """
4
4
 
5
- __version__ = "2.7.0"
5
+ __version__ = "2.7.1"
6
6
  __author__ = "Mark Nottingham <mnot@mnot.net>"
7
7
  __copyright__ = """\
8
8
  Copyright (c) 2008- Mark Nottingham
@@ -453,28 +453,12 @@ in standalone server mode. Details follow.
453
453
  (b"Content-Type", DIRECTORY_CONTENT_TYPE.encode("ascii")),
454
454
  (b"Cache-Control", b"max-age=%d" % DIRECTORY_MAX_AGE),
455
455
  ]
456
- authority = self.request_authority()
457
- if authority:
458
- headers += signer.sign_directory(authority)
456
+ headers += signer.sign_directory()
459
457
  self.exchange.response_start(b"200", b"OK", headers)
460
458
  self.exchange.response_body(body)
461
459
  self.exchange.response_done([])
462
460
  return None
463
461
 
464
- def request_authority(self) -> str:
465
- "The authority (Host) of the incoming request, for signing."
466
- for name, value in self.req_hdrs:
467
- if name.lower() == b"host":
468
- host = value.decode("ascii", "replace").strip().lower()
469
- # Drop a default port so the signed @authority matches what a
470
- # verifier normalizes to (RFC 9421 derives @authority this way).
471
- for suffix in (":443", ":80"):
472
- if host.endswith(suffix):
473
- host = host[: -len(suffix)]
474
- break
475
- return host
476
- return ""
477
-
478
462
  def not_found(self, path: bytes) -> None:
479
463
  headers = []
480
464
  headers.append((b"Content-Type", b"text/plain"))
@@ -89,7 +89,7 @@ class SingleEntryHtmlFormatter(BaseHtmlFormatter):
89
89
  def finish_output(self) -> None:
90
90
  self.final_status()
91
91
  media_type = self.resource.response.headers.parsed.get("content-type", [""])[0]
92
- if self.resource.response.complete or self.resource.nonfinal_responses:
92
+ if self.resource.has_response or self.resource.nonfinal_responses:
93
93
  validator_link = self.validators.get(media_type, None)
94
94
  if validator_link and self.resource.response.complete:
95
95
  validator_link = validator_link % e_query_arg(self.resource.request.uri or "")
@@ -355,6 +355,7 @@ class TableHtmlFormatter(BaseHtmlFormatter):
355
355
  **{
356
356
  "droid_lists": self.make_droid_lists(self.resource),
357
357
  "problems": self.problems,
358
+ "show_har": self.resource.response.complete,
358
359
  "levels": levels,
359
360
  "is_saved": is_saved,
360
361
  "save_mtime": save_mtime,
@@ -17,7 +17,7 @@
17
17
 
18
18
  {% if resource.fetch_error %}
19
19
  <p class='error'>
20
- {{ resource.fetch_error.desc }}
20
+ {{ resource.fetch_error.desc }}{% if resource.fetch_error.detail %} ({{ resource.fetch_error.detail }}){% endif %}
21
21
  </p>
22
22
  {% endif %}
23
23
 
@@ -38,6 +38,7 @@
38
38
  <span class='option' title='{{ _("How large the response headers are, including the status line") }}'>{{
39
39
  _("response headers") }}: {{ resource.response_header_length|f_num }} {{ _("bytes") }}</span>
40
40
 
41
+ {% if resource.response.complete %}
41
42
  <span class='option' title='{{ _("How large the response content is") }}'>{{ _("content") }}: {{
42
43
  resource.response.content_length|f_num }} {{ _("bytes") }}</span>
43
44
 
@@ -46,6 +47,7 @@
46
47
  _("transfer overhead") }}: {{ (resource.response.transfer_length -
47
48
  resource.response.content_length)|f_num }} {{ _("bytes") }}</span>
48
49
  {%- endif %}
50
+ {% endif %}
49
51
 
50
52
  <br />
51
53
 
@@ -53,7 +55,7 @@
53
55
  <a href='#' id='body_view' accesskey='b'>{{ _("view content") }}</a>
54
56
  </div>
55
57
 
56
- {% if is_resource %}
58
+ {% if is_resource and resource.response.complete %}
57
59
  <div class='option'>
58
60
  {{ har_link }}
59
61
  </div>
@@ -27,6 +27,15 @@
27
27
  {% endif %}
28
28
  {%- endmacro %}
29
29
 
30
+ {% macro notes_cell(resource) %}
31
+ <td>
32
+ {% for problem in resource.response.notes if problem.level in [levels.WARN, levels.BAD] %}
33
+ <span class='prob_num'>{{ problem|index_problem }}<span class='hidden'><span class='tip'>{{ problem.detail
34
+ }}</span></span></span>
35
+ {% endfor %}
36
+ </td>
37
+ {%- endmacro %}
38
+
30
39
  <table id='summary'>
31
40
  {% for heading, droids in droid_lists %}
32
41
  {% if heading != "" %}
@@ -70,20 +79,21 @@
70
79
  <td>{{ yes_no(resource.gzip_support) }}</td>
71
80
  {% endif %}
72
81
  <td>{{ yes_no(resource.partial_support) }}</td>
73
- <td>
74
- {% for problem in resource.response.notes if problem.level in [levels.WARN, levels.BAD] %}
75
- <span class='prob_num'>{{ problem|index_problem }}<span class='hidden'><span class='tip'>{{ problem.detail
76
- }}</span></span></span>
77
- {% endfor %}
78
- </td>
82
+ {{ notes_cell(resource) }}
79
83
  {% else %}
80
- <td colspan="11">
84
+ {% if resource.has_response %}
85
+ <td>{{ resource.response.status_code_str }}</td>
86
+ {% endif %}
87
+ <td colspan="{{ 9 if resource.has_response else 11 }}">
81
88
  {% if resource.fetch_error == None %}
82
89
  {{ _("response incomplete") }}
83
90
  {% else %}
84
91
  {{ resource.fetch_error.desc or _("unknown problem") }}
85
92
  {% endif %}
86
93
  </td>
94
+ {% if resource.has_response %}
95
+ {{ notes_cell(resource) }}
96
+ {% endif %}
87
97
  {% endif %}
88
98
  </tr>
89
99
  {% endfor %}
@@ -91,9 +101,11 @@
91
101
  </table>
92
102
 
93
103
  <div class="options">
104
+ {% if show_har %}
94
105
  <div class='option'>
95
106
  {{ har_link }}
96
107
  </div>
108
+ {% endif %}
97
109
  {% if not is_saved %}
98
110
  {% if allow_save %}
99
111
  <div class='option' title='{{ _("Save these results for future reference") }}'>
@@ -63,7 +63,7 @@
63
63
 
64
64
  <body class="{{ extra_body_class }}">
65
65
 
66
- {{ links.save_form(test_id, descend) | safe }}
66
+ {% if test_id %}{{ links.save_form(test_id, descend) | safe }}{% endif %}
67
67
 
68
68
  <div id="request">
69
69
  <h1><a href="{{ links.home_link() }}"><span class="hilight"><abbr title="Resource Expert Droid">RED</abbr></span>bot</a>{{ extra_title
@@ -86,30 +86,30 @@ class BaseTextFormatter(Formatter):
86
86
 
87
87
  def finish_output(self) -> None:
88
88
  "Fill in the template with RED's results."
89
- if self.resource.response.complete or self.resource.nonfinal_responses:
89
+ response = self.resource.response
90
+ if self.resource.has_response or self.resource.nonfinal_responses:
90
91
  if self.resource.nonfinal_responses:
91
92
  self.output(
92
93
  NL.join([self.format_headers(r) for r in self.resource.nonfinal_responses])
93
94
  + NL
94
95
  + NL
95
96
  )
96
- if self.resource.response.complete:
97
- self.output(self.format_headers(self.resource.response) + NL + NL)
98
- elif isinstance(self.resource.fetch_error, httperr.HttpError):
99
- self.output(
100
- self.error_template % self._format_fetch_error(self.resource.fetch_error)
101
- )
97
+ if self.resource.has_response:
98
+ self.output(self.format_headers(response) + NL + NL)
99
+ if not response.complete:
100
+ self.output_fetch_error(self.resource)
102
101
 
103
102
  self.output(self.format_recommendations(self.resource) + NL)
104
103
  else:
105
- if self.resource.fetch_error is None:
106
- pass
107
- elif isinstance(self.resource.fetch_error, httperr.HttpError):
108
- self.output(
109
- self.error_template % self._format_fetch_error(self.resource.fetch_error)
110
- )
111
- else:
112
- raise AssertionError("Unknown incomplete response error.")
104
+ self.output_fetch_error(self.resource)
105
+
106
+ def output_fetch_error(self, resource: HttpResource) -> None:
107
+ "Output the error for a resource whose fetch failed, if there is one."
108
+ if resource.fetch_error is None:
109
+ return
110
+ if not isinstance(resource.fetch_error, httperr.HttpError):
111
+ raise AssertionError("Unknown incomplete response error.")
112
+ self.output(self.error_template % self._format_fetch_error(resource.fetch_error))
113
113
 
114
114
  @staticmethod
115
115
  def _format_fetch_error(error: httperr.HttpError) -> str:
@@ -227,7 +227,12 @@ class TextListFormatter(BaseTextFormatter):
227
227
  subresources.sort(key=operator.attrgetter("request.uri"))
228
228
  for subresource in subresources:
229
229
  self.output(self.format_uri(subresource) + NL + NL)
230
- self.output(self.format_headers(subresource.response) + NL + NL)
230
+ if not subresource.has_response: # no response to show
231
+ self.output_fetch_error(subresource)
232
+ else:
233
+ self.output(self.format_headers(subresource.response) + NL + NL)
234
+ if not subresource.response.complete:
235
+ self.output_fetch_error(subresource)
231
236
  self.output(self.format_recommendations(subresource) + NL + NL)
232
237
 
233
238
  def format_uri(self, resource: HttpResource) -> str:
@@ -15,7 +15,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
15
15
  from urllib.parse import urljoin
16
16
 
17
17
  import thor
18
- from thor.http.error import UrlError
18
+ from thor.http.error import HttpError, UrlError
19
19
  from thor.http.uri import parse_uri
20
20
 
21
21
  from redbot.resource import link_parse
@@ -59,7 +59,7 @@ class HttpResource(RedFetcher):
59
59
  if descend is true, the response will be parsed for links and HttpResources started for each
60
60
  link, enumerated in .linked. Linked resources don't run active checks.
61
61
 
62
- Emits "check_done" when everything has finished.
62
+ Emits "check_done" once, when everything has finished.
63
63
  """
64
64
 
65
65
  check_name = "default"
@@ -86,7 +86,9 @@ class HttpResource(RedFetcher):
86
86
  self.links: Dict[str, Set[str]] = {}
87
87
  self.link_count: int = 0
88
88
  self.linked: List[Tuple[HttpResource, str]] = [] # linked HttpResources
89
- self._link_parser = link_parse.HTMLLinkParser(self.response, [self.process_link])
89
+ self._link_parser = link_parse.HTMLLinkParser(
90
+ self.response, [self.process_link], err=self._link_error
91
+ )
90
92
  if self.descend or config.getboolean("content_links", False):
91
93
  self.response.decoded.processors.append(self._link_parser.feed_bytes)
92
94
 
@@ -95,8 +97,11 @@ class HttpResource(RedFetcher):
95
97
  Response is available; perform subordinate requests (e.g., conneg check).
96
98
  """
97
99
  if self.response.complete and self.active_checks_enabled:
98
- for active_check in list(self.subreqs.values()):
99
- self.add_check(active_check)
100
+ # Register every check before starting any: one that finishes synchronously mustn't
101
+ # empty the task map while others are still to be started.
102
+ active = list(self.subreqs.values())
103
+ self.add_check(*active)
104
+ for active_check in active:
100
105
  active_check.check()
101
106
  else:
102
107
  self.finish_check()
@@ -140,7 +145,8 @@ class HttpResource(RedFetcher):
140
145
  raise KeyError(f"* Can't find {resource} in task map: {self._task_map}") from None
141
146
  tasks_left = len(self._task_map)
142
147
  # self.emit("debug", "%s checks remaining: %i" % (repr(self), tasks_left))
143
- if tasks_left == 0:
148
+ # Links that fail immediately mustn't complete us while we're still reading the response.
149
+ if tasks_left == 0 and self.fetch_done and not self.check_done:
144
150
  self.check_done = True
145
151
  self.emit("check_done")
146
152
 
@@ -167,7 +173,12 @@ class HttpResource(RedFetcher):
167
173
  if self.descend and tag not in ["a"] and link not in self.links[tag]:
168
174
  linked = HttpResource(self.config, active_checks_enabled=False)
169
175
  credentials, other_headers = split_credentials(self.request.headers.text)
170
- linked.set_request(urljoin(base, link), headers=other_headers)
176
+ uri: Optional[str]
177
+ try:
178
+ uri = urljoin(base, link)
179
+ except ValueError:
180
+ uri = None
181
+ linked.set_request(uri if uri is not None else link, headers=other_headers)
171
182
  # Compare the normalised URIs, not the link text: normalisation can change the host.
172
183
  # Credentials stay off unless both origins are known and equal.
173
184
  linked_origin = origin_of(linked.request.uri)
@@ -177,11 +188,33 @@ class HttpResource(RedFetcher):
177
188
  )
178
189
  self.linked.append((linked, tag))
179
190
  self.add_check(linked)
180
- linked.check()
191
+ if uri is None: # can't be resolved; list it as failed and carry on
192
+ self._fail_link(linked, UrlError("link could not be resolved"))
193
+ else:
194
+ try:
195
+ linked.check()
196
+ except Exception as why: # pylint: disable=broad-except
197
+ self._fail_link(linked, HttpError(str(why)))
181
198
  self.links[tag].add(link)
182
199
  if not self.response.base_uri:
183
200
  self.response.base_uri = base
184
201
 
202
+ def _fail_link(self, linked: "HttpResource", error: HttpError) -> None:
203
+ "Record why a linked resource couldn't be checked, and complete it."
204
+ linked.fetch_error = linked.fetch_error or error
205
+ linked.stop()
206
+ if linked in self._task_map: # stop() didn't complete it
207
+ self.finish_check(linked)
208
+ try: # after cleanup, so a failing listener can't undo it
209
+ uri = (linked.request.uri or "")[:200]
210
+ self.emit("debug", f"checking link {uri} failed: {error!r}")
211
+ except Exception: # pylint: disable=broad-except
212
+ pass
213
+
214
+ def _link_error(self, message: str) -> None:
215
+ "Report a problem parsing or handling links."
216
+ self.emit("debug", f"link parsing: {message}")
217
+
185
218
  def stop(self) -> None:
186
219
  "Stop the resource and any sub-resources."
187
220
  for task in list(self._task_map):
@@ -116,6 +116,11 @@ class RedFetcher(thor.events.EventEmitter):
116
116
  out.append("fetch_done")
117
117
  return f"<{', '.join(out)} at {id(self):#x}>"
118
118
 
119
+ @property
120
+ def has_response(self) -> bool:
121
+ "Whether a status line arrived, so there are headers to show even if the body failed."
122
+ return self.response.status_code is not None
123
+
119
124
  def preflight(self) -> bool:
120
125
  """
121
126
  Check to see if we should bother running. Return True
@@ -189,6 +194,13 @@ class RedFetcher(thor.events.EventEmitter):
189
194
  "Start an exchange for the current request, optionally adding extra headers."
190
195
  assert self.request.method, "method not set in _send_request"
191
196
  assert self.request.uri, "uri not set in _send_request"
197
+ try:
198
+ wire_uri = self.request.uri.encode("ascii")
199
+ except UnicodeEncodeError:
200
+ # e.g., httplint leaves a non-ASCII password in the URI unencoded
201
+ self.fetch_error = httperr.UrlError("URI contains non-ASCII characters")
202
+ self._fetch_done()
203
+ return
192
204
  self.exchange = self.client.exchange()
193
205
  self.exchange.on("response_nonfinal", self._response_nonfinal)
194
206
  self.exchange.once("response_start", self._response_start)
@@ -210,7 +222,7 @@ class RedFetcher(thor.events.EventEmitter):
210
222
  self.request.start_time = time.time()
211
223
  self.exchange.request_start(
212
224
  self.request.method.encode("ascii"),
213
- self.request.uri.encode("ascii"),
225
+ wire_uri,
214
226
  req_hdrs,
215
227
  )
216
228
  if not self.fetch_done: # the request could have immediately failed.
@@ -292,6 +304,7 @@ class RedFetcher(thor.events.EventEmitter):
292
304
  assert self.exchange.res_version, "exchange.res_version not set in _response_start"
293
305
  self.response.process_response_topline(self.exchange.res_version, status, phrase)
294
306
  self.response.process_headers(res_headers)
307
+ self.response_header_length = self.exchange.input_header_length
295
308
  self.emit("response_headers_available")
296
309
 
297
310
  def _response_body(self, chunk: bytes) -> None:
@@ -341,10 +354,12 @@ class RedFetcher(thor.events.EventEmitter):
341
354
  self.response.notes.add("body", EXTRA_DATA, sample=err_sample)
342
355
  elif isinstance(error, httperr.ChunkError):
343
356
  self.response.notes.add("field-transfer-encoding", BAD_CHUNK, chunk_sample=err_sample)
357
+ self.fetch_error = error # thor stops the response here, so it never completes
344
358
  elif isinstance(error, httperr.HeaderSpaceError) and error.detail:
345
- subject = f"field-{error.detail.lower().strip()}"
346
- self.response.notes.add(subject, HEADER_NAME_SPACE, header_name=error.detail)
347
- return
359
+ subject = f"field-{err_sample.lower().strip()}"
360
+ self.response.notes.add(subject, HEADER_NAME_SPACE, header_name=err_sample)
361
+ # thor sends nothing after an error, so the response never completes.
362
+ self.fetch_error = error
348
363
  else:
349
364
  self.fetch_error = error
350
365
  self._fetch_done()
@@ -7,15 +7,30 @@ Parse links from a stream of HTML data.
7
7
  import codecs
8
8
  from html.parser import HTMLParser
9
9
  from typing import Any, Callable, Dict, List, Optional, Tuple
10
+ from urllib.parse import urljoin, urlsplit
10
11
 
11
- from httplint.field.utils import split_string, unquote_string
12
+ from httplint.charset import is_text_encoding, whatwg_encoding
13
+ from httplint.field.utils import split_params, unquote_string
12
14
  from httplint.message import HttpMessageLinter
13
- from httplint.syntax import rfc9110
14
15
 
15
16
  from redbot.utils import decode_text
16
17
 
17
18
  DEFAULT_ENCODING = "utf-8"
18
19
  MAX_FEED_BYTES = 2 * 1024 * 1024
20
+ # A <base> inside these isn't the document's base (noscript: as when scripting is enabled).
21
+ INERT_TAGS = ("template", "svg", "math", "noscript")
22
+
23
+ # Python codecs that decode bytes to text but that a <meta> must not select: UTF-7 and
24
+ # UTF-16/32 (HTML treats a UTF-16 <meta> as UTF-8), and ones that aren't character encodings.
25
+ NOT_META_CHARSETS = (
26
+ "utf_7",
27
+ "utf_16",
28
+ "utf_32",
29
+ "unicode_escape",
30
+ "raw_unicode_escape",
31
+ "idna",
32
+ "punycode",
33
+ )
19
34
 
20
35
 
21
36
  class HTMLLinkParser(HTMLParser):
@@ -52,7 +67,7 @@ class HTMLLinkParser(HTMLParser):
52
67
  self,
53
68
  message: HttpMessageLinter,
54
69
  link_procs: List[Callable[[str, str, str, str], None]],
55
- err: Optional[Callable[[str], int]] = None,
70
+ err: Optional[Callable[[str], None]] = None,
56
71
  ) -> None:
57
72
  self.message = message
58
73
  self.link_procs = link_procs
@@ -61,6 +76,9 @@ class HTMLLinkParser(HTMLParser):
61
76
  self.last_err_pos: int = 0
62
77
  self.ok = True
63
78
  self.bytes_fed = 0
79
+ self.base_seen = False
80
+ self.inert_depth = {tag: 0 for tag in INERT_TAGS}
81
+ self.decode_as: Optional[str] = None
64
82
  HTMLParser.__init__(self)
65
83
 
66
84
  def __getstate__(self) -> Dict[str, Any]:
@@ -73,7 +91,7 @@ class HTMLLinkParser(HTMLParser):
73
91
  if self.bytes_fed > MAX_FEED_BYTES:
74
92
  self.ok = False
75
93
  return
76
- encoding = self.message.character_encoding or DEFAULT_ENCODING
94
+ encoding = self.decode_as or self.message.character_encoding or DEFAULT_ENCODING
77
95
  decoded = decode_text(bchunk, encoding, "ignore")
78
96
  self.feed(decoded)
79
97
 
@@ -106,7 +124,13 @@ class HTMLLinkParser(HTMLParser):
106
124
  for proc in self.link_procs:
107
125
  proc(self.message.base_uri, target, tag, title)
108
126
  elif tag == "base":
109
- self.message.base_uri = dict(attrs).get("href", self.message.base_uri) or ""
127
+ attr_d = dict(attrs)
128
+ inert = any(self.inert_depth.values())
129
+ if "href" in attr_d and not self.base_seen and not inert: # first <base href> counts
130
+ self.base_seen = True
131
+ self.message.base_uri = resolve_base(self.message.base_uri, attr_d["href"] or "")
132
+ elif tag in self.inert_depth:
133
+ self.inert_depth[tag] += 1
110
134
  elif tag == "meta":
111
135
  attr_d = dict(attrs)
112
136
  if (attr_d.get("http-equiv", "") or "").lower() != "content-type":
@@ -119,18 +143,36 @@ class HTMLLinkParser(HTMLParser):
119
143
  media_type, params = ct, ""
120
144
  media_type = media_type.lower()
121
145
  param_dict: Dict[str, Optional[str]] = {}
122
- for param in split_string(params, rfc9110.parameter, r"\s*;\s*"):
146
+ for param in split_params(params):
123
147
  try:
124
148
  attr, val = param.split("=", 1)
125
149
  param_dict[attr.lower()] = unquote_string(val)
126
150
  except ValueError:
127
151
  param_dict[param.lower()] = None
128
- enc = param_dict.get("charset", self.message.character_encoding) or DEFAULT_ENCODING
129
- try:
130
- codecs.lookup(enc)
131
- self.message.character_encoding = enc
132
- except LookupError:
133
- pass
152
+ self._set_meta_charset(param_dict.get("charset"))
153
+
154
+ def _set_meta_charset(self, label: Optional[str]) -> None:
155
+ """
156
+ Apply a <meta> charset. A label that the Encoding Standard and Python both know is
157
+ recorded on the message, as for the header. A Python-only text encoding (`latin-1`,
158
+ `mac-roman`) is only used to decode later chunks, so the message never claims it.
159
+ Anything else is ignored.
160
+ """
161
+ if not label or not is_text_encoding(label):
162
+ return
163
+ encoding = whatwg_encoding(label)
164
+ if encoding == "replacement":
165
+ return
166
+ if encoding:
167
+ self.message.whatwg_encoding = encoding
168
+ self.message.character_encoding = label
169
+ self.decode_as = None
170
+ elif not codecs.lookup(label).name.replace("-", "_").startswith(NOT_META_CHARSETS):
171
+ self.decode_as = label
172
+
173
+ def handle_endtag(self, tag: str) -> None:
174
+ if self.inert_depth.get(tag):
175
+ self.inert_depth[tag] -= 1
134
176
 
135
177
  def error(self, message: str) -> None:
136
178
  self.errors += 1
@@ -145,5 +187,19 @@ class HTMLLinkParser(HTMLParser):
145
187
  self.err(message)
146
188
 
147
189
 
190
+ def resolve_base(page_uri: str, href: str) -> str:
191
+ """
192
+ Resolve a <base href> against the page's URI. As in the HTML spec, an unparseable href or a
193
+ data: or javascript: URI leaves the page URI as the base (Chromium uses about:blank instead).
194
+ """
195
+ try:
196
+ base = urljoin(page_uri, href.strip(" \t\n\f\r"))
197
+ if urlsplit(base).scheme.lower() in ("data", "javascript"):
198
+ return page_uri
199
+ except ValueError:
200
+ return page_uri
201
+ return base
202
+
203
+
148
204
  class BadErrorIReallyMeanIt(Exception):
149
205
  """See http://bugs.python.org/issue8885 for why this is necessary."""
@@ -87,6 +87,14 @@ class WebBotAuthSigner:
87
87
  raise WebBotAuthError("Web Bot Auth key must be an Ed25519 private key.")
88
88
  self._key = key
89
89
  self.directory = directory.strip().strip('"')
90
+ # Verifiers derive @authority from the directory URL they fetched. A
91
+ # scheme-less directory is taken as https.
92
+ try:
93
+ self.directory_authority = self.authority(
94
+ self.directory if "://" in self.directory else f"https://{self.directory}"
95
+ )
96
+ except ValueError as why:
97
+ raise WebBotAuthError(f"Invalid Web Bot Auth directory '{directory}'.") from why
90
98
  self.validity = validity
91
99
  self.key_created = key_created
92
100
 
@@ -121,15 +129,16 @@ class WebBotAuthSigner:
121
129
 
122
130
  def _build_signature(
123
131
  self,
124
- components: List[Tuple[str, str]],
132
+ components: List[Tuple[http_sf.types.ItemType, str]],
125
133
  tag: str,
126
134
  validity: int,
127
135
  ) -> Tuple[str, str]:
128
136
  """
129
137
  Construct the RFC 9421 signature base over ``components`` (each an
130
- ordered (component-name, component-value) pair), sign it, and return the
131
- Signature-Input and Signature dictionary-member values for SIG_LABEL.
132
- ``validity`` is the signature lifetime in seconds.
138
+ ordered (component-identifier, component-value) pair; an identifier is a
139
+ structured-field item, so it can carry parameters such as ``req``), sign
140
+ it, and return the Signature-Input and Signature dictionary-member values
141
+ for SIG_LABEL. ``validity`` is the signature lifetime in seconds.
133
142
 
134
143
  Structured-field values are serialized with ``http_sf`` so the
135
144
  @signature-params line in the base and the Signature-Input header are
@@ -144,10 +153,10 @@ class WebBotAuthSigner:
144
153
  "nonce": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
145
154
  "tag": tag,
146
155
  }
147
- covered: List[http_sf.types.ItemType] = [name for name, _ in components]
156
+ covered = [identifier for identifier, _ in components]
148
157
  inner_list: http_sf.types.InnerListType = (covered, params)
149
158
 
150
- base_lines = [f"{http_sf.ser(name)}: {value}" for name, value in components]
159
+ base_lines = [f"{http_sf.ser(identifier)}: {value}" for identifier, value in components]
151
160
  base_lines.append(f'{http_sf.ser("@signature-params")}: {http_sf.ser([inner_list])}')
152
161
  signature = self._key.sign("\n".join(base_lines).encode("utf-8"))
153
162
 
@@ -157,7 +166,10 @@ class WebBotAuthSigner:
157
166
  "Return the Web Bot Auth headers to add to a request for ``uri``."
158
167
  agent_field = http_sf.ser(self.directory) # Signature-Agent value (an sf-string)
159
168
  sig_input, sig = self._build_signature(
160
- [("@authority", self.authority(uri)), ("signature-agent", agent_field)],
169
+ [
170
+ (("@authority", {}), self.authority(uri)),
171
+ (("signature-agent", {}), agent_field),
172
+ ],
161
173
  REQUEST_TAG,
162
174
  self.validity,
163
175
  )
@@ -167,10 +179,13 @@ class WebBotAuthSigner:
167
179
  (b"Signature", sig.encode("ascii")),
168
180
  ]
169
181
 
170
- def sign_directory(self, authority: str) -> List[Tuple[bytes, bytes]]:
171
- "Return the signature headers for a directory response served at ``authority``."
182
+ def sign_directory(self) -> List[Tuple[bytes, bytes]]:
183
+ "Return the signature headers for the directory response."
184
+ # Directory draft section 5.2: @authority must carry req (RFC 9421 section 2.4).
172
185
  sig_input, sig = self._build_signature(
173
- [("@authority", authority)], DIRECTORY_TAG, DIRECTORY_VALIDITY
186
+ [(("@authority", {"req": True}), self.directory_authority)],
187
+ DIRECTORY_TAG,
188
+ DIRECTORY_VALIDITY,
174
189
  )
175
190
  return [
176
191
  (b"Signature-Input", sig_input.encode("ascii")),