Skip to content

Commit 7c0106c

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

5 files changed

Lines changed: 214 additions & 63 deletions

File tree

oras/copy/copy.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
1. The algorithm is a post-order concurrent DAG walk
2+
Children are always fully committed before their parent is pushed. This is not just an ordering preference — it is a correctness invariant. A registry will reject a manifest whose layers do not yet exist. The semaphore-release trick (region.end() before recursing into children, region.start() before copying self) is what makes this safe at any concurrency level, including 1.
3+
2. Deduplication is a first-class primitive, not an afterthought
4+
StatusTracker is the first gate every node passes through. Without it, shared blobs referenced by multiple manifests in an index would be fetched and pushed N times concurrently and race on dst.push(). The tracker collapses N workers into 1 doer + N-1 waiters.
5+
3. The cache proxy decouples resolution from traversal
6+
CacheProxy absorbs manifest and index bytes on first fetch (during _resolve_root / successors). When _process_node later copies the root, it reads from memory instead of re-hitting the network. The stop_caching flag exists because map_root may re-fetch different content (platform selection) that should not pollute the cache.
7+
4. Hooks are the extension seam, not subclassing
8+
pre_copy, post_copy, on_copy_skipped, on_mounted are injected callables. The tagging mechanism (ensuring dst_ref ends up pointing at the root) is implemented entirely through these hooks, not through a special code path in the traversal. SkipNode raised from pre_copy short-circuits _do_copy_node — this is how ReferencePusher atomically pushes + tags the root in a single PUT.
9+
5. Two destination capabilities drive two tagging strategies
10+
- Plain Target (e.g., disk layout, in-memory): tag is a separate call; the hook fires after the copy
11+
- ReferencePusher (e.g., OCI registry): tag is embedded in the push URL; the hook fires before the copy and skips the normal push..
12+
6. Mount is an opportunistic cross-repo blob copy
13+
_mount_or_copy_node only applies to non-manifest blobs when dst is a Mounter. It posts a server-side copy hint; the registry may honor it (201 → done) or refuse (202 → fall back to full upload). The fallback is unified with normal upload via the get_content closure, so mount and copy share the same post/pre-copy hook lifecycle.
14+
7. Foreign layer filtering is a pre-traversal filter, not a skip hook
15+
remove_foreign_layers() strips non-distributable layers from the successors list. These layers are never fetched, never pushed, and never dedup-checked. If you need to transfer them, you must override find_successors.
16+
17+
```
18+
### COPY Overview
19+
20+
ACTION copy(src, src_ref, dst, dst_ref, opts):
21+
state ← ValidatingInputs
22+
opts ← opts.clone() or default Options -- mutation safety
23+
24+
if dst_ref == "": dst_ref ← src_ref
25+
26+
-- ValidatingInputs
27+
assert src ≠ null else raise CopyError(SOURCE, "Resolve")
28+
assert dst ≠ null else raise CopyError(DEST, "Resolve")
29+
state ← CreatingProxy
30+
31+
-- CreatingProxy
32+
proxy ← CacheProxy(base=src, max_bytes=opts.max_metadata_bytes)
33+
state ← ResolvingRoot
34+
35+
-- ResolvingRoot
36+
root ← resolve_root(src, src_ref, proxy) -- see below
37+
state ← MappingRoot
38+
39+
-- MappingRoot
40+
if opts.map_root ≠ null:
41+
proxy.stop_caching ← true
42+
root ← opts.map_root(proxy, root)
43+
proxy.stop_caching ← false
44+
state ← InstallingHooks
45+
46+
-- InstallingHooks
47+
install_tagging_hooks(dst, dst_ref, proxy, root, opts) -- see below
48+
state ← RunningGraph
49+
50+
-- RunningGraph
51+
copy_graph(src=src, dst=dst, root=root, proxy=proxy, opts=opts)
52+
state ← Done(root)
53+
return root
54+
```
55+
56+
```
57+
### Concurrency
58+
59+
─────────────────────────────────────────────────────────────────────
60+
WORKER LAUNCHER (with semaphore gating)
61+
─────────────────────────────────────────────────────────────────────
62+
63+
ACTION launch_workers(sem, fn, descriptors, cancel):
64+
for each desc in descriptors:
65+
thread ← new daemon Thread:
66+
if cancel.is_set(): return
67+
region ← LimitedRegion(sem)
68+
region.start() -- acquire slot (blocks if full)
69+
if cancel.is_set():
70+
region.end(); return
71+
try:
72+
fn(region, desc)
73+
catch e:
74+
first_error ← e -- capture first error only
75+
cancel.set() -- signal all workers to stop
76+
finally:
77+
region.end() -- always release slot
78+
thread.start()
79+
80+
81+
─────────────────────────────────────────────────────────────────────
82+
COPY NODE
83+
─────────────────────────────────────────────────────────────────────
84+
85+
Per-node: Claimed → CheckingExistence
86+
→ [Skipped | FindingSuccessors]
87+
→ [YieldingSlot → WaitingForChildren → ReacquiringSlot]?
88+
→ Copying → NodeDone
89+
90+
Single copy: PreCopy → [Skipped_ByHook | Transferring → PostCopy → NodeCopied]
91+
92+
States: Fetching → Buffering → VerifyingDigest → VerifyingSize
93+
→ Pushing → Transferred
94+
95+
```

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)