Skip to content

Commit d6d2cdf

Browse files
committed
Add content verification
Signed-off-by: Judah Rand <17158624+judahrand@users.noreply.github.com>
1 parent a209c6c commit d6d2cdf

4 files changed

Lines changed: 263 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are:
1414
The versions coincide with releases on pip. Only major versions will be released as tags on Github.
1515

1616
## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x)
17+
- validate pulled blob size and digest before writing or extracting content (0.2.43)
1718
- add Layout `copy` for pull_from_registry capability (0.2.42)
1819
- make `get_manifest()` validation optional, fix `Accept` header join, and expand default `Accept` header types to cover all supported response types for the `/v2/<name>/manifests/<reference>` endpoint (0.2.41)
1920
- fix preemptive exit in non-empty `auths` lookup when `credsStore` or `credHelpers` is used (0.2.40)

oras/provider.py

Lines changed: 119 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
__license__ = "Apache-2.0"
44

55
import copy
6+
import enum
7+
import hashlib
8+
import hmac
69
import os
10+
import re
711
import sys
812
import urllib
913
from contextlib import contextmanager, nullcontext
1014
from dataclasses import asdict
1115
from http.cookiejar import DefaultCookiePolicy
1216
from tempfile import TemporaryDirectory
13-
from typing import Callable, Generator, List, Optional, Tuple, Union
17+
from typing import Any, Callable, Generator, List, Optional, Tuple, Union
1418

1519
import jsonschema
1620
import requests
@@ -27,6 +31,87 @@
2731
from oras.types import container_type
2832
from oras.utils.fileio import PathAndOptionalContent
2933

34+
_DIGEST_PATTERN = re.compile(
35+
r"^(?P<algorithm>[a-z0-9]+(?:[+._-][a-z0-9]+)*):" r"(?P<encoded>[a-zA-Z0-9=_-]+)$"
36+
)
37+
38+
39+
class RegisteredDigestAlgorithm(str, enum.Enum):
40+
SHA256 = "sha256"
41+
SHA512 = "sha512"
42+
43+
def hasher(self) -> Any:
44+
try:
45+
return hashlib.new(self.value)
46+
except ValueError as error:
47+
raise ValueError(f"Unsupported OCI digest algorithm: {self}.") from error
48+
49+
50+
def _parse_digest(digest: str) -> Tuple[RegisteredDigestAlgorithm, str]:
51+
"""Parse and validate an OCI digest's algorithm and encoded value."""
52+
if not isinstance(digest, str):
53+
raise ValueError(f"Invalid OCI digest: {digest!r}.")
54+
55+
match = _DIGEST_PATTERN.fullmatch(digest)
56+
if not match:
57+
raise ValueError(f"Invalid OCI digest: {digest!r}.")
58+
59+
algorithm = match.group("algorithm")
60+
try:
61+
registered_algorithm = RegisteredDigestAlgorithm(algorithm)
62+
except ValueError as error:
63+
raise ValueError(f"Unsupported OCI digest algorithm: {algorithm}.") from error
64+
encoded = match.group("encoded")
65+
encoded_length = registered_algorithm.hasher().digest_size * 2
66+
if encoded_length is None:
67+
raise ValueError(f"Unsupported OCI digest algorithm: {algorithm}.")
68+
69+
if not re.fullmatch(f"[a-f0-9]{{{encoded_length}}}", encoded):
70+
raise ValueError(
71+
f"Invalid {algorithm} digest encoding: expected {encoded_length} "
72+
"lowercase hexadecimal characters."
73+
)
74+
75+
return registered_algorithm, encoded
76+
77+
78+
class Digest:
79+
def __init__(self, digest: str) -> None:
80+
self.algorithm, self.encoded = _parse_digest(digest)
81+
82+
@property
83+
def digest(self) -> str:
84+
return f"{self.algorithm.value}:{self.encoded}"
85+
86+
def __str__(self) -> str:
87+
return self.digest
88+
89+
90+
def _validate_downloaded_blob(
91+
path: str,
92+
digest: Digest,
93+
expected_size: int,
94+
) -> None:
95+
"""Validate a downloaded blob's descriptor size and digest."""
96+
actual_size = os.path.getsize(path)
97+
if actual_size != expected_size:
98+
raise ValueError(
99+
f"Downloaded blob size mismatch for {digest}: expected "
100+
f"{expected_size} bytes, got {actual_size} bytes."
101+
)
102+
103+
hasher = digest.algorithm.hasher()
104+
with open(path, "rb") as blob:
105+
for chunk in iter(lambda: blob.read(8192), b""):
106+
hasher.update(chunk)
107+
108+
actual_encoded = hasher.hexdigest()
109+
if not hmac.compare_digest(actual_encoded, digest.encoded):
110+
raise ValueError(
111+
f"Downloaded blob digest mismatch: expected {digest}, got "
112+
f"{digest.algorithm.value}:{actual_encoded}."
113+
)
114+
30115

