portolan-python 0.1.2__tar.gz → 0.1.3__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.
- {portolan_python-0.1.2 → portolan_python-0.1.3}/.coverage +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/PKG-INFO +1 -1
- {portolan_python-0.1.2 → portolan_python-0.1.3}/pyproject.toml +1 -1
- {portolan_python-0.1.2 → portolan_python-0.1.3}/src/portolan/registry.py +48 -10
- {portolan_python-0.1.2 → portolan_python-0.1.3}/tests/test_registry_api.py +108 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/uv.lock +1 -1
- {portolan_python-0.1.2 → portolan_python-0.1.3}/.github/workflows/release.yml +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/.gitignore +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/Makefile +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/README.md +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/docs/development.md +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/docs/distribution.md +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/docs/examples.md +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/docs/scope.md +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/src/portolan/__init__.py +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/src/portolan/catalog.py +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/src/portolan/py.typed +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/src/portolan/validation.py +0 -0
- {portolan_python-0.1.2 → portolan_python-0.1.3}/tests/test_catalog_api.py +0 -0
|
Binary file
|
|
@@ -56,7 +56,7 @@ def load_registry_entries(
|
|
|
56
56
|
entries.append(
|
|
57
57
|
RegistryCatalogEntry(
|
|
58
58
|
id=registry_id,
|
|
59
|
-
url=href,
|
|
59
|
+
url=urljoin(registry_url, href),
|
|
60
60
|
title=title if isinstance(title, str) else None,
|
|
61
61
|
status=status if isinstance(status, str) else None,
|
|
62
62
|
)
|
|
@@ -107,13 +107,19 @@ def _write_catalog_tree(
|
|
|
107
107
|
output_root: Path,
|
|
108
108
|
fetch_json: Callable[[str], JsonObject],
|
|
109
109
|
visited: set[str] | None = None,
|
|
110
|
+
targets: dict[Path, str] | None = None,
|
|
110
111
|
) -> None:
|
|
111
112
|
if visited is None:
|
|
112
113
|
visited = set()
|
|
114
|
+
if targets is None:
|
|
115
|
+
targets = {}
|
|
113
116
|
visited.add(document_url)
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
|
|
118
|
+
target = _target_document_path(root_url, document_url, output_root)
|
|
119
|
+
owner = targets.get(target)
|
|
120
|
+
if owner is not None and owner != document_url:
|
|
121
|
+
raise ValueError(f"Registry documents map to the same local path: {owner}, {document_url}")
|
|
122
|
+
targets[target] = document_url
|
|
117
123
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
118
124
|
if document.get("type") == "Collection":
|
|
119
125
|
document = _with_absolute_asset_hrefs(document_url, document)
|
|
@@ -128,9 +134,23 @@ def _write_catalog_tree(
|
|
|
128
134
|
child_url = urljoin(document_url, href)
|
|
129
135
|
if child_url in visited:
|
|
130
136
|
continue
|
|
137
|
+
child_target = _target_document_path(root_url, child_url, output_root)
|
|
138
|
+
owner = targets.get(child_target)
|
|
139
|
+
if owner is not None and owner != child_url:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Registry documents map to the same local path: {owner}, {child_url}"
|
|
142
|
+
)
|
|
131
143
|
child = fetch_json(child_url)
|
|
132
144
|
if child.get("type") in {"Catalog", "Collection"}:
|
|
133
|
-
_write_catalog_tree(
|
|
145
|
+
_write_catalog_tree(
|
|
146
|
+
child_url,
|
|
147
|
+
child,
|
|
148
|
+
root_url,
|
|
149
|
+
output_root,
|
|
150
|
+
fetch_json,
|
|
151
|
+
visited,
|
|
152
|
+
targets,
|
|
153
|
+
)
|
|
134
154
|
|
|
135
155
|
|
|
136
156
|
def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> JsonObject:
|
|
@@ -155,13 +175,31 @@ def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> Jso
|
|
|
155
175
|
|
|
156
176
|
|
|
157
177
|
def _relative_document_path(root_url: str, document_url: str) -> Path:
|
|
158
|
-
|
|
159
|
-
|
|
178
|
+
root = urlparse(root_url)
|
|
179
|
+
document = urlparse(document_url)
|
|
180
|
+
if (document.scheme.lower(), document.netloc.lower()) != (
|
|
181
|
+
root.scheme.lower(),
|
|
182
|
+
root.netloc.lower(),
|
|
183
|
+
):
|
|
184
|
+
raise ValueError(f"Child document has a different origin: {document_url}")
|
|
185
|
+
root_path = Path(root.path).parent
|
|
186
|
+
document_path = Path(document.path)
|
|
160
187
|
try:
|
|
161
|
-
|
|
162
|
-
except ValueError:
|
|
163
|
-
|
|
164
|
-
|
|
188
|
+
relative_path = document_path.relative_to(root_path)
|
|
189
|
+
except ValueError as err:
|
|
190
|
+
raise ValueError(f"Child document is outside catalog root: {document_url}") from err
|
|
191
|
+
if ".." in relative_path.parts:
|
|
192
|
+
raise ValueError(f"Child document is outside catalog root: {document_url}")
|
|
193
|
+
return relative_path
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _target_document_path(root_url: str, document_url: str, output_root: Path) -> Path:
|
|
197
|
+
relative_path = _relative_document_path(root_url, document_url)
|
|
198
|
+
resolved_root = output_root.resolve()
|
|
199
|
+
target = (output_root / relative_path).resolve()
|
|
200
|
+
if not target.is_relative_to(resolved_root):
|
|
201
|
+
raise ValueError(f"Child document escapes catalog root: {document_url}")
|
|
202
|
+
return target
|
|
165
203
|
|
|
166
204
|
|
|
167
205
|
def _fallback_catalog_id(catalog_url: str) -> str:
|
|
@@ -57,6 +57,26 @@ def test_load_registry_entries_filters_to_valid_children() -> None:
|
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_load_registry_entries_resolves_relative_child_urls() -> None:
|
|
61
|
+
registry = {
|
|
62
|
+
"links": [
|
|
63
|
+
{
|
|
64
|
+
"rel": "child",
|
|
65
|
+
"href": "../demo/catalog.json",
|
|
66
|
+
"portolan_registry:id": "demo",
|
|
67
|
+
"portolan_registry:status": "valid",
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
entries = load_registry_entries(
|
|
73
|
+
"https://registry.test/exports/catalogs.json",
|
|
74
|
+
fetch_json=lambda url: registry,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert entries[0].url == "https://registry.test/demo/catalog.json"
|
|
78
|
+
|
|
79
|
+
|
|
60
80
|
def test_load_registry_entries_supports_catalog_ids_include_stale_and_limit() -> None:
|
|
61
81
|
registry = {
|
|
62
82
|
"links": [
|
|
@@ -196,6 +216,94 @@ def test_download_registry_catalog_skips_child_cycles(tmp_path: Path) -> None:
|
|
|
196
216
|
assert fetched_urls == [root_url, child_url]
|
|
197
217
|
|
|
198
218
|
|
|
219
|
+
@pytest.mark.parametrize(
|
|
220
|
+
("child_url", "message"),
|
|
221
|
+
[
|
|
222
|
+
("https://other.test/demo/collection.json", "different origin"),
|
|
223
|
+
("https://example.test/demo/../../outside.json", "outside catalog root"),
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
def test_download_registry_catalog_rejects_unsafe_child_urls_before_fetch(
|
|
227
|
+
child_url: str,
|
|
228
|
+
message: str,
|
|
229
|
+
tmp_path: Path,
|
|
230
|
+
) -> None:
|
|
231
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
232
|
+
catalog = {
|
|
233
|
+
"type": "Catalog",
|
|
234
|
+
"id": "demo",
|
|
235
|
+
"links": [{"rel": "child", "href": child_url}],
|
|
236
|
+
}
|
|
237
|
+
fetched_urls: list[str] = []
|
|
238
|
+
|
|
239
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
240
|
+
fetched_urls.append(url)
|
|
241
|
+
if url != root_url:
|
|
242
|
+
raise AssertionError(f"Fetched unsafe child URL: {url}")
|
|
243
|
+
return catalog
|
|
244
|
+
|
|
245
|
+
with pytest.raises(ValueError, match=message):
|
|
246
|
+
download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
|
|
247
|
+
|
|
248
|
+
assert fetched_urls == [root_url]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_download_registry_catalog_rejects_symlink_escape(tmp_path: Path) -> None:
|
|
252
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
253
|
+
child_url = "https://example.test/demo/linked/collection.json"
|
|
254
|
+
output_dir = tmp_path / "output"
|
|
255
|
+
outside_dir = tmp_path / "outside"
|
|
256
|
+
catalog_root = output_dir / "demo"
|
|
257
|
+
outside_dir.mkdir()
|
|
258
|
+
catalog_root.mkdir(parents=True)
|
|
259
|
+
(catalog_root / "linked").symlink_to(outside_dir, target_is_directory=True)
|
|
260
|
+
responses = {
|
|
261
|
+
root_url: {
|
|
262
|
+
"type": "Catalog",
|
|
263
|
+
"id": "demo",
|
|
264
|
+
"links": [{"rel": "child", "href": child_url}],
|
|
265
|
+
},
|
|
266
|
+
child_url: _collection("linked", {}),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
with pytest.raises(ValueError, match="escapes catalog root"):
|
|
270
|
+
download_registry_catalog(
|
|
271
|
+
root_url,
|
|
272
|
+
output_dir,
|
|
273
|
+
fetch_json=lambda url: responses[url],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
assert not (outside_dir / "collection.json").exists()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_download_registry_catalog_rejects_local_path_collisions(tmp_path: Path) -> None:
|
|
280
|
+
root_url = "https://example.test/demo/catalog.json"
|
|
281
|
+
first_url = "https://example.test/demo/collection.json?version=1"
|
|
282
|
+
second_url = "https://example.test/demo/collection.json?version=2"
|
|
283
|
+
responses = {
|
|
284
|
+
root_url: {
|
|
285
|
+
"type": "Catalog",
|
|
286
|
+
"id": "demo",
|
|
287
|
+
"links": [
|
|
288
|
+
{"rel": "child", "href": first_url},
|
|
289
|
+
{"rel": "child", "href": second_url},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
first_url: _collection("first", {}),
|
|
293
|
+
second_url: _collection("second", {}),
|
|
294
|
+
}
|
|
295
|
+
fetched_urls: list[str] = []
|
|
296
|
+
|
|
297
|
+
def fetch_json(url: str) -> dict[str, Any]:
|
|
298
|
+
fetched_urls.append(url)
|
|
299
|
+
return responses[url]
|
|
300
|
+
|
|
301
|
+
with pytest.raises(ValueError, match="same local path"):
|
|
302
|
+
download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
|
|
303
|
+
|
|
304
|
+
assert fetched_urls == [root_url, first_url]
|
|
305
|
+
|
|
306
|
+
|
|
199
307
|
def test_download_registry_catalog_recurses_nested_catalogs_and_uses_fallback_id(
|
|
200
308
|
tmp_path: Path,
|
|
201
309
|
) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|