static-http 0.1.5__tar.gz → 1.0.0__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 (25) hide show
  1. {static_http-0.1.5/src/static_http.egg-info → static_http-1.0.0}/PKG-INFO +1 -1
  2. {static_http-0.1.5 → static_http-1.0.0}/pyproject.toml +1 -1
  3. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/__init__.py +1 -1
  4. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/cli.py +1 -1
  5. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/keyboard.py +5 -5
  6. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/server.py +25 -11
  7. {static_http-0.1.5 → static_http-1.0.0/src/static_http.egg-info}/PKG-INFO +1 -1
  8. static_http-1.0.0/tests/test_qrcode.py +355 -0
  9. {static_http-0.1.5 → static_http-1.0.0}/tests/test_server.py +54 -0
  10. static_http-0.1.5/tests/test_qrcode.py +0 -116
  11. {static_http-0.1.5 → static_http-1.0.0}/LICENSE +0 -0
  12. {static_http-0.1.5 → static_http-1.0.0}/README.md +0 -0
  13. {static_http-0.1.5 → static_http-1.0.0}/setup.cfg +0 -0
  14. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/__main__.py +0 -0
  15. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/qrcode.py +0 -0
  16. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/ranges.py +0 -0
  17. {static_http-0.1.5 → static_http-1.0.0}/src/http_here/urls.py +0 -0
  18. {static_http-0.1.5 → static_http-1.0.0}/src/static_http.egg-info/SOURCES.txt +0 -0
  19. {static_http-0.1.5 → static_http-1.0.0}/src/static_http.egg-info/dependency_links.txt +0 -0
  20. {static_http-0.1.5 → static_http-1.0.0}/src/static_http.egg-info/entry_points.txt +0 -0
  21. {static_http-0.1.5 → static_http-1.0.0}/src/static_http.egg-info/requires.txt +0 -0
  22. {static_http-0.1.5 → static_http-1.0.0}/src/static_http.egg-info/top_level.txt +0 -0
  23. {static_http-0.1.5 → static_http-1.0.0}/tests/test_cli.py +0 -0
  24. {static_http-0.1.5 → static_http-1.0.0}/tests/test_ranges.py +0 -0
  25. {static_http-0.1.5 → static_http-1.0.0}/tests/test_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.5
3
+ Version: 1.0.0
4
4
  Summary: A temporary dependency-free static HTTP server with byte-range support.
