withcache 0.5.2__tar.gz → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.5.2
3
+ Version: 0.6.0
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>
@@ -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.5.2",
5
+ .version = "0.6.0",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -6,12 +6,17 @@
6
6
  - ``withcache.client``: a tiny, stdlib-only library for other tools to consume
7
7
  a cache-host (build serve URLs, probe what's cached) without re-implementing
8
8
  the ``/b/`` URL scheme.
9
+ - ``withcache.oras``: OCI registry adapter. Parses ``oras://...`` references
10
+ and resolves them to a plain HTTPS blob URL + bearer headers. The cache-host
11
+ uses it on a cold miss; library consumers (e.g. ``bty``) import it to
12
+ validate catalog entries and pre-resolve content digests.
9
13
 
10
14
  All modules are stdlib-only and self-contained.
11
15
  """
12
16
 
17
+ from . import oras
13
18
  from .client import blob_url, cache_base, is_cached, serve_url
14
19
 
15
- __version__ = "0.5.2"
20
+ __version__ = "0.6.0"
16
21
 
17
- __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "serve_url"]
22
+ __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -0,0 +1,543 @@
1
+ """ORAS / OCI registry adapter for fetching disk images and other artifacts.
2
+
3
+ Resolves ``oras://`` references against an OCI v2 registry into a plain
4
+ HTTPS blob URL plus the auth headers the registry expects. The cache-host
5
+ (``withcache.server``) calls in here on a cold miss when the decoded
6
+ origin URL is ``oras://...`` so the existing Range-resume fetch loop
7
+ can stream the bytes; library consumers (e.g. ``bty``) import the same
8
+ helpers to validate catalog entries and pre-resolve content digests.
9
+
10
+ URL shape::
11
+
12
+ oras://<host>[:port]/<owner>/<repo>[/<extra>]:<tag>
13
+ oras://<host>[:port]/<owner>/<repo>[/<extra>]@sha256:<64-hex>
14
+
15
+ Digest-pinned references skip the manifest fetch (the digest IS the
16
+ address); tag references go through manifest resolution + layer pick.
17
+
18
+ Why ``oras://`` and not ``ghcr:``
19
+ ---------------------------------
20
+
21
+ The ORAS spelling disambiguates from container references. A reader
22
+ who sees ``ghcr.io/safl/nosi/debian-sysdev:latest`` in a docs example
23
+ might reach for ``docker pull`` or ``podman run`` -- which would
24
+ fail and leave them confused, because nosi publishes disk-image
25
+ artifacts, not runnable container images. ``oras://`` is the
26
+ ecosystem term for OCI-Registry-As-Storage; an operator googling it
27
+ lands at oras.land which explicitly explains "store arbitrary
28
+ artifacts, not just containers". The ``://`` form also composes
29
+ with other registries -- ``oras://quay.io/...``,
30
+ ``oras://registry.example.com:5000/...`` -- without per-registry
31
+ schemes.
32
+
33
+ .. _ORAS: https://oras.land/
34
+
35
+ Auth
36
+ ----
37
+
38
+ Spec-compliant OCI v2 registries (GHCR included) return 401 on every
39
+ request even for public packages. Their ``/token`` endpoint
40
+ mints anonymous tokens on a plain credential-less GET. So the flow
41
+ is: hit ``https://<host>/token``, take the returned bearer, set
42
+ ``Authorization: Bearer`` on the manifest + blob requests. No
43
+ registry login, no PAT, no secrets shipped.
44
+
45
+ The token endpoint is built from the URL's host (``ghcr.io`` ->
46
+ ``https://ghcr.io/token``), which works for GHCR and any registry
47
+ that follows the same convention. Registries with non-standard auth
48
+ flows (private registries with custom realms, e.g.) would need the
49
+ proper ``WWW-Authenticate`` challenge dance instead -- noted as
50
+ future work; not needed for the homelab / nosi use case this module
51
+ ships for.
52
+
53
+ Layer picker
54
+ ------------
55
+
56
+ A nosi manifest carries two layers: the ``.img.gz`` disk image and a
57
+ ``.sha256`` sidecar. The picker drops layers whose
58
+ ``org.opencontainers.image.title`` annotation ends in a known sidecar
59
+ suffix (``.sha256``, ``.sha512``, ``.sig``, ``.asc``, ``.pem``,
60
+ ``.cert``, ``.sbom``, ``.att``, ``.json``), then takes the largest
61
+ remaining layer by declared size. Manifests with no useful
62
+ annotations fall through to the largest layer overall -- a reasonable
63
+ bet that the image bytes dwarf any metadata sidecar.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import json
69
+ import re
70
+ import time
71
+ import urllib.error
72
+ import urllib.parse
73
+ import urllib.request
74
+ from dataclasses import dataclass
75
+ from typing import Any
76
+
77
+ ORAS_SCHEME = "oras://"
78
+
79
+ # Transient HTTP statuses worth retrying: 429 (rate limit, common on
80
+ # GHCR / Docker Hub under load) plus the gateway/server-blip 5xx range.
81
+ # Everything else (401/403 auth, 404 not-found, other 4xx) is permanent
82
+ # and raised immediately, since retrying would just stall the caller.
83
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
84
+ _RETRY_ATTEMPTS = 3
85
+ _RETRY_BACKOFF = 0.5 # seconds; exponential: 0.5, 1.0 between attempts
86
+
87
+ # Defensive ceiling on the metadata-fetch response body. Real-world
88
+ # OCI tokens are <2 KiB and manifests are <100 KiB; anything beyond
89
+ # this is either a misconfigured registry or a deliberately hostile
90
+ # response. Capping the read keeps a runaway registry from exhausting
91
+ # the host process's RAM on a tag resolve. Blob bytes do NOT go
92
+ # through this helper (they stream via the cache-host's resume loop
93
+ # with its own bounds).
94
+ _MAX_METADATA_BYTES = 10 * 1024 * 1024 # 10 MiB
95
+
96
+ # Accept type covers OCI v1 + Docker v2 manifest media types so the
97
+ # registry doesn't bounce us with a 406 if the package was originally
98
+ # pushed as a Docker manifest.
99
+ _MANIFEST_ACCEPT = (
100
+ "application/vnd.oci.image.manifest.v1+json,"
101
+ "application/vnd.docker.distribution.manifest.v2+json"
102
+ )
103
+
104
+ # Layer titles ending in any of these are non-image sidecars (sha
105
+ # sums, signatures, attestations); skip them when picking the image
106
+ # layer so a future ``oras attach`` on the same artifact doesn't
107
+ # silently start being served.
108
+ _SIDECAR_SUFFIXES = (
109
+ ".sha256",
110
+ ".sha512",
111
+ ".sig",
112
+ ".asc",
113
+ ".pem",
114
+ ".cert",
115
+ ".sbom",
116
+ ".att",
117
+ ".json",
118
+ )
119
+
120
+ # Layer mediaTypes that are NEVER disk images. A manifest whose largest
121
+ # layer carries one of these is a Helm chart, a Cosign signature, an
122
+ # in-toto attestation, a SPDX SBOM, etc. Without this filter
123
+ # ``pick_image_layer`` would happily pick the Helm tarball or the
124
+ # signature blob and downstream consumers (e.g. ``bty.flash``) would
125
+ # write its bytes onto an operator's target disk (tar headers /
126
+ # signature bytes into the MBR). An operator typing ``oras://`` at a
127
+ # non-image artifact deserves a clear error, not corruption.
128
+ _NON_IMAGE_LAYER_MEDIA_TYPES = (
129
+ # Helm OCI charts.
130
+ "application/vnd.cncf.helm.chart.v1.tar+gzip",
131
+ "application/vnd.cncf.helm.chart.provenance.v1.prov",
132
+ # Cosign signatures.
133
+ "application/vnd.dev.cosign.simplesigning.v1+json",
134
+ "application/vnd.dev.cosign.artifact.sig.v1+json",
135
+ # in-toto attestations.
136
+ "application/vnd.in-toto+json",
137
+ "application/vnd.dev.cosign.artifact.bundle.v1+json",
138
+ "application/vnd.dsse.envelope.v1+json",
139
+ # SBOMs.
140
+ "application/spdx+json",
141
+ "application/vnd.cyclonedx+json",
142
+ # OCI empty descriptor (used by artifact references with no payload).
143
+ "application/vnd.oci.empty.v1+json",
144
+ )
145
+
146
+
147
+ def _is_non_image_layer(layer: dict[str, Any]) -> bool:
148
+ media_type = layer.get("mediaType")
149
+ if not isinstance(media_type, str):
150
+ return False
151
+ return media_type in _NON_IMAGE_LAYER_MEDIA_TYPES
152
+
153
+
154
+ class OrasError(OSError):
155
+ """Raised on parse / resolution / fetch errors against an OCI registry.
156
+
157
+ Inherits from :class:`OSError` so it's caught by callers that
158
+ handle remote-I/O failures generically -- semantically the same
159
+ family as :class:`urllib.error.URLError`, which also subclasses
160
+ :class:`OSError`. Code paths that need to distinguish ORAS-
161
+ specific failures from arbitrary network errors still can:
162
+ :class:`OrasError` is a strict subclass."""
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class OrasRef:
167
+ """Parsed ``oras://`` reference.
168
+
169
+ Exactly one of ``tag`` / ``digest`` is set. ``digest`` references
170
+ skip the manifest fetch (the digest is content-addressed, so the
171
+ blob URL is fully determined). ``tag`` references go through the
172
+ manifest to resolve a layer digest first.
173
+ """
174
+
175
+ host: str # e.g. "ghcr.io" or "registry.example.com:5000"
176
+ repository: str # e.g. "safl/nosi/debian-sysdev"
177
+ tag: str | None = None
178
+ digest: str | None = None
179
+
180
+ @property
181
+ def manifest_locator(self) -> str:
182
+ """Value used in the ``/manifests/<X>`` URL path."""
183
+ if self.digest is not None:
184
+ return self.digest
185
+ assert self.tag is not None, "OrasRef must have either tag or digest set"
186
+ return self.tag
187
+
188
+
189
+ # Host: DNS hostname (or registry.example.com:5000 with optional port).
190
+ # Repository: lowercase alnum + ``/_.-``, must contain at least one
191
+ # ``/`` after the host (owner + repo). Tag: OCI tag charset (alnum +
192
+ # ``._-``). Digest: only sha256 today; future algorithms would need
193
+ # extending. Layout overall::
194
+ #
195
+ # <host>[:port]/<repo>(:<tag>|@sha256:<hex>)
196
+ #
197
+ # applied to the body after stripping the ``oras://`` scheme.
198
+ _REF_RE = re.compile(
199
+ r"^"
200
+ r"(?P<host>[a-zA-Z0-9][a-zA-Z0-9.-]*(?::[0-9]+)?)"
201
+ r"/"
202
+ r"(?P<repo>[a-z0-9][a-z0-9/_.-]*)"
203
+ r"(?:(?:@(?P<digest>sha256:[0-9a-f]{64}))"
204
+ r"|(?::(?P<tag>[A-Za-z0-9._-]+)))"
205
+ r"$"
206
+ )
207
+
208
+
209
+ def parse_ref(ref: str) -> OrasRef:
210
+ """Parse an ``oras://`` reference into a :class:`OrasRef`.
211
+
212
+ Accepts the two canonical forms::
213
+
214
+ oras://<host>/<owner>/<repo>[/<extra>]:<tag>
215
+ oras://<host>/<owner>/<repo>[/<extra>]@sha256:<64-hex>
216
+
217
+ Raises :class:`OrasError` on any malformed input. The repository
218
+ component must contain at least one ``/`` -- a bare top-level
219
+ path like ``oras://ghcr.io/nosi:latest`` is rejected because OCI's
220
+ URL scheme requires owner+repo under the host.
221
+ """
222
+ if not ref.startswith(ORAS_SCHEME):
223
+ raise OrasError(f"not an oras:// reference: {ref!r}")
224
+ body = ref[len(ORAS_SCHEME) :]
225
+ if not body:
226
+ raise OrasError(f"empty oras:// reference: {ref!r}")
227
+ match = _REF_RE.match(body)
228
+ if match is None:
229
+ raise OrasError(
230
+ f"malformed oras:// reference {ref!r}: "
231
+ f"expected oras://<host>/<owner>/<repo>:<tag> or "
232
+ f"oras://<host>/<owner>/<repo>@sha256:<hex>"
233
+ )
234
+ repo = match.group("repo")
235
+ if "/" not in repo:
236
+ raise OrasError(
237
+ f"oras:// reference must include <host>/<owner>/<repo>: "
238
+ f"{ref!r} (got bare repo {repo!r} after host)"
239
+ )
240
+ return OrasRef(
241
+ host=match.group("host"),
242
+ repository=repo,
243
+ tag=match.group("tag"),
244
+ digest=match.group("digest"),
245
+ )
246
+
247
+
248
+ def _urlopen_retry(req: urllib.request.Request | str, *, timeout: float) -> bytes:
249
+ """GET ``req`` and return the body bytes, retrying transient
250
+ failures with exponential backoff.
251
+
252
+ Retries on :data:`_RETRYABLE_STATUS` (429 / 5xx) and on raw
253
+ connection errors / timeouts. Permanent HTTP errors (401/403/404,
254
+ other 4xx) raise immediately -- a retry can't fix an auth or
255
+ not-found failure, only delay the inevitable. After
256
+ :data:`_RETRY_ATTEMPTS` the last error is re-raised so the caller's
257
+ ``except`` (which wraps into :class:`OrasError`) still fires.
258
+ """
259
+ last: BaseException | None = None
260
+ for attempt in range(_RETRY_ATTEMPTS):
261
+ try:
262
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
263
+ # Cap the read: a registry that returns a 500 MiB
264
+ # "manifest" should fail loud, not silently consume
265
+ # process RAM. ``read(N)`` returns up to N bytes; we
266
+ # take N+1 and reject the response if it overflows.
267
+ data = bytes(resp.read(_MAX_METADATA_BYTES + 1))
268
+ if len(data) > _MAX_METADATA_BYTES:
269
+ raise OrasError(f"oras metadata response exceeded {_MAX_METADATA_BYTES} bytes")
270
+ return data
271
+ except urllib.error.HTTPError as exc:
272
+ if exc.code not in _RETRYABLE_STATUS:
273
+ raise
274
+ last = exc
275
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
276
+ last = exc
277
+ if attempt < _RETRY_ATTEMPTS - 1:
278
+ time.sleep(_RETRY_BACKOFF * (2**attempt))
279
+ assert last is not None # loop ran at least once
280
+ raise last
281
+
282
+
283
+ def parse_www_authenticate(header: str) -> dict[str, str]:
284
+ """Parse an OCI ``WWW-Authenticate: Bearer realm="...",service="...",
285
+ scope="..."`` challenge into a dict of its (lower-cased) params.
286
+
287
+ Returns ``{}`` for a non-Bearer or unparseable challenge so callers
288
+ can treat "no usable challenge" uniformly.
289
+ """
290
+ header = header.strip()
291
+ if header[:7].lower() != "bearer ":
292
+ return {}
293
+ return {m.group(1).lower(): m.group(2) for m in re.finditer(r'(\w+)="([^"]*)"', header[7:])}
294
+
295
+
296
+ def _token_from_endpoint(url: str, host: str, repository: str, *, timeout: float) -> str:
297
+ """GET a token endpoint and extract the bearer. Raises OrasError."""
298
+ payload = json.loads(_urlopen_retry(url, timeout=timeout))
299
+ # OCI registries return ``token``; some spell it ``access_token``.
300
+ token = payload.get("token") or payload.get("access_token")
301
+ if not isinstance(token, str) or not token:
302
+ raise OrasError(
303
+ f"oras token response for {host}/{repository} did not contain "
304
+ f"a token (keys: {sorted(payload.keys())})"
305
+ )
306
+ return token
307
+
308
+
309
+ def _discover_bearer_challenge(host: str, *, timeout: float) -> dict[str, str]:
310
+ """Probe ``GET https://{host}/v2/`` and return the parsed Bearer
311
+ challenge from the 401 response's ``WWW-Authenticate`` header.
312
+
313
+ The spec-compliant way to find a registry's token endpoint when it
314
+ isn't the GHCR-convention ``https://<host>/token`` (Docker Hub uses
315
+ ``auth.docker.io``, e.g.). Returns ``{}`` if the registry answers
316
+ 200 (no auth) or sends no usable challenge."""
317
+ req = urllib.request.Request(f"https://{host}/v2/", method="GET")
318
+ try:
319
+ with urllib.request.urlopen(req, timeout=timeout):
320
+ return {} # 200: no auth challenge to discover
321
+ except urllib.error.HTTPError as exc:
322
+ if exc.code != 401 or exc.headers is None:
323
+ return {}
324
+ return parse_www_authenticate(exc.headers.get("WWW-Authenticate", ""))
325
+ except (urllib.error.URLError, TimeoutError, OSError):
326
+ return {}
327
+
328
+
329
+ def fetch_anonymous_token(host: str, repository: str, *, timeout: float = 30.0) -> str:
330
+ """Grab an anonymous bearer token for ``repository:pull``.
331
+
332
+ Spec-compliant OCI v2 registries expose a token endpoint that mints
333
+ short-lived anonymous bearers for public packages (no creds, no
334
+ PAT). Two-step:
335
+
336
+ 1. Try the GHCR / oras.land convention ``https://<host>/token`` --
337
+ one request for the common case.
338
+ 2. If that fails, fall back to the spec discovery: ping
339
+ ``GET /v2/``, read the realm advertised in the ``WWW-Authenticate``
340
+ Bearer challenge, and fetch from there. This covers registries
341
+ whose token endpoint isn't ``<host>/token`` (Docker Hub ->
342
+ ``auth.docker.io``, Quay's custom realm, etc.).
343
+ """
344
+ scope = f"repository:{urllib.parse.quote(repository, safe='/')}:pull"
345
+ conv_url = f"https://{host}/token?service={host}&scope={scope}"
346
+ try:
347
+ return _token_from_endpoint(conv_url, host, repository, timeout=timeout)
348
+ except (OSError, json.JSONDecodeError, ValueError, OrasError) as conv_exc:
349
+ # Convention failed; try spec discovery before giving up.
350
+ challenge = _discover_bearer_challenge(host, timeout=timeout)
351
+ realm = challenge.get("realm")
352
+ if realm:
353
+ service = challenge.get("service", host)
354
+ disc_url = f"{realm}?service={urllib.parse.quote(service, safe='')}&scope={scope}"
355
+ try:
356
+ return _token_from_endpoint(disc_url, host, repository, timeout=timeout)
357
+ except (OSError, json.JSONDecodeError, ValueError, OrasError) as exc:
358
+ raise OrasError(
359
+ f"oras token fetch failed for {host}/{repository} via discovered "
360
+ f"realm {realm}: {exc}"
361
+ ) from exc
362
+ # No usable discovery; surface the original conventional error.
363
+ raise OrasError(
364
+ f"oras token fetch failed for {host}/{repository}: {conv_exc}"
365
+ ) from conv_exc
366
+
367
+
368
+ def fetch_manifest(ref: OrasRef, token: str, *, timeout: float = 30.0) -> dict[str, Any]:
369
+ """Fetch the OCI manifest for ``ref`` using a previously-acquired token.
370
+
371
+ Returns the parsed JSON. Raises :class:`OrasError` on network or
372
+ parse failure. The caller is responsible for layer selection.
373
+ """
374
+ locator = urllib.parse.quote(ref.manifest_locator, safe=":")
375
+ url = f"https://{ref.host}/v2/{ref.repository}/manifests/{locator}"
376
+ request = urllib.request.Request(
377
+ url,
378
+ headers={
379
+ "Authorization": f"Bearer {token}",
380
+ "Accept": _MANIFEST_ACCEPT,
381
+ },
382
+ )
383
+ try:
384
+ payload = json.loads(_urlopen_retry(request, timeout=timeout))
385
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
386
+ raise OrasError(
387
+ f"oras manifest fetch failed for "
388
+ f"{ref.host}/{ref.repository}:{ref.manifest_locator}: {exc}"
389
+ ) from exc
390
+ if not isinstance(payload, dict):
391
+ raise OrasError(
392
+ f"oras manifest for "
393
+ f"{ref.host}/{ref.repository}:{ref.manifest_locator} "
394
+ f"is not a JSON object"
395
+ )
396
+ return payload
397
+
398
+
399
+ def _layer_title(layer: dict[str, Any]) -> str:
400
+ annotations = layer.get("annotations")
401
+ if not isinstance(annotations, dict):
402
+ return ""
403
+ title = annotations.get("org.opencontainers.image.title", "")
404
+ return title if isinstance(title, str) else ""
405
+
406
+
407
+ def pick_image_layer(manifest: dict[str, Any]) -> dict[str, Any]:
408
+ """Pick the disk-image layer from a (possibly multi-layer) manifest.
409
+
410
+ Drops sidecar-looking layers by title-annotation suffix, then takes
411
+ the largest by declared ``size``. A manifest with no usable
412
+ annotations falls through to the largest layer overall -- the image
413
+ bytes will dwarf any metadata blob in practice.
414
+
415
+ Raises :class:`OrasError` if the manifest has no layers at all.
416
+ """
417
+ layers = manifest.get("layers")
418
+ if not isinstance(layers, list) or not layers:
419
+ # A multi-arch image *index* (``manifests`` instead of
420
+ # ``layers``) is a common cause: a rolling tag that resolves
421
+ # to an OCI index rather than a single artifact manifest. Name
422
+ # it so the operator points at a concrete manifest/digest
423
+ # instead of staring at "no layers".
424
+ if isinstance(manifest.get("manifests"), list) and manifest["manifests"]:
425
+ raise OrasError(
426
+ "oras ref resolved to a multi-arch image index, not a single "
427
+ "artifact manifest; reference a concrete platform manifest by "
428
+ "its @sha256:<digest> instead of the index tag"
429
+ )
430
+ raise OrasError("manifest has no layers")
431
+
432
+ # First pass: drop layers whose mediaType is definitively not a
433
+ # disk image (Helm chart, Cosign sig, SBOM, attestation, ...).
434
+ # Unlike title-suffix sidecars, these are never the right pick AND
435
+ # never get a fallback; flashing a Helm chart's tar+gzip into a
436
+ # disk's MBR is the data-loss scenario this guard exists for.
437
+ rejected_media_types: list[str] = []
438
+ image_candidates: list[dict[str, Any]] = []
439
+ for layer in layers:
440
+ if not isinstance(layer, dict):
441
+ continue
442
+ if _is_non_image_layer(layer):
443
+ mt = layer.get("mediaType")
444
+ if isinstance(mt, str):
445
+ rejected_media_types.append(mt)
446
+ continue
447
+ image_candidates.append(layer)
448
+ if not image_candidates:
449
+ if rejected_media_types:
450
+ unique = ", ".join(sorted(set(rejected_media_types)))
451
+ raise OrasError(
452
+ f"oras ref resolved to a non-disk-image artifact "
453
+ f"(layer mediaTypes: {unique}); refusing to serve "
454
+ f"signature / Helm chart / SBOM blobs as disk images"
455
+ )
456
+ raise OrasError("manifest has no usable layers")
457
+
458
+ # Second pass: among the image-eligible layers, prefer ones that
459
+ # DON'T look like title-annotation sidecars (.sha256 / .sig / ...
460
+ # filenames). If every remaining layer carries a sidecar-looking
461
+ # title, fall back to "pick the largest" so the resolver gets a
462
+ # chance to fail loudly downstream rather than this picker
463
+ # mis-classifying.
464
+ image_like: list[dict[str, Any]] = []
465
+ for layer in image_candidates:
466
+ title = _layer_title(layer)
467
+ if title and any(title.endswith(suffix) for suffix in _SIDECAR_SUFFIXES):
468
+ continue
469
+ image_like.append(layer)
470
+ candidates = image_like or image_candidates
471
+ return max(candidates, key=lambda layer: layer.get("size") or 0)
472
+
473
+
474
+ @dataclass(frozen=True)
475
+ class ResolvedBlob:
476
+ """Everything a fetcher needs to stream the image bytes.
477
+
478
+ ``blob_url`` is the final ``/v2/<repo>/blobs/sha256:<digest>``
479
+ endpoint. ``headers`` carries the bearer the registry expects. The
480
+ ``digest`` is what the fetcher should verify the downloaded bytes
481
+ against -- when the caller started from a tag, this is the digest
482
+ the registry resolved to right now, frozen for the rest of the
483
+ fetch.
484
+ """
485
+
486
+ blob_url: str
487
+ headers: dict[str, str]
488
+ digest: str
489
+ size: int | None
490
+ title: str | None
491
+
492
+
493
+ def resolve_ref(ref: str | OrasRef, *, timeout: float = 30.0) -> ResolvedBlob:
494
+ """Resolve an ``oras://`` reference (or pre-parsed :class:`OrasRef`)
495
+ to a :class:`ResolvedBlob`.
496
+
497
+ For tag references: anonymous token -> manifest -> layer pick ->
498
+ ``ResolvedBlob`` with the layer's content-addressed digest. The
499
+ digest is frozen at resolve time so a tag that moves under us
500
+ between resolve and fetch still produces the bytes we committed to.
501
+
502
+ For digest-pinned references: anonymous token only; the blob URL is
503
+ fully determined by the digest, the manifest fetch is skipped, and
504
+ size / title come back as ``None`` (the descriptor's optional
505
+ ``size_bytes`` field can carry that info instead if known).
506
+ """
507
+ if isinstance(ref, str):
508
+ ref = parse_ref(ref)
509
+ token = fetch_anonymous_token(ref.host, ref.repository, timeout=timeout)
510
+ headers = {"Authorization": f"Bearer {token}"}
511
+
512
+ if ref.digest is not None:
513
+ digest = ref.digest
514
+ size: int | None = None
515
+ title: str | None = None
516
+ else:
517
+ manifest = fetch_manifest(ref, token, timeout=timeout)
518
+ layer = pick_image_layer(manifest)
519
+ raw_digest = layer.get("digest")
520
+ if not isinstance(raw_digest, str) or not raw_digest.startswith("sha256:"):
521
+ raise OrasError(
522
+ f"picked layer for "
523
+ f"{ref.host}/{ref.repository}:{ref.manifest_locator} "
524
+ f"has unusable digest {raw_digest!r}"
525
+ )
526
+ digest = raw_digest
527
+ layer_size = layer.get("size")
528
+ size = layer_size if isinstance(layer_size, int) else None
529
+ title = _layer_title(layer) or None
530
+
531
+ blob_url = f"https://{ref.host}/v2/{ref.repository}/blobs/{digest}"
532
+ return ResolvedBlob(
533
+ blob_url=blob_url,
534
+ headers=headers,
535
+ digest=digest,
536
+ size=size,
537
+ title=title,
538
+ )
539
+
540
+
541
+ def is_oras_url(url: str) -> bool:
542
+ """True iff ``url`` is an ``oras://`` reference rather than http(s)://."""
543
+ return url.startswith(ORAS_SCHEME)
@@ -41,7 +41,7 @@ import urllib.request
41
41
  from dataclasses import dataclass, field
42
42
  from datetime import datetime, timezone
43
43
 
44
- from . import __version__
44
+ from . import __version__, oras
45
45
 
46
46
  CHUNK = 64 * 1024
47
47
  USER_AGENT = f"withcache-cache/{__version__}"
@@ -316,6 +316,7 @@ class Store:
316
316
  cancel=None,
317
317
  headers=None,
318
318
  max_resume_attempts: int = RESUME_MAX_ATTEMPTS,
319
+ fetch_resolver=None,
319
320
  ) -> sqlite3.Row:
