Skip to content

Commit 70ed8e7

Browse files
committed
feat(crates): deterministic crate resolver
Add app/crates/layout.py (separate crate/result key prefixes) and resolver.py. `resolve_crate()` locates a crate by direct stat checks on "canonical keys" instead of prefix-listing. - Distinguishes zip vs directory, - confirms ro-crate-metadata.json for directories, and - reports "AmbiguousCrate" / "CrateNotFound" - Fixes sibling false-match, .zip-in-id, and some missing-metadata gaps. Closes #175
1 parent 837c74d commit 70ed8e7

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

app/crates/layout.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Canonical object-key layout for crates and their validation results.
2+
3+
A single place defines where crates and results live in the bucket. Crates and
4+
results use *separate* prefixes so a result object can never collide with, or be
5+
mistaken for, a crate object.
6+
7+
Layout (given a ``crate_prefix`` and ``results_prefix``):
8+
9+
- Crate (zip): ``{crate_prefix}/{id}.zip``
10+
- Crate (directory): ``{crate_prefix}/{id}/`` containing ``ro-crate-metadata.json``
11+
- Validation result: ``{results_prefix}/{id}.json``
12+
"""
13+
14+
METADATA_FILENAME = "ro-crate-metadata.json"
15+
16+
17+
def _join(prefix: str, suffix: str) -> str:
18+
"""Join an optional prefix and a suffix with a single separator."""
19+
prefix = prefix.strip("/")
20+
return f"{prefix}/{suffix}" if prefix else suffix
21+
22+
23+
def crate_zip_key(crate_prefix: str, crate_id: str) -> str:
24+
"""Object key for a crate stored as a zip archive."""
25+
return _join(crate_prefix, f"{crate_id}.zip")
26+
27+
28+
def crate_dir_prefix(crate_prefix: str, crate_id: str) -> str:
29+
"""Key prefix (with trailing slash) for a crate stored as a directory."""
30+
return _join(crate_prefix, f"{crate_id}/")
31+
32+
33+
def crate_metadata_key(crate_prefix: str, crate_id: str) -> str:
34+
"""Object key for the metadata file inside a directory-style crate."""
35+
return _join(crate_prefix, f"{crate_id}/{METADATA_FILENAME}")
36+
37+
38+
def result_key(results_prefix: str, crate_id: str) -> str:
39+
"""Object key for a crate's stored validation result."""
40+
return _join(results_prefix, f"{crate_id}.json")

app/crates/resolver.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Deterministic crate resolution over a StorageBackend.
2+
3+
Resolution is by direct existence checks on canonical keys, never by listing a
4+
prefix and substring-matching. This removes the previous fragilities: it cannot
5+
false-match a sibling prefix, it confirms a directory crate actually contains
6+
``ro-crate-metadata.json``, and it treats the ID as opaque (so a ``.zip`` in the
7+
ID is harmless). Ambiguity and absence are reported explicitly.
8+
"""
9+
10+
from dataclasses import dataclass
11+
12+
from app.crates.ids import validate_crate_id
13+
from app.crates.layout import crate_zip_key, crate_dir_prefix, crate_metadata_key
14+
from app.storage.base import StorageBackend
15+
from app.storage.errors import ObjectNotFound
16+
17+
18+
@dataclass(frozen=True)
19+
class ResolvedCrate:
20+
"""A crate located in storage.
21+
22+
``key`` is the zip object key for a zip crate, or the directory prefix
23+
(with trailing slash) for a directory crate.
24+
"""
25+
26+
crate_id: str
27+
key: str
28+
is_zip: bool
29+
30+
31+
class CrateNotFound(Exception):
32+
"""Raised when no crate exists for the given ID."""
33+
34+
35+
class AmbiguousCrate(Exception):
36+
"""Raised when both a zip and a directory crate exist for the same ID."""
37+
38+
39+
def _object_exists(storage: StorageBackend, key: str) -> bool:
40+
try:
41+
storage.stat(key)
42+
return True
43+
except ObjectNotFound:
44+
return False
45+
46+
47+
def resolve_crate(
48+
storage: StorageBackend, crate_id: str, crate_prefix: str
49+
) -> ResolvedCrate:
50+
"""Resolve ``crate_id`` to a concrete crate object.
51+
52+
:raises InvalidCrateId: If the ID is malformed.
53+
:raises AmbiguousCrate: If both zip and directory forms exist.
54+
:raises CrateNotFound: If neither form exists.
55+
"""
56+
validate_crate_id(crate_id)
57+
58+
zip_key = crate_zip_key(crate_prefix, crate_id)
59+
metadata_key = crate_metadata_key(crate_prefix, crate_id)
60+
61+
zip_exists = _object_exists(storage, zip_key)
62+
directory_exists = _object_exists(storage, metadata_key)
63+
64+
if zip_exists and directory_exists:
65+
raise AmbiguousCrate(
66+
f"Crate {crate_id!r} exists as both a zip and a directory; refusing to guess."
67+
)
68+
if zip_exists:
69+
return ResolvedCrate(crate_id=crate_id, key=zip_key, is_zip=True)
70+
if directory_exists:
71+
return ResolvedCrate(
72+
crate_id=crate_id,
73+
key=crate_dir_prefix(crate_prefix, crate_id),
74+
is_zip=False,
75+
)
76+
raise CrateNotFound(f"No crate found for ID {crate_id!r}")

tests/test_crate_layout.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Tests for canonical crate/result key construction."""
2+
3+
import pytest
4+
5+
from app.crates.layout import (
6+
crate_zip_key,
7+
crate_dir_prefix,
8+
crate_metadata_key,
9+
result_key,
10+
)
11+
12+
13+
def test_crate_zip_key_under_prefix():
14+
assert crate_zip_key("crates", "foo") == "crates/foo.zip"
15+
16+
17+
def test_crate_dir_prefix_under_prefix():
18+
assert crate_dir_prefix("crates", "foo") == "crates/foo/"
19+
20+
21+
def test_crate_metadata_key_under_prefix():
22+
assert crate_metadata_key("crates", "foo") == "crates/foo/ro-crate-metadata.json"
23+
24+
25+
def test_result_key_uses_separate_results_prefix():
26+
assert result_key("validation-results", "foo") == "validation-results/foo.json"
27+
28+
29+
@pytest.mark.parametrize("prefix", ["", "crates/"])
30+
def test_prefix_edge_cases_are_normalised(prefix):
31+
"""An empty prefix maps to the bucket root; a trailing slash is not doubled."""
32+
expected = "foo.zip" if prefix == "" else "crates/foo.zip"
33+
assert crate_zip_key(prefix, "foo") == expected

