static-http 0.1.4__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.
- {static_http-0.1.4/src/static_http.egg-info → static_http-1.0.0}/PKG-INFO +1 -1
- {static_http-0.1.4 → static_http-1.0.0}/pyproject.toml +1 -1
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/__init__.py +1 -1
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/cli.py +1 -1
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/keyboard.py +5 -5
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/qrcode.py +35 -65
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/server.py +25 -11
- {static_http-0.1.4 → static_http-1.0.0/src/static_http.egg-info}/PKG-INFO +1 -1
- static_http-1.0.0/tests/test_qrcode.py +355 -0
- {static_http-0.1.4 → static_http-1.0.0}/tests/test_server.py +54 -0
- static_http-0.1.4/tests/test_qrcode.py +0 -57
- {static_http-0.1.4 → static_http-1.0.0}/LICENSE +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/README.md +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/setup.cfg +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/__main__.py +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/ranges.py +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/http_here/urls.py +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/static_http.egg-info/SOURCES.txt +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/static_http.egg-info/dependency_links.txt +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/static_http.egg-info/entry_points.txt +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/static_http.egg-info/requires.txt +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/src/static_http.egg-info/top_level.txt +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/tests/test_cli.py +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/tests/test_ranges.py +0 -0
- {static_http-0.1.4 → static_http-1.0.0}/tests/test_urls.py +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-
|
|
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-
|
|
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-
|
|
84
|
+
thread = threading.Thread(target=_watch, name="static-http-keyboard", daemon=True)
|
|
85
85
|
thread.start()
|
|
86
86
|
return stop_event, True
|
|
@@ -36,37 +36,28 @@ def _gf_mul(x: int, y: int) -> int:
|
|
|
36
36
|
return result
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def _gf_pow(x: int, power: int) -> int:
|
|
40
|
-
result = 1
|
|
41
|
-
for _ in range(power):
|
|
42
|
-
result = _gf_mul(result, x)
|
|
43
|
-
return result
|
|
44
|
-
|
|
45
|
-
|
|
46
39
|
def _generator_poly(degree: int) -> list[int]:
|
|
47
|
-
result = [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
for j, right_value in enumerate(right):
|
|
57
|
-
result[i + j] ^= _gf_mul(left_value, right_value)
|
|
40
|
+
result = [0] * degree
|
|
41
|
+
result[-1] = 1
|
|
42
|
+
root = 1
|
|
43
|
+
for _ in range(degree):
|
|
44
|
+
for i in range(degree):
|
|
45
|
+
result[i] = _gf_mul(result[i], root)
|
|
46
|
+
if i + 1 < degree:
|
|
47
|
+
result[i] ^= result[i + 1]
|
|
48
|
+
root = _gf_mul(root, 0x02)
|
|
58
49
|
return result
|
|
59
50
|
|
|
60
51
|
|
|
61
52
|
def _reed_solomon(data: list[int], degree: int) -> list[int]:
|
|
62
|
-
|
|
63
|
-
result =
|
|
64
|
-
for
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
for
|
|
68
|
-
result[i
|
|
69
|
-
return result
|
|
53
|
+
divisor = _generator_poly(degree)
|
|
54
|
+
result = [0] * degree
|
|
55
|
+
for byte in data:
|
|
56
|
+
factor = byte ^ result.pop(0)
|
|
57
|
+
result.append(0)
|
|
58
|
+
for i, coefficient in enumerate(divisor):
|
|
59
|
+
result[i] ^= _gf_mul(coefficient, factor)
|
|
60
|
+
return result
|
|
70
61
|
|
|
71
62
|
|
|
72
63
|
def _append_bits(bits: list[int], value: int, length: int) -> None:
|
|
@@ -139,7 +130,6 @@ def _draw_function_patterns(matrix: list[list[bool | None]], function: list[list
|
|
|
139
130
|
_set_function(matrix, function, 6, i, i % 2 == 0)
|
|
140
131
|
_set_function(matrix, function, i, 6, i % 2 == 0)
|
|
141
132
|
|
|
142
|
-
_set_function(matrix, function, size - 8, 8, True)
|
|
143
133
|
_draw_format_bits(matrix, function, 0)
|
|
144
134
|
|
|
145
135
|
|
|
@@ -199,44 +189,24 @@ def _format_bits(mask: int) -> int:
|
|
|
199
189
|
def _draw_format_bits(matrix: list[list[bool | None]], function: list[list[bool]], mask: int) -> None:
|
|
200
190
|
bits = _format_bits(mask)
|
|
201
191
|
size = len(matrix)
|
|
202
|
-
|
|
203
|
-
(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
(size - 1, 8),
|
|
221
|
-
(size - 2, 8),
|
|
222
|
-
(size - 3, 8),
|
|
223
|
-
(size - 4, 8),
|
|
224
|
-
(size - 5, 8),
|
|
225
|
-
(size - 6, 8),
|
|
226
|
-
(size - 7, 8),
|
|
227
|
-
(8, size - 8),
|
|
228
|
-
(8, size - 7),
|
|
229
|
-
(8, size - 6),
|
|
230
|
-
(8, size - 5),
|
|
231
|
-
(8, size - 4),
|
|
232
|
-
(8, size - 3),
|
|
233
|
-
(8, size - 2),
|
|
234
|
-
(8, size - 1),
|
|
235
|
-
]
|
|
236
|
-
for i, (row, col) in enumerate(positions_a):
|
|
237
|
-
_set_function(matrix, function, row, col, ((bits >> i) & 1) == 1)
|
|
238
|
-
for i, (row, col) in enumerate(positions_b):
|
|
239
|
-
_set_function(matrix, function, row, col, ((bits >> i) & 1) == 1)
|
|
192
|
+
for i in range(15):
|
|
193
|
+
dark = ((bits >> i) & 1) == 1
|
|
194
|
+
|
|
195
|
+
if i < 6:
|
|
196
|
+
_set_function(matrix, function, i, 8, dark)
|
|
197
|
+
elif i < 8:
|
|
198
|
+
_set_function(matrix, function, i + 1, 8, dark)
|
|
199
|
+
else:
|
|
200
|
+
_set_function(matrix, function, size - 15 + i, 8, dark)
|
|
201
|
+
|
|
202
|
+
if i < 8:
|
|
203
|
+
_set_function(matrix, function, 8, size - i - 1, dark)
|
|
204
|
+
elif i == 8:
|
|
205
|
+
_set_function(matrix, function, 8, 7, dark)
|
|
206
|
+
else:
|
|
207
|
+
_set_function(matrix, function, 8, 14 - i, dark)
|
|
208
|
+
|
|
209
|
+
_set_function(matrix, function, size - 8, 8, True)
|
|
240
210
|
|
|
241
211
|
|
|
242
212
|
def _penalty(matrix: list[list[bool | None]]) -> int:
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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
|
|
|
@@ -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,57 +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_qr_warns_when_terminal_too_narrow() -> None:
|
|
49
|
-
fake_size = namedtuple("Size", "columns lines")(20, 24)
|
|
50
|
-
monkeypatch = pytest.MonkeyPatch()
|
|
51
|
-
monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
|
|
52
|
-
out = io.StringIO()
|
|
53
|
-
try:
|
|
54
|
-
assert not qrcode.render_qr("http://localhost:8080/", stream=out)
|
|
55
|
-
finally:
|
|
56
|
-
monkeypatch.undo()
|
|
57
|
-
assert "too narrow" in out.getvalue().lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|