diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ad8d11f..5cdede86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DataCube.resample_spatial()` now supports parameterized `resolution` and `projection` arguments. ([#897](https://github.com/Open-EO/openeo-python-client/issues/897)) - Sanitize asset download filenames (e.g. strip slashes, (semi)colon, hash, ...), instead of blindly using the asset key as filename. ([#820](https://github.com/Open-EO/openeo-python-client/issues/820)) - Support parameters in `DataCube` apply- and band-math operations ([#903](https://github.com/Open-EO/openeo-python-client/issues/903)) +- Support STAC `file:local_path` for asset download target paths. ([#902](https://github.com/Open-EO/openeo-python-client/issues/902)) ### Changed diff --git a/openeo/rest/job.py b/openeo/rest/job.py index 939266fa8..71166fec3 100644 --- a/openeo/rest/job.py +++ b/openeo/rest/job.py @@ -6,7 +6,8 @@ import re import time import typing -from pathlib import Path +import warnings +from pathlib import Path, PurePosixPath from typing import Dict, List, Optional, Union from urllib.parse import urlparse @@ -465,6 +466,8 @@ def _make_filename(self) -> str: based on: asset key (which is not guaranteed to consist of filename-safe characters) and filename in href (if any) """ + if local_path := self._make_filename_from_local_path(): + return local_path if re.fullmatch(r"^[\w_.-]+\.[a-zA-Z0-9]{1,10}$", self.key): # Legacy mode: asset key already looks like a filename @@ -483,6 +486,34 @@ def _make_filename(self) -> str: filename += extension return filename + def _make_filename_from_local_path(self) -> Optional[str]: + if "file:local_path" not in self.metadata: + return None + local_path = self.metadata["file:local_path"] + reason = None + if not isinstance(local_path, str): + reason = "not a string" + elif "\\" in local_path: + reason = "contains backslash path separators" + else: + path = PurePosixPath(local_path) + if path.is_absolute(): + reason = "is not relative" + elif any(p == ".." for p in path.parts): + reason = "contains parent directory references" + else: + parts = [p for p in path.parts if p not in {"", "."}] + if parts: + return str(Path(*parts)) + reason = "does not contain a filename" + + warnings.warn( + f"Ignoring invalid STAC file:local_path metadata value {local_path!r}: {reason}.", + UserWarning, + stacklevel=2, + ) + return None + def download( self, target: Optional[Union[Path, str]] = None, diff --git a/tests/rest/test_job.py b/tests/rest/test_job.py index a2f21e1dd..3f80193d4 100644 --- a/tests/rest/test_job.py +++ b/tests/rest/test_job.py @@ -1110,6 +1110,49 @@ def test_download_make_filename(self, tmp_path, job, key, href, media_type, expe assert res == tmp_path / expected assert res.read_bytes() == b"data" + def test_download_uses_file_local_path(self, tmp_path, job, requests_mock): + href = "http://data.test/dl/asset" + requests_mock.head(href, headers={"Content-Length": "data"}) + requests_mock.get(href, text="data") + + asset = ResultAsset( + job=job, + key="asset", + href=href, + metadata={"type": "image/tiff", "file:local_path": "tiles/2026/result.tiff"}, + ) + res = asset.download(tmp_path) + assert res == tmp_path / "tiles" / "2026" / "result.tiff" + assert res.read_bytes() == b"data" + + @pytest.mark.parametrize( + "local_path", + [ + "tiles\\2026\\result.tiff", + "../result.tiff", + "tiles/../result.tiff", + "", + ".", + "/tmp/result.tiff", + ], + ) + def test_download_warns_and_ignores_invalid_file_local_path(self, tmp_path, job, requests_mock, local_path): + href = "http://data.test/dl/fallback.tiff" + requests_mock.head(href, headers={"Content-Length": "data"}) + requests_mock.get(href, text="data") + + asset = ResultAsset( + job=job, + key="asset", + href=href, + metadata={"type": "image/tiff", "file:local_path": local_path}, + ) + with pytest.warns(UserWarning, match="file:local_path"): + res = asset.download(tmp_path) + + assert res == tmp_path / "asset-fallback.tiff" + assert res.read_bytes() == b"data" + @pytest.mark.parametrize( ["list_jobs_kwargs", "expected_qs"],