tests/test_crate_resolver.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for deterministic crate resolution over a StorageBackend."""
2+
3+
import pytest
4+
5+
from app.crates.ids import InvalidCrateId
6+
from app.crates.resolver import (
7+
resolve_crate,
8+
ResolvedCrate,
9+
CrateNotFound,
10+
AmbiguousCrate,
11+
)
12+
from app.storage.memory import InMemoryStorage
13+
14+
PREFIX = "crates"
15+
16+
17+
@pytest.fixture
18+
def storage() -> InMemoryStorage:
19+
return InMemoryStorage()
20+
21+
22+
def test_resolves_zip_crate(storage):
23+
storage.put_bytes("crates/foo.zip", b"PK...")
24+
25+
resolved = resolve_crate(storage, "foo", PREFIX)
26+
27+
assert isinstance(resolved, ResolvedCrate)
28+
assert resolved.crate_id == "foo"
29+
assert resolved.is_zip is True
30+
assert resolved.key == "crates/foo.zip"
31+
32+
33+
def test_resolves_directory_crate_with_metadata(storage):
34+
storage.put_bytes("crates/foo/ro-crate-metadata.json", b"{}")
35+
storage.put_bytes("crates/foo/data.csv", b"x")
36+
37+
resolved = resolve_crate(storage, "foo", PREFIX)
38+
39+
assert resolved.is_zip is False
40+
assert resolved.key == "crates/foo/"
41+
42+
43+
def test_directory_without_metadata_is_not_a_crate(storage):
44+
"""A directory lacking ro-crate-metadata.json must not resolve (old TODO)."""
45+
storage.put_bytes("crates/foo/data.csv", b"x")
46+
47+
with pytest.raises(CrateNotFound):
48+
resolve_crate(storage, "foo", PREFIX)
49+
50+
51+
def test_ambiguous_when_both_zip_and_directory_exist(storage):
52+
storage.put_bytes("crates/foo.zip", b"PK...")
53+
storage.put_bytes("crates/foo/ro-crate-metadata.json", b"{}")
54+
55+
with pytest.raises(AmbiguousCrate):
56+
resolve_crate(storage, "foo", PREFIX)
57+
58+
59+
def test_missing_crate_raises_not_found(storage):
60+
with pytest.raises(CrateNotFound):
61+
resolve_crate(storage, "absent", PREFIX)
62+
63+
64+
def test_sibling_prefix_does_not_false_match(storage):
65+
"""Resolving 'foo' must not match 'foobar' (old prefix-substring bug)."""
66+
storage.put_bytes("crates/foobar.zip", b"PK...")
67+
68+
with pytest.raises(CrateNotFound):
69+
resolve_crate(storage, "foo", PREFIX)
70+
71+
72+
def test_zip_suffix_in_id_resolves_as_directory(storage):
73+
"""An ID containing '.zip' is opaque: a directory crate named 'data.zip' resolves."""
74+
storage.put_bytes("crates/data.zip/ro-crate-metadata.json", b"{}")
75+
76+
resolved = resolve_crate(storage, "data.zip", PREFIX)
77+
78+
assert resolved.is_zip is False
79+
assert resolved.key == "crates/data.zip/"
80+
81+
82+
def test_invalid_id_propagates(storage):
83+
with pytest.raises(InvalidCrateId):
84+
resolve_crate(storage, "../etc", PREFIX)
85+
86+
87+
def test_result_object_does_not_satisfy_crate_resolution(storage):
88+
"""A stored result under a separate prefix never counts as the crate itself."""
89+
storage.put_bytes("validation-results/foo.json", b"{}")
90+
91+
with pytest.raises(CrateNotFound):
92+
resolve_crate(storage, "foo", PREFIX)

0 commit comments

Comments
 (0)