net-kingdom/tools/contract-tests/oci_extract.py

84 lines
3.5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Extract one file from a digest-pinned public OCI image without docker (NK-WP-0058-T02). Read-only.
python3 tools/contract-tests/oci_extract.py --image forgejo.coulomb.social/coulomb/key-cape@sha256:<64 hex> \
--file keycape --out DIR/keycape
The manifest must hash to the pinned digest, and every layer read must hash to its manifest digest, so the file
is exactly the one in the pinned image. The pull token is the registry's anonymous one; no credential is used.
The newest layer holding the file wins, as in the image's own filesystem. Exit 0 when the file is written.
"""
import argparse
import hashlib
import io
import json
import re
import sys
import tarfile
import urllib.parse
import urllib.request
from pathlib import Path
ACCEPT = ', '.join(('application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json'))
class ExtractError(Exception):
pass
def get(url, token=None, accept=None, opener=urllib.request.urlopen):
headers = {**({'Authorization': 'Bearer ' + token} if token else {}), **({'Accept': accept} if accept else {})}
with opener(urllib.request.Request(url, headers=headers), timeout=120) as response:
return response.read()
def pull_token(host, repository, opener=urllib.request.urlopen):
query = urllib.parse.urlencode({'service': 'container_registry', 'scope': f'repository:{repository}:pull'})
return json.loads(get(f'https://{host}/v2/token?{query}', opener=opener))['token']
def extract(image, wanted, out, opener=urllib.request.urlopen):
match = re.fullmatch(r'([^/]+)/(.+)@(sha256:[0-9a-f]{64})', image)
if not match:
raise ExtractError('image must be host/repository@sha256:<64 hex>')
host, repository, digest = match.groups()
token = pull_token(host, repository, opener)
raw = get(f'https://{host}/v2/{repository}/manifests/{digest}', token, ACCEPT, opener)
if 'sha256:' + hashlib.sha256(raw).hexdigest() != digest:
raise ExtractError('manifest_digest_mismatch')
manifest = json.loads(raw)
if 'layers' not in manifest:
raise ExtractError('not_a_single_platform_manifest')
for layer in reversed(manifest['layers']):
blob = get(f"https://{host}/v2/{repository}/blobs/{layer['digest']}", token, opener=opener)
if 'sha256:' + hashlib.sha256(blob).hexdigest() != layer['digest']:
raise ExtractError('layer_digest_mismatch')
with tarfile.open(fileobj=io.BytesIO(blob), mode='r:*') as archive:
for member in archive.getmembers():
if member.isfile() and member.name.lstrip('./') == wanted.lstrip('/'):
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(archive.extractfile(member).read())
out.chmod(0o755)
return {'image': digest, 'layer': layer['digest'], 'file': wanted,
'sha256': hashlib.sha256(out.read_bytes()).hexdigest()}
raise ExtractError('file_not_in_image')
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('--image', required=True)
p.add_argument('--file', required=True)
p.add_argument('--out', required=True, type=Path)
a = p.parse_args(argv)
try:
print(json.dumps(extract(a.image, a.file, a.out)))
except ExtractError as error:
print(json.dumps({'status': 'refused', 'reason': str(error)}))
return 1
return 0
if __name__ == '__main__':
sys.exit(main())