Skip to content

Commit d331b3b

Browse files
fix(artifacts): normalize session_id across all artifact service backends
InMemoryArtifactService, FileArtifactService, and GcsArtifactService all keyed session-scoped artifact storage on the raw, un-normalized session_id string. Session services (InMemorySessionService, SqliteSessionService) strip whitespace from session_id before using it (#6892, #6941/#6942), so a session created with a padded id is stored under the trimmed one -- but its artifacts, saved with the same padded id, landed in a sibling namespace the session itself is never keyed under: one logical session, two artifact namespaces. This also let a whitespace-only session_id slip past validate_path_segment (which only rejects an actually-empty string) and be used verbatim as a literal path/key segment. Adds artifact_util.normalize_session_id(), used at every entry point that builds a storage key/path or path from a caller-supplied session_id: save/load/list/delete/list_versions/list_artifact_versions/ get_artifact_version in the in-memory and GCS services, and the shared _session_artifacts_dir() choke point in the file-based service. Also applied inside parse_artifact_uri() and get_artifact_uri() so a padded id can never leak into (or be read back out of) an artifact reference URI, which is what the same-session artifact-reference scope check compares against. Does not touch session services -- that normalization already landed via
1 parent e6bdb4d commit d331b3b

6 files changed

Lines changed: 187 additions & 1 deletion

File tree

‎src/google/adk/artifacts/artifact_util.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,24 @@ class ParsedArtifactUri(NamedTuple):
6161
)
6262

6363

64+
def normalize_session_id(session_id: str | None) -> str | None:
65+
"""Normalizes a caller-supplied session id.
66+
67+
Strips surrounding whitespace the same way the session services do, so an
68+
artifact saved against a padded session id lands in the same storage
69+
namespace as the (normalized) session it belongs to, instead of a sibling
70+
namespace no caller using the trimmed id can ever reach.
71+
72+
Args:
73+
session_id: The caller-supplied session id, or None for a user-scoped
74+
artifact.
75+
76+
Returns:
77+
The stripped session id, or None if `session_id` was None.
78+
"""
79+
return session_id.strip() if session_id is not None else None
80+
81+
6482
def parse_artifact_uri(uri: str) -> ParsedArtifactUri | None:
6583
"""Parses an artifact URI.
6684
@@ -78,7 +96,7 @@ def parse_artifact_uri(uri: str) -> ParsedArtifactUri | None:
7896
return ParsedArtifactUri(
7997
app_name=match.group(1),
8098
user_id=match.group(2),
81-
session_id=match.group(3),
99+
session_id=normalize_session_id(match.group(3)),
82100
filename=match.group(4),
83101
version=int(match.group(5)),
84102
)
@@ -115,6 +133,7 @@ def get_artifact_uri(
115133
Returns:
116134
The constructed artifact URI.
117135
"""
136+
session_id = normalize_session_id(session_id)
118137
if session_id:
119138
return f"artifact://apps/{app_name}/users/{user_id}/sessions/{session_id}/artifacts/{filename}/versions/{version}"
120139
else:

‎src/google/adk/artifacts/file_artifact_service.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ def _user_artifacts_dir(base_root: Path) -> Path:
256256

257257
def _session_artifacts_dir(base_root: Path, session_id: str) -> Path:
258258
"""Returns the path that stores session-scoped artifacts."""
259+
session_id = session_id.strip()
259260
artifact_util.validate_path_segment(session_id, "session_id")
260261
return base_root / "sessions" / session_id / "artifacts"
261262

