Current behavior
parse_data_root_relative_uri (src/hflow/uri.py:10) refuses a URI that escapes above the data root, at :33:
normalized = normpath(candidate)
if normalized == ".." or normalized.startswith("../"):
raise ValueError(f"{candidate!r} is not relative to the data root")
It does not refuse one that normalizes to the data root itself. Measured on current main:
'.' -> accepted '.'
'./' -> accepted './'
'a/..' -> accepted 'a/..'
'a/b/../..' -> accepted 'a/b/../..'
Each of those names a directory, not an episode, and the failure lands later and further away. With a local data root, resolve_episode_reference hands the data root directory itself to App.process:
'.' -> /tmp/<data root> is_dir=True
'a/..' -> /tmp/<data root>/a/.. is_dir=False
With a bucket data root it is worse, because object stores do not normalize key segments the way a filesystem does. The literal . and .. are sent as part of the key:
'.' -> s3://bucket/root/.
'a/..' -> s3://bucket/root/a/..
#314 centralized this parser exactly so the CLI, the workspace server, and the SDK agree about what a valid ingest URI is. "Names the data root rather than an episode" is a case the parser is now the right place to catch, and it already computes the normalized form it would need.
Pattern to copy
The refusal one line above at uri.py:33 is the model: normalize for the check, keep the candidate's own spelling in the returned value, and raise ValueError naming the offending URI. The three entry points already turn that into a CLI exit 2, an HTTP 400, and an SDK ValueError before any request, so nothing outside uri.py needs to change.
The boundary tests live in three places and all three should keep agreeing:
What to build
Refuse a URI whose normalized form is ., with the same error shape as the existing refusals.
Keep a/../b.mcap accepted and unchanged in the value returned. #314's non-goals are explicit that a safe internal segment must not be rewritten into a different persisted identity, and there is a test asserting exactly that at test_runtime_client.py:415.
Definition of done
., ./, a/.., and a/b/../.. are refused by the parser.
a/../b.mcap is still accepted and still returned with its own spelling, not normalized to b.mcap.
- The message names the URI, like the neighbouring refusals do.
- The refusal appears at all three entry points without changing any of them, and the existing CLI exit 2 and server 400 behaviour is unchanged.
- A test in each of the three boundary test files, added to the existing parametrize lists rather than as new functions.
- Prove it bites: delete the new condition, watch those tests go red, put it back. Put the result in the PR body.
Validation
uv sync --locked --all-extras
uv run ruff check --fix
uv run ruff format
uv run ty check
uv run pytest -q tests/test_runtime_cli.py tests/test_runtime_client.py \
packages/hflow-server/tests/test_server_runtime.py
uv run pytest -q
Run plain uv run pytest -q from the repo root: pytest tests alone misses packages/hflow-server/tests.
Notes
Small and self-contained: one condition in one function, plus a row in three existing parametrize lists. A good way to see how one parser at a boundary serves three entry points, which is the shape #314 was about.
Current behavior
parse_data_root_relative_uri(src/hflow/uri.py:10) refuses a URI that escapes above the data root, at:33:It does not refuse one that normalizes to the data root itself. Measured on current main:
Each of those names a directory, not an episode, and the failure lands later and further away. With a local data root,
resolve_episode_referencehands the data root directory itself toApp.process:With a bucket data root it is worse, because object stores do not normalize key segments the way a filesystem does. The literal
.and..are sent as part of the key:#314 centralized this parser exactly so the CLI, the workspace server, and the SDK agree about what a valid ingest URI is. "Names the data root rather than an episode" is a case the parser is now the right place to catch, and it already computes the normalized form it would need.
Pattern to copy
The refusal one line above at
uri.py:33is the model: normalize for the check, keep the candidate's own spelling in the returned value, and raiseValueErrornaming the offending URI. The three entry points already turn that into a CLI exit 2, an HTTP 400, and an SDKValueErrorbefore any request, so nothing outsideuri.pyneeds to change.The boundary tests live in three places and all three should keep agreeing:
tests/test_runtime_client.py:392parametrizes the refused shapes, and:415parametrizes the accepted ones.packages/hflow-server/tests/test_server_runtime.py:338covers the server's 400.tests/test_runtime_cli.pycovers the CLI's exit 2.What to build
Refuse a URI whose normalized form is
., with the same error shape as the existing refusals.Keep
a/../b.mcapaccepted and unchanged in the value returned. #314's non-goals are explicit that a safe internal segment must not be rewritten into a different persisted identity, and there is a test asserting exactly that attest_runtime_client.py:415.Definition of done
.,./,a/.., anda/b/../..are refused by the parser.a/../b.mcapis still accepted and still returned with its own spelling, not normalized tob.mcap.Validation
Run plain
uv run pytest -qfrom the repo root:pytest testsalone missespackages/hflow-server/tests.Notes
Small and self-contained: one condition in one function, plus a row in three existing parametrize lists. A good way to see how one parser at a boundary serves three entry points, which is the shape #314 was about.