webget-cli 0.6.0__tar.gz → 0.7.2__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.
- {webget_cli-0.6.0 → webget_cli-0.7.2}/PKG-INFO +34 -1
- {webget_cli-0.6.0 → webget_cli-0.7.2}/README.md +31 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/pyproject.toml +4 -2
- webget_cli-0.7.2/tests/test_adversarial_auth.py +145 -0
- webget_cli-0.7.2/tests/test_adversarial_cache.py +179 -0
- webget_cli-0.7.2/tests/test_adversarial_concurrency.py +54 -0
- webget_cli-0.7.2/tests/test_adversarial_http.py +153 -0
- webget_cli-0.7.2/tests/test_adversarial_mcp.py +188 -0
- webget_cli-0.7.2/tests/test_adversarial_ssrf.py +170 -0
- webget_cli-0.7.2/tests/test_auth_review.py +109 -0
- webget_cli-0.7.2/tests/test_browser_ssrf.py +205 -0
- webget_cli-0.7.2/tests/test_cache_review.py +114 -0
- webget_cli-0.7.2/tests/test_concurrency_review.py +82 -0
- webget_cli-0.7.2/tests/test_firecrawl_policy.py +89 -0
- webget_cli-0.7.2/tests/test_integration_ladder.py +219 -0
- webget_cli-0.7.2/tests/test_mcp_leak_review.py +142 -0
- webget_cli-0.7.2/tests/test_mcp_server.py +102 -0
- webget_cli-0.7.2/tests/test_mcp_smoke.py +45 -0
- webget_cli-0.7.2/tests/test_security_review.py +192 -0
- webget_cli-0.7.2/tests/test_size_review.py +80 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/tests/test_webget.py +88 -9
- {webget_cli-0.6.0 → webget_cli-0.7.2}/webget_cli.egg-info/PKG-INFO +34 -1
- webget_cli-0.7.2/webget_cli.egg-info/SOURCES.txt +29 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/webget_cli.egg-info/entry_points.txt +1 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/webget_cli.egg-info/requires.txt +3 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/webget_cli.egg-info/top_level.txt +1 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/webget_cli.py +415 -98
- webget_cli-0.7.2/webget_mcp.py +151 -0
- webget_cli-0.6.0/webget_cli.egg-info/SOURCES.txt +0 -11
- {webget_cli-0.6.0 → webget_cli-0.7.2}/LICENSE +0 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/setup.cfg +0 -0
- {webget_cli-0.6.0 → webget_cli-0.7.2}/webget_cli.egg-info/dependency_links.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webget-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys.
|
|
5
5
|
Author-email: David Tarigan <tarigansdavid@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -25,6 +25,8 @@ Requires-Dist: trafilatura
|
|
|
25
25
|
Requires-Dist: html2text
|
|
26
26
|
Provides-Extra: browser
|
|
27
27
|
Requires-Dist: crawl4ai>=0.9; extra == "browser"
|
|
28
|
+
Provides-Extra: mcp
|
|
29
|
+
Requires-Dist: fastmcp>=2; extra == "mcp"
|
|
28
30
|
Provides-Extra: dev
|
|
29
31
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
32
|
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
@@ -210,6 +212,37 @@ fetches never collide. Failures are never cached.
|
|
|
210
212
|
> **Privacy note:** cached content is plaintext JSON on disk. If you fetch
|
|
211
213
|
> authenticated/personal pages, use `--no-cache`.
|
|
212
214
|
|
|
215
|
+
## MCP server
|
|
216
|
+
|
|
217
|
+
`webget_mcp.py` exposes the same ladder as an MCP server (`search`,
|
|
218
|
+
`fetch`, `search_fetch`), so agents like opencode can search and scrape
|
|
219
|
+
without API keys:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pip install "webget-cli[mcp]"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Register as a local MCP server in opencode:
|
|
226
|
+
|
|
227
|
+
```jsonc
|
|
228
|
+
{
|
|
229
|
+
"mcp": {
|
|
230
|
+
"webget": {
|
|
231
|
+
"type": "local",
|
|
232
|
+
"command": ["webget-mcp"],
|
|
233
|
+
"enabled": true
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
240
|
+
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
241
|
+
|
|
242
|
+
> **Limitation:** MCP tools do not expose `--profile`/`--cookies`, so
|
|
243
|
+
> authenticated pages are out of scope for the MCP server. Use the CLI
|
|
244
|
+
> (`webget login`, `webget u --profile ...`) for session-based fetching.
|
|
245
|
+
|
|
213
246
|
## Development
|
|
214
247
|
|
|
215
248
|
```bash
|
|
@@ -178,6 +178,37 @@ fetches never collide. Failures are never cached.
|
|
|
178
178
|
> **Privacy note:** cached content is plaintext JSON on disk. If you fetch
|
|
179
179
|
> authenticated/personal pages, use `--no-cache`.
|
|
180
180
|
|
|
181
|
+
## MCP server
|
|
182
|
+
|
|
183
|
+
`webget_mcp.py` exposes the same ladder as an MCP server (`search`,
|
|
184
|
+
`fetch`, `search_fetch`), so agents like opencode can search and scrape
|
|
185
|
+
without API keys:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
pip install "webget-cli[mcp]"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Register as a local MCP server in opencode:
|
|
192
|
+
|
|
193
|
+
```jsonc
|
|
194
|
+
{
|
|
195
|
+
"mcp": {
|
|
196
|
+
"webget": {
|
|
197
|
+
"type": "local",
|
|
198
|
+
"command": ["webget-mcp"],
|
|
199
|
+
"enabled": true
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Then prompt with `use webget` for search and scrape tasks. Run the server
|
|
206
|
+
standalone with `webget-mcp` (stdio transport) or `python webget_mcp.py`.
|
|
207
|
+
|
|
208
|
+
> **Limitation:** MCP tools do not expose `--profile`/`--cookies`, so
|
|
209
|
+
> authenticated pages are out of scope for the MCP server. Use the CLI
|
|
210
|
+
> (`webget login`, `webget u --profile ...`) for session-based fetching.
|
|
211
|
+
|
|
181
212
|
## Development
|
|
182
213
|
|
|
183
214
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webget-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Local search + scrape CLI with an HTTP fast path, optional browser fallback, and authenticated session profiles. Zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -35,13 +35,15 @@ Changelog = "https://github.com/DavidPandleton/webget/blob/main/CHANGELOG.md"
|
|
|
35
35
|
|
|
36
36
|
[project.optional-dependencies]
|
|
37
37
|
browser = ["crawl4ai>=0.9"]
|
|
38
|
+
mcp = ["fastmcp>=2"]
|
|
38
39
|
dev = ["pytest>=8", "ruff>=0.6"]
|
|
39
40
|
|
|
40
41
|
[project.scripts]
|
|
41
42
|
webget = "webget_cli:main"
|
|
43
|
+
webget-mcp = "webget_mcp:main"
|
|
42
44
|
|
|
43
45
|
[tool.setuptools]
|
|
44
|
-
py-modules = ["webget_cli"]
|
|
46
|
+
py-modules = ["webget_cli", "webget_mcp"]
|
|
45
47
|
|
|
46
48
|
[tool.pytest.ini_options]
|
|
47
49
|
testpaths = ["tests"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Adversarial auth + cookie isolation tests: profiles, storage state,
|
|
2
|
+
logout domain scoping, session reuse, corrupted state."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import webget_cli as webget
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def _fetch(url, **kw):
|
|
14
|
+
return await webget.scrape_many([url], max_chars=2000, no_cache=True, **kw)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _one(res):
|
|
18
|
+
return res[next(iter(res))]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cookie(name, domain, expires=-1):
|
|
22
|
+
return {
|
|
23
|
+
"name": name,
|
|
24
|
+
"value": "v",
|
|
25
|
+
"domain": domain,
|
|
26
|
+
"path": "/",
|
|
27
|
+
"secure": False,
|
|
28
|
+
"expires": expires,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestProfileSafety:
|
|
33
|
+
def test_profile_dir_rejects_traversal(self):
|
|
34
|
+
for name in ("../../etc", "..", ".", "a/b", "a\\b", "~evil", "", "/abs"):
|
|
35
|
+
with pytest.raises(SystemExit):
|
|
36
|
+
webget.profile_dir(name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestStorageState:
|
|
40
|
+
def test_load_profile_cookies_missing_file(self, isolated_env):
|
|
41
|
+
assert webget.load_profile_cookies("ghost") is None
|
|
42
|
+
|
|
43
|
+
def test_load_profile_cookies_corrupt(self, isolated_env):
|
|
44
|
+
p = webget.profile_state_path("campus")
|
|
45
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
46
|
+
with open(p, "w") as f:
|
|
47
|
+
f.write("{not json")
|
|
48
|
+
assert webget.load_profile_cookies("campus") is None
|
|
49
|
+
|
|
50
|
+
def test_load_profile_cookies_ok(self, isolated_env):
|
|
51
|
+
p = webget.profile_state_path("campus")
|
|
52
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
53
|
+
with open(p, "w") as f:
|
|
54
|
+
json.dump({"cookies": [_cookie("s", ".campus.example")]}, f)
|
|
55
|
+
ck = webget.load_profile_cookies("campus")
|
|
56
|
+
assert ck and ck[0]["name"] == "s"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestCookieDomainMatching:
|
|
60
|
+
def test_belongs_to_subdomains(self):
|
|
61
|
+
host = "campus.example"
|
|
62
|
+
assert webget._cookie_belongs_to("campus.example", host)
|
|
63
|
+
assert webget._cookie_belongs_to(".campus.example", host)
|
|
64
|
+
assert webget._cookie_belongs_to(".api.campus.example", host)
|
|
65
|
+
assert webget._cookie_belongs_to("api.campus.example", host)
|
|
66
|
+
# not unrelated, not suffix-trap
|
|
67
|
+
assert not webget._cookie_belongs_to("github.com", host)
|
|
68
|
+
assert not webget._cookie_belongs_to("notevil.com", host)
|
|
69
|
+
assert not webget._cookie_belongs_to("example.com.evil.com", host)
|
|
70
|
+
|
|
71
|
+
def test_domain_match_public(self):
|
|
72
|
+
assert webget._domain_match(".example.com", "api.example.com")
|
|
73
|
+
assert not webget._domain_match(".example.com", "example.com.evil.com")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestLogoutScoping:
|
|
77
|
+
def test_logout_prunes_only_target_domain(self, isolated_env):
|
|
78
|
+
"""Storage-state pruning: logout campus.example keeps github cookies."""
|
|
79
|
+
state = {
|
|
80
|
+
"cookies": [
|
|
81
|
+
_cookie("campus", ".campus.example"),
|
|
82
|
+
_cookie("api", ".api.campus.example"),
|
|
83
|
+
_cookie("gh", ".github.com"),
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
new_state, removed = webget._prune_storage_cookies(state, "campus.example")
|
|
87
|
+
assert removed == 2
|
|
88
|
+
domains = {c["domain"] for c in new_state["cookies"]}
|
|
89
|
+
assert ".campus.example" not in domains
|
|
90
|
+
assert ".api.campus.example" not in domains
|
|
91
|
+
assert ".github.com" in domains # unrelated survives
|
|
92
|
+
|
|
93
|
+
def test_logout_preserves_unrelated_domains_file(self, isolated_env):
|
|
94
|
+
"""End-to-end through _write_json: the file on disk is updated."""
|
|
95
|
+
state = {"cookies": [_cookie("a", ".a.com"), _cookie("b", ".b.com")]}
|
|
96
|
+
p = webget.profile_state_path("work")
|
|
97
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
98
|
+
with open(p, "w") as f:
|
|
99
|
+
json.dump(state, f)
|
|
100
|
+
with open(p) as f:
|
|
101
|
+
loaded = json.load(f)
|
|
102
|
+
new_state, removed = webget._prune_storage_cookies(loaded, "a.com")
|
|
103
|
+
assert removed == 1
|
|
104
|
+
domains = {c["domain"] for c in new_state["cookies"]}
|
|
105
|
+
assert ".a.com" not in domains and ".b.com" in domains
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestSessionReuse:
|
|
109
|
+
def test_profile_cookies_sent_to_gated_page(self, fresh_cache, isolated_env):
|
|
110
|
+
server = fresh_cache
|
|
111
|
+
"""Cookie-gated /cookie-gated returns 403 without a cookie, success with."""
|
|
112
|
+
res = asyncio.run(_fetch(server.url("/cookie-gated")))
|
|
113
|
+
assert _one(res)["status"] == "blocked"
|
|
114
|
+
|
|
115
|
+
# seed a profile with a cookie for the server host
|
|
116
|
+
host = server.host
|
|
117
|
+
p = webget.profile_state_path("local")
|
|
118
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
119
|
+
with open(p, "w") as f:
|
|
120
|
+
json.dump({"cookies": [_cookie("session", host)]}, f)
|
|
121
|
+
|
|
122
|
+
res = asyncio.run(_fetch(server.url("/cookie-gated"), profile="local"))
|
|
123
|
+
assert _one(res)["status"] == "success"
|
|
124
|
+
|
|
125
|
+
def test_explicit_cookies_win_over_profile(self, fresh_cache, isolated_env):
|
|
126
|
+
server = fresh_cache
|
|
127
|
+
ck = [_cookie("session", server.host)]
|
|
128
|
+
res = asyncio.run(_fetch(server.url("/cookie-gated"), cookies=ck))
|
|
129
|
+
assert _one(res)["status"] == "success"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestProfileMeta:
|
|
133
|
+
def test_corrupt_state_reports_corrupt(self, isolated_env):
|
|
134
|
+
d = webget.profile_dir("broken")
|
|
135
|
+
os.makedirs(d, exist_ok=True)
|
|
136
|
+
with open(webget.profile_state_path("broken"), "w") as f:
|
|
137
|
+
f.write("{{{{")
|
|
138
|
+
assert webget._profile_meta("broken")["status"] == "corrupt"
|
|
139
|
+
|
|
140
|
+
def test_expired_cookies_reported_expired(self, isolated_env):
|
|
141
|
+
d = webget.profile_dir("old")
|
|
142
|
+
os.makedirs(d, exist_ok=True)
|
|
143
|
+
with open(webget.profile_state_path("old"), "w") as f:
|
|
144
|
+
json.dump({"cookies": [_cookie("s", ".x.com", expires=1000000000)]}, f)
|
|
145
|
+
assert webget._profile_meta("old")["status"] == "expired"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Adversarial cache tests: isolation keys, corruption, concurrency, atomicity."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import webget_cli as webget
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestCacheKeys:
|
|
12
|
+
def test_profile_isolation(self):
|
|
13
|
+
a = webget._cache_path("https://x.com", None, None, 1000, "p1")
|
|
14
|
+
b = webget._cache_path("https://x.com", None, None, 1000, "p2")
|
|
15
|
+
assert a != b
|
|
16
|
+
|
|
17
|
+
def test_anonymous_vs_profile(self):
|
|
18
|
+
a = webget._cache_path("https://x.com", None, None, 1000, None)
|
|
19
|
+
b = webget._cache_path("https://x.com", None, None, 1000, "p1")
|
|
20
|
+
assert a != b
|
|
21
|
+
|
|
22
|
+
def test_headers_in_key(self):
|
|
23
|
+
a = webget._cache_path("https://x.com", None, {"A": "1"}, 1000, None)
|
|
24
|
+
b = webget._cache_path("https://x.com", None, {"A": "2"}, 1000, None)
|
|
25
|
+
assert a != b
|
|
26
|
+
|
|
27
|
+
def test_cookies_in_key(self):
|
|
28
|
+
ck = [{"name": "s", "value": "v", "domain": "x.com"}]
|
|
29
|
+
a = webget._cache_path("https://x.com", None, None, 1000, None)
|
|
30
|
+
b = webget._cache_path("https://x.com", ck, None, 1000, None)
|
|
31
|
+
assert a != b
|
|
32
|
+
|
|
33
|
+
def test_cookie_order_independent(self):
|
|
34
|
+
c1 = [
|
|
35
|
+
{"name": "a", "value": "1", "domain": "x.com"},
|
|
36
|
+
{"name": "b", "value": "2", "domain": "x.com"},
|
|
37
|
+
]
|
|
38
|
+
c2 = [
|
|
39
|
+
{"name": "b", "value": "2", "domain": "x.com"},
|
|
40
|
+
{"name": "a", "value": "1", "domain": "x.com"},
|
|
41
|
+
]
|
|
42
|
+
assert webget._cache_path("https://x.com", c1, None, 1000, None) == webget._cache_path(
|
|
43
|
+
"https://x.com", c2, None, 1000, None
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def test_max_chars_in_key(self):
|
|
47
|
+
assert webget._cache_path("https://x.com", None, None, 500, None) != webget._cache_path(
|
|
48
|
+
"https://x.com", None, None, 900, None
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestCacheCorruption:
|
|
53
|
+
def test_corrupt_cache_returns_none(self, isolated_env):
|
|
54
|
+
p = webget._cache_path("https://x.com", None, None, 1000, None)
|
|
55
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
56
|
+
with open(p, "w") as f:
|
|
57
|
+
f.write("{oops")
|
|
58
|
+
assert webget.cache_get("https://x.com", None, None, 1000, 3600) is None
|
|
59
|
+
|
|
60
|
+
def test_expired_cache_returns_none(self, isolated_env):
|
|
61
|
+
p = webget._cache_path("https://x.com", None, None, 1000, None)
|
|
62
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
63
|
+
with open(p, "w") as f:
|
|
64
|
+
json.dump({"status": "success"}, f)
|
|
65
|
+
os.utime(p, (time.time() - 7200, time.time() - 7200)) # 2h old, ttl 3600
|
|
66
|
+
assert webget.cache_get("https://x.com", None, None, 1000, 3600) is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestCacheConcurrency:
|
|
70
|
+
def test_concurrent_writes_never_corrupt(self, isolated_env):
|
|
71
|
+
"""20 threads writing the same cache entry: file must stay valid JSON."""
|
|
72
|
+
url = "https://concurrent.test/page"
|
|
73
|
+
results = []
|
|
74
|
+
|
|
75
|
+
def writer(i):
|
|
76
|
+
try:
|
|
77
|
+
webget.cache_put(url, None, None, 1000, {"status": "success", "i": i}, None)
|
|
78
|
+
results.append(("ok", i))
|
|
79
|
+
except Exception as e: # noqa: BLE001
|
|
80
|
+
results.append(("err", str(e)))
|
|
81
|
+
|
|
82
|
+
threads = [threading.Thread(target=writer, args=(i,)) for i in range(20)]
|
|
83
|
+
for t in threads:
|
|
84
|
+
t.start()
|
|
85
|
+
for t in threads:
|
|
86
|
+
t.join()
|
|
87
|
+
|
|
88
|
+
assert not [r for r in results if r[0] == "err"]
|
|
89
|
+
p = webget._cache_path(url, None, None, 1000, None)
|
|
90
|
+
with open(p) as f:
|
|
91
|
+
data = json.load(f) # must parse
|
|
92
|
+
assert data["status"] == "success"
|
|
93
|
+
|
|
94
|
+
def test_concurrent_writers_use_unique_tmp(self, isolated_env):
|
|
95
|
+
"""Deterministic regression (real bug caught 2026-08-08): concurrent
|
|
96
|
+
writers must NEVER share a tmp path. A shared '<path>.tmp' lets
|
|
97
|
+
threads interleave bytes in one file, so the atomic rename publishes
|
|
98
|
+
a corrupt document (observed as 'Extra data' JSONDecodeError). This
|
|
99
|
+
spies on os.replace instead of hoping the race window opens."""
|
|
100
|
+
url = "https://unique-tmp.test/page"
|
|
101
|
+
real_replace = os.replace
|
|
102
|
+
seen = []
|
|
103
|
+
lock = threading.Lock()
|
|
104
|
+
|
|
105
|
+
def spy(src, dst):
|
|
106
|
+
with lock:
|
|
107
|
+
seen.append(src)
|
|
108
|
+
return real_replace(src, dst)
|
|
109
|
+
|
|
110
|
+
os.replace = spy
|
|
111
|
+
try:
|
|
112
|
+
threads = [
|
|
113
|
+
threading.Thread(
|
|
114
|
+
target=webget.cache_put,
|
|
115
|
+
args=(url, None, None, 1000, {"status": "success", "i": i}, None),
|
|
116
|
+
)
|
|
117
|
+
for i in range(20)
|
|
118
|
+
]
|
|
119
|
+
for t in threads:
|
|
120
|
+
t.start()
|
|
121
|
+
for t in threads:
|
|
122
|
+
t.join()
|
|
123
|
+
finally:
|
|
124
|
+
os.replace = real_replace
|
|
125
|
+
|
|
126
|
+
final = webget._cache_path(url, None, None, 1000, None)
|
|
127
|
+
assert len(seen) == 20, f"expected 20 renames, got {len(seen)}"
|
|
128
|
+
assert len(set(seen)) == 20, "concurrent writers shared a tmp path"
|
|
129
|
+
for src in seen:
|
|
130
|
+
assert src.endswith(".tmp")
|
|
131
|
+
assert src != final
|
|
132
|
+
leftover = [f for f in os.listdir(isolated_env["cache"]) if f.endswith(".tmp")]
|
|
133
|
+
assert leftover == [], f"leftover tmp files: {leftover}"
|
|
134
|
+
|
|
135
|
+
def test_no_partial_file_on_write(self, isolated_env):
|
|
136
|
+
"""Atomic write: cache_put must write via tmp + os.replace, so a
|
|
137
|
+
crash mid-write can never leave a truncated file at the real path."""
|
|
138
|
+
|
|
139
|
+
url = "https://atomic.test/page"
|
|
140
|
+
real_replace = os.replace
|
|
141
|
+
calls = []
|
|
142
|
+
|
|
143
|
+
def spy(src, dst):
|
|
144
|
+
calls.append((src, dst))
|
|
145
|
+
return real_replace(src, dst)
|
|
146
|
+
|
|
147
|
+
os.replace = spy
|
|
148
|
+
try:
|
|
149
|
+
webget.cache_put(url, None, None, 1000, {"status": "success", "x": "y" * 5000}, None)
|
|
150
|
+
finally:
|
|
151
|
+
os.replace = real_replace
|
|
152
|
+
|
|
153
|
+
assert calls, "cache_put must use os.replace (tmp + rename)"
|
|
154
|
+
tmp, dst = calls[0]
|
|
155
|
+
assert tmp.endswith(".tmp")
|
|
156
|
+
assert dst == webget._cache_path(url, None, None, 1000, None)
|
|
157
|
+
with open(dst) as f:
|
|
158
|
+
assert json.load(f)["x"] == "y" * 5000
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestCacheBehavior:
|
|
162
|
+
def test_hit_returns_normalized(self, isolated_env):
|
|
163
|
+
url = "https://cache.test/a"
|
|
164
|
+
webget.cache_put(
|
|
165
|
+
url, None, None, 1000, {"status": "success", "markdown": "hello world"}, None
|
|
166
|
+
)
|
|
167
|
+
hit = webget.cache_get(url, None, None, 1000, 3600)
|
|
168
|
+
assert hit is not None and hit["status"] == "success"
|
|
169
|
+
|
|
170
|
+
def test_failures_never_cached(self, isolated_env):
|
|
171
|
+
# cache_put is only called by record() on success; verify no path writes
|
|
172
|
+
# an error entry by calling with a marker.
|
|
173
|
+
url = "https://cache.test/err"
|
|
174
|
+
webget.cache_put(url, None, None, 1000, {"status": "error", "markdown": ""}, None)
|
|
175
|
+
hit = webget.cache_get(url, None, None, 1000, 3600)
|
|
176
|
+
# Best-effort cache: even error entries are storable; the ladder
|
|
177
|
+
# prevents them from being written. This test documents the contract:
|
|
178
|
+
# cache stores whatever record() passed it.
|
|
179
|
+
assert hit is None or hit["status"] == "error" # tolerated
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Adversarial concurrency tests: bounded parallelism, duplicate URLs,
|
|
2
|
+
shared-state races in scrape_many."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
import webget_cli as webget
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _fetch_many(urls, **kw):
|
|
10
|
+
return await webget.scrape_many(urls, max_chars=2000, no_cache=True, **kw)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestBoundedConcurrency:
|
|
14
|
+
def test_http_concurrency_is_bounded(self, server, fresh_cache, monkeypatch):
|
|
15
|
+
"""100 unique URLs against /concurrency must never exceed the cap."""
|
|
16
|
+
urls = [server.url(f"/concurrency?i={i}") for i in range(100)]
|
|
17
|
+
server.reset_counters()
|
|
18
|
+
res = asyncio.run(_fetch_many(urls, per_url_timeout=10))
|
|
19
|
+
assert len(res) == 100
|
|
20
|
+
cap = webget._DEFAULT_CONCURRENCY
|
|
21
|
+
assert server.max_active <= cap, f"observed {server.max_active} concurrent, cap {cap}"
|
|
22
|
+
|
|
23
|
+
def test_custom_concurrency_cap(self, server, fresh_cache, monkeypatch):
|
|
24
|
+
"""max_concurrency=3 must be honored."""
|
|
25
|
+
urls = [server.url(f"/concurrency?i={i}") for i in range(30)]
|
|
26
|
+
server.reset_counters()
|
|
27
|
+
res = asyncio.run(_fetch_many(urls, per_url_timeout=10, max_concurrency=3))
|
|
28
|
+
assert len(res) == 30
|
|
29
|
+
assert server.max_active <= 3, f"observed {server.max_active}, cap 3"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestDuplicateURLs:
|
|
33
|
+
def test_duplicate_urls_fetched_once(self, server, fresh_cache):
|
|
34
|
+
"""[u, u] must produce ONE attempt per URL, not duplicate work."""
|
|
35
|
+
url = server.url("/normal")
|
|
36
|
+
res = asyncio.run(_fetch_many([url, url]))
|
|
37
|
+
out = res[url]
|
|
38
|
+
assert out["attempts"] == 1
|
|
39
|
+
assert len(res) == 1 # deduped key
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestBatchScaling:
|
|
43
|
+
def test_batch_200_mixed(self, server, fresh_cache):
|
|
44
|
+
"""Mixed batch (success, login, blocked, error) all complete."""
|
|
45
|
+
paths = ["/normal", "/login", "/403", "/429", "/thin", "/redirect"]
|
|
46
|
+
urls = [server.url(p) for p in paths] * 34 # 204 URLs
|
|
47
|
+
res = asyncio.run(_fetch_many(urls, per_url_timeout=10))
|
|
48
|
+
assert len(res) == len(set(urls))
|
|
49
|
+
statuses = {v["status"] for v in res.values()}
|
|
50
|
+
assert "success" in statuses
|
|
51
|
+
assert {"login_required", "blocked", "error"} & statuses
|
|
52
|
+
|
|
53
|
+
def test_empty_batch_returns_empty(self):
|
|
54
|
+
assert asyncio.run(_fetch_many([])) == {}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Adversarial HTTP tests: URL handling, status codes, redirects, bodies."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import webget_cli as webget
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _fetch(url, **kw):
|
|
11
|
+
return await webget.scrape_many([url], no_cache=True, **kw)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _one(res):
|
|
15
|
+
return res[next(iter(res))]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------- URL handling ----------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestInvalidURLs:
|
|
22
|
+
@pytest.mark.parametrize(
|
|
23
|
+
"bad",
|
|
24
|
+
[
|
|
25
|
+
"not a url",
|
|
26
|
+
"://:",
|
|
27
|
+
"",
|
|
28
|
+
"ftp://example.com/file",
|
|
29
|
+
"file:///etc/passwd",
|
|
30
|
+
"data:text/html,hello",
|
|
31
|
+
"javascript:alert(1)",
|
|
32
|
+
"http://",
|
|
33
|
+
"http://exa mple.com",
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
def test_invalid_urls_do_not_crash(self, bad):
|
|
37
|
+
res = asyncio.run(_fetch(bad))
|
|
38
|
+
out = _one(res)
|
|
39
|
+
assert out["status"] in ("error", "blocked")
|
|
40
|
+
assert "method" in out
|
|
41
|
+
|
|
42
|
+
def test_no_scheme_added_by_httpx(self):
|
|
43
|
+
# httpx refuses scheme-less URLs; ladder must record it as error.
|
|
44
|
+
res = asyncio.run(_fetch("example.com"))
|
|
45
|
+
assert _one(res)["status"] == "error"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestConnectionFailures:
|
|
49
|
+
def test_connection_refused(self):
|
|
50
|
+
res = asyncio.run(_fetch("http://127.0.0.1:1/", per_url_timeout=5))
|
|
51
|
+
out = _one(res)
|
|
52
|
+
assert out["status"] == "error"
|
|
53
|
+
assert "method" in out # any method is fine; must not crash
|
|
54
|
+
|
|
55
|
+
def test_dns_failure(self):
|
|
56
|
+
res = asyncio.run(
|
|
57
|
+
_fetch("http://nonexistent-host-webget-audit.invalid/", per_url_timeout=8)
|
|
58
|
+
)
|
|
59
|
+
assert _one(res)["status"] == "error"
|
|
60
|
+
|
|
61
|
+
def test_timeout(self, fresh_cache):
|
|
62
|
+
server = fresh_cache
|
|
63
|
+
res = asyncio.run(_fetch(server.url("/timeout?sec=5"), per_url_timeout=1))
|
|
64
|
+
assert _one(res)["status"] == "error"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------- status codes ----------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestStatusCodes:
|
|
71
|
+
@pytest.mark.parametrize("path,expected", [("/401", "login_required"), ("/403", "blocked")])
|
|
72
|
+
def test_auth_statuses(self, fresh_cache, path, expected):
|
|
73
|
+
server = fresh_cache
|
|
74
|
+
res = asyncio.run(_fetch(server.url(path)))
|
|
75
|
+
assert _one(res)["status"] == expected
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize("path", ["/404", "/500", "/429"])
|
|
78
|
+
def test_error_statuses(self, fresh_cache, path):
|
|
79
|
+
server = fresh_cache
|
|
80
|
+
res = asyncio.run(_fetch(server.url(path)))
|
|
81
|
+
assert _one(res)["status"] in ("error", "blocked") # 429 -> blocked
|
|
82
|
+
|
|
83
|
+
def test_403_login_words_login_required(self, fresh_cache):
|
|
84
|
+
server = fresh_cache
|
|
85
|
+
res = asyncio.run(_fetch(server.url("/403-login")))
|
|
86
|
+
assert _one(res)["status"] == "login_required"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------- redirects ----------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestRedirects:
|
|
93
|
+
@pytest.mark.parametrize(
|
|
94
|
+
"path", ["/redirect", "/redirect-301", "/redirect-307", "/redirect-308"]
|
|
95
|
+
)
|
|
96
|
+
def test_redirects_followed(self, fresh_cache, path):
|
|
97
|
+
server = fresh_cache
|
|
98
|
+
res = asyncio.run(_fetch(server.url(path)))
|
|
99
|
+
out = _one(res)
|
|
100
|
+
assert out["status"] == "success", out.get("error")
|
|
101
|
+
|
|
102
|
+
def test_redirect_chain(self, fresh_cache):
|
|
103
|
+
server = fresh_cache
|
|
104
|
+
res = asyncio.run(_fetch(server.url("/redirect-chain?n=5")))
|
|
105
|
+
assert _one(res)["status"] == "success"
|
|
106
|
+
|
|
107
|
+
def test_redirect_loop_errors(self, fresh_cache):
|
|
108
|
+
server = fresh_cache
|
|
109
|
+
res = asyncio.run(_fetch(server.url("/redirect-loop")))
|
|
110
|
+
out = _one(res)
|
|
111
|
+
# httpx raises TooManyRedirects after default max; must be error, not hang.
|
|
112
|
+
assert out["status"] == "error"
|
|
113
|
+
assert "redirect" in (out.get("error") or "").lower() or out["status"] == "error"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------- response bodies ----------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestResponseBodies:
|
|
120
|
+
def test_empty_response(self, fresh_cache):
|
|
121
|
+
server = fresh_cache
|
|
122
|
+
res = asyncio.run(_fetch(server.url("/empty")))
|
|
123
|
+
# empty body -> markdown empty -> "content too thin" -> error terminal
|
|
124
|
+
assert _one(res)["status"] in ("error", "blocked")
|
|
125
|
+
|
|
126
|
+
def test_malformed_html(self, fresh_cache):
|
|
127
|
+
server = fresh_cache
|
|
128
|
+
res = asyncio.run(_fetch(server.url("/malformed")))
|
|
129
|
+
# html2text usually still extracts something; at minimum no crash
|
|
130
|
+
assert "status" in _one(res)
|
|
131
|
+
|
|
132
|
+
def test_binary_response(self, fresh_cache):
|
|
133
|
+
server = fresh_cache
|
|
134
|
+
res = asyncio.run(_fetch(server.url("/binary")))
|
|
135
|
+
assert _one(res)["status"] in ("error", "blocked", "success")
|
|
136
|
+
|
|
137
|
+
def test_json_response(self, fresh_cache):
|
|
138
|
+
server = fresh_cache
|
|
139
|
+
res = asyncio.run(_fetch(server.url("/json")))
|
|
140
|
+
assert _one(res)["status"] in ("error", "blocked", "success")
|
|
141
|
+
|
|
142
|
+
def test_gzip_response(self, fresh_cache):
|
|
143
|
+
server = fresh_cache
|
|
144
|
+
res = asyncio.run(_fetch(server.url("/gzip")))
|
|
145
|
+
assert _one(res)["status"] == "success" # httpx auto-decompresses
|
|
146
|
+
|
|
147
|
+
def test_huge_response_is_bounded(self, fresh_cache):
|
|
148
|
+
server = fresh_cache
|
|
149
|
+
# 5MB body; scrape_many must truncate, not blow memory or hang.
|
|
150
|
+
res = asyncio.run(_fetch(server.url("/huge"), max_chars=1000))
|
|
151
|
+
out = _one(res)
|
|
152
|
+
assert out["status"] == "success"
|
|
153
|
+
assert len(out["markdown"]) <= 1000
|