Fix: reject whitespace-padded artifact filenames in all backends - #224
Open
AmaadMartin wants to merge 3 commits into
Open
Fix: reject whitespace-padded artifact filenames in all backends#224AmaadMartin wants to merge 3 commits into
AmaadMartin wants to merge 3 commits into
Conversation
added 3 commits
August 11, 2026 11:00
A filename is a storage key, but FileArtifactService trimmed it before mapping it onto a directory name, so ' a.txt' and 'a.txt' addressed the same artifact while the in-memory and GCS backends kept them apart. Saving the padded name appended a version to the unpadded artifact, and loading it returned the other artifact's content. Every backend now rejects a padded filename on save with one InputValidationError, and the file backend reports it as absent on reads instead of resolving onto the unpadded artifact. The rejection runs before the path join, because ' ../../secret.txt' otherwise resolves back inside the scope root and aliases 'secret.txt'.
…kends Replays the padded-filename cases against all three backends so they cannot drift apart again, and adds a regression test for the ' ../../secret.txt' alias that removing the strip would otherwise open.
The 'cannot be stored apart from its unpadded form' explanation appeared in five places. The constraint stays at each public entry point, but the reason now lives only beside the Windows-trimming link in artifact_util. _read_artifact_dir loses an Args block that restated its own parameters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Closes: #issue_number
Related: #issue_number
Problem:
FileArtifactServicetrimmed a filename before it mapped the name onto a directory, so' a.txt'and'a.txt'addressed the same artifact. Saving the padded name appended a version to the unpadded artifact, loading it returned the other artifact's content, and deleting it destroyed the other artifact.InMemoryArtifactServiceandGcsArtifactServicedo not trim, so the three backends disagreed about what a filename means.Solution: All three backends now reject a whitespace-padded filename on save with one
InputValidationError, and the file backend reports such a name as absent on every read path. Rejection is the only rule all backends can honour: Windows path normalization strips trailing spaces and periods from a path component (reference), so'a.txt 'cannot be stored apart from'a.txt'there. The check runs before the path join, not merely in place of the trim, because' ../../secret.txt'otherwise resolves back inside the scope root and silently aliases'secret.txt'.This narrows the accepted input contract. A caller that saved
' a.txt'and relied on it landing on'a.txt'now gets an error; that reliance was the bug. Existing data needs no migration, because a padded name was already written to the trimmed directory and stays readable under its unpadded form.Collision check: I scanned all 223 pull requests on this fork (
gh pr list --state all --limit 400) and diffed every open branch againstmain. No other pull request touchessrc/google/adk/artifacts/, so this does not duplicate or overlap with one.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
pytest tests/unittests/artifacts tests/unittests/tools/test_forwarding_artifact_service.py tests/unittests/cli/utils/test_local_storage.py -q-> 636 passed. The 544 pre-existing artifact tests are unchanged and still pass, includingtest_file_save_artifact_rejects_out_of_scope_pathsand theINVALID_PATH_SEGMENT_CASESfamilies.I also ran the consumers of the narrowed contract:
pytest tests/unittests/plugins/test_save_files_as_artifacts.py tests/unittests/flows/llm_flows/test_code_execution.py tests/unittests/flows/llm_flows/test_audio_cache_manager.py tests/unittests/agents/test_context.py -q-> 87 passed.Coverage: every executable line and branch this branch adds to
src/is covered (--cov=google.adk.artifacts --cov-branch).artifact_util.pyreports 100% line and branch coverage.Mutation checks. I ran the 65 new tests against broken code four times to prove they can fail:
"",".","my report.txt"), which must pass both before and after._resolve_scoped_artifact_path, leaving only the removed.strip()-> 12 fail, includingtest_file_padded_traversal_filename_does_not_alias_scope_rootwithFailed: DID NOT RAISE InputValidationError. Under this mutation the reproduction script below printsload 'secret.txt' -> overwrittenandversions 'secret.txt' -> [0, 1]: the alias is real, and the containment check does not catch it._read_artifact_dir-> 5 fail, so the read paths are pinned separately from the save path.is_whitespace_padded_filenamechanged to ignore theuser:prefix -> 8 fail, so the after-the-prefix case is pinned.CI coverage gap, stated plainly.
.github/workflows/continuous-integration.ymlpasses--ignore=tests/unittests/artifacts/test_artifact_service.pyto the unit-test job. That exclusion predates this change and I did not touch it, but it means CI does not run the cross-backend conformance tests added here; onlytest_artifact_util.pyruns. I ran the excluded file locally on the pushed commit, as recorded above.Static checks:
ruff check src/google/adk/artifacts/,isort --check-only,pyink --checkandscripts/compliance_checks.pyall pass.mypy src/google/adk/artifacts/reports the same single pre-existing error asmain(google.cloud has no attribute "storage"), and no new one.Manual End-to-End (E2E) Tests:
Run this against a real temporary directory and a real in-memory service, with no mocks:
Output, identical for
FileArtifactServiceandInMemoryArtifactService:The traversal trap, on
FileArtifactService: after saving'secret.txt', saving' ../../secret.txt'raises the whitespace error,load_artifact(' ../../secret.txt')returnsNone, and'secret.txt'still holds"top secret"at version 0.GcsArtifactServiceneeds a real bucket and credentials, so I could not exercise it without mocks. Its rejection is the first statement of_save_artifact, before any bucket call, and it is covered by the mocked-bucket conformance tests.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.
CI result on this commit
All 14 test jobs pass on the reviewed commit: Unit Tests on Python 3.10-3.14, Mypy Check on 3.10-3.13, and A2A v0.3 Tests on 3.10-3.14.
Two notes, neither caused by this change.
Unit Tests (Python 3.13)failed once ontest_unsafe_local_code_executor.py::test_kill_execution_kills_what_the_code_spawned(assert not True where True = _is_alive(3293)), then passed on re-run. That test polls process liveness after a teardown and is timing-sensitive; PR #181 on this fork tracks it. My diff touches no file undercode_executors/, and the same job passed on the previous commit, which differs only in docstring text.The
Pre-commit Linterfails on one hook,update-constraints. The hook regeneratesconstraints-3.1x.txt, and the only change it produces is the snapshot date in the header comment (--exclude-newer 2026-07-24->2026-08-07), so it drifts with the calendar. PR #223, which touches no constraints file either, fails the same hook the same way. My diff touches nopyproject.tomland noconstraints-*.txt, and committing regenerated constraints here would be exactly the unrelated churn the contribution guide asks us to keep out of a fix. Every other pre-commit hook passes, both in CI and locally overmain..HEAD.