‎src/google/adk/artifacts/gcs_artifact_service.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def _get_blob_prefix(
213213
if self._file_has_user_namespace(filename):
214214
return f"{app_name}/{user_id}/user/{filename}"
215215

216+
session_id = artifact_util.normalize_session_id(session_id)
216217
if session_id is None:
217218
raise InputValidationError(
218219
"Session ID must be provided for session-scoped artifacts."
@@ -263,6 +264,7 @@ def _save_artifact(
263264
artifact_util._validate_session_id_for_flat_storage(session_id)
264265

265266
artifact = ensure_part(artifact)
267+
session_id = artifact_util.normalize_session_id(session_id)
266268
blob_metadata = {k: str(v) for k, v in (custom_metadata or {}).items()}
267269
if artifact.inline_data and artifact.inline_data.display_name:
268270
blob_metadata[_GCS_DISPLAY_NAME_METADATA_KEY] = (
@@ -358,6 +360,7 @@ def _load_artifact(
358360
*,
359361
max_depth: int = artifact_util._MAX_ARTIFACT_REFERENCE_DEPTH,
360362
) -> Optional[types.Part]:
363+
session_id = artifact_util.normalize_session_id(session_id)
361364
if version is None:
362365
versions = self._list_versions(
363366
app_name=app_name,
@@ -435,6 +438,7 @@ def _list_artifact_keys(
435438
) -> list[str]:
436439
artifact_util.validate_path_segment(app_name, "app_name")
437440
artifact_util.validate_path_segment(user_id, "user_id")
441+
session_id = artifact_util.normalize_session_id(session_id)
438442
if session_id is not None:
439443
artifact_util.validate_path_segment(session_id, "session_id")
440444
filenames = set()
@@ -640,6 +644,7 @@ def _get_authenticated_url_sync(
640644
max_depth: int = artifact_util._MAX_ARTIFACT_REFERENCE_DEPTH,
641645
) -> Optional[str]:
642646
"""Generates an authenticated browser URL for an artifact."""
647+
session_id = artifact_util.normalize_session_id(session_id)
643648
if version is None:
644649
versions = self._list_versions(
645650
app_name=app_name,
@@ -738,6 +743,7 @@ def _get_signed_url_sync(
738743
max_depth: int = artifact_util._MAX_ARTIFACT_REFERENCE_DEPTH,
739744
) -> Optional[str]:
740745
"""Generates a time-limited signed URL for an artifact."""
746+
session_id = artifact_util.normalize_session_id(session_id)
741747
if version is None:
742748
versions = self._list_versions(
743749
app_name=app_name,

‎src/google/adk/artifacts/in_memory_artifact_service.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ def _artifact_path(
108108
if self._file_has_user_namespace(filename):
109109
return f"{app_name}/{user_id}/user/{filename}"
110110

111+
session_id = artifact_util.normalize_session_id(session_id)
111112
if session_id is None:
112113
raise InputValidationError(
113114
"Session ID must be provided for session-scoped artifacts."
@@ -133,6 +134,7 @@ async def save_artifact(
133134
)
134135
artifact_util._validate_session_id_for_flat_storage(session_id)
135136
artifact = ensure_part(artifact)
137+
session_id = artifact_util.normalize_session_id(session_id)
136138
path = self._artifact_path(app_name, user_id, filename, session_id)
137139
if path not in self.artifacts:
138140
self.artifacts[path] = []
@@ -210,6 +212,7 @@ async def _load_artifact(
210212
remaining_depth: int,
211213
) -> Optional[types.Part]:
212214
"""Loads an artifact, following at most `remaining_depth` references."""
215+
session_id = artifact_util.normalize_session_id(session_id)
213216
path = self._artifact_path(app_name, user_id, filename, session_id)
214217
versions = self.artifacts.get(path)
215218
if not versions:
@@ -256,6 +259,7 @@ async def list_artifact_keys(
256259
) -> list[str]:
257260
artifact_util.validate_path_segment(app_name, "app_name")
258261
artifact_util.validate_path_segment(user_id, "user_id")
262+
session_id = artifact_util.normalize_session_id(session_id)
259263
if session_id is not None:
260264
artifact_util.validate_path_segment(session_id, "session_id")
261265
usernamespace_prefix = f"{app_name}/{user_id}/user/"

‎tests/unittests/artifacts/test_artifact_service.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,125 @@ async def test_file_allows_reserved_user_as_session_id(
362362
assert loaded == types.Part(text="hello")
363363

364364

365+
@pytest.mark.asyncio
366+
@pytest.mark.parametrize(
367+
"service_type",
368+
[
369+
ArtifactServiceType.IN_MEMORY,
370+
ArtifactServiceType.GCS,
371+
ArtifactServiceType.FILE,
372+
],
373+
)
374+
async def test_padded_session_id_lands_in_same_namespace_as_trimmed(
375+
service_type, artifact_service_factory
376+
):
377+
"""An artifact saved against a whitespace-padded session id must be
378+
reachable, listable, and deletable using the trimmed id, since that is the
379+
id the corresponding session is actually stored under (session services
380+
normalize session_id the same way)."""
381+
artifact_service = artifact_service_factory(service_type)
382+
artifact = types.Part(text="hello")
383+
384+
await artifact_service.save_artifact(
385+
app_name="app0",
386+
user_id="user0",
387+
session_id="sess0\n",
388+
filename="report.txt",
389+
artifact=artifact,
390+
)
391+
392+
# Reachable under the trimmed id, the one the session itself is keyed on.
393+
assert (
394+
await artifact_service.load_artifact(
395+
app_name="app0",
396+
user_id="user0",
397+
session_id="sess0",
398+
filename="report.txt",
399+
)
400+
== artifact
401+
)
402+
assert "report.txt" in await artifact_service.list_artifact_keys(
403+
app_name="app0", user_id="user0", session_id="sess0"
404+
)
405+
406+
# Also reachable under the original padded id: both forms must resolve to
407+
# the same stored artifact rather than the padded id silently shadowing it
408+
# in a namespace only the padded id itself could ever reach again.
409+
assert (
410+
await artifact_service.load_artifact(
411+
app_name="app0",
412+
user_id="user0",
413+
session_id="sess0\n",
414+
filename="report.txt",
415+
)
416+
== artifact
417+
)
418+
419+
await artifact_service.delete_artifact(
420+
app_name="app0",
421+
user_id="user0",
422+
session_id="sess0",
423+
filename="report.txt",
424+
)
425+
assert not await artifact_service.load_artifact(
426+
app_name="app0",
427+
user_id="user0",
428+
session_id="sess0\n",
429+
filename="report.txt",
430+
)
431+
432+
433+
@pytest.mark.asyncio
434+
@pytest.mark.parametrize(
435+
"service_type",
436+
[
437+
ArtifactServiceType.IN_MEMORY,
438+
ArtifactServiceType.GCS,
439+
],
440+
)
441+
async def test_artifact_reference_allows_padded_session_id_at_call_site(
442+
service_type, artifact_service_factory
443+
):
444+
"""A caller that consistently uses a padded session id must still be able
445+
to save and resolve an artifact reference within that session: the scope
446+
check must compare normalized ids on both sides, not the caller's raw
447+
string against a URI minted from the (already normalized) stored id."""
448+
artifact_service = artifact_service_factory(service_type)
449+
450+
await artifact_service.save_artifact(
451+
app_name="app0",
452+
user_id="user0",
453+
session_id="sess0\n",
454+
filename="source.txt",
455+
artifact=types.Part(text="hello"),
456+
)
457+
458+
ref = types.Part(
459+
file_data=types.FileData(
460+
file_uri=(
461+
"artifact://apps/app0/users/user0/sessions/sess0/"
462+
"artifacts/source.txt/versions/0"
463+
),
464+
mime_type="text/plain",
465+
)
466+
)
467+
await artifact_service.save_artifact(
468+
app_name="app0",
469+
user_id="user0",
470+
session_id="sess0\n",
471+
filename="ref.txt",
472+
artifact=ref,
473+
)
474+
475+
loaded = await artifact_service.load_artifact(
476+
app_name="app0",
477+
user_id="user0",
478+
session_id="sess0\n",
479+
filename="ref.txt",
480+
)
481+
assert loaded == types.Part(text="hello")
482+
483+
365484
@pytest.mark.asyncio
366485
@pytest.mark.parametrize(
367486
"service_type",

‎tests/unittests/artifacts/test_artifact_util.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,43 @@ def test_get_user_scoped_artifact_uri():
112112
assert uri == "artifact://apps/app2/users/user2/artifacts/file2/versions/456"
113113

114114

115+
def test_normalize_session_id_strips_whitespace():
116+
assert artifact_util.normalize_session_id(" sess0\n") == "sess0"
117+
118+
119+
def test_normalize_session_id_passes_none_through():
120+
assert artifact_util.normalize_session_id(None) is None
121+
122+
123+
def test_get_artifact_uri_normalizes_padded_session_id():
124+
"""A padded session id must not leak into the constructed URI, since the
125+
session it points at is stored under the trimmed id."""
126+
uri = artifact_util.get_artifact_uri(
127+
app_name="app1",
128+
user_id="user1",
129+
session_id=" session1\n",
130+
filename="file1",
131+
version=123,
132+
)
133+
assert (
134+
uri
135+
== "artifact://apps/app1/users/user1/sessions/session1/artifacts/file1/versions/123"
136+
)
137+
138+
139+
def test_parse_artifact_uri_normalizes_a_legacy_padded_session_id():
140+
"""A URI minted before this fix could carry a padded session id in its own
141+
path segment; parsing it must still yield the trimmed id so downstream
142+
scope checks compare like with like."""
143+
uri = (
144+
"artifact://apps/app1/users/user1/sessions/session1"
145+
" /artifacts/file1/versions/123"
146+
)
147+
parsed = artifact_util.parse_artifact_uri(uri)
148+
assert parsed is not None
149+
assert parsed.session_id == "session1"
150+
151+
115152
def test_is_artifact_ref_true():
116153
"""Tests is_artifact_ref with a valid artifact reference."""
117154
artifact = types.Part(

0 commit comments

Comments
 (0)