truhe 1.0.0__py3-none-any.whl

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.
truhe/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from truhe.filesystem import FileSystemBlobStore # noqa
2
+ from truhe.format import EncryptionError # noqa
3
+ from truhe.format import Keyring # noqa
4
+ from truhe.sealed import EncryptedBlobStore # noqa
5
+ from truhe.store import BlobStoreError # noqa
6
+ from truhe.store import BlobStream # noqa
7
+ from truhe.store import ListedBlob # noqa
8
+ from truhe.store import MemoryBlobStore # noqa
9
+ from truhe.store import StoredBlob # noqa
truhe/_s3.py ADDED
@@ -0,0 +1,118 @@
1
+ """What the synchronous and the asynchronous S3 store share: addresses, the
2
+ listing, the reason of a refusal. No I/O."""
3
+
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from datetime import timezone
7
+ from truhe.sigv4 import UNRESERVED
8
+ from truhe.store import ListedBlob
9
+ from urllib.parse import quote
10
+ from urllib.parse import urlencode
11
+ from xml.etree import ElementTree
12
+ import base64
13
+ import hashlib
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Bucket:
18
+ """Where a bucket is.
19
+
20
+ **Virtual hosted** by default (``{bucket}.{endpoint host}``), the form
21
+ providers document, and the bucket is then in the host name, which is
22
+ signed. ``path_style`` puts it in the path (``{endpoint}/{bucket}``) for
23
+ stores which do not resolve bucket host names, e.g. MinIO or Garage
24
+ without a domain.
25
+ """
26
+
27
+ endpoint: str
28
+ name: str
29
+ path_style: bool = False
30
+
31
+ def _scheme_host(self):
32
+ scheme = 'http' if self.endpoint.startswith('http://') else 'https'
33
+ host = self.endpoint.removeprefix('https://').removeprefix('http://')
34
+ return scheme, host.rstrip('/')
35
+
36
+ def url(self):
37
+ scheme, host = self._scheme_host()
38
+ if self.path_style:
39
+ return f'{scheme}://{host}/{self.name}'
40
+ return f'{scheme}://{self.name}.{host}/'
41
+
42
+ def object_url(self, key):
43
+ """The object's URL. The key is quoted per segment, ``/`` stays."""
44
+ scheme, host = self._scheme_host()
45
+ quoted = quote(key, safe='/' + UNRESERVED)
46
+ if self.path_style:
47
+ return f'{scheme}://{host}/{self.name}/{quoted}'
48
+ return f'{scheme}://{self.name}.{host}/{quoted}'
49
+
50
+ def list_url(self, prefix, token=None):
51
+ """``ListObjectsV2`` for ``prefix``, from ``token`` on."""
52
+ query = {'list-type': '2', 'prefix': prefix}
53
+ if token:
54
+ query['continuation-token'] = token
55
+ return self.url() + '?' + urlencode(query, quote_via=quote, safe=UNRESERVED)
56
+
57
+
58
+ def content_md5(data):
59
+ return base64.b64encode(hashlib.md5(data).digest()).decode()
60
+
61
+
62
+ def payload_sha256(data):
63
+ return hashlib.sha256(data).hexdigest()
64
+
65
+
66
+ def _local(tag):
67
+ return tag.rsplit('}', 1)[-1]
68
+
69
+
70
+ def _children(element, name):
71
+ return [child for child in element if _local(child.tag) == name]
72
+
73
+
74
+ def _text(element, name):
75
+ found = _children(element, name)
76
+ return found[0].text if found else None
77
+
78
+
79
+ def _timestamp(value):
80
+ # S3 writes ``2026-10-07T10:00:00.000Z``, ``fromisoformat`` before Python
81
+ # 3.11 does not read the ``Z``.
82
+ return datetime.fromisoformat(value.replace('Z', '+00:00')).astimezone(timezone.utc)
83
+
84
+
85
+ def parse_listing(data):
86
+ """The entries of one page of a listing, and the token of the next page or
87
+ None."""
88
+ root = ElementTree.fromstring(data)
89
+ blobs = [
90
+ ListedBlob(
91
+ key=_text(entry, 'Key'),
92
+ size=int(_text(entry, 'Size')),
93
+ modified=_timestamp(_text(entry, 'LastModified')),
94
+ )
95
+ for entry in _children(root, 'Contents')
96
+ ]
97
+ if _text(root, 'IsTruncated') != 'true':
98
+ return blobs, None
99
+ return blobs, _text(root, 'NextContinuationToken')
100
+
101
+
102
+ def reason(method, key, status, body):
103
+ """A message which names the cause and not just the number.
104
+
105
+ S3 puts the reason in an XML body. A bare 403 sends the reader looking at
106
+ credentials when the answer was ``SignatureDoesNotMatch``.
107
+ """
108
+ detail = ''
109
+ try:
110
+ root = ElementTree.fromstring(body or b'')
111
+ except ElementTree.ParseError:
112
+ root = None
113
+ if root is not None:
114
+ for name in ('Code', 'Message'):
115
+ text = root.findtext(name)
116
+ if text:
117
+ detail += f' {text}'
118
+ return f'{method} {key}: {status}{detail}'
truhe/aio/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from truhe.aio.sealed import EncryptedBlobStore # noqa
2
+ from truhe.aio.store import MemoryBlobStore # noqa
truhe/aio/s3.py ADDED
@@ -0,0 +1,148 @@
1
+ """The asynchronous blob store against an S3 compatible store, over ``httpx``.
2
+
3
+ Install with ``truhe[httpx]``. The requests of :mod:`truhe.s3`: bytes signed
4
+ with ``Content-MD5``, streams with ``UNSIGNED-PAYLOAD`` and their
5
+ ``Content-Length``.
6
+ """
7
+
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from datetime import timezone
12
+ from truhe._s3 import Bucket
13
+ from truhe._s3 import content_md5
14
+ from truhe._s3 import parse_listing
15
+ from truhe._s3 import payload_sha256
16
+ from truhe._s3 import reason
17
+ from truhe.aio.store import counted
18
+ from truhe.sigv4 import Credentials
19
+ from truhe.sigv4 import EMPTY_PAYLOAD_SHA256
20
+ from truhe.sigv4 import signed_headers
21
+ from truhe.sigv4 import UNSIGNED_PAYLOAD
22
+ from truhe.store import BlobStoreError
23
+ from truhe.store import BlobStream
24
+ from truhe.store import checked_length
25
+ from truhe.store import is_bytes
26
+ from truhe.store import READ_BYTES
27
+ from truhe.store import StoredBlob
28
+ import httpx
29
+
30
+
31
+ @dataclass
32
+ class S3BlobStore:
33
+ """Signed access to one bucket.
34
+
35
+ ``client`` is passed in, so the caller owns its lifetime. A client per
36
+ request would throw away connection reuse.
37
+ """
38
+
39
+ credentials: Credentials
40
+ endpoint: str
41
+ bucket: str
42
+ client: httpx.AsyncClient
43
+ path_style: bool = False
44
+
45
+ def __post_init__(self):
46
+ self._bucket = Bucket(self.endpoint, self.bucket, self.path_style)
47
+
48
+ def url_for(self, key):
49
+ return self._bucket.object_url(key)
50
+
51
+ def _signed(self, method, url, payload_hash=EMPTY_PAYLOAD_SHA256, headers=None):
52
+ return signed_headers(
53
+ credentials=self.credentials,
54
+ method=method,
55
+ url=url,
56
+ headers=headers,
57
+ payload_hash=payload_hash,
58
+ now=datetime.now(timezone.utc),
59
+ )
60
+
61
+ async def put(self, key, data, content_type, content_length=None):
62
+ length = checked_length(data, content_length)
63
+ url = self.url_for(key)
64
+ headers = {'Content-Type': content_type}
65
+ if is_bytes(data):
66
+ data = bytes(data)
67
+ headers['Content-MD5'] = content_md5(data)
68
+ signed = self._signed('PUT', url, payload_sha256(data), headers)
69
+ content = data
70
+ else:
71
+ # httpx leaves out the chunked transfer encoding when the
72
+ # Content-Length is set, S3 refuses it without its own framing.
73
+ headers['Content-Length'] = str(length)
74
+ signed = self._signed('PUT', url, UNSIGNED_PAYLOAD, headers)
75
+ content = counted(data, length)
76
+ response = await self.client.request('PUT', url, headers=signed, content=content)
77
+ if response.status_code >= 400:
78
+ raise BlobStoreError(
79
+ reason('PUT', key, response.status_code, response.content)
80
+ )
81
+ return StoredBlob(size=length, content_type=content_type)
82
+
83
+ @asynccontextmanager
84
+ async def open(self, key):
85
+ url = self.url_for(key)
86
+ request = self.client.build_request('GET', url, headers=self._signed('GET', url))
87
+ response = await self.client.send(request, stream=True)
88
+ try:
89
+ if response.status_code == 404:
90
+ raise KeyError(key)
91
+ if response.status_code >= 400:
92
+ await response.aread()
93
+ raise BlobStoreError(
94
+ reason('GET', key, response.status_code, response.content)
95
+ )
96
+ yield BlobStream(
97
+ meta=StoredBlob(
98
+ size=int(response.headers.get('content-length', 0)),
99
+ content_type=response.headers.get('content-type'),
100
+ ),
101
+ chunks=response.aiter_bytes(READ_BYTES),
102
+ )
103
+ finally:
104
+ # Always: an abandoned streaming response holds its connection out
105
+ # of the pool until the client is closed.
106
+ await response.aclose()
107
+
108
+ async def head(self, key):
109
+ url = self.url_for(key)
110
+ response = await self.client.request(
111
+ 'HEAD', url, headers=self._signed('HEAD', url)
112
+ )
113
+ if response.status_code == 404:
114
+ return None
115
+ if response.status_code >= 400:
116
+ raise BlobStoreError(reason('HEAD', key, response.status_code, b''))
117
+ return StoredBlob(
118
+ size=int(response.headers.get('content-length', 0)),
119
+ content_type=response.headers.get('content-type'),
120
+ )
121
+
122
+ async def delete(self, key):
123
+ url = self.url_for(key)
124
+ response = await self.client.request(
125
+ 'DELETE', url, headers=self._signed('DELETE', url)
126
+ )
127
+ if response.status_code >= 400 and response.status_code != 404:
128
+ raise BlobStoreError(
129
+ reason('DELETE', key, response.status_code, response.content)
130
+ )
131
+
132
+ async def list(self, prefix):
133
+ """``ListObjectsV2``, page by page along the continuation token."""
134
+ token = None
135
+ while True:
136
+ url = self._bucket.list_url(prefix, token)
137
+ response = await self.client.request(
138
+ 'GET', url, headers=self._signed('GET', url)
139
+ )
140
+ if response.status_code >= 400:
141
+ raise BlobStoreError(
142
+ reason('LIST', prefix, response.status_code, response.content)
143
+ )
144
+ blobs, token = parse_listing(response.content)
145
+ for blob in blobs:
146
+ yield blob
147
+ if token is None:
148
+ return
truhe/aio/sealed.py ADDED
@@ -0,0 +1,89 @@
1
+ """The asynchronous blob store which seals on the way in and opens on the way
2
+ out. The format is :mod:`truhe.format`, the behaviour the one of
3
+ :class:`truhe.sealed.EncryptedBlobStore`."""
4
+
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import dataclass
7
+ from truhe.format import ciphertext_length
8
+ from truhe.format import DEFAULT_MAGIC
9
+ from truhe.format import Keyring
10
+ from truhe.format import Opener
11
+ from truhe.format import plaintext_length
12
+ from truhe.format import seal
13
+ from truhe.format import Sealer
14
+ from truhe.store import BlobStream
15
+ from truhe.store import checked_length
16
+ from truhe.store import is_bytes
17
+ from truhe.store import ListedBlob
18
+ from truhe.store import StoredBlob
19
+
20
+
21
+ async def sealed(keyring, chunks, content_length, magic=DEFAULT_MAGIC):
22
+ """Iterate the stored form of the async ``chunks``."""
23
+ sealer = Sealer(keyring, content_length, magic=magic)
24
+ yield sealer.header
25
+ async for piece in chunks:
26
+ for block in sealer.feed(piece):
27
+ yield block
28
+ for block in sealer.finish():
29
+ yield block
30
+
31
+
32
+ async def unsealed(keyring, key, chunks, magic=DEFAULT_MAGIC):
33
+ """Iterate the plaintext of a stored object from the async ``chunks``."""
34
+ opener = Opener(keyring, key, magic=magic)
35
+ async for piece in chunks:
36
+ for plain in opener.feed(piece):
37
+ yield plain
38
+ for plain in opener.finish():
39
+ yield plain
40
+
41
+
42
+ @dataclass
43
+ class EncryptedBlobStore:
44
+ """Encrypts what goes into the asynchronous ``inner`` with ``keyring``."""
45
+
46
+ inner: object
47
+ keyring: Keyring
48
+ magic: bytes = DEFAULT_MAGIC
49
+
50
+ async def put(self, key, data, content_type, content_length=None):
51
+ length = checked_length(data, content_length)
52
+ if is_bytes(data):
53
+ await self.inner.put(key, seal(self.keyring, data, self.magic), content_type)
54
+ else:
55
+ await self.inner.put(
56
+ key,
57
+ sealed(self.keyring, data, length, self.magic),
58
+ content_type,
59
+ ciphertext_length(length),
60
+ )
61
+ return StoredBlob(size=length, content_type=content_type)
62
+
63
+ @asynccontextmanager
64
+ async def open(self, key):
65
+ async with self.inner.open(key) as stream:
66
+ yield BlobStream(
67
+ meta=StoredBlob(
68
+ size=plaintext_length(stream.meta.size),
69
+ content_type=stream.meta.content_type,
70
+ ),
71
+ chunks=unsealed(self.keyring, key, stream.chunks, self.magic),
72
+ )
73
+
74
+ async def head(self, key):
75
+ stored = await self.inner.head(key)
76
+ if stored is None:
77
+ return None
78
+ return StoredBlob(
79
+ size=plaintext_length(stored.size), content_type=stored.content_type
80
+ )
81
+
82
+ async def delete(self, key):
83
+ await self.inner.delete(key)
84
+
85
+ async def list(self, prefix):
86
+ async for blob in self.inner.list(prefix):
87
+ yield ListedBlob(
88
+ key=blob.key, size=plaintext_length(blob.size), modified=blob.modified
89
+ )
truhe/aio/store.py ADDED
@@ -0,0 +1,116 @@
1
+ """The asynchronous blob store interface, and an in-memory store.
2
+
3
+ The operations of :mod:`truhe.store`, as coroutines: ``put`` takes bytes or an
4
+ async iterable of bytes, ``open`` is an async context manager whose stream
5
+ yields asynchronously, ``list`` is an async iterator. The data classes and
6
+ errors are the synchronous ones.
7
+ """
8
+
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass
11
+ from dataclasses import field
12
+ from datetime import datetime
13
+ from datetime import timezone
14
+ from truhe.store import BlobStoreError
15
+ from truhe.store import BlobStream
16
+ from truhe.store import checked_length
17
+ from truhe.store import is_bytes
18
+ from truhe.store import ListedBlob
19
+ from truhe.store import READ_BYTES
20
+ from truhe.store import StoredBlob
21
+ from typing import Protocol
22
+
23
+
24
+ class AsyncBlobStore(Protocol):
25
+ async def put(self, key, data, content_type, content_length=None):
26
+ """Store ``data``, bytes or an async iterable of bytes, under ``key``.
27
+ ``content_length`` is required for an iterable."""
28
+
29
+ def open(self, key):
30
+ """Async context manager yielding a :class:`~truhe.store.BlobStream`
31
+ with async ``chunks``. Raises ``KeyError`` if there is no such
32
+ object."""
33
+
34
+ async def head(self, key):
35
+ """:class:`~truhe.store.StoredBlob`, or None."""
36
+
37
+ async def delete(self, key):
38
+ """Remove the object. Removing what is not there is no error."""
39
+
40
+ def list(self, prefix):
41
+ """Async iterator of :class:`~truhe.store.ListedBlob`, in key order."""
42
+
43
+
44
+ def _too_many(content_length):
45
+ return BlobStoreError(
46
+ f'more than {content_length} bytes to store, {content_length} announced'
47
+ )
48
+
49
+
50
+ async def counted(chunks, content_length):
51
+ """Pass ``chunks`` on and fail as soon as they disagree with
52
+ ``content_length``. The piece which completes the announced length is only
53
+ passed on once the stream is known to end there, see
54
+ :func:`truhe.store.counted`."""
55
+ iterator = aiter(chunks)
56
+ seen = 0
57
+ async for chunk in iterator:
58
+ seen += len(chunk)
59
+ if seen > content_length:
60
+ raise _too_many(content_length)
61
+ if seen == content_length:
62
+ async for extra in iterator:
63
+ if extra:
64
+ raise _too_many(content_length)
65
+ yield chunk
66
+ if seen != content_length:
67
+ raise BlobStoreError(f'{seen} bytes to store, {content_length} announced')
68
+
69
+
70
+ @dataclass
71
+ class MemoryBlobStore:
72
+ """Keeps objects in a dict. For tests, see the synchronous one."""
73
+
74
+ objects: dict = field(default_factory=dict)
75
+ content_types: dict = field(default_factory=dict)
76
+ modified: dict = field(default_factory=dict)
77
+ read_size: int = READ_BYTES
78
+
79
+ async def put(self, key, data, content_type, content_length=None):
80
+ length = checked_length(data, content_length)
81
+ if not is_bytes(data):
82
+ data = b''.join([chunk async for chunk in counted(data, length)])
83
+ self.objects[key] = bytes(data)
84
+ self.content_types[key] = content_type
85
+ self.modified[key] = datetime.now(timezone.utc)
86
+ return StoredBlob(size=length, content_type=content_type)
87
+
88
+ def _meta(self, key):
89
+ return StoredBlob(
90
+ size=len(self.objects[key]), content_type=self.content_types.get(key)
91
+ )
92
+
93
+ @asynccontextmanager
94
+ async def open(self, key):
95
+ data = self.objects[key]
96
+
97
+ async def chunks():
98
+ for i in range(0, len(data), self.read_size):
99
+ yield data[i : i + self.read_size]
100
+
101
+ yield BlobStream(meta=self._meta(key), chunks=chunks())
102
+
103
+ async def head(self, key):
104
+ return self._meta(key) if key in self.objects else None
105
+
106
+ async def delete(self, key):
107
+ self.objects.pop(key, None)
108
+ self.content_types.pop(key, None)
109
+ self.modified.pop(key, None)
110
+
111
+ async def list(self, prefix):
112
+ for key in sorted(self.objects):
113
+ if key.startswith(prefix):
114
+ yield ListedBlob(
115
+ key=key, size=len(self.objects[key]), modified=self.modified[key]
116
+ )
truhe/filesystem.py ADDED
@@ -0,0 +1,116 @@
1
+ """A blob store on the local filesystem.
2
+
3
+ The path is the key below the root. For development, and in operation where a
4
+ local or mounted directory is the store. Wrapped in
5
+ :class:`~truhe.sealed.EncryptedBlobStore` like every store, what lies there is
6
+ ciphertext.
7
+
8
+ **Writes are atomic**: a temporary file next to the target, renamed into place.
9
+ A crash leaves the old state or the new one, never half an object. Temporary
10
+ files start with a dot and are never listed.
11
+
12
+ **A key never leaves the root.** Segments which are empty, ``.`` or ``..`` are
13
+ refused, this is the one backend in which a bad key reaches a filesystem path.
14
+ """
15
+
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from datetime import timezone
20
+ from pathlib import Path
21
+ from truhe.store import BlobStoreError
22
+ from truhe.store import BlobStream
23
+ from truhe.store import checked_length
24
+ from truhe.store import counted
25
+ from truhe.store import is_bytes
26
+ from truhe.store import ListedBlob
27
+ from truhe.store import READ_BYTES
28
+ from truhe.store import StoredBlob
29
+ import os
30
+ import tempfile
31
+
32
+
33
+ TEMP_SUFFIX = '.tmp-'
34
+
35
+
36
+ @dataclass
37
+ class FileSystemBlobStore:
38
+ root: Path
39
+
40
+ def __post_init__(self):
41
+ self.root = Path(self.root)
42
+
43
+ def _path(self, key):
44
+ segments = key.split('/')
45
+ if key.startswith('/') or any(s in ('', '.', '..') for s in segments):
46
+ raise BlobStoreError(f'{key!r} is no valid key')
47
+ return self.root.joinpath(*segments)
48
+
49
+ def put(self, key, data, content_type, content_length=None):
50
+ length = checked_length(data, content_length)
51
+ chunks = [data] if is_bytes(data) else counted(data, length)
52
+ path = self._path(key)
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ fd, temp = tempfile.mkstemp(dir=path.parent, prefix=f'.{path.name}{TEMP_SUFFIX}')
55
+ try:
56
+ with os.fdopen(fd, 'wb') as handle:
57
+ for chunk in chunks:
58
+ handle.write(chunk)
59
+ handle.flush()
60
+ os.fsync(handle.fileno())
61
+ os.replace(temp, path)
62
+ except BaseException:
63
+ Path(temp).unlink(missing_ok=True)
64
+ raise
65
+ return StoredBlob(size=length)
66
+
67
+ @contextmanager
68
+ def open(self, key):
69
+ path = self._path(key)
70
+ try:
71
+ handle = path.open('rb')
72
+ except FileNotFoundError:
73
+ raise KeyError(key) from None
74
+ try:
75
+ size = os.fstat(handle.fileno()).st_size
76
+ yield BlobStream(meta=StoredBlob(size=size), chunks=_read(handle))
77
+ finally:
78
+ handle.close()
79
+
80
+ def head(self, key):
81
+ try:
82
+ return StoredBlob(size=self._path(key).stat().st_size)
83
+ except FileNotFoundError:
84
+ return None
85
+
86
+ def delete(self, key):
87
+ self._path(key).unlink(missing_ok=True)
88
+
89
+ def list(self, prefix):
90
+ # Walk from the deepest folder the prefix names in full, then filter by
91
+ # the rest, ``org1/`` must not walk ``org10/``.
92
+ folder = prefix.rsplit('/', 1)[0] if '/' in prefix else ''
93
+ start = self._path(folder) if folder else self.root
94
+ found = []
95
+ for dirpath, _, filenames in os.walk(start):
96
+ for name in filenames:
97
+ if name.startswith('.') and TEMP_SUFFIX in name:
98
+ continue
99
+ path = Path(dirpath) / name
100
+ key = path.relative_to(self.root).as_posix()
101
+ if key.startswith(prefix):
102
+ found.append((key, path.stat()))
103
+ for key, stat in sorted(found):
104
+ yield ListedBlob(
105
+ key=key,
106
+ size=stat.st_size,
107
+ modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
108
+ )
109
+
110
+
111
+ def _read(handle):
112
+ while True:
113
+ chunk = handle.read(READ_BYTES)
114
+ if not chunk:
115
+ return
116
+ yield chunk