5
5
  Author: John Paul Ellis
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "static-http"
7
- version = "0.1.5"
7
+ version = "1.0.0"
8
8
  description = "A temporary dependency-free static HTTP server with byte-range support."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Utilities for running a dependency-free local static HTTP server."""
2
2
 
3
- __version__ = "0.1.5"
3
+ __version__ = "1.0.0"
@@ -128,7 +128,7 @@ def _build_parser() -> argparse.ArgumentParser:
128
128
  parser.add_argument(
129
129
  "--include-hidden",
130
130
  action="store_true",
131
- help="Include dot-prefixed files/directories when serving and listing directories.",
131
+ help="Include dot-prefixed and platform-hidden files/directories when serving and listing directories.",
132
132
  )
133
133
  parser.add_argument(
134
134
  "--version",
@@ -23,14 +23,14 @@ def start_quit_watcher(on_quit) -> tuple[threading.Event, bool]:
23
23
  while not stop_event.is_set():
24
24
  if msvcrt.kbhit():
25
25
  ch = msvcrt.getwch()
26
- if ch.lower() == "q":
26
+ if ch.lower() == "q" or ch == "\x03":
27
27
  if not stop_event.is_set():
28
28
  stop_event.set()
29
29
  on_quit()
30
30
  if stop_event.wait(0.1):
31
31
  return
32
32
 
33
- thread = threading.Thread(target=_watch, name="http-here-keyboard", daemon=True)
33
+ thread = threading.Thread(target=_watch, name="static-http-keyboard", daemon=True)
34
34
  thread.start()
35
35
  return stop_event, False
36
36
  except Exception:
@@ -56,7 +56,7 @@ def start_quit_watcher(on_quit) -> tuple[threading.Event, bool]:
56
56
  if not ready:
57
57
  continue
58
58
  ch = os.read(fd, 1).decode(errors="ignore")
59
- if ch.lower() == "q":
59
+ if ch.lower() == "q" or ch == "\x03":
60
60
  stop_event.set()
61
61
  on_quit()
62
62
  finally:
@@ -65,7 +65,7 @@ def start_quit_watcher(on_quit) -> tuple[threading.Event, bool]:
65
65
  except Exception:
66
66
  pass
67
67
 
68
- thread = threading.Thread(target=_watch, name="http-here-keyboard", daemon=True)
68
+ thread = threading.Thread(target=_watch, name="static-http-keyboard", daemon=True)
69
69
  thread.start()
70
70
  return stop_event, False
71
71
  except Exception:
@@ -81,6 +81,6 @@ def start_quit_watcher(on_quit) -> tuple[threading.Event, bool]:
81
81
  on_quit()
82
82
  return
83
83
 
84
- thread = threading.Thread(target=_watch, name="http-here-keyboard", daemon=True)
84
+ thread = threading.Thread(target=_watch, name="static-http-keyboard", daemon=True)
85
85
  thread.start()
86
86
  return stop_event, True
@@ -40,6 +40,10 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
40
40
  for name, value in self._extra_headers.items():
41
41
  self.send_header(name, value)
42
42
 
43
+ def end_headers(self) -> None:
44
+ self._add_custom_headers()
45
+ super().end_headers()
46
+
43
47
  def log_message(self, format: str, *args) -> None:
44
48
  if self._quiet:
45
49
  return
@@ -69,6 +73,14 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
69
73
  def _is_hidden_entry(self, name: str, path: str) -> bool:
70
74
  return self._is_hidden(name) or self._has_hidden_attribute(path)
71
75
 
76
+ def _is_within_root(self, path: str) -> bool:
77
+ root = os.path.realpath(self.directory)
78
+ real_candidate = os.path.realpath(path)
79
+ try:
80
+ return os.path.commonpath([real_candidate, root]) == root
81
+ except ValueError:
82
+ return False
83
+
72
84
  def translate_path(self, path: str) -> str | None:
73
85
  # Safe path mapping inspired by SimpleHTTPRequestHandler, with strict traversal defense.
74
86
  path = path.split("?", 1)[0]
@@ -97,9 +109,7 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
97
109
  return None
98
110
 
99
111
  candidate = os.path.normpath(candidate)
100
- root = os.path.realpath(self.directory)
101
- real_candidate = os.path.realpath(candidate)
102
- if os.path.commonpath([real_candidate, root]) != root:
112
+ if not self._is_within_root(candidate):
103
113
  return None
104
114
 
105
115
  return candidate
@@ -109,7 +119,6 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
109
119
  self.send_header("Content-Range", unsatisfiable_content_range(size))
110
120
  self.send_header("Accept-Ranges", "bytes")
111
121
  self.send_header("Content-Length", "0")
112
- self._add_custom_headers()
113
122
  self.end_headers()
114
123
 
115
124
  def _write_not_modified(self, path: str, mtime: datetime) -> None:
@@ -118,7 +127,6 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
118
127
  self.send_header("Content-Type", self.guess_type(path))
119
128
  self.send_header("Last-Modified", self.date_time_string(mtime.timestamp()))
120
129
  self.send_header("Content-Length", "0")
121
- self._add_custom_headers()
122
130
  self.end_headers()
123
131
 
124
132
  def send_head(self) -> io.BufferedIOBase | None:
@@ -135,13 +143,17 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
135
143
  location = urllib.parse.urlunsplit(("", "", parts.path + "/", parts.query, parts.fragment))
136
144
  self.send_response(HTTPStatus.MOVED_PERMANENTLY)
137
145
  self.send_header("Location", location)
138
- self._add_custom_headers()
146
+ self.send_header("Content-Length", "0")
139
147
  self.end_headers()
140
148
  return None
141
149
 
142
150
  for index_name in ("index.html", "index.htm"):
143
151
  index_path = os.path.join(path, index_name)
144
- if os.path.exists(index_path):
152
+ if (
153
+ os.path.exists(index_path)
154
+ and not self._is_hidden_entry(index_name, index_path)
155
+ and self._is_within_root(index_path)
156
+ ):
145
157
  path = index_path
146
158
  break
147
159
  else:
@@ -150,6 +162,10 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
150
162
  return None
151
163
  return self.list_directory(path)
152
164
 
165
+ if not self._is_within_root(path):
166
+ self.send_error(HTTPStatus.BAD_REQUEST, "Invalid request path")
167
+ return None
168
+
153
169
  ctype = self.guess_type(path)
154
170
  try:
155
171
  f = open(path, "rb")
@@ -165,7 +181,7 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
165
181
  return None
166
182
 
167
183
  file_size = stats.st_size
168
- mtime = datetime.fromtimestamp(stats.st_mtime, tz=timezone.utc)
184
+ mtime = datetime.fromtimestamp(stats.st_mtime, tz=timezone.utc).replace(microsecond=0)
169
185
 
170
186
  if_modified_since = self.headers.get("If-Modified-Since")
171
187
  if if_modified_since:
@@ -206,9 +222,8 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
206
222
  f = _RangeFile(f, end - start + 1)
207
223
 
208
224
  self.send_header("Content-Type", ctype)
209
- self.send_header("Last-Modified", self.date_time_string(stats.st_mtime))
225
+ self.send_header("Last-Modified", self.date_time_string(mtime.timestamp()))
210
226
  self.send_header("Accept-Ranges", "bytes")
211
- self._add_custom_headers()
212
227
  self.end_headers()
213
228
  return f
214
229
  except Exception:
@@ -264,7 +279,6 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
264
279
  self.send_response(HTTPStatus.OK)
265
280
  self.send_header("Content-Type", f"text/html; charset={enc}")
266
281
  self.send_header("Content-Length", str(len(encoded)))
267
- self._add_custom_headers()
268
282
  self.end_headers()
269
283
  return f
270
284
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: static-http
3
- Version: 0.1.5
3
+ Version: 1.0.0
4
4
  Summary: A temporary dependency-free static HTTP server with byte-range support.
5
5
  Author: John Paul Ellis
6
6
  License-Expression: MIT
@@ -0,0 +1,355 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import re
5
+ from collections import namedtuple
6
+
7
+ import pytest
8
+
9
+ from http_here import qrcode
10
+
11
+
12
+ class _TtyStringIO(io.StringIO):
13
+ def isatty(self) -> bool:
14
+ return True
15
+
16
+
17
+ _FORMAT_MASK_BY_BITS = {
18
+ int("111011111000100", 2): 0,
19
+ int("111001011110011", 2): 1,
20
+ int("111110110101010", 2): 2,
21
+ int("111100010011101", 2): 3,
22
+ int("110011000101111", 2): 4,
23
+ int("110001100011000", 2): 5,
24
+ int("110110001000001", 2): 6,
25
+ int("110100101110110", 2): 7,
26
+ }
27
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
28
+
29
+
30
+ def _wide_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
31
+ fake_size = namedtuple("Size", "columns lines")(120, 40)
32
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
33
+
34
+
35
+ def _parse_plain_rendered_matrix(rendered: str) -> list[list[bool]]:
36
+ qr_lines = rendered.splitlines()[:-1]
37
+ size = len(qr_lines) - 8
38
+ assert size > 0
39
+
40
+ matrix = []
41
+ for line in qr_lines[4 : 4 + size]:
42
+ cells = [line[i : i + 2] for i in range(0, len(line), 2)]
43
+ assert len(cells) >= size + 8
44
+ matrix.append([cell != " " for cell in cells[4 : 4 + size]])
45
+ return matrix
46
+
47
+
48
+ def _parse_ansi_cells(line: str) -> list[bool]:
49
+ cells = []
50
+ dark = False
51
+ space_count = 0
52
+ index = 0
53
+
54
+ while index < len(line):
55
+ match = _ANSI_RE.match(line, index)
56
+ if match:
57
+ sequence = match.group(0)
58
+ if sequence == "\x1b[40m":
59
+ dark = True
60
+ elif sequence in {"\x1b[107m", "\x1b[0m"}:
61
+ dark = False
62
+ index = match.end()
63
+ continue
64
+
65
+ assert line[index] == " "
66
+ space_count += 1
67
+ if space_count == 2:
68
+ cells.append(dark)
69
+ space_count = 0
70
+ index += 1
71
+
72
+ assert space_count == 0
73
+ return cells
74
+
75
+
76
+ def _parse_ansi_rendered_matrix(rendered: str) -> list[list[bool]]:
77
+ qr_lines = rendered.splitlines()[:-1]
78
+ size = len(qr_lines) - 8
79
+ assert size > 0
80
+
81
+ matrix = []
82
+ for line in qr_lines[4 : 4 + size]:
83
+ cells = _parse_ansi_cells(line)
84
+ assert len(cells) >= size + 8
85
+ matrix.append(cells[4 : 4 + size])
86
+ return matrix
87
+
88
+
89
+ def _mark_function_modules(size: int) -> list[list[bool]]:
90
+ version = (size - 17) // 4
91
+ function = [[False for _ in range(size)] for _ in range(size)]
92
+
93
+ def mark(row: int, col: int) -> None:
94
+ if 0 <= row < size and 0 <= col < size:
95
+ function[row][col] = True
96
+
97
+ def mark_finder(top: int, left: int) -> None:
98
+ for row in range(top - 1, top + 8):
99
+ for col in range(left - 1, left + 8):
100
+ mark(row, col)
101
+
102
+ mark_finder(0, 0)
103
+ mark_finder(0, size - 7)
104
+ mark_finder(size - 7, 0)
105
+
106
+ if version > 1:
107
+ center = size - 7
108
+ for row in range(center - 2, center + 3):
109
+ for col in range(center - 2, center + 3):
110
+ mark(row, col)
111
+
112
+ for i in range(8, size - 8):
113
+ mark(6, i)
114
+ mark(i, 6)
115
+
116
+ for i in range(15):
117
+ if i < 6:
118
+ mark(i, 8)
119
+ elif i < 8:
120
+ mark(i + 1, 8)
121
+ else:
122
+ mark(size - 15 + i, 8)
123
+
124
+ if i < 8:
125
+ mark(8, size - i - 1)
126
+ elif i == 8:
127
+ mark(8, 7)
128
+ else:
129
+ mark(8, 14 - i)
130
+
131
+ mark(size - 8, 8)
132
+ return function
133
+
134
+
135
+ def _read_format_mask(matrix: list[list[bool]]) -> int:
136
+ size = len(matrix)
137
+ bits = 0
138
+
139
+ for i in range(15):
140
+ if i < 6:
141
+ row, col = i, 8
142
+ elif i < 8:
143
+ row, col = i + 1, 8
144
+ else:
145
+ row, col = size - 15 + i, 8
146
+
147
+ if matrix[row][col]:
148
+ bits |= 1 << i
149
+
150
+ assert bits in _FORMAT_MASK_BY_BITS, f"unknown format bits: {bits:015b}"
151
+ return _FORMAT_MASK_BY_BITS[bits]
152
+
153
+
154
+ def _mask(mask: int, row: int, col: int) -> bool:
155
+ if mask == 0:
156
+ return (row + col) % 2 == 0
157
+ if mask == 1:
158
+ return row % 2 == 0
159
+ if mask == 2:
160
+ return col % 3 == 0
161
+ if mask == 3:
162
+ return (row + col) % 3 == 0
163
+ if mask == 4:
164
+ return (row // 2 + col // 3) % 2 == 0
165
+ if mask == 5:
166
+ return (row * col) % 2 + (row * col) % 3 == 0
167
+ if mask == 6:
168
+ return ((row * col) % 2 + (row * col) % 3) % 2 == 0
169
+ return ((row + col) % 2 + (row * col) % 3) % 2 == 0
170
+
171
+
172
+ def _read_data_bits(matrix: list[list[bool]], mask: int) -> list[int]:
173
+ size = len(matrix)
174
+ function = _mark_function_modules(size)
175
+ bits = []
176
+ row = size - 1
177
+ direction = -1
178
+ col = size - 1
179
+
180
+ while col > 0:
181
+ if col == 6:
182
+ col -= 1
183
+ while 0 <= row < size:
184
+ for offset in range(2):
185
+ c = col - offset
186
+ if function[row][c]:
187
+ continue
188
+ bit = matrix[row][c]
189
+ if _mask(mask, row, c):
190
+ bit = not bit
191
+ bits.append(1 if bit else 0)
192
+ row += direction
193
+ row -= direction
194
+ direction = -direction
195
+ col -= 2
196
+
197
+ return bits
198
+
199
+
200
+ def _decode_rendered_qr(matrix: list[list[bool]]) -> str:
201
+ bits = _read_data_bits(matrix, _read_format_mask(matrix))
202
+ cursor = 0
203
+
204
+ def read(length: int) -> int:
205
+ nonlocal cursor
206
+ value = 0
207
+ for bit in bits[cursor : cursor + length]:
208
+ value = (value << 1) | bit
209
+ cursor += length
210
+ return value
211
+
212
+ mode = read(4)
213
+ assert mode == 0b0100
214
+
215
+ byte_count = read(8)
216
+ payload = bytes(read(8) for _ in range(byte_count))
217
+ return payload.decode("utf-8")
218
+
219
+
220
+ def test_qr_renders_on_wide_terminal() -> None:
221
+ fake_size = namedtuple("Size", "columns lines")(80, 24)
222
+ monkeypatch = pytest.MonkeyPatch()
223
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
224
+ out = io.StringIO()
225
+ try:
226
+ assert qrcode.render_qr("http://localhost:8080/", stream=out)
227
+ finally:
228
+ monkeypatch.undo()
229
+ assert out.getvalue().strip() != ""
230
+
231
+
232
+ def test_qr_uses_forced_terminal_contrast_on_tty() -> None:
233
+ fake_size = namedtuple("Size", "columns lines")(80, 24)
234
+ monkeypatch = pytest.MonkeyPatch()
235
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
236
+ out = _TtyStringIO()
237
+ try:
238
+ assert qrcode.render_qr("http://localhost:8080/", stream=out)
239
+ finally:
240
+ monkeypatch.undo()
241
+ rendered = out.getvalue()
242
+ assert "\x1b[40m" in rendered
243
+ assert "\x1b[107m" in rendered
244
+
245
+
246
+ def test_plain_rendered_qr_output_decodes_to_original_url(monkeypatch: pytest.MonkeyPatch) -> None:
247
+ _wide_terminal(monkeypatch)
248
+ url = "http://192.168.1.205:8080/"
249
+ out = io.StringIO()
250
+
251
+ assert qrcode.render_qr(url, stream=out)
252
+
253
+ assert _decode_rendered_qr(_parse_plain_rendered_matrix(out.getvalue())) == url
254
+
255
+
256
+ def test_ansi_rendered_qr_output_decodes_to_original_url(monkeypatch: pytest.MonkeyPatch) -> None:
257
+ _wide_terminal(monkeypatch)
258
+ url = "http://localhost:8080/"
259
+ out = _TtyStringIO()
260
+
261
+ assert qrcode.render_qr(url, stream=out)
262
+
263
+ assert _decode_rendered_qr(_parse_ansi_rendered_matrix(out.getvalue())) == url
264
+
265
+
266
+ def test_qr_payload_limit_is_78_utf8_bytes() -> None:
267
+ multibyte = "\u00e9"
268
+
269
+ assert len(("a" * 78).encode("utf-8")) == 78
270
+ assert len((multibyte * 39).encode("utf-8")) == 78
271
+
272
+ assert len(qrcode._build_matrix("a" * 78)) == 33
273
+ assert len(qrcode._build_matrix(multibyte * 39)) == 33
274
+
275
+ with pytest.raises(ValueError, match="too long"):
276
+ qrcode._build_matrix("a" * 79)
277
+ with pytest.raises(ValueError, match="too long"):
278
+ qrcode._build_matrix(multibyte * 40)
279
+
280
+
281
+ def test_qr_known_format_bits_and_version_selection() -> None:
282
+ assert f"{qrcode._format_bits(0):015b}" == "111011111000100"
283
+ assert len(qrcode._build_matrix("http://localhost:8080/")) == 25
284
+ assert len(qrcode._build_matrix("http://192.168.1.205:8080/")) == 25
285
+
286
+
287
+ def test_reed_solomon_matches_known_version_2_l_codewords() -> None:
288
+ data = qrcode._encode_data(b"http://localhost:8080/", 2)
289
+
290
+ assert data == [
291
+ 65,
292
+ 102,
293
+ 135,
294
+ 71,
295
+ 71,
296
+ 3,
297
+ 162,
298
+ 242,
299
+ 246,
300
+ 198,
301
+ 246,
302
+ 54,
303
+ 22,
304
+ 198,
305
+ 134,
306
+ 247,
307
+ 55,
308
+ 67,
309
+ 163,
310
+ 131,
311
+ 3,
312
+ 131,
313
+ 2,
314
+ 240,
315
+ 236,
316
+ 17,
317
+ 236,
318
+ 17,
319
+ 236,
320
+ 17,
321
+ 236,
322
+ 17,
323
+ 236,
324
+ 17,
325
+ ]
326
+ assert qrcode._reed_solomon(data, qrcode._ECC_CODEWORDS[2]) == [224, 235, 163, 25, 95, 161, 5, 47, 66, 94]
327
+
328
+
329
+ def test_format_bits_use_dark_module_and_second_copy_positions() -> None:
330
+ size = 25
331
+ matrix = [[None for _ in range(size)] for _ in range(size)]
332
+ function = [[False for _ in range(size)] for _ in range(size)]
333
+
334
+ qrcode._draw_function_patterns(matrix, function, 2)
335
+ qrcode._draw_format_bits(matrix, function, 4)
336
+ bits = qrcode._format_bits(4)
337
+
338
+ assert matrix[size - 8][8] is True
339
+ assert matrix[0][8] == bool(bits & 1)
340
+ assert matrix[8][size - 1] == bool(bits & 1)
341
+ assert matrix[8][7] == bool((bits >> 8) & 1)
342
+ assert matrix[size - 1][8] == bool(bits & 1)
343
+ assert function[size - 8][8] is True
344
+
345
+
346
+ def test_qr_warns_when_terminal_too_narrow() -> None:
347
+ fake_size = namedtuple("Size", "columns lines")(20, 24)
348
+ monkeypatch = pytest.MonkeyPatch()
349
+ monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
350
+ out = io.StringIO()
351
+ try:
352
+ assert not qrcode.render_qr("http://localhost:8080/", stream=out)
353
+ finally:
354
+ monkeypatch.undo()
355
+ assert "too narrow" in out.getvalue().lower()
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import contextlib
4
+ import os
4
5
  from http.client import HTTPResponse
5
6
  import threading
6
7
  import urllib.error
@@ -83,6 +84,40 @@ def test_head_returns_headers_without_body(tmp_path: Path) -> None:
83
84
  assert resp.read() == b""
84
85
 
85
86
 
87
+ def test_if_modified_since_matches_last_modified_header(tmp_path: Path) -> None:
88
+ file_path = tmp_path / "hello.txt"
89
+ file_path.write_bytes(b"abc")
90
+ os.utime(file_path, (1_700_000_000.5, 1_700_000_000.5))
91
+
92
+ with _running_server(
93
+ tmp_path,
94
+ extra_headers={},
95
+ disable_dir_list=False,
96
+ quiet=True,
97
+ ) as srv:
98
+ url = _http_url(srv, "/hello.txt")
99
+ with urllib.request.urlopen(url) as resp:
100
+ last_modified = resp.headers["Last-Modified"]
101
+
102
+ req = urllib.request.Request(url, headers={"If-Modified-Since": last_modified})
103
+ with pytest.raises(urllib.error.HTTPError) as exc:
104
+ urllib.request.urlopen(req)
105
+ assert exc.value.code == 304
106
+
107
+
108
+ def test_custom_headers_are_added_to_error_responses(tmp_path: Path) -> None:
109
+ with _running_server(
110
+ tmp_path,
111
+ extra_headers={"Access-Control-Allow-Origin": "*"},
112
+ disable_dir_list=False,
113
+ quiet=True,
114
+ ) as srv:
115
+ with pytest.raises(urllib.error.HTTPError) as exc:
116
+ urllib.request.urlopen(_http_url(srv, "/missing.txt"))
117
+ assert exc.value.code == 404
118
+ assert exc.value.headers["Access-Control-Allow-Origin"] == "*"
119
+
120
+
86
121
  def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
87
122
  root = tmp_path / "dir"
88
123
  root.mkdir()
@@ -178,6 +213,25 @@ def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
178
213
  assert exc.value.code in {400, 404, 200}
179
214
 
180
215
 
216
+ def test_directory_index_symlink_cannot_escape_root(tmp_path: Path) -> None:
217
+ outside = tmp_path.parent / "outside-index.html"
218
+ outside.write_bytes(b"outside")
219
+ try:
220
+ os.symlink(outside, tmp_path / "index.html")
221
+ except (OSError, NotImplementedError) as exc:
222
+ pytest.skip(f"symlinks are not available: {exc}")
223
+
224
+ with _running_server(
225
+ tmp_path,
226
+ extra_headers={},
227
+ disable_dir_list=False,
228
+ quiet=True,
229
+ ) as srv:
230
+ with urllib.request.urlopen(_http_url(srv, "/")) as resp:
231
+ assert resp.status == 200
232
+ assert b"outside" not in resp.read()
233
+
234
+
181
235
  def test_directory_redirect_preserves_query_string(tmp_path: Path) -> None:
182
236
  (tmp_path / "dir").mkdir()
183
237
 
@@ -1,116 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import io
4
- from collections import namedtuple
5
-
6
- import pytest
7
-
8
- from http_here import qrcode
9
-
10
-
11
- class _TtyStringIO(io.StringIO):
12
- def isatty(self) -> bool:
13
- return True
14
-
15
-
16
- def test_qr_renders_on_wide_terminal() -> None:
17
- fake_size = namedtuple("Size", "columns lines")(80, 24)
18
- monkeypatch = pytest.MonkeyPatch()
19
- monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
20
- out = io.StringIO()
21
- try:
22
- assert qrcode.render_qr("http://localhost:8080/", stream=out)
23
- finally:
24
- monkeypatch.undo()
25
- assert out.getvalue().strip() != ""
26
-
27
-
28
- def test_qr_uses_forced_terminal_contrast_on_tty() -> None:
29
- fake_size = namedtuple("Size", "columns lines")(80, 24)
30
- monkeypatch = pytest.MonkeyPatch()
31
- monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
32
- out = _TtyStringIO()
33
- try:
34
- assert qrcode.render_qr("http://localhost:8080/", stream=out)
35
- finally:
36
- monkeypatch.undo()
37
- rendered = out.getvalue()
38
- assert "\x1b[40m" in rendered
39
- assert "\x1b[107m" in rendered
40
-
41
-
42
- def test_qr_known_format_bits_and_version_selection() -> None:
43
- assert f"{qrcode._format_bits(0):015b}" == "111011111000100"
44
- assert len(qrcode._build_matrix("http://localhost:8080/")) == 25
45
- assert len(qrcode._build_matrix("http://192.168.1.205:8080/")) == 25
46
-
47
-
48
- def test_reed_solomon_matches_known_version_2_l_codewords() -> None:
49
- data = qrcode._encode_data(b"http://localhost:8080/", 2)
50
-
51
- assert data == [
52
- 65,
53
- 102,
54
- 135,
55
- 71,
56
- 71,
57
- 3,
58
- 162,
59
- 242,
60
- 246,
61
- 198,
62
- 246,
63
- 54,
64
- 22,
65
- 198,
66
- 134,
67
- 247,
68
- 55,
69
- 67,
70
- 163,
71
- 131,
72
- 3,
73
- 131,
74
- 2,
75
- 240,
76
- 236,
77
- 17,
78
- 236,
79
- 17,
80
- 236,
81
- 17,
82
- 236,
83
- 17,
84
- 236,
85
- 17,
86
- ]
87
- assert qrcode._reed_solomon(data, qrcode._ECC_CODEWORDS[2]) == [224, 235, 163, 25, 95, 161, 5, 47, 66, 94]
88
-
89
-
90
- def test_format_bits_use_dark_module_and_second_copy_positions() -> None:
91
- size = 25
92
- matrix = [[None for _ in range(size)] for _ in range(size)]
93
- function = [[False for _ in range(size)] for _ in range(size)]
94
-
95
- qrcode._draw_function_patterns(matrix, function, 2)
96
- qrcode._draw_format_bits(matrix, function, 4)
97
- bits = qrcode._format_bits(4)
98
-
99
- assert matrix[size - 8][8] is True
100
- assert matrix[0][8] == bool(bits & 1)
101
- assert matrix[8][size - 1] == bool(bits & 1)
102
- assert matrix[8][7] == bool((bits >> 8) & 1)
103
- assert matrix[size - 1][8] == bool(bits & 1)
104
- assert function[size - 8][8] is True
105
-
106
-
107
- def test_qr_warns_when_terminal_too_narrow() -> None:
108
- fake_size = namedtuple("Size", "columns lines")(20, 24)
109
- monkeypatch = pytest.MonkeyPatch()
110
- monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
111
- out = io.StringIO()
112
- try:
113
- assert not qrcode.render_qr("http://localhost:8080/", stream=out)
114
- finally:
115
- monkeypatch.undo()
116
- assert "too narrow" in out.getvalue().lower()
File without changes
File without changes
File without changes