withcache 0.8.5__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.
Files changed (26) hide show
  1. {withcache-0.8.5 → withcache-0.8.6}/PKG-INFO +1 -1
  2. {withcache-0.8.5 → withcache-0.8.6}/deploy/Containerfile +9 -1
  3. {withcache-0.8.5 → withcache-0.8.6}/shim/build.zig.zon +1 -1
  4. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/__init__.py +1 -1
  5. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/server.py +22 -3
  6. {withcache-0.8.5 → withcache-0.8.6}/tests/test_withcache.py +35 -0
  7. {withcache-0.8.5 → withcache-0.8.6}/.gitignore +0 -0
  8. {withcache-0.8.5 → withcache-0.8.6}/LICENSE +0 -0
  9. {withcache-0.8.5 → withcache-0.8.6}/README.md +0 -0
  10. {withcache-0.8.5 → withcache-0.8.6}/deploy/compose.yml +0 -0
  11. {withcache-0.8.5 → withcache-0.8.6}/hatch_build.py +0 -0
  12. {withcache-0.8.5 → withcache-0.8.6}/pyproject.toml +0 -0
  13. {withcache-0.8.5 → withcache-0.8.6}/shim/build.zig +0 -0
  14. {withcache-0.8.5 → withcache-0.8.6}/shim/shim.zig +0 -0
  15. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/_shim.py +0 -0
  16. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/client.py +0 -0
  17. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/oras.py +0 -0
  19. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/static/bootstrap-icons.min.css +0 -0
  20. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/static/bootstrap.min.css +0 -0
  21. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  22. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  23. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/static/htmx.min.js +0 -0
  24. {withcache-0.8.5 → withcache-0.8.6}/src/withcache/wgetwithcache.py +0 -0
  25. {withcache-0.8.5 → withcache-0.8.6}/tests/test_differential.py +0 -0
  26. {withcache-0.8.5 → 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.5
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 python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8081/healthz',timeout=2).status==200 else 1)"
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",
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.5"
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
- form = self.read_form()
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
- self.redirect("/ui/cached", set_cookie=cookie)
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
- return {k: v[0] for k, v in urllib.parse.parse_qs(body).items()}
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")
@@ -1316,5 +1316,40 @@ class TestCatalogAdminEndpoints(unittest.TestCase):
1316
1316
  httpd2.server_close()
1317
1317
 
1318
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
+
1319
1354
  if __name__ == "__main__":
1320
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