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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: portolan-python
3
- Version: 0.1.2
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.2"
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
  )
@@ -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
- relative_path = _relative_document_path(root_url, document_url)
116
- target = output_root / relative_path
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(child_url, child, root_url, output_root, fetch_json, visited)
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
- root_path = Path(urlparse(root_url).path).parent
159
- 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)
160
187
  try:
161
- relative = document_path.relative_to(root_path)
162
- except ValueError:
163
- return Path(document_path.name or "catalog.json")
164
- 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
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:
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "portolan-python"
471
- version = "0.1.2"
471
+ version = "0.1.3"
472
472
  source = { editable = "." }
473
473
 
474
474
  [package.optional-dependencies]