withcache 0.8.4__tar.gz → 0.8.6__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.
- {withcache-0.8.4 → withcache-0.8.6}/PKG-INFO +1 -1
- {withcache-0.8.4 → withcache-0.8.6}/deploy/Containerfile +9 -1
- {withcache-0.8.4 → withcache-0.8.6}/shim/build.zig.zon +1 -1
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/__init__.py +1 -1
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/server.py +22 -3
- {withcache-0.8.4 → withcache-0.8.6}/tests/test_withcache.py +89 -0
- {withcache-0.8.4 → withcache-0.8.6}/.gitignore +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/LICENSE +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/README.md +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/deploy/compose.yml +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/hatch_build.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/pyproject.toml +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/shim/build.zig +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/shim/shim.zig +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/_shim.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/client.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/oras.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/tests/test_differential.py +0 -0
- {withcache-0.8.4 → withcache-0.8.6}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.6
|
|
4
4
|
Summary: Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)
|
|
5
5
|
Project-URL: Homepage, https://github.com/safl/withcache
|
|
6
6
|
Author-email: "Simon A. F. Lund" <safl@safl.dk>
|
|
@@ -12,6 +12,14 @@ COPY pyproject.toml README.md hatch_build.py /app/
|
|
|
12
12
|
COPY src /app/src
|
|
13
13
|
RUN pip install --no-cache-dir /app
|
|
14
14
|
|
|
15
|
+
# curl: HEALTHCHECK below. ``-f`` maps any 4xx/5xx to a non-zero
|
|
16
|
+
# exit without dumping a Traceback, matching the nbdmux sibling's
|
|
17
|
+
# Pass 5 pattern; if a future /healthz ever grows a non-200 branch
|
|
18
|
+
# (e.g. cache-disk-full check), no log-noise regression.
|
|
19
|
+
RUN apt-get update \
|
|
20
|
+
&& apt-get install -y --no-install-recommends curl \
|
|
21
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
22
|
+
|
|
15
23
|
# Run as non-root; /data is the persistent volume for blobs + sqlite.
|
|
16
24
|
RUN useradd --create-home --uid 10001 app \
|
|
17
25
|
&& mkdir -p /data && chown app:app /data
|
|
@@ -25,6 +33,6 @@ VOLUME ["/data"]
|
|
|
25
33
|
# with WITHCACHE_SESSION_SECRET.
|
|
26
34
|
|
|
27
35
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
|
28
|
-
CMD
|
|
36
|
+
CMD curl -fsS -o /dev/null --max-time 2 http://127.0.0.1:8081/healthz || exit 1
|
|
29
37
|
|
|
30
38
|
ENTRYPOINT ["withcache-server", "--host", "0.0.0.0", "--port", "8081", "--data-dir", "/data"]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.name = .withcache_shim,
|
|
3
3
|
// Zig requires a literal here; keep it in lockstep with the project's
|
|
4
4
|
// single source (src/withcache/__init__.py) via `make bump` / `make version-check`.
|
|
5
|
-
.version = "0.8.
|
|
5
|
+
.version = "0.8.6",
|
|
6
6
|
.fingerprint = 0xd7d96c5ed212ccaa,
|
|
7
7
|
.minimum_zig_version = "0.16.0",
|
|
8
8
|
.paths = .{
|
|
@@ -17,6 +17,6 @@ All modules are stdlib-only and self-contained.
|
|
|
17
17
|
from . import oras
|
|
18
18
|
from .client import blob_url, cache_base, is_cached, serve_url
|
|
19
19
|
|
|
20
|
-
__version__ = "0.8.
|
|
20
|
+
__version__ = "0.8.6"
|
|
21
21
|
|
|
22
22
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -1087,7 +1087,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1087
1087
|
|
|
1088
1088
|
def do_POST(self):
|
|
1089
1089
|
parsed = urllib.parse.urlsplit(self.path)
|
|
1090
|
-
|
|
1090
|
+
try:
|
|
1091
|
+
form = self.read_form()
|
|
1092
|
+
except ValueError:
|
|
1093
|
+
# Duplicate field(s) -- reject with 400 rather than let
|
|
1094
|
+
# a first-value-wins collapse hide a payload silently.
|
|
1095
|
+
self.send_text(400, "malformed form (duplicate fields?)\n")
|
|
1096
|
+
return
|
|
1091
1097
|
if parsed.path == "/ui/login":
|
|
1092
1098
|
self.handle_login_submit(form)
|
|
1093
1099
|
elif parsed.path == "/ui/logout":
|
|
@@ -1208,8 +1214,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1208
1214
|
f"{Auth.COOKIE}={self.auth.make_token()}; HttpOnly; "
|
|
1209
1215
|
f"SameSite=Lax; Path=/; Max-Age={Auth.MAX_AGE}"
|
|
1210
1216
|
)
|
|
1211
|
-
|
|
1217
|
+
# Log BEFORE the redirect so the line lands even if the
|
|
1218
|
+
# client drops the connection mid-write, matching the
|
|
1219
|
+
# ordering of the failure branch below.
|
|
1212
1220
|
print(f"{self.address_string()} - login succeeded", flush=True)
|
|
1221
|
+
self.redirect("/ui/cached", set_cookie=cookie)
|
|
1213
1222
|
else:
|
|
1214
1223
|
print(f"{self.address_string()} - login failed", flush=True)
|
|
1215
1224
|
self.send_html(401, self.render_login(error="Invalid password."))
|
|
@@ -1310,9 +1319,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1310
1319
|
|
|
1311
1320
|
# -- helpers -----------------------------------------------------------
|
|
1312
1321
|
def read_form(self) -> dict:
|
|
1322
|
+
"""Parse form-encoded body into ``{key: value}``. Rejects
|
|
1323
|
+
duplicate keys with ValueError -- an operator-facing form
|
|
1324
|
+
never has repeated field names, so ``name=a&name=b`` is
|
|
1325
|
+
either a bug or a client trying to hide a payload from a
|
|
1326
|
+
naive read of the first value. Callers wrap the call and
|
|
1327
|
+
turn a ValueError into a 400."""
|
|
1313
1328
|
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
1314
1329
|
body = self.rfile.read(length).decode("utf-8") if length else ""
|
|
1315
|
-
|
|
1330
|
+
pairs = urllib.parse.parse_qs(body)
|
|
1331
|
+
dups = [k for k, v in pairs.items() if len(v) > 1]
|
|
1332
|
+
if dups:
|
|
1333
|
+
raise ValueError(f"duplicate form field(s): {', '.join(sorted(dups))}")
|
|
1334
|
+
return {k: v[0] for k, v in pairs.items()}
|
|
1316
1335
|
|
|
1317
1336
|
def send_text(self, code: int, text: str):
|
|
1318
1337
|
data = text.encode("utf-8")
|
|
@@ -31,6 +31,60 @@ from withcache import _shim, client, curlwithcache, server, wgetwithcache
|
|
|
31
31
|
# --------------------------------------------------------------------------
|
|
32
32
|
# Auth: signed-cookie round-trip, tamper + wrong-secret rejection, expiry
|
|
33
33
|
# --------------------------------------------------------------------------
|
|
34
|
+
class TestResolveSecret(unittest.TestCase):
|
|
35
|
+
"""resolve_secret is the whole basis of the cookie-auth trust
|
|
36
|
+
boundary: the HMAC key that signs session tokens. Three branches
|
|
37
|
+
-- env-set, file-persisted, fresh-generation -- and a
|
|
38
|
+
security-adjacent invariant claimed in its docstring: 'a blank
|
|
39
|
+
env value must NOT silently weaken signing'. Mirrors nbdmux's
|
|
40
|
+
TestResolveSecret since the two functions are the same shape."""
|
|
41
|
+
|
|
42
|
+
def setUp(self):
|
|
43
|
+
self.tmpdir = tempfile.mkdtemp()
|
|
44
|
+
self._saved = os.environ.get("WITHCACHE_SESSION_SECRET")
|
|
45
|
+
|
|
46
|
+
def tearDown(self):
|
|
47
|
+
if self._saved is None:
|
|
48
|
+
os.environ.pop("WITHCACHE_SESSION_SECRET", None)
|
|
49
|
+
else:
|
|
50
|
+
os.environ["WITHCACHE_SESSION_SECRET"] = self._saved
|
|
51
|
+
|
|
52
|
+
def test_env_set_wins(self):
|
|
53
|
+
os.environ["WITHCACHE_SESSION_SECRET"] = "operator-chosen-secret"
|
|
54
|
+
self.assertEqual(server.resolve_secret(self.tmpdir), b"operator-chosen-secret")
|
|
55
|
+
|
|
56
|
+
def test_env_blank_falls_through_to_fresh_generation(self):
|
|
57
|
+
"""The docstring promises a blank env value must NOT silently
|
|
58
|
+
weaken signing. A fresh secret must land under data_dir and
|
|
59
|
+
NOT be the empty string."""
|
|
60
|
+
os.environ["WITHCACHE_SESSION_SECRET"] = " "
|
|
61
|
+
got = server.resolve_secret(self.tmpdir)
|
|
62
|
+
self.assertGreaterEqual(len(got), 32)
|
|
63
|
+
self.assertNotEqual(got, b"")
|
|
64
|
+
persisted = os.path.join(self.tmpdir, "session-secret")
|
|
65
|
+
self.assertTrue(os.path.exists(persisted))
|
|
66
|
+
|
|
67
|
+
def test_env_unset_generates_and_persists(self):
|
|
68
|
+
os.environ.pop("WITHCACHE_SESSION_SECRET", None)
|
|
69
|
+
got = server.resolve_secret(self.tmpdir)
|
|
70
|
+
self.assertGreaterEqual(len(got), 32)
|
|
71
|
+
with open(os.path.join(self.tmpdir, "session-secret"), "rb") as f:
|
|
72
|
+
self.assertEqual(f.read(), got)
|
|
73
|
+
|
|
74
|
+
def test_file_persisted_returned_on_second_call(self):
|
|
75
|
+
os.environ.pop("WITHCACHE_SESSION_SECRET", None)
|
|
76
|
+
first = server.resolve_secret(self.tmpdir)
|
|
77
|
+
second = server.resolve_secret(self.tmpdir)
|
|
78
|
+
self.assertEqual(first, second)
|
|
79
|
+
|
|
80
|
+
def test_persisted_file_permissions_are_private(self):
|
|
81
|
+
os.environ.pop("WITHCACHE_SESSION_SECRET", None)
|
|
82
|
+
server.resolve_secret(self.tmpdir)
|
|
83
|
+
path = os.path.join(self.tmpdir, "session-secret")
|
|
84
|
+
mode = os.stat(path).st_mode & 0o777
|
|
85
|
+
self.assertEqual(mode, 0o600)
|
|
86
|
+
|
|
87
|
+
|
|
34
88
|
class TestAuth(unittest.TestCase):
|
|
35
89
|
def test_token_roundtrip(self):
|
|
36
90
|
a = server.Auth(b"secret-key", "pw")
|
|
@@ -1262,5 +1316,40 @@ class TestCatalogAdminEndpoints(unittest.TestCase):
|
|
|
1262
1316
|
httpd2.server_close()
|
|
1263
1317
|
|
|
1264
1318
|
|
|
1319
|
+
class TestReadFormDuplicateKeys(unittest.TestCase):
|
|
1320
|
+
"""read_form rejects duplicate form keys so a first-value-wins
|
|
1321
|
+
collapse can't hide a payload silently -- mirrors nbdmux's
|
|
1322
|
+
Pass 6 hardening for cross-project parity."""
|
|
1323
|
+
|
|
1324
|
+
def setUp(self):
|
|
1325
|
+
self.httpd, self.store = _start_withcache()
|
|
1326
|
+
self.host = "127.0.0.1"
|
|
1327
|
+
self.port = self.httpd.server_address[1]
|
|
1328
|
+
|
|
1329
|
+
def tearDown(self):
|
|
1330
|
+
self.httpd.shutdown()
|
|
1331
|
+
self.httpd.server_close()
|
|
1332
|
+
|
|
1333
|
+
def test_duplicate_form_field_returns_400(self):
|
|
1334
|
+
import http.client
|
|
1335
|
+
|
|
1336
|
+
# Craft a raw body with duplicate ``url`` fields; urlencode +
|
|
1337
|
+
# dict argument would collapse them client-side.
|
|
1338
|
+
body = "url=https%3A%2F%2Fx%2Fa&url=https%3A%2F%2Fx%2Fb"
|
|
1339
|
+
conn = http.client.HTTPConnection(self.host, self.port)
|
|
1340
|
+
try:
|
|
1341
|
+
conn.request(
|
|
1342
|
+
"POST",
|
|
1343
|
+
"/admin/fetch",
|
|
1344
|
+
body=body,
|
|
1345
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
1346
|
+
)
|
|
1347
|
+
resp = conn.getresponse()
|
|
1348
|
+
self.assertEqual(resp.status, 400)
|
|
1349
|
+
resp.read()
|
|
1350
|
+
finally:
|
|
1351
|
+
conn.close()
|
|
1352
|
+
|
|
1353
|
+
|
|
1265
1354
|
if __name__ == "__main__":
|
|
1266
1355
|
unittest.main(verbosity=2)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|