Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 32 additions & 1 deletion openeo/rest/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions tests/rest/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure to cover all aspects of the file:local_path validation: windows separators, "..", empty dirs, non-relative paths, ....

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added invalid file:local_path cases for Windows separators, parent traversal, absolute paths, and empty paths in 942b238.

)
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"],
Expand Down