portolan-python 0.1.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: portolan-python
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A lightweight Python implementation of the Portolan specification.
5
5
  Author: Portolan contributors
6
6
  License: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "portolan-python"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "A lightweight Python implementation of the Portolan specification."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
  )
@@ -106,9 +106,20 @@ def _write_catalog_tree(
106
106
  root_url: str,
107
107
  output_root: Path,
108
108
  fetch_json: Callable[[str], JsonObject],
109
+ visited: set[str] | None = None,
110
+ targets: dict[Path, str] | None = None,
109
111
  ) -> None:
110
- relative_path = _relative_document_path(root_url, document_url)
111
- target = output_root / relative_path
112
+ if visited is None:
113
+ visited = set()
114
+ if targets is None:
115
+ targets = {}
116
+ visited.add(document_url)
117
+
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
112
123
  target.parent.mkdir(parents=True, exist_ok=True)
113
124
  if document.get("type") == "Collection":
114
125
  document = _with_absolute_asset_hrefs(document_url, document)
@@ -121,9 +132,25 @@ def _write_catalog_tree(
121
132
  if not isinstance(href, str):
122
133
  continue
123
134
  child_url = urljoin(document_url, href)
135
+ if child_url in visited:
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
+ )
124
143
  child = fetch_json(child_url)
125
144
  if child.get("type") in {"Catalog", "Collection"}:
126
- _write_catalog_tree(child_url, child, root_url, output_root, fetch_json)
145
+ _write_catalog_tree(
146
+ child_url,
147
+ child,
148
+ root_url,
149
+ output_root,
150
+ fetch_json,
151
+ visited,
152
+ targets,
153
+ )
127
154
 
128
155
 
129
156
  def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> JsonObject:
@@ -148,13 +175,31 @@ def _with_absolute_asset_hrefs(document_url: str, collection: JsonObject) -> Jso
148
175
 
149
176
 
150
177
  def _relative_document_path(root_url: str, document_url: str) -> Path:
151
- root_path = Path(urlparse(root_url).path).parent
152
- document_path = Path(urlparse(document_url).path)
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)
153
187
  try:
154
- relative = document_path.relative_to(root_path)
155
- except ValueError:
156
- return Path(document_path.name or "catalog.json")
157
- return relative
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
158
203
 
159
204
 
160
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": [
@@ -169,6 +189,121 @@ def test_download_registry_catalog_writes_snapshot_with_absolute_asset_hrefs(
169
189
  assert collection["assets"]["data"]["href"] == "https://example.test/demo/roads/roads.parquet"
170
190
 
171
191
 
192
+ def test_download_registry_catalog_skips_child_cycles(tmp_path: Path) -> None:
193
+ root_url = "https://example.test/demo/catalog.json"
194
+ child_url = "https://example.test/demo/roads/collection.json"
195
+ responses = {
196
+ root_url: {
197
+ "type": "Catalog",
198
+ "id": "demo",
199
+ "links": [{"rel": "child", "href": "./roads/collection.json"}],
200
+ },
201
+ child_url: {
202
+ **_collection("roads", {}),
203
+ "links": [{"rel": "child", "href": "../catalog.json"}],
204
+ },
205
+ }
206
+ fetched_urls: list[str] = []
207
+
208
+ def fetch_json(url: str) -> dict[str, Any]:
209
+ fetched_urls.append(url)
210
+ if fetched_urls.count(url) > 1:
211
+ raise AssertionError(f"Fetched document twice: {url}")
212
+ return responses[url]
213
+
214
+ download_registry_catalog(root_url, tmp_path, fetch_json=fetch_json)
215
+
216
+ assert fetched_urls == [root_url, child_url]
217
+
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
+
172
307
  def test_download_registry_catalog_recurses_nested_catalogs_and_uses_fallback_id(
173
308
  tmp_path: Path,
174
309
  ) -> None:
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "portolan-python"
471
- version = "0.1.1"
471
+ version = "0.1.3"
472
472
  source = { editable = "." }
473
473
 
474
474
  [package.optional-dependencies]