31116
@contextmanager
32117
def temporary_empty_config() -> Generator[str, None, None]:
@@ -889,14 +974,14 @@ def pull(
889974
:type outdir: str
890975
:param target: target location to pull from
891976
:type target: str
977+
:raises ValueError: if a layer descriptor or downloaded layer is invalid
892978
"""
893979
container = self.get_container(target)
894980
self.auth.load_configs(
895981
container, configs=[config_path] if config_path else None
896982
)
897983
manifest = self.get_manifest(container, allowed_media_type)
898984
outdir = outdir or oras.utils.get_tmpdir()
899-
overwrite = overwrite
900985

901986
files = []
902987
for layer in manifest.get("layers", []):
@@ -917,17 +1002,40 @@ def pull(
9171002
)
9181003
continue
9191004

920-
# A directory will need to be uncompressed and moved
921-
if layer["mediaType"] == oras.defaults.default_blob_dir_media_type:
922-
targz = oras.utils.get_tmpfile(suffix=".tar.gz")
923-
self.download_blob(container, layer["digest"], targz)
1005+
digest = Digest(layer["digest"])
1006+
expected_size = layer["size"]
1007+
if (
1008+
not isinstance(expected_size, int)
1009+
or isinstance(expected_size, bool)
1010+
or expected_size < 0
1011+
):
1012+
raise ValueError(
1013+
f"Invalid OCI descriptor size for {digest.digest}: {expected_size!r}."
1014+
)
1015+
1016+
outfile_dir = os.path.dirname(outfile)
1017+
if outfile_dir and not os.path.exists(outfile_dir):
1018+
oras.utils.mkdir_p(outfile_dir)
1019+
1020+
# Keep downloaded content private until its descriptor is verified.
1021+
with TemporaryDirectory(prefix=".oras-", dir=outfile_dir) as tmpdir:
1022+
is_directory = (
1023+
layer["mediaType"] == oras.defaults.default_blob_dir_media_type
1024+
)
1025+
staged = os.path.join(tmpdir, "blob.tar.gz" if is_directory else "blob")
1026+
self.download_blob(container, digest.digest, staged)
1027+
_validate_downloaded_blob(
1028+
staged,
1029+
digest,
1030+
expected_size,
1031+
)
9241032

925-
# The artifact will be extracted to the correct name
926-
oras.utils.extract_targz(targz, os.path.dirname(outfile))
1033+
# A verified directory archive can now be safely consumed.
1034+
if is_directory:
1035+
oras.utils.extract_targz(staged, outfile_dir)
1036+
else:
1037+
os.replace(staged, outfile)
9271038

928-
# Anything else just extracted directly
929-
else:
930-
self.download_blob(container, layer["digest"], outfile)
9311039
logger.info(f"Successfully pulled {outfile}.")
9321040
files.append(outfile)
9331041
return files

oras/tests/test_provider.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
__copyright__ = "Copyright The ORAS Authors."
33
__license__ = "Apache-2.0"
44

5+
import hashlib
56
import os
67
import subprocess
78
from pathlib import Path
@@ -17,6 +18,147 @@
1718
here = Path(__file__).resolve().parent
1819

1920

21+
def make_pull_client(monkeypatch, layer, content):
22+
"""Create a registry client whose pull inputs do not require a live registry."""
23+
client = oras.provider.Registry(insecure=True)
24+
monkeypatch.setattr(client, "get_container", lambda target: target)
25+
monkeypatch.setattr(client.auth, "load_configs", lambda *args, **kwargs: None)
26+
monkeypatch.setattr(
27+
client,
28+
"get_manifest",
29+
lambda container, allowed_media_type: {"layers": [layer]},
30+
)
31+
32+
def download_blob(container, digest, outfile):
33+
Path(outfile).write_bytes(content)
34+
return outfile
35+
36+
monkeypatch.setattr(client, "download_blob", download_blob)
37+
return client
38+
39+
40+
def test_digest_string_round_trip():
41+
original = f"sha256:{hashlib.sha256(b'content').hexdigest()}"
42+
43+
assert str(oras.provider.Digest(original)) == original
44+
45+
46+
@pytest.mark.parametrize("algorithm", ["sha256", "sha512"])
47+
def test_pull_validates_registered_digest(monkeypatch, tmp_path, algorithm):
48+
content = b"verified content"
49+
digest = f"{algorithm}:{hashlib.new(algorithm, content).hexdigest()}"
50+
layer = {
51+
"mediaType": oras.defaults.default_blob_media_type,
52+
"size": len(content),
53+
"digest": digest,
54+
"annotations": {oras.defaults.annotation_title: "artifact.txt"},
55+
}
56+
client = make_pull_client(monkeypatch, layer, content)
57+
58+
files = client.pull("registry.example/repository:tag", outdir=str(tmp_path))
59+
60+
outfile = tmp_path / "artifact.txt"
61+
assert files == [str(outfile)]
62+
assert outfile.read_bytes() == content
63+
64+
65+
def test_pull_rejects_digest_mismatch_without_replacing_file(monkeypatch, tmp_path):
66+
expected_content = b"expected content"
67+
downloaded_content = b"corrupt! content"
68+
digest = f"sha256:{hashlib.sha256(expected_content).hexdigest()}"
69+
layer = {
70+
"mediaType": oras.defaults.default_blob_media_type,
71+
"size": len(downloaded_content),
72+
"digest": digest,
73+
"annotations": {oras.defaults.annotation_title: "artifact.txt"},
74+
}
75+
client = make_pull_client(monkeypatch, layer, downloaded_content)
76+
outfile = tmp_path / "artifact.txt"
77+
outfile.write_bytes(b"existing content")
78+
79+
with pytest.raises(ValueError) as error:
80+
client.pull("registry.example/repository:tag", outdir=str(tmp_path))
81+
82+
actual_digest = f"sha256:{hashlib.sha256(downloaded_content).hexdigest()}"
83+
assert str(error.value) == (
84+
f"Downloaded blob digest mismatch: expected {digest}, got {actual_digest}."
85+
)
86+
assert outfile.read_bytes() == b"existing content"
87+
assert not list(tmp_path.glob(".oras-*"))
88+
89+
90+
def test_pull_rejects_size_mismatch(monkeypatch, tmp_path):
91+
content = b"content"
92+
digest = f"sha256:{hashlib.sha256(content).hexdigest()}"
93+
layer = {
94+
"mediaType": oras.defaults.default_blob_media_type,
95+
"size": len(content) + 1,
96+
"digest": digest,
97+
"annotations": {oras.defaults.annotation_title: "artifact.txt"},
98+
}
99+
client = make_pull_client(monkeypatch, layer, content)
100+
101+
with pytest.raises(ValueError, match="Downloaded blob size mismatch"):
102+
client.pull("registry.example/repository:tag", outdir=str(tmp_path))
103+
104+
assert not (tmp_path / "artifact.txt").exists()
105+
assert not list(tmp_path.glob(".oras-*"))
106+
107+
108+
def test_pull_validates_directory_before_extraction(monkeypatch, tmp_path):
109+
expected_content = b"expected archive"
110+
downloaded_content = b"corrupted archive"
111+
digest = f"sha256:{hashlib.sha256(expected_content).hexdigest()}"
112+
layer = {
113+
"mediaType": oras.defaults.default_blob_dir_media_type,
114+
"size": len(downloaded_content),
115+
"digest": digest,
116+
"annotations": {oras.defaults.annotation_title: "artifact"},
117+
}
118+
client = make_pull_client(monkeypatch, layer, downloaded_content)
119+
extracted = False
120+
121+
def extract_targz(*args, **kwargs):
122+
nonlocal extracted
123+
extracted = True
124+
125+
monkeypatch.setattr(oras.utils, "extract_targz", extract_targz)
126+
127+
with pytest.raises(ValueError, match="Downloaded blob digest mismatch"):
128+
client.pull("registry.example/repository:tag", outdir=str(tmp_path))
129+
130+
assert not extracted
131+
assert not (tmp_path / "artifact").exists()
132+
assert not list(tmp_path.glob(".oras-*"))
133+
134+
135+
@pytest.mark.parametrize(
136+
("digest", "error"),
137+
[
138+
("sha256+b64u:YWJj", "Unsupported OCI digest algorithm"),
139+
(f"sha384:{'a' * 96}", "Unsupported OCI digest algorithm"),
140+
(f"sha256:{'A' * 64}", "Invalid sha256 digest encoding"),
141+
(f"sha256:{'a' * 63}", "Invalid sha256 digest encoding"),
142+
("sha256:not!hex", "Invalid OCI digest"),
143+
("sha256:", "Invalid OCI digest"),
144+
(f"SHA256:{'a' * 64}", "Invalid OCI digest"),
145+
],
146+
)
147+
def test_pull_rejects_invalid_digest_encoding(monkeypatch, tmp_path, digest, error):
148+
layer = {
149+
"mediaType": oras.defaults.default_blob_media_type,
150+
"size": 0,
151+
"digest": digest,
152+
"annotations": {oras.defaults.annotation_title: "artifact.txt"},
153+
}
154+
client = make_pull_client(monkeypatch, layer, b"")
155+
156+
with pytest.raises(ValueError, match=error):
157+
client.pull("registry.example/repository:tag", outdir=str(tmp_path))
158+
159+
assert not (tmp_path / "artifact.txt").exists()
160+
161+
20162
@pytest.mark.with_auth(False)
21163
def test_annotated_registry_push(tmp_path, registry, credentials, target):
22164
"""

oras/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
__copyright__ = "Copyright The ORAS Authors."
33
__license__ = "Apache-2.0"
44

5-
__version__ = "0.2.42"
5+
__version__ = "0.2.43"
66
AUTHOR = "Vanessa Sochat"
77
EMAIL = "vsoch@users.noreply.github.com"
88
NAME = "oras"

0 commit comments

Comments
 (0)