Skip to content

Commit 837c74d

Browse files
committed
feat(crates): add crate-ID validation
Add app/crates/ids.py: validate_crate_id / is_valid_crate_id and an InvalidCrateId exception. IDs are constrained to a safe charset (start alphanumeric, [A-Za-z0-9._-], max 128, no '/' or '..'). Fixes the ".zip in crate ID" fragility. IDs are no longer parsed. Closes #174
1 parent a3aa2e7 commit 837c74d

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

app/crates/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Crate identification, layout, and resolution within object storage."""

app/crates/ids.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Strict validation for crate identifiers.
2+
3+
A crate ID is treated as a single-segment label. Constraining it to a safe charset
4+
means it can be composed into object keys and local paths without risk of collisions
5+
or traversal, and removes the need to parse meaning (such as a ``.zip`` suffix) back
6+
out of it.
7+
"""
8+
9+
import re
10+
11+
# Start with an alphanumeric (so no leading dot/dash), then up to 127 more of a
12+
# restricted set. No slashes, whitespace, or non-ASCII; max length 128.
13+
_CRATE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
14+
15+
16+
class InvalidCrateId(ValueError):
17+
"""Raised when a crate ID does not meet the required format."""
18+
19+
20+
def is_valid_crate_id(crate_id: object) -> bool:
21+
"""Return whether ``crate_id`` is a well-formed crate identifier."""
22+
if not isinstance(crate_id, str):
23+
return False
24+
if ".." in crate_id:
25+
return False
26+
return _CRATE_ID_PATTERN.match(crate_id) is not None
27+
28+
29+
def validate_crate_id(crate_id: object) -> str:
30+
"""Return ``crate_id`` unchanged if valid, else raise :class:`InvalidCrateId`."""
31+
if not is_valid_crate_id(crate_id):
32+
raise InvalidCrateId(
33+
f"Invalid crate ID: {crate_id!r}. Crate IDs must start with a letter "
34+
"or digit and contain only letters, digits, '.', '_' or '-' "
35+
"(max 128 characters, no '/' or '..')."
36+
)
37+
return crate_id

tests/test_crate_ids.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for strict crate-ID validation."""
2+
3+
import pytest
4+
5+
from app.crates.ids import validate_crate_id, is_valid_crate_id, InvalidCrateId
6+
7+
8+
@pytest.mark.parametrize(
9+
"crate_id",
10+
[
11+
"a",
12+
"crate-123",
13+
"my_crate.v2",
14+
"ABC.def-123_456",
15+
"release.zip", # ".zip" in the ID is harmless now: IDs are opaque
16+
"x" * 128, # max length
17+
],
18+
)
19+
def test_valid_ids_are_accepted(crate_id):
20+
assert validate_crate_id(crate_id) == crate_id
21+
assert is_valid_crate_id(crate_id) is True
22+
23+
24+
@pytest.mark.parametrize(
25+
"crate_id",
26+
[
27+
"", # empty
28+
".hidden", # leading dot
29+
"-leading-dash", # must start alphanumeric
30+
"a/b", # path separator
31+
"../etc/passwd", # traversal
32+
"a..b", # parent-dir sequence
33+
"with space", # whitespace
34+
"tab\tchar", # control char
35+
"x" * 129, # too long
36+
"unicodé", # non-ASCII
37+
],
38+
)
39+
def test_invalid_ids_are_rejected(crate_id):
40+
assert is_valid_crate_id(crate_id) is False
41+
with pytest.raises(InvalidCrateId):
42+
validate_crate_id(crate_id)
43+
44+
45+
def test_non_string_is_rejected():
46+
assert is_valid_crate_id(None) is False
47+
with pytest.raises(InvalidCrateId):
48+
validate_crate_id(None)
49+
50+
51+
def test_error_message_names_the_offending_id():
52+
with pytest.raises(InvalidCrateId) as exc_info:
53+
validate_crate_id("a/b")
54+
assert "a/b" in str(exc_info.value)

0 commit comments

Comments
 (0)