crabcrop-url 0.1.0__tar.gz

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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: crabcrop-url
3
+ Version: 0.1.0
4
+ Summary: Build and sign crabcrop image URLs
5
+ Author: Maxwell Dayvson Da Silva
6
+ License: MIT
7
+ Project-URL: Homepage, https://crabcrop.org
8
+ Project-URL: Repository, https://github.com/dayvson/crabcrop
9
+ Keywords: image,url,signing,hmac,cdn,crabcrop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Graphics
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # crabcrop_url
18
+
19
+ Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from Python. Standard library only; the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md) and this package runs the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json) as its test suite, so it cannot drift from the server.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from crabcrop_url import build_url, build_srcset, purge_group_signature, purge_signature, sign, upload_signature, verify
25
+
26
+ # A signed URL. The operation string is passed verbatim and may
27
+ # contain several segments and preset: references.
28
+ url = build_url(
29
+ "https://images.example.com",
30
+ "your-secret",
31
+ "crop:1200x630:smart/filters:format(webp)",
32
+ "https://example.com/photo.jpg",
33
+ )
34
+
35
+ # An expiring URL (unix seconds), emitted as a leading exp: segment.
36
+ expiring = build_url(
37
+ "https://images.example.com",
38
+ "your-secret",
39
+ "300x200",
40
+ "https://example.com/photo.jpg",
41
+ expires=1893456000,
42
+ )
43
+
44
+ # A responsive srcset: one {width}x0 URL per width.
45
+ srcset = build_srcset(
46
+ "https://images.example.com",
47
+ "your-secret",
48
+ "https://example.com/photo.jpg",
49
+ [400, 800, 1200],
50
+ )
51
+
52
+ # The raw signature for a remainder, if you assemble URLs yourself.
53
+ signature = sign("your-secret", "300x200/https://example.com/photo.jpg")
54
+
55
+ # Verify a signature in constant time (malformed tags return False).
56
+ ok = verify("your-secret", signature, "300x200/https://example.com/photo.jpg")
57
+
58
+ # Purge signatures for the server's /purge and /purge-group endpoints
59
+ # are domain-separated under a derived subkey; the helpers sign the
60
+ # right strings so callers never assemble them by hand.
61
+ purge = purge_signature("your-secret", "300x200/https://example.com/photo.jpg")
62
+ group = purge_group_signature("your-secret", "thumb")
63
+
64
+ # Upload signatures for the server's PUT /upload/<signature>/<name>
65
+ # endpoint live in a third domain, under their own derived subkey.
66
+ upload = upload_signature("your-secret", "heroes/photo.jpg")
67
+ ```
68
+
69
+ Pass `None` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode. The secret may be `str` or `bytes`.
70
+
71
+ ## Tests
72
+
73
+ ```bash
74
+ python3 -m unittest discover -s tests
75
+ ```
@@ -0,0 +1,59 @@
1
+ # crabcrop_url
2
+
3
+ Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from Python. Standard library only; the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md) and this package runs the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json) as its test suite, so it cannot drift from the server.
4
+
5
+ ## Usage
6
+
7
+ ```python
8
+ from crabcrop_url import build_url, build_srcset, purge_group_signature, purge_signature, sign, upload_signature, verify
9
+
10
+ # A signed URL. The operation string is passed verbatim and may
11
+ # contain several segments and preset: references.
12
+ url = build_url(
13
+ "https://images.example.com",
14
+ "your-secret",
15
+ "crop:1200x630:smart/filters:format(webp)",
16
+ "https://example.com/photo.jpg",
17
+ )
18
+
19
+ # An expiring URL (unix seconds), emitted as a leading exp: segment.
20
+ expiring = build_url(
21
+ "https://images.example.com",
22
+ "your-secret",
23
+ "300x200",
24
+ "https://example.com/photo.jpg",
25
+ expires=1893456000,
26
+ )
27
+
28
+ # A responsive srcset: one {width}x0 URL per width.
29
+ srcset = build_srcset(
30
+ "https://images.example.com",
31
+ "your-secret",
32
+ "https://example.com/photo.jpg",
33
+ [400, 800, 1200],
34
+ )
35
+
36
+ # The raw signature for a remainder, if you assemble URLs yourself.
37
+ signature = sign("your-secret", "300x200/https://example.com/photo.jpg")
38
+
39
+ # Verify a signature in constant time (malformed tags return False).
40
+ ok = verify("your-secret", signature, "300x200/https://example.com/photo.jpg")
41
+
42
+ # Purge signatures for the server's /purge and /purge-group endpoints
43
+ # are domain-separated under a derived subkey; the helpers sign the
44
+ # right strings so callers never assemble them by hand.
45
+ purge = purge_signature("your-secret", "300x200/https://example.com/photo.jpg")
46
+ group = purge_group_signature("your-secret", "thumb")
47
+
48
+ # Upload signatures for the server's PUT /upload/<signature>/<name>
49
+ # endpoint live in a third domain, under their own derived subkey.
50
+ upload = upload_signature("your-secret", "heroes/photo.jpg")
51
+ ```
52
+
53
+ Pass `None` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode. The secret may be `str` or `bytes`.
54
+
55
+ ## Tests
56
+
57
+ ```bash
58
+ python3 -m unittest discover -s tests
59
+ ```
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "crabcrop-url"
7
+ version = "0.1.0"
8
+ description = "Build and sign crabcrop image URLs"
9
+ license = { text = "MIT" }
10
+ readme = "README.md"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Maxwell Dayvson Da Silva" }]
13
+ keywords = ["image", "url", "signing", "hmac", "cdn", "crabcrop"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Multimedia :: Graphics",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://crabcrop.org"
23
+ Repository = "https://github.com/dayvson/crabcrop"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,212 @@
1
+ """Build and sign crabcrop URLs, per ``spec/url-signing.md``
2
+ in the crabcrop repository. Standard library only.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import hmac
9
+ from typing import Optional, Sequence, Union
10
+
11
+ Secret = Union[str, bytes, None]
12
+
13
+
14
+ def _key(secret: Union[str, bytes]) -> bytes:
15
+ return secret.encode("utf-8") if isinstance(secret, str) else secret
16
+
17
+
18
+ _KEY_ID_CHARS = frozenset(
19
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
20
+ )
21
+
22
+
23
+ def is_valid_key_id(key_id: str) -> bool:
24
+ """Whether ``key_id`` is a well-formed key ID: 1 to 32
25
+ characters of ``[A-Za-z0-9_-]``."""
26
+ return (
27
+ isinstance(key_id, str)
28
+ and 1 <= len(key_id) <= 32
29
+ and all(c in _KEY_ID_CHARS for c in key_id)
30
+ )
31
+
32
+
33
+ def sign_with_key_id(
34
+ secret: Union[str, bytes], key_id: str, remainder: str
35
+ ) -> str:
36
+ """The signature segment for a keyed signature:
37
+ ``<key-id>:`` followed by the hex tag ``sign`` computes.
38
+ Servers verify a keyed segment against the named key
39
+ only, which is what makes secret rotation possible
40
+ without invalidating every outstanding URL."""
41
+ if not is_valid_key_id(key_id):
42
+ raise ValueError(
43
+ f"invalid key ID {key_id!r}: 1 to 32 characters of [A-Za-z0-9_-]"
44
+ )
45
+ return f"{key_id}:{sign(secret, remainder)}"
46
+
47
+
48
+ def sign(secret: Union[str, bytes], remainder: str) -> str:
49
+ """Compute the signature segment for a remainder: the
50
+ lowercase hex HMAC-SHA256 of ``remainder`` under ``secret``.
51
+ The remainder is everything after the signature segment in
52
+ the URL, exactly as it will appear."""
53
+ return hmac.new(_key(secret), remainder.encode("utf-8"), hashlib.sha256).hexdigest()
54
+
55
+
56
+ def purge_key(secret: Union[str, bytes]) -> bytes:
57
+ """The purge subkey: the raw bytes of the HMAC-SHA256
58
+ of the domain label ``crabcrop/purge-key`` under the
59
+ secret. Purge signatures live under this derived key,
60
+ so an image signature can never be replayed as one."""
61
+ return bytes.fromhex(sign(secret, "crabcrop/purge-key"))
62
+
63
+
64
+ def purge_signature(secret: Union[str, bytes], target: str) -> str:
65
+ """The signature segment for
66
+ ``POST /purge/<signature>/<path>``: HMAC-SHA256 of the
67
+ target path under the purge subkey."""
68
+ key = purge_key(secret)
69
+ return hmac.new(key, target.encode("utf-8"), hashlib.sha256).hexdigest()
70
+
71
+
72
+ def purge_group_signature(secret: Union[str, bytes], group: str) -> str:
73
+ """The signature segment for
74
+ ``POST /purge-group/<signature>/<group>``: a purge
75
+ signature over the literal string ``purge-group/<group>``,
76
+ which appears nowhere in the request URL. The helper
77
+ exists so nobody has to know the signed string."""
78
+ return purge_signature(secret, f"purge-group/{group}")
79
+
80
+
81
+ def upload_key(secret: Union[str, bytes]) -> bytes:
82
+ """The upload subkey: the raw bytes of the HMAC-SHA256
83
+ of the domain label ``crabcrop/upload-key`` under the
84
+ secret. Upload signatures live under this derived key,
85
+ so neither an image signature nor a purge signature can
86
+ be replayed as one."""
87
+ return bytes.fromhex(sign(secret, "crabcrop/upload-key"))
88
+
89
+
90
+ def upload_signature(secret: Union[str, bytes], name: str) -> str:
91
+ """The signature segment for
92
+ ``PUT /upload/<signature>/<name>``: HMAC-SHA256 of the
93
+ object name under the upload subkey."""
94
+ key = upload_key(secret)
95
+ return hmac.new(key, name.encode("utf-8"), hashlib.sha256).hexdigest()
96
+
97
+
98
+ def verify(secret: Union[str, bytes], signature: str, remainder: str) -> bool:
99
+ """Check a signature segment against a remainder, with
100
+ the Rust implementation's semantics: the expected tag
101
+ is recomputed and compared in constant time
102
+ (``hmac.compare_digest``), so a non-canonical tag (an
103
+ uppercase one, say) fails like any other mismatch, and
104
+ a malformed one returns False instead of raising."""
105
+ if not isinstance(signature, str) or not signature.isascii():
106
+ return False
107
+ # A keyed segment (`<key-id>:<tag>`) verifies against the
108
+ # one secret given here: the ID is routing metadata, and
109
+ # this function's question is whether this secret made
110
+ # the tag. A malformed ID makes the segment malformed.
111
+ if ":" in signature:
112
+ key_id, _, tag = signature.partition(":")
113
+ if not is_valid_key_id(key_id):
114
+ return False
115
+ signature = tag
116
+ return hmac.compare_digest(sign(secret, remainder), signature)
117
+
118
+
119
+ def _validate_url_text(text: str, what: str) -> None:
120
+ """Fail loudly on inputs that can never verify (audit
121
+ P-4): the remainder travels as a raw URL path, so ``?`` /
122
+ ``#`` would truncate it and whitespace, control, and
123
+ non-ASCII characters cannot be sent as-is."""
124
+ for c in text:
125
+ if c in "?#" or not c.isascii() or ord(c) <= 0x20 or ord(c) == 0x7F:
126
+ raise ValueError(
127
+ f"{what} contains a character that cannot appear in a crabcrop URL: {c!r}"
128
+ )
129
+
130
+
131
+ def _validate_expires(expires: int) -> None:
132
+ """The server accepts only an unsigned 64-bit integer
133
+ (audit P-5)."""
134
+ if (
135
+ isinstance(expires, bool)
136
+ or not isinstance(expires, int)
137
+ or expires < 0
138
+ or expires > 2**64 - 1
139
+ ):
140
+ raise ValueError(
141
+ f"expires must be a non-negative integer of unix seconds, got {expires!r}"
142
+ )
143
+
144
+
145
+ def build_url(
146
+ base: str,
147
+ secret: Secret,
148
+ operations: str,
149
+ source: str,
150
+ expires: Optional[int] = None,
151
+ key_id: Optional[str] = None,
152
+ ) -> str:
153
+ """Assemble a signed (or ``unsafe``) crabcrop URL.
154
+ ``operations`` is the operation string verbatim (several
155
+ ``/``-separated segments and ``preset:`` references
156
+ included). ``expires`` is a unix-seconds expiry, emitted as
157
+ a leading ``exp:`` segment. A ``None`` secret produces the
158
+ literal ``unsafe`` signature segment, for servers running
159
+ with unsafe or presets-only configurations."""
160
+ remainder = ""
161
+ if expires is not None:
162
+ _validate_expires(expires)
163
+ remainder += f"exp:{expires}/"
164
+ _validate_url_text(operations, "operations")
165
+ _validate_url_text(source, "source")
166
+ if operations:
167
+ remainder += f"{operations}/"
168
+ remainder += source
169
+ if secret is None:
170
+ if key_id is not None:
171
+ raise ValueError("key_id requires a secret: an unsafe URL has no key to name")
172
+ signature = "unsafe"
173
+ elif key_id is not None:
174
+ signature = sign_with_key_id(secret, key_id, remainder)
175
+ else:
176
+ signature = sign(secret, remainder)
177
+ return f"{base.rstrip('/')}/{signature}/{remainder}"
178
+
179
+
180
+ def _validate_width(width: int) -> None:
181
+ """A srcset width becomes a ``{width}x0`` size segment,
182
+ so it must satisfy the grammar's dimension rules: a
183
+ positive integer within the server cap. Anything else
184
+ mints a URL the server rejects (or worse, ``-300``
185
+ reads as a flip), so it raises here, at build time."""
186
+ if (
187
+ isinstance(width, bool)
188
+ or not isinstance(width, int)
189
+ or width < 1
190
+ or width > 16383
191
+ ):
192
+ raise ValueError(
193
+ f"srcset width must be a positive integer no larger than 16383, got {width!r}"
194
+ )
195
+
196
+
197
+ def build_srcset(
198
+ base: str,
199
+ secret: Secret,
200
+ source: str,
201
+ widths: Sequence[int],
202
+ expires: Optional[int] = None,
203
+ ) -> str:
204
+ """Build an ``srcset`` attribute value: one ``{width}x0``
205
+ URL per width, entries formatted ``{url} {width}w`` and
206
+ joined by ``, ``. An expiry, when given, applies to every
207
+ entry."""
208
+ for w in widths:
209
+ _validate_width(w)
210
+ return ", ".join(
211
+ f"{build_url(base, secret, f'{w}x0', source, expires)} {w}w" for w in widths
212
+ )
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: crabcrop-url
3
+ Version: 0.1.0
4
+ Summary: Build and sign crabcrop image URLs
5
+ Author: Maxwell Dayvson Da Silva
6
+ License: MIT
7
+ Project-URL: Homepage, https://crabcrop.org
8
+ Project-URL: Repository, https://github.com/dayvson/crabcrop
9
+ Keywords: image,url,signing,hmac,cdn,crabcrop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Graphics
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # crabcrop_url
18
+
19
+ Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from Python. Standard library only; the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md) and this package runs the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json) as its test suite, so it cannot drift from the server.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from crabcrop_url import build_url, build_srcset, purge_group_signature, purge_signature, sign, upload_signature, verify
25
+
26
+ # A signed URL. The operation string is passed verbatim and may
27
+ # contain several segments and preset: references.
28
+ url = build_url(
29
+ "https://images.example.com",
30
+ "your-secret",
31
+ "crop:1200x630:smart/filters:format(webp)",
32
+ "https://example.com/photo.jpg",
33
+ )
34
+
35
+ # An expiring URL (unix seconds), emitted as a leading exp: segment.
36
+ expiring = build_url(
37
+ "https://images.example.com",
38
+ "your-secret",
39
+ "300x200",
40
+ "https://example.com/photo.jpg",
41
+ expires=1893456000,
42
+ )
43
+
44
+ # A responsive srcset: one {width}x0 URL per width.
45
+ srcset = build_srcset(
46
+ "https://images.example.com",
47
+ "your-secret",
48
+ "https://example.com/photo.jpg",
49
+ [400, 800, 1200],
50
+ )
51
+
52
+ # The raw signature for a remainder, if you assemble URLs yourself.
53
+ signature = sign("your-secret", "300x200/https://example.com/photo.jpg")
54
+
55
+ # Verify a signature in constant time (malformed tags return False).
56
+ ok = verify("your-secret", signature, "300x200/https://example.com/photo.jpg")
57
+
58
+ # Purge signatures for the server's /purge and /purge-group endpoints
59
+ # are domain-separated under a derived subkey; the helpers sign the
60
+ # right strings so callers never assemble them by hand.
61
+ purge = purge_signature("your-secret", "300x200/https://example.com/photo.jpg")
62
+ group = purge_group_signature("your-secret", "thumb")
63
+
64
+ # Upload signatures for the server's PUT /upload/<signature>/<name>
65
+ # endpoint live in a third domain, under their own derived subkey.
66
+ upload = upload_signature("your-secret", "heroes/photo.jpg")
67
+ ```
68
+
69
+ Pass `None` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode. The secret may be `str` or `bytes`.
70
+
71
+ ## Tests
72
+
73
+ ```bash
74
+ python3 -m unittest discover -s tests
75
+ ```
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/crabcrop_url/__init__.py
4
+ src/crabcrop_url.egg-info/PKG-INFO
5
+ src/crabcrop_url.egg-info/SOURCES.txt
6
+ src/crabcrop_url.egg-info/dependency_links.txt
7
+ src/crabcrop_url.egg-info/top_level.txt
8
+ tests/test_vectors.py
@@ -0,0 +1 @@
1
+ crabcrop_url
@@ -0,0 +1,188 @@
1
+ """The package's signing suite is the canonical vector file
2
+ from the spec directory, shared by every implementation."""
3
+
4
+ import json
5
+ import sys
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from crabcrop_url import ( # noqa: E402
12
+ build_srcset,
13
+ build_url,
14
+ purge_group_signature,
15
+ purge_signature,
16
+ sign,
17
+ upload_signature,
18
+ sign_with_key_id,
19
+ verify,
20
+ )
21
+
22
+ VECTORS = json.loads(
23
+ (Path(__file__).resolve().parents[3] / "spec" / "test-vectors.json").read_text()
24
+ )
25
+
26
+
27
+ class TestVectors(unittest.TestCase):
28
+ def test_all_vectors(self):
29
+ self.assertGreaterEqual(len(VECTORS), 5)
30
+ for v in VECTORS:
31
+ with self.subTest(description=v["description"]):
32
+ self.assertEqual(sign(v["secret"], v["remainder"]), v["signature"])
33
+
34
+ def test_verify_accepts_the_vectors_and_rejects_tampering(self):
35
+ for v in VECTORS:
36
+ with self.subTest(description=v["description"]):
37
+ self.assertTrue(verify(v["secret"], v["signature"], v["remainder"]))
38
+ self.assertFalse(verify(v["secret"], v["signature"], v["remainder"] + "x"))
39
+ self.assertFalse(verify("wrong-secret", v["signature"], v["remainder"]))
40
+ # A non-canonical tag fails: the server
41
+ # accepts only the lowercase spelling.
42
+ self.assertFalse(
43
+ verify(v["secret"], v["signature"].upper(), v["remainder"])
44
+ )
45
+ self.assertFalse(verify("s", "not-a-signature", "300x200/https://e.com/a.jpg"))
46
+
47
+
48
+ class TestPurgeSignatures(unittest.TestCase):
49
+ def test_published_vectors(self):
50
+ # The same pinned literals the Rust crate and the
51
+ # TypeScript SDK assert.
52
+ self.assertEqual(
53
+ purge_signature("test-secret", "300x200/https://example.com/a.jpg"),
54
+ "9b58a43a984e47cbde3ec08333290acad06c48a5e9b85b99124ae725218e90e8",
55
+ )
56
+ self.assertEqual(
57
+ purge_group_signature("test-secret", "thumb"),
58
+ "2f779d54c25154a7528fb334973dd2c92e08b2575ee1359a7d426421b083b5d6",
59
+ )
60
+ self.assertEqual(
61
+ purge_group_signature("test-secret", "thumb"),
62
+ purge_signature("test-secret", "purge-group/thumb"),
63
+ )
64
+
65
+
66
+ class TestUploadSignatures(unittest.TestCase):
67
+ def test_published_vectors(self):
68
+ # The same pinned literals the Rust crate and the
69
+ # TypeScript SDK assert.
70
+ self.assertEqual(
71
+ upload_signature("test-secret", "heroes/cat.jpg"),
72
+ "81d87b588203fdad32f5431c0a893bf7ddad8188f5cca123a1b057a439b34b84",
73
+ )
74
+ self.assertNotEqual(
75
+ upload_signature("test-secret", "heroes/cat.jpg"),
76
+ purge_signature("test-secret", "heroes/cat.jpg"),
77
+ )
78
+
79
+
80
+ class TestBuilders(unittest.TestCase):
81
+ def test_spec_worked_example(self):
82
+ self.assertEqual(
83
+ build_url(
84
+ "https://images.example.com/",
85
+ "your-secret",
86
+ "300x200",
87
+ "https://example.com/photo.jpg",
88
+ ),
89
+ "https://images.example.com/"
90
+ "3c1f6c180116661877bd4c85fd201a178087d74c1cc188cc584e819d901308c6"
91
+ "/300x200/https://example.com/photo.jpg",
92
+ )
93
+
94
+ def test_expiry_and_null_secret(self):
95
+ self.assertEqual(
96
+ build_url(
97
+ "https://img.example.com",
98
+ None,
99
+ "preset:thumb",
100
+ "s3://media/a.jpg",
101
+ expires=1893456000,
102
+ ),
103
+ "https://img.example.com/unsafe/exp:1893456000/preset:thumb/s3://media/a.jpg",
104
+ )
105
+
106
+ def test_srcset(self):
107
+ self.assertEqual(
108
+ build_srcset(
109
+ "https://img.example.com", None, "https://example.com/a.jpg", [320, 640]
110
+ ),
111
+ "https://img.example.com/unsafe/320x0/https://example.com/a.jpg 320w, "
112
+ "https://img.example.com/unsafe/640x0/https://example.com/a.jpg 640w",
113
+ )
114
+
115
+ def test_srcset_rejects_widths_the_grammar_cannot_express(self):
116
+ # 320.5 is not an integer, -300 is the flip spelling,
117
+ # 0 is not a size, and 16384 is past the server cap:
118
+ # each would mint a URL the server rejects.
119
+ for bad in (320.5, -300, 0, 16384, True):
120
+ with self.assertRaises(ValueError):
121
+ build_srcset(
122
+ "https://img.example.com", None, "https://example.com/a.jpg", [bad]
123
+ )
124
+ build_srcset(
125
+ "https://img.example.com", None, "https://example.com/a.jpg", [1, 16383]
126
+ )
127
+
128
+
129
+ def test_builders_reject_unbuildable_urls(self):
130
+ # Audit P-4 and P-5: these can never verify, so the
131
+ # builders raise instead of minting dead URLs.
132
+ for source in (
133
+ "https://example.com/a.jpg?w=1",
134
+ "https://example.com/a.jpg#frag",
135
+ "https://example.com/my photo.jpg",
136
+ "https://example.com/café.jpg",
137
+ ):
138
+ with self.assertRaises(ValueError, msg=source):
139
+ build_url("https://img.example.com", b"k", "300x200", source)
140
+ for bad_expires in (-1, 1.5, True, 2**64):
141
+ with self.assertRaises(ValueError, msg=repr(bad_expires)):
142
+ build_url(
143
+ "https://img.example.com",
144
+ b"k",
145
+ "300x200",
146
+ "https://example.com/a.jpg",
147
+ expires=bad_expires,
148
+ )
149
+
150
+
151
+ class TestKeyIds(unittest.TestCase):
152
+ def test_sign_with_key_id_stamps_the_segment(self):
153
+ remainder = "300x200/example.com/i.jpg"
154
+ self.assertEqual(
155
+ sign_with_key_id("test-secret", "k2", remainder),
156
+ f"k2:{sign('test-secret', remainder)}",
157
+ )
158
+ self.assertEqual(
159
+ build_url(
160
+ "https://img.example.com",
161
+ "test-secret",
162
+ "300x200",
163
+ "example.com/i.jpg",
164
+ key_id="k2",
165
+ ),
166
+ f"https://img.example.com/k2:{sign('test-secret', remainder)}/300x200/example.com/i.jpg",
167
+ )
168
+ with self.assertRaises(ValueError):
169
+ sign_with_key_id("test-secret", "bad id", remainder)
170
+ with self.assertRaises(ValueError):
171
+ build_url(
172
+ "https://img.example.com",
173
+ None,
174
+ "300x200",
175
+ "example.com/i.jpg",
176
+ key_id="k2",
177
+ )
178
+
179
+ def test_verify_accepts_a_keyed_segment_for_its_secret(self):
180
+ remainder = "300x200/example.com/i.jpg"
181
+ segment = sign_with_key_id("old-secret", "k-old", remainder)
182
+ self.assertTrue(verify("old-secret", segment, remainder))
183
+ self.assertFalse(verify("new-secret", segment, remainder))
184
+ self.assertFalse(verify("old-secret", "bad id:" + sign("old-secret", remainder), remainder))
185
+
186
+
187
+ if __name__ == "__main__":
188
+ unittest.main()