Skip to content

Commit dd326a7

Browse files
committed
chore: address comments and improve test coverage (6)
Signed-off-by: jiwangCHEN <hyper1char@gmail.com>
1 parent 38cb05b commit dd326a7

4 files changed

Lines changed: 119 additions & 63 deletions

File tree

oras/copy/graph.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,20 @@ def _copy_node(
347347

348348

349349
def _verify_digest(data: bytes, expected_digest: str) -> None:
350-
"""Verify fetched content matches the expected digest."""
351-
if not expected_digest or ":" not in expected_digest:
350+
"""Verify fetched content matches the expected digest.
351+
352+
An empty digest means there is nothing to verify against and is skipped.
353+
A malformed digest or an unsupported algorithm is treated as an error so
354+
callers never mistake unverified content for verified content.
355+
"""
356+
if not expected_digest:
352357
return
358+
if ":" not in expected_digest:
359+
raise CopyError(
360+
"VerifyDigest",
361+
CopyErrorOrigin.SOURCE,
362+
ValueError(f"invalid digest format: {expected_digest!r}"),
363+
)
353364
algorithm, expected_hash = expected_digest.split(":", 1)
354365
try:
355366
h = hashlib.new(algorithm)

oras/tests/conftest.py

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,8 @@
1-
import io
21
import os
3-
import threading
42
from dataclasses import dataclass
5-
from typing import BinaryIO, Dict, Optional
63

74
import pytest
85

9-
from oras.types import Descriptor
10-
11-
12-
class InMemoryTarget:
13-
"""
14-
Thread-safe in-memory Target for copy engine tests.
15-
16-
Implements the full Target protocol (fetch, exists, push, tag, resolve)
17-
using plain dicts backed by a threading.Lock. Raises FileExistsError
18-
on duplicate push so copy engine idempotency handling is exercised.
19-
"""
20-
21-
def __init__(self):
22-
self._content: Dict[str, bytes] = {}
23-
self._tags: Dict[str, Descriptor] = {}
24-
self._lock = threading.Lock()
25-
26-
def fetch(self, desc: Descriptor) -> BinaryIO:
27-
digest = desc.get("digest", "")
28-
with self._lock:
29-
data = self._content.get(digest)
30-
if data is None:
31-
raise FileNotFoundError(f"content not found: {digest}")
32-
return io.BytesIO(data)
33-
34-
def exists(self, desc: Descriptor) -> bool:
35-
digest = desc.get("digest", "")
36-
with self._lock:
37-
return digest in self._content
38-
39-
def push(self, desc: Descriptor, content: BinaryIO) -> None:
40-
data = content.read()
41-
digest = desc.get("digest", "")
42-
with self._lock:
43-
if digest in self._content:
44-
raise FileExistsError(f"content already exists: {digest}")
45-
self._content[digest] = data
46-
47-
def tag(self, desc: Descriptor, reference: str) -> None:
48-
with self._lock:
49-
self._tags[reference] = desc
50-
51-
def resolve(self, reference: str) -> Descriptor:
52-
with self._lock:
53-
desc = self._tags.get(reference)
54-
if desc is None:
55-
raise FileNotFoundError(f"reference not found: {reference}")
56-
return desc
57-
58-
def get_content(self, digest: str) -> bytes:
59-
with self._lock:
60-
return self._content.get(digest, b"")
61-
62-
def get_tag(self, reference: str) -> Optional[Descriptor]:
63-
with self._lock:
64-
return self._tags.get(reference)
65-
666

677
@dataclass
688
class TestCredentials:

oras/tests/helpers.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Shared test helpers for the oras test suite.
3+
4+
Plain helper module (not a conftest) so it can be safely imported by tests.
5+
Pytest may load ``conftest.py`` under its own internal module name, and
6+
importing it as a package module can cause it to execute twice, leading to
7+
confusing fixture/state duplication. Reusable helpers therefore live here.
8+
"""
9+
10+
import io
11+
import threading
12+
from typing import BinaryIO, Dict, Optional
13+
14+
from oras.types import Descriptor
15+
16+
17+
class InMemoryTarget:
18+
"""
19+
Thread-safe in-memory Target for copy engine tests.
20+
21+
Implements the full Target protocol (fetch, exists, push, tag, resolve)
22+
using plain dicts backed by a threading.Lock. Raises FileExistsError
23+
on duplicate push so copy engine idempotency handling is exercised.
24+
"""
25+
26+
def __init__(self):
27+
self._content: Dict[str, bytes] = {}
28+
self._tags: Dict[str, Descriptor] = {}
29+
self._lock = threading.Lock()
30+
31+
def fetch(self, desc: Descriptor) -> BinaryIO:
32+
digest = desc.get("digest", "")
33+
with self._lock:
34+
data = self._content.get(digest)
35+
if data is None:
36+
raise FileNotFoundError(f"content not found: {digest}")
37+
return io.BytesIO(data)
38+
39+
def exists(self, desc: Descriptor) -> bool:
40+
digest = desc.get("digest", "")
41+
with self._lock:
42+
return digest in self._content
43+
44+
def push(self, desc: Descriptor, content: BinaryIO) -> None:
45+
data = content.read()
46+
digest = desc.get("digest", "")
47+
with self._lock:
48+
if digest in self._content:
49+
raise FileExistsError(f"content already exists: {digest}")
50+
self._content[digest] = data
51+
52+
def tag(self, desc: Descriptor, reference: str) -> None:
53+
with self._lock:
54+
self._tags[reference] = desc
55+
56+
def resolve(self, reference: str) -> Descriptor:
57+
with self._lock:
58+
desc = self._tags.get(reference)
59+
if desc is None:
60+
raise FileNotFoundError(f"reference not found: {reference}")
61+
return desc
62+
63+
def get_content(self, digest: str) -> bytes:
64+
with self._lock:
65+
return self._content.get(digest, b"")
66+
67+
def get_tag(self, reference: str) -> Optional[Descriptor]:
68+
with self._lock:
69+
return self._tags.get(reference)

oras/tests/test_copy.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
)
3232
from oras.copy.graph import LimitedRegion, copy_graph, successors
3333
from oras.copy.tracker import StatusTracker
34-
from oras.tests.conftest import InMemoryTarget # shared test fixture
34+
from oras.tests.helpers import InMemoryTarget # shared test helper
3535
from oras.types import (
3636
descriptor_key,
3737
descriptors_equal,
@@ -2114,6 +2114,42 @@ def test_correct_content_passes_verification(self):
21142114
assert dst.exists(blob_desc)
21152115
assert dst.get_content(blob_desc["digest"]) == blob_data
21162116

2117+
def test_invalid_digest_format_raises_error(self):
2118+
"""A non-empty but malformed digest must be rejected, not skipped."""
2119+
src = InMemoryTarget()
2120+
dst = InMemoryTarget()
2121+
2122+
blob_data = b"deadbeef"
2123+
bad_desc = {
2124+
"mediaType": "application/octet-stream",
2125+
"digest": "deadbeef", # missing "algorithm:" prefix
2126+
"size": len(blob_data),
2127+
}
2128+
with src._lock:
2129+
src._content[bad_desc["digest"]] = blob_data
2130+
2131+
with pytest.raises(CopyError, match="invalid digest format"):
2132+
copy_graph(src, dst, bad_desc)
2133+
assert not dst.exists(bad_desc)
2134+
2135+
def test_unsupported_digest_algorithm_raises_error(self):
2136+
"""A digest with an unsupported algorithm must be rejected, not skipped."""
2137+
src = InMemoryTarget()
2138+
dst = InMemoryTarget()
2139+
2140+
blob_data = b"payload"
2141+
bad_desc = {
2142+
"mediaType": "application/octet-stream",
2143+
"digest": "fakealgo:abcdef",
2144+
"size": len(blob_data),
2145+
}
2146+
with src._lock:
2147+
src._content[bad_desc["digest"]] = blob_data
2148+
2149+
with pytest.raises(CopyError, match="unsupported digest algorithm"):
2150+
copy_graph(src, dst, bad_desc)
2151+
assert not dst.exists(bad_desc)
2152+
21172153

21182154
# ---------------------------------------------------------------------------
21192155
# Tests for options mutation safety (C3 fix)

0 commit comments

Comments
 (0)