320
321
  """Operator-triggered: pull the artifact from origin and store it.
321
322
 
@@ -326,6 +327,15 @@ class Store:
326
327
  bearer token bty pre-resolved for an oras blob). Raises :class:`CacheFull`
327
328
  if the cache is already at --max-bytes.
328
329
 
330
+ ``fetch_resolver`` is an optional zero-arg callable that returns
331
+ ``(fetch_url, fetch_headers)`` for the current attempt. When set,
332
+ every resume attempt re-invokes it -- so an ``oras://...`` cache key
333
+ can be backed by a fresh registry bearer + fresh signed CDN URL on
334
+ each retry (both have short TTLs the resume loop will otherwise blow
335
+ through). When unset, every attempt hits ``url`` directly. The
336
+ ``fetch_headers`` are layered under the caller-supplied ``headers``,
337
+ so an operator-provided override wins.
338
+
329
339
  Resume-on-truncation: if the upstream stream ends before its
330
340
  declared Content-Length, the partial bytes are kept and the
331
341
  next attempt requests ``Range: bytes=<got>-`` so the fetch
@@ -346,23 +356,32 @@ class Store:
346
356
  normalized = self.normalize(url)
347
357
  key = self.key_of(normalized)
348
358
  tmp = os.path.join(self.tmp_dir, key + ".part")
349
- base_headers = {"User-Agent": USER_AGENT}
350
- if headers:
351
- base_headers.update(headers)
352
359
  sha = hashlib.sha256()
353
360
  size = 0
354
361
  total: int | None = None
355
362
  content_type: str | None = None
356
363
  try:
357
364
  for _ in range(max_resume_attempts):
358
- req_headers = dict(base_headers)
365
+ # Resolve fetch URL + headers afresh per attempt: for
366
+ # oras://, the bearer + signed CDN URL each have short
367
+ # TTLs the prior attempt may have blown through; for
368
+ # plain HTTP the resolver is unset and we fetch ``url``
369
+ # with a stable header set.
370
+ if fetch_resolver is not None:
371
+ fetch_url, resolved_headers = fetch_resolver()
372
+ else:
373
+ fetch_url = url
374
+ resolved_headers = {}
375
+ req_headers = {"User-Agent": USER_AGENT, **resolved_headers}
376
+ if headers:
377
+ req_headers.update(headers)
359
378
  if size > 0:
360
379
  # Resume from where the previous attempt cut.
361
380
  # A 206 response continues the stream; a 200
362
381
  # means the origin ignored Range (e.g. a dumb
363
382
  # static server) and we restart from 0.
364
383
  req_headers["Range"] = f"bytes={size}-"
365
- req = urllib.request.Request(url, headers=req_headers)
384
+ req = urllib.request.Request(fetch_url, headers=req_headers)
366
385
  with urllib.request.urlopen(req, timeout=120) as resp:
367
386
  status = getattr(resp, "status", None) or resp.getcode()
368
387
  if content_type is None:
@@ -605,11 +624,26 @@ class DownloadManager:
605
624
  job.status = "running"
606
625
  job.started_at = time.time()
607
626
  try:
627
+ # For oras://, the cache key stays the original ref but
628
+ # the actual request goes through a freshly-minted
629
+ # bearer + resolved blob URL. The resolver runs per
630
+ # resume attempt so a long fetch survives bearer /
631
+ # signed-URL TTL expiry mid-stream (the same kind of
632
+ # cut the surrounding Range-resume loop was built for).
633
+ fetch_resolver: object = None
634
+ if oras.is_oras_url(job.url):
635
+
636
+ def _oras_resolve(_url: str = job.url) -> tuple[str, dict[str, str]]:
637
+ resolved = oras.resolve_ref(_url)
638
+ return resolved.blob_url, dict(resolved.headers)
639
+
640
+ fetch_resolver = _oras_resolve
608
641
  row = self.store.store_from_origin(
609
642
  job.url,
610
643
  progress=lambda done, total, j=job: _set_progress(j, done, total),
611
644
  cancel=job._cancel.is_set,
612
645
  headers=job.headers,
646
+ fetch_resolver=fetch_resolver,
613
647
  )
614
648
  with self._lock:
615
649
  job.status = "completed"
@@ -0,0 +1,480 @@
1
+ """Stdlib-only tests for withcache.oras (the OCI registry adapter).
2
+
3
+ Parser tests are pure; resolver tests mock ``urllib.request.urlopen`` so
4
+ they run on offline CI and don't hit a real registry. Mirrors the test
5
+ matrix that lived at ``bty/tests/test_oras.py`` before withcache took
6
+ over OCI handling.
7
+ """
8
+
9
+ import http.server
10
+ import io
11
+ import json
12
+ import os
13
+ import socketserver
14
+ import sys
15
+ import tempfile
16
+ import threading
17
+ import unittest
18
+ import urllib.error
19
+ from typing import Any
20
+ from unittest.mock import patch
21
+
22
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
23
+
24
+ from withcache import oras, server # noqa: E402
25
+
26
+ # A trimmed-down version of a real nosi manifest -- two layers (the
27
+ # .img.gz and a .sha256 sidecar), one annotation each, OCI media types.
28
+ _NOSI_MANIFEST: dict[str, Any] = {
29
+ "schemaVersion": 2,
30
+ "mediaType": "application/vnd.oci.image.manifest.v1+json",
31
+ "artifactType": "application/vnd.nosi.disk-image.v1+gzip",
32
+ "layers": [
33
+ {
34
+ "mediaType": "application/vnd.nosi.disk-image.layer.v1+gzip",
35
+ "digest": "sha256:" + "aa" * 32,
36
+ "size": 1923658046,
37
+ "annotations": {"org.opencontainers.image.title": "nosi-debian-sysdev-x86_64.img.gz"},
38
+ },
39
+ {
40
+ "mediaType": "text/plain",
41
+ "digest": "sha256:" + "bb" * 32,
42
+ "size": 130,
43
+ "annotations": {
44
+ "org.opencontainers.image.title": "nosi-debian-sysdev-x86_64.img.gz.sha256"
45
+ },
46
+ },
47
+ ],
48
+ }
49
+
50
+
51
+ class _BytesResp(io.BytesIO):
52
+ def __enter__(self) -> "_BytesResp":
53
+ return self
54
+
55
+ def __exit__(self, *_a: object) -> None:
56
+ return None
57
+
58
+
59
+ def _http_error(code: int) -> urllib.error.HTTPError:
60
+ return urllib.error.HTTPError("https://x/y", code, f"err {code}", {}, None) # type: ignore[arg-type]
61
+
62
+
63
+ def _make_urlopen_mock(token: str = "anon-token-xyz"):
64
+ """urlopen replacement returning a token payload for /token and the
65
+ nosi manifest for any /manifests/ URL."""
66
+
67
+ def _fake_urlopen(req, timeout=None):
68
+ url = req if isinstance(req, str) else req.full_url
69
+ if "/token" in url:
70
+ return _BytesResp(json.dumps({"token": token}).encode())
71
+ if "/manifests/" in url:
72
+ return _BytesResp(json.dumps(_NOSI_MANIFEST).encode())
73
+ raise AssertionError(f"unexpected URL in test: {url}")
74
+
75
+ return _fake_urlopen
76
+
77
+
78
+ class TestParseRef(unittest.TestCase):
79
+ def test_tag_form(self):
80
+ ref = oras.parse_ref("oras://ghcr.io/safl/nosi/debian-sysdev:latest")
81
+ self.assertEqual(ref.host, "ghcr.io")
82
+ self.assertEqual(ref.repository, "safl/nosi/debian-sysdev")
83
+ self.assertEqual(ref.tag, "latest")
84
+ self.assertIsNone(ref.digest)
85
+ self.assertEqual(ref.manifest_locator, "latest")
86
+
87
+ def test_digest_form(self):
88
+ digest = "sha256:" + "ab" * 32
89
+ ref = oras.parse_ref(f"oras://ghcr.io/safl/nosi/debian-sysdev@{digest}")
90
+ self.assertEqual(ref.host, "ghcr.io")
91
+ self.assertEqual(ref.repository, "safl/nosi/debian-sysdev")
92
+ self.assertIsNone(ref.tag)
93
+ self.assertEqual(ref.digest, digest)
94
+ self.assertEqual(ref.manifest_locator, digest)
95
+
96
+ def test_owner_repo_minimum(self):
97
+ """Two-segment owner/repo under the host is the minimum."""
98
+ ref = oras.parse_ref("oras://ghcr.io/owner/repo:v1")
99
+ self.assertEqual(ref.host, "ghcr.io")
100
+ self.assertEqual(ref.repository, "owner/repo")
101
+
102
+ def test_accepts_host_with_port(self):
103
+ """Private registries on non-443 ports preserve host:port verbatim."""
104
+ ref = oras.parse_ref("oras://registry.example.com:5000/foo/bar:v1")
105
+ self.assertEqual(ref.host, "registry.example.com:5000")
106
+ self.assertEqual(ref.repository, "foo/bar")
107
+
108
+ def test_rejects_bare_repo_after_host(self):
109
+ with self.assertRaisesRegex(oras.OrasError, r"host.*owner.*repo"):
110
+ oras.parse_ref("oras://ghcr.io/nosi:latest")
111
+
112
+ def test_rejects_missing_scheme(self):
113
+ with self.assertRaisesRegex(oras.OrasError, "not an oras://"):
114
+ oras.parse_ref("ghcr.io/safl/nosi:latest")
115
+
116
+ def test_rejects_empty_body(self):
117
+ with self.assertRaisesRegex(oras.OrasError, "empty"):
118
+ oras.parse_ref("oras://")
119
+
120
+ def test_rejects_missing_tag_and_digest(self):
121
+ """Tagless / digestless refs aren't pullable."""
122
+ with self.assertRaisesRegex(oras.OrasError, "malformed"):
123
+ oras.parse_ref("oras://ghcr.io/safl/nosi/debian-sysdev")
124
+
125
+ def test_rejects_short_digest(self):
126
+ """sha256 must be 64 hex chars; partial digests would mis-address."""
127
+ with self.assertRaisesRegex(oras.OrasError, "malformed"):
128
+ oras.parse_ref("oras://ghcr.io/safl/nosi/debian-sysdev@sha256:abc123")
129
+
130
+
131
+ class TestPickImageLayer(unittest.TestCase):
132
+ def test_skips_sha256_sidecar(self):
133
+ layer = oras.pick_image_layer(_NOSI_MANIFEST)
134
+ title = layer["annotations"]["org.opencontainers.image.title"]
135
+ self.assertEqual(title, "nosi-debian-sysdev-x86_64.img.gz")
136
+ self.assertFalse(title.endswith(".sha256"))
137
+
138
+ def test_picks_largest_when_no_sidecar(self):
139
+ manifest: dict[str, Any] = {
140
+ "layers": [
141
+ {"digest": "sha256:" + "11" * 32, "size": 100, "annotations": {}},
142
+ {"digest": "sha256:" + "22" * 32, "size": 1_000_000, "annotations": {}},
143
+ ]
144
+ }
145
+ self.assertEqual(oras.pick_image_layer(manifest)["size"], 1_000_000)
146
+
147
+ def test_raises_on_empty_layers(self):
148
+ with self.assertRaisesRegex(oras.OrasError, "no layers"):
149
+ oras.pick_image_layer({"layers": []})
150
+
151
+ def test_raises_on_non_list_layers(self):
152
+ with self.assertRaisesRegex(oras.OrasError, "no layers"):
153
+ oras.pick_image_layer({"layers": "not-a-list"})
154
+
155
+ def test_skips_non_dict_layer_entries(self):
156
+ manifest: dict[str, Any] = {
157
+ "layers": [
158
+ None,
159
+ "garbage",
160
+ {"digest": "sha256:" + "33" * 32, "size": 4096, "annotations": {}},
161
+ ]
162
+ }
163
+ self.assertEqual(oras.pick_image_layer(manifest)["size"], 4096)
164
+
165
+ def test_names_multiarch_index(self):
166
+ """A multi-arch image index gets a specific error pointing the
167
+ operator at a concrete digest, not the generic 'no layers'."""
168
+ index = {
169
+ "mediaType": "application/vnd.oci.image.index.v1+json",
170
+ "manifests": [
171
+ {"digest": "sha256:" + "aa" * 32, "platform": {"architecture": "amd64"}},
172
+ {"digest": "sha256:" + "bb" * 32, "platform": {"architecture": "arm64"}},
173
+ ],
174
+ }
175
+ with self.assertRaisesRegex(oras.OrasError, "multi-arch image index"):
176
+ oras.pick_image_layer(index)
177
+
178
+ def test_refuses_helm_chart(self):
179
+ """Helm tarball mediaType must be refused: flashing tar+gzip
180
+ bytes onto a target disk is data-loss territory."""
181
+ helm: dict[str, Any] = {
182
+ "layers": [
183
+ {
184
+ "digest": "sha256:" + "55" * 32,
185
+ "size": 4_096,
186
+ "mediaType": "application/vnd.cncf.helm.chart.v1.tar+gzip",
187
+ },
188
+ {
189
+ "digest": "sha256:" + "66" * 32,
190
+ "size": 256,
191
+ "mediaType": "application/vnd.cncf.helm.chart.provenance.v1.prov",
192
+ },
193
+ ]
194
+ }
195
+ with self.assertRaisesRegex(oras.OrasError, "non-disk-image artifact"):
196
+ oras.pick_image_layer(helm)
197
+
198
+ def test_refuses_cosign_sig(self):
199
+ cosign: dict[str, Any] = {
200
+ "layers": [
201
+ {
202
+ "digest": "sha256:" + "77" * 32,
203
+ "size": 512,
204
+ "mediaType": "application/vnd.dev.cosign.simplesigning.v1+json",
205
+ },
206
+ ]
207
+ }
208
+ with self.assertRaisesRegex(oras.OrasError, "non-disk-image artifact"):
209
+ oras.pick_image_layer(cosign)
210
+
211
+ def test_picks_image_alongside_provenance(self):
212
+ """One real image layer + a non-image sidecar still resolves
213
+ to the image; only all-non-image manifests get refused."""
214
+ mixed: dict[str, Any] = {
215
+ "layers": [
216
+ {
217
+ "digest": "sha256:" + "aa" * 32,
218
+ "size": 256,
219
+ "mediaType": "application/vnd.in-toto+json",
220
+ },
221
+ {
222
+ "digest": "sha256:" + "bb" * 32,
223
+ "size": 2_000_000,
224
+ "annotations": {"org.opencontainers.image.title": "appliance.img.gz"},
225
+ },
226
+ ]
227
+ }
228
+ self.assertEqual(oras.pick_image_layer(mixed)["size"], 2_000_000)
229
+
230
+ def test_falls_back_when_all_look_like_sidecars(self):
231
+ """If every layer is annotated with a sidecar-suffix title,
232
+ fall back to picking the largest (the resolver fails downstream
233
+ rather than silently mis-classifying)."""
234
+ manifest: dict[str, Any] = {
235
+ "layers": [
236
+ {
237
+ "digest": "sha256:" + "33" * 32,
238
+ "size": 50,
239
+ "annotations": {"org.opencontainers.image.title": "a.sha256"},
240
+ },
241
+ {
242
+ "digest": "sha256:" + "44" * 32,
243
+ "size": 500,
244
+ "annotations": {"org.opencontainers.image.title": "b.sha256"},
245
+ },
246
+ ]
247
+ }
248
+ self.assertEqual(oras.pick_image_layer(manifest)["size"], 500)
249
+
250
+
251
+ class TestResolveRef(unittest.TestCase):
252
+ def test_tag_resolves_to_layer_digest(self):
253
+ with patch("urllib.request.urlopen", _make_urlopen_mock()):
254
+ resolved = oras.resolve_ref("oras://ghcr.io/safl/nosi/debian-sysdev:latest")
255
+ self.assertEqual(resolved.digest, "sha256:" + "aa" * 32)
256
+ self.assertEqual(resolved.size, 1923658046)
257
+ self.assertEqual(resolved.title, "nosi-debian-sysdev-x86_64.img.gz")
258
+ self.assertEqual(
259
+ resolved.blob_url,
260
+ f"https://ghcr.io/v2/safl/nosi/debian-sysdev/blobs/sha256:{'aa' * 32}",
261
+ )
262
+ self.assertEqual(resolved.headers, {"Authorization": "Bearer anon-token-xyz"})
263
+
264
+ def test_digest_skips_manifest(self):
265
+ """Digest-pinned references skip the manifest fetch entirely."""
266
+
267
+ def _strict_urlopen(req, timeout=None):
268
+ url = req if isinstance(req, str) else req.full_url
269
+ if "/manifests/" in url:
270
+ raise AssertionError("digest-pinned ref should not fetch manifest")
271
+ return _BytesResp(json.dumps({"token": "pinned-token"}).encode())
272
+
273
+ digest = "sha256:" + "cd" * 32
274
+ with patch("urllib.request.urlopen", _strict_urlopen):
275
+ resolved = oras.resolve_ref(f"oras://ghcr.io/safl/nosi/debian-sysdev@{digest}")
276
+ self.assertEqual(resolved.digest, digest)
277
+ self.assertIsNone(resolved.size)
278
+ self.assertIsNone(resolved.title)
279
+ self.assertEqual(
280
+ resolved.blob_url, f"https://ghcr.io/v2/safl/nosi/debian-sysdev/blobs/{digest}"
281
+ )
282
+
283
+ def test_uses_host_from_url_in_token_endpoint(self):
284
+ """Token endpoint URL must follow the ref's host (not hardcoded
285
+ to ghcr.io). Verifies cross-registry support."""
286
+ seen: list[str] = []
287
+
288
+ def _capturing(req, timeout=None):
289
+ url = req if isinstance(req, str) else req.full_url
290
+ seen.append(url)
291
+ if "/token" in url:
292
+ return _BytesResp(json.dumps({"token": "tok"}).encode())
293
+ return _BytesResp(json.dumps(_NOSI_MANIFEST).encode())
294
+
295
+ with patch("urllib.request.urlopen", _capturing):
296
+ oras.resolve_ref("oras://registry.example.com:5000/foo/bar:v1")
297
+
298
+ self.assertTrue(
299
+ any(u.startswith("https://registry.example.com:5000/token") for u in seen),
300
+ f"expected custom-host token URL in {seen}",
301
+ )
302
+
303
+ def test_propagates_token_failure(self):
304
+ def _failing(req, timeout=None):
305
+ raise OSError("network unreachable")
306
+
307
+ with patch("urllib.request.urlopen", _failing):
308
+ with self.assertRaisesRegex(oras.OrasError, "token fetch failed"):
309
+ oras.resolve_ref("oras://ghcr.io/safl/nosi/debian-sysdev:latest")
310
+
311
+
312
+ class TestIsOrasUrl(unittest.TestCase):
313
+ def test_recognises_only_oras_scheme(self):
314
+ self.assertTrue(oras.is_oras_url("oras://ghcr.io/safl/nosi/debian-sysdev:latest"))
315
+ self.assertFalse(oras.is_oras_url("https://ghcr.io/v2/safl/nosi/debian-sysdev/blobs/sha:x"))
316
+ self.assertFalse(oras.is_oras_url("https://example.invalid/x.img.gz"))
317
+ # ``ghcr:`` is NOT recognised; the explicit ``oras://`` form is required.
318
+ self.assertFalse(oras.is_oras_url("ghcr:safl/nosi/debian-sysdev:latest"))
319
+
320
+
321
+ class TestUrlopenRetry(unittest.TestCase):
322
+ def test_retries_transient_then_succeeds(self):
323
+ with patch.object(oras.time, "sleep", lambda *_a: None):
324
+ calls = {"n": 0}
325
+
326
+ def _flaky(req, timeout=None):
327
+ calls["n"] += 1
328
+ if calls["n"] == 1:
329
+ raise _http_error(503)
330
+ return _BytesResp(b"ok")
331
+
332
+ with patch("urllib.request.urlopen", _flaky):
333
+ self.assertEqual(oras._urlopen_retry("https://x/y", timeout=5), b"ok")
334
+ self.assertEqual(calls["n"], 2)
335
+
336
+ def test_does_not_retry_permanent(self):
337
+ with patch.object(oras.time, "sleep", lambda *_a: None):
338
+ calls = {"n": 0}
339
+
340
+ def _notfound(req, timeout=None):
341
+ calls["n"] += 1
342
+ raise _http_error(404)
343
+
344
+ with patch("urllib.request.urlopen", _notfound):
345
+ with self.assertRaises(urllib.error.HTTPError):
346
+ oras._urlopen_retry("https://x/y", timeout=5)
347
+ self.assertEqual(calls["n"], 1)
348
+
349
+ def test_exhausts_then_reraises(self):
350
+ with patch.object(oras.time, "sleep", lambda *_a: None):
351
+ calls = {"n": 0}
352
+
353
+ def _down(req, timeout=None):
354
+ calls["n"] += 1
355
+ raise _http_error(503)
356
+
357
+ with patch("urllib.request.urlopen", _down):
358
+ with self.assertRaises(urllib.error.HTTPError):
359
+ oras._urlopen_retry("https://x/y", timeout=5)
360
+ self.assertEqual(calls["n"], oras._RETRY_ATTEMPTS)
361
+
362
+
363
+ class TestFetchAnonymousToken(unittest.TestCase):
364
+ def test_rides_through_transient_503(self):
365
+ with patch.object(oras.time, "sleep", lambda *_a: None):
366
+ calls = {"n": 0}
367
+
368
+ def _flaky(req, timeout=None):
369
+ calls["n"] += 1
370
+ if calls["n"] == 1:
371
+ raise _http_error(503)
372
+ return _BytesResp(json.dumps({"token": "tok"}).encode())
373
+
374
+ with patch("urllib.request.urlopen", _flaky):
375
+ self.assertEqual(oras.fetch_anonymous_token("ghcr.io", "owner/repo"), "tok")
376
+ self.assertEqual(calls["n"], 2)
377
+
378
+ def test_discovers_realm_when_convention_fails(self):
379
+ """Conventional <host>/token 404s -> read the /v2/ Bearer
380
+ challenge's realm and fetch from there (Docker-Hub style)."""
381
+ with patch.object(oras.time, "sleep", lambda *_a: None):
382
+ realm = "https://auth.example.io/token"
383
+
384
+ def _fake(req, timeout=None):
385
+ url = req if isinstance(req, str) else req.full_url
386
+ if url.startswith("https://reg.example.io/token"):
387
+ raise _http_error(404)
388
+ if url == "https://reg.example.io/v2/":
389
+ raise urllib.error.HTTPError(
390
+ url,
391
+ 401,
392
+ "unauthorized",
393
+ {"WWW-Authenticate": f'Bearer realm="{realm}",service="reg.example.io"'}, # type: ignore[arg-type]
394
+ None,
395
+ )
396
+ if url.startswith(realm):
397
+ return _BytesResp(json.dumps({"token": "disc-tok"}).encode())
398
+ raise AssertionError(f"unexpected URL in test: {url}")
399
+
400
+ with patch("urllib.request.urlopen", _fake):
401
+ self.assertEqual(
402
+ oras.fetch_anonymous_token("reg.example.io", "owner/repo"), "disc-tok"
403
+ )
404
+
405
+
406
+ class TestParseWwwAuthenticate(unittest.TestCase):
407
+ def test_bearer(self):
408
+ params = oras.parse_www_authenticate(
409
+ 'Bearer realm="https://a/token",service="svc",scope="repository:x:pull"'
410
+ )
411
+ self.assertEqual(params["realm"], "https://a/token")
412
+ self.assertEqual(params["service"], "svc")
413
+ self.assertEqual(params["scope"], "repository:x:pull")
414
+
415
+ def test_ignores_non_bearer(self):
416
+ self.assertEqual(oras.parse_www_authenticate('Basic realm="x"'), {})
417
+ self.assertEqual(oras.parse_www_authenticate(""), {})
418
+
419
+
420
+ # --------------------------------------------------------------------------
421
+ # Integration: Store.store_from_origin with a fetch_resolver
422
+ # --------------------------------------------------------------------------
423
+ _BLOB_PAYLOAD = b"oras-blob-bytes-" * 64 # 1 KiB
424
+
425
+
426
+ class _BlobOrigin(http.server.BaseHTTPRequestHandler):
427
+ """Fake registry CDN: serves the blob bytes on any GET, no auth check."""
428
+
429
+ def do_GET(self):
430
+ self.send_response(200)
431
+ self.send_header("Content-Type", "application/octet-stream")
432
+ self.send_header("Content-Length", str(len(_BLOB_PAYLOAD)))
433
+ self.end_headers()
434
+ self.wfile.write(_BLOB_PAYLOAD)
435
+
436
+ def log_message(self, format, *args):
437
+ pass
438
+
439
+
440
+ class TestStoreFromOriginWithResolver(unittest.TestCase):
441
+ """The contract that makes oras work end-to-end: the cache key is
442
+ the original ``url`` (the ``oras://`` ref), but the actual HTTP
443
+ request is sent to whatever the ``fetch_resolver`` callback hands
444
+ back. A subsequent ``get_blob(oras_url)`` must hit -- not require
445
+ re-resolving."""
446
+
447
+ def setUp(self):
448
+ self.httpd = socketserver.TCPServer(("127.0.0.1", 0), _BlobOrigin)
449
+ self.port = self.httpd.server_address[1]
450
+ self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True)
451
+ self.t.start()
452
+ self.store = server.Store(tempfile.mkdtemp(), keep_query=False)
453
+
454
+ def tearDown(self):
455
+ self.httpd.shutdown()
456
+ self.httpd.server_close()
457
+
458
+ def test_cache_key_is_oras_url_fetch_goes_through_resolver(self):
459
+ oras_url = "oras://ghcr.io/safl/nosi/sample:latest"
460
+ cdn_url = f"http://127.0.0.1:{self.port}/blobs/sha256:fake"
461
+
462
+ calls = {"n": 0}
463
+
464
+ def _resolver():
465
+ calls["n"] += 1
466
+ return cdn_url, {"Authorization": "Bearer test-bearer"}
467
+
468
+ row = self.store.store_from_origin(oras_url, fetch_resolver=_resolver)
469
+ self.assertEqual(row["size"], len(_BLOB_PAYLOAD))
470
+ # Resolver called at least once (a clean fetch is one attempt).
471
+ self.assertGreaterEqual(calls["n"], 1)
472
+ # Cache key is bound to the oras:// ref, not the CDN URL.
473
+ hit = self.store.get_blob(oras_url)
474
+ self.assertIsNotNone(hit)
475
+ # The CDN URL is NOT directly looked up: clients only know the oras ref.
476
+ self.assertIsNone(self.store.get_blob(cdn_url))
477
+
478
+
479
+ if __name__ == "__main__":
480
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes