decdn 0.1.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.
decdn/_verify.py ADDED
@@ -0,0 +1,119 @@
1
+ """The body guard: a stream that passes the gateway's bytes through, hashes them as they go when
2
+ the request named the bytes by hash, and ends with an error instead of finishing when they do not
3
+ hash to it, when fewer bytes arrive than declared, or when more arrive than the body may hold."""
4
+
5
+ from collections.abc import AsyncIterator, Iterator
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+
10
+ from decdn.errors import DecdnError
11
+ from decdn.hashing import create_blake3
12
+
13
+ _READ_ERRORS = (httpx.HTTPError, httpx.StreamError)
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class GuardOptions:
18
+ """What a guarded body must satisfy."""
19
+
20
+ # The hash the whole body must have, or `None` to skip the check.
21
+ hash: str | None = None
22
+ # The fewest bytes the body holds: the manifest's size, or Content-Length.
23
+ length: int | None = None
24
+ # The most bytes the body may hold: the manifest's size, or the window asked for.
25
+ max: int | None = None
26
+
27
+
28
+ class _State:
29
+ """The running count and hash of a guarded body."""
30
+
31
+ def __init__(self, opts: GuardOptions) -> None:
32
+ self.opts = opts
33
+ self.hasher = create_blake3() if opts.hash is not None else None
34
+ self.delivered = 0
35
+
36
+ def accept(self, chunk: bytes) -> None:
37
+ """Count `chunk`, or raise `TRUNCATED` when it runs past `max`."""
38
+ self.delivered += len(chunk)
39
+ if self.opts.max is not None and self.delivered > self.opts.max:
40
+ raise DecdnError("TRUNCATED", f"the body ran past the {self.opts.max} bytes it may hold")
41
+ if self.hasher is not None:
42
+ self.hasher.update(chunk)
43
+
44
+ def finish(self) -> None:
45
+ """Raise when the body ended short or hashes to something else."""
46
+ length = self.opts.length
47
+ if length is not None and self.delivered < length:
48
+ raise DecdnError("TRUNCATED", f"the body ended after {self.delivered} of {length} bytes")
49
+ if self.hasher is not None and self.opts.hash is not None:
50
+ got = self.hasher.hex()
51
+ if got != self.opts.hash:
52
+ raise DecdnError("HASH_MISMATCH", f"the body hashes to {got}, not {self.opts.hash}")
53
+
54
+
55
+ def guard(chunks: Iterator[bytes], opts: GuardOptions) -> Iterator[bytes]:
56
+ """Yield `chunks`, guarded.
57
+
58
+ A failed read is `TRUNCATED`, a short or long body `TRUNCATED`, a wrong hash `HASH_MISMATCH`.
59
+ """
60
+ state = _State(opts)
61
+ try:
62
+ while True:
63
+ try:
64
+ chunk = next(chunks)
65
+ except StopIteration:
66
+ break
67
+ except _READ_ERRORS as e:
68
+ raise DecdnError("TRUNCATED", "the body ended early") from e
69
+ state.accept(chunk)
70
+ yield chunk
71
+ state.finish()
72
+ finally:
73
+ close = getattr(chunks, "close", None)
74
+ if close is not None:
75
+ close()
76
+
77
+
78
+ async def aguard(chunks: AsyncIterator[bytes], opts: GuardOptions) -> AsyncIterator[bytes]:
79
+ """Yield `chunks`, guarded: the async form of `guard`."""
80
+ state = _State(opts)
81
+ try:
82
+ while True:
83
+ try:
84
+ chunk = await anext(chunks)
85
+ except StopAsyncIteration:
86
+ break
87
+ except _READ_ERRORS as e:
88
+ raise DecdnError("TRUNCATED", "the body ended early") from e
89
+ state.accept(chunk)
90
+ yield chunk
91
+ state.finish()
92
+ finally:
93
+ aclose = getattr(chunks, "aclose", None)
94
+ if aclose is not None:
95
+ await aclose()
96
+
97
+
98
+ def collect(chunks: Iterator[bytes], limit: int | None = None) -> bytes:
99
+ """Return every byte of `chunks`, joined."""
100
+ parts: list[bytes] = []
101
+ total = 0
102
+ for chunk in chunks:
103
+ total += len(chunk)
104
+ if limit is not None and total > limit:
105
+ raise DecdnError("MALFORMED_MANIFEST", f"the body is larger than {limit} bytes")
106
+ parts.append(chunk)
107
+ return b"".join(parts)
108
+
109
+
110
+ async def acollect(chunks: AsyncIterator[bytes], limit: int | None = None) -> bytes:
111
+ """Return every byte of `chunks`, joined: the async form of `collect`."""
112
+ parts: list[bytes] = []
113
+ total = 0
114
+ async for chunk in chunks:
115
+ total += len(chunk)
116
+ if limit is not None and total > limit:
117
+ raise DecdnError("MALFORMED_MANIFEST", f"the body is larger than {limit} bytes")
118
+ parts.append(chunk)
119
+ return b"".join(parts)
decdn/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
decdn/base32.py ADDED
@@ -0,0 +1,54 @@
1
+ """Site labels: a bundle's manifest hash as RFC 4648 base32, lowercase and unpadded.
2
+
3
+ A label is 52 characters, inside the 63-character DNS label limit, as the gateway writes them
4
+ (https://github.com/decdn/decdn/blob/main/crates/cli/src/commands/gateway/site.rs).
5
+ """
6
+
7
+ from decdn._hex import from_hex, is_hash, to_hex
8
+ from decdn.errors import DecdnError
9
+
10
+ _ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
11
+ _LABEL_LEN = 52
12
+
13
+
14
+ def hash_to_base32(hash: str) -> str:
15
+ """Return the site label of a 64-hex-character hash."""
16
+ data = from_hex(hash) if is_hash(hash) else None
17
+ if data is None:
18
+ raise DecdnError("BAD_URI", f"not a 64-hex-character hash: {hash}")
19
+ out: list[str] = []
20
+ buffer = 0
21
+ bits = 0
22
+ for b in data:
23
+ buffer = (buffer << 8) | b
24
+ bits += 8
25
+ while bits >= 5:
26
+ bits -= 5
27
+ out.append(_ALPHABET[(buffer >> bits) & 31])
28
+ buffer &= (1 << bits) - 1
29
+ if bits > 0:
30
+ out.append(_ALPHABET[(buffer << (5 - bits)) & 31])
31
+ return "".join(out)
32
+
33
+
34
+ def base32_to_hash(label: str) -> str | None:
35
+ """Return the hash a canonical site label encodes, as 64 lowercase hex characters.
36
+
37
+ Return `None` for anything else: the wrong length, uppercase, or nonzero trailing bits (a
38
+ second spelling the gateway refuses).
39
+ """
40
+ if len(label) != _LABEL_LEN or any(c not in _ALPHABET for c in label):
41
+ return None
42
+ out = bytearray()
43
+ buffer = 0
44
+ bits = 0
45
+ for c in label:
46
+ buffer = (buffer << 5) | _ALPHABET.index(c)
47
+ bits += 5
48
+ if bits >= 8:
49
+ bits -= 8
50
+ out.append((buffer >> bits) & 0xFF)
51
+ buffer &= (1 << bits) - 1
52
+ if buffer != 0:
53
+ return None
54
+ return to_hex(bytes(out))
decdn/capability.py ADDED
@@ -0,0 +1,109 @@
1
+ """`dcap1:` capability tokens, as the Rust encoder writes them.
2
+
3
+ The encoder is
4
+ https://github.com/decdn/decdn/blob/main/crates/incentive/src/capability_grant.rs. A token
5
+ is `dcap1:` + base64url (no padding) of the postcard encoding of
6
+ `{pool_id: [u8;32], signer: [u8;20], spending_cap: u64, expiry: u64,
7
+ owner_signature: Vec<u8>}`. Postcard writes a byte string as a varint length and the
8
+ bytes, and an integer as a LEB128 varint.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from decdn._hex import to_hex
14
+ from decdn.errors import DecdnError
15
+
16
+ _PREFIX = "dcap1:"
17
+ _B64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Capability:
22
+ """A pool owner's spending grant to a delegate signer."""
23
+
24
+ pool_id: str
25
+ """The on-chain pool, `0x` + 64 hex."""
26
+ signer: str
27
+ """The delegate voucher-signing address, `0x` + 40 hex."""
28
+ spending_cap: int
29
+ """Cumulative spend ceiling in USDC base units."""
30
+ expiry: int
31
+ """Unix-seconds expiry."""
32
+ owner_signature: str
33
+ """The owner's raw EIP-712 signature, `0x` + hex."""
34
+
35
+
36
+ def _bad(message: str) -> DecdnError:
37
+ return DecdnError("BAD_CAPABILITY", message)
38
+
39
+
40
+ def _truncated() -> DecdnError:
41
+ return _bad("capability token payload is malformed (truncated or corrupt)")
42
+
43
+
44
+ def _from_base64url(body: str) -> bytes:
45
+ out = bytearray()
46
+ buffer = 0
47
+ bits = 0
48
+ for c in body:
49
+ v = _B64URL.find(c)
50
+ if v < 0:
51
+ raise _bad("capability token body is not valid base64url")
52
+ buffer = ((buffer << 6) | v) & 0xFFFF
53
+ bits += 6
54
+ if bits >= 8:
55
+ bits -= 8
56
+ out.append((buffer >> bits) & 0xFF)
57
+ if len(body) % 4 == 1 or (buffer & ((1 << bits) - 1)) != 0:
58
+ raise _bad("capability token body is not valid base64url")
59
+ return bytes(out)
60
+
61
+
62
+ class _Reader:
63
+ def __init__(self, data: bytes) -> None:
64
+ self._data = data
65
+ self._at = 0
66
+
67
+ def varint(self) -> int:
68
+ value = 0
69
+ for shift in range(0, 70, 7):
70
+ if self._at >= len(self._data):
71
+ raise _truncated()
72
+ b = self._data[self._at]
73
+ self._at += 1
74
+ value |= (b & 0x7F) << shift
75
+ if b & 0x80 == 0:
76
+ if value >= 1 << 64:
77
+ raise _truncated()
78
+ return value
79
+ raise _truncated()
80
+
81
+ def bytes_of(self, length: int | None = None) -> bytes:
82
+ n = self.varint()
83
+ if length is not None and n != length:
84
+ raise _truncated()
85
+ if self._at + n > len(self._data):
86
+ raise _truncated()
87
+ out = self._data[self._at : self._at + n]
88
+ self._at += n
89
+ return out
90
+
91
+ def done(self) -> bool:
92
+ return self._at == len(self._data)
93
+
94
+
95
+ def parse_capability(token: str) -> Capability:
96
+ """Decode a `dcap1:` token. Raises `BAD_CAPABILITY` for anything else."""
97
+ if not token.startswith(_PREFIX):
98
+ raise _bad("not a dcap1 capability token (missing the `dcap1:` prefix)")
99
+ r = _Reader(_from_base64url(token[len(_PREFIX) :]))
100
+ capability = Capability(
101
+ pool_id=f"0x{to_hex(r.bytes_of(32))}",
102
+ signer=f"0x{to_hex(r.bytes_of(20))}",
103
+ spending_cap=r.varint(),
104
+ expiry=r.varint(),
105
+ owner_signature=f"0x{to_hex(r.bytes_of())}",
106
+ )
107
+ if not r.done():
108
+ raise _truncated()
109
+ return capability