Skip to content
40 changes: 40 additions & 0 deletions src/google/adk/evaluation/_path_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations


def validate_path_segment(value: str, field_name: str) -> None:
"""Rejects values that could alter a filesystem path.

Args:
value: The caller-supplied identifier.
field_name: Human-readable field name used in error messages.

Raises:
ValueError: If the value contains path separators, traversal segments, or
null bytes.
"""
if not value:
raise ValueError(f"{field_name} must not be empty.")
if "\x00" in value:
raise ValueError(f"{field_name} must not contain null bytes.")
if "/" in value or "\\" in value:
raise ValueError(
f"{field_name} {value!r} must not contain path separators."
)
if value in (".", ".."):
raise ValueError(
f"{field_name} {value!r} must not contain traversal segments."
)
5 changes: 5 additions & 0 deletions src/google/adk/evaluation/local_eval_set_results_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ..errors.not_found_error import NotFoundError
from ._eval_set_results_manager_utils import create_eval_set_result
from ._eval_set_results_manager_utils import parse_eval_set_result_json
from ._path_validation import validate_path_segment
from .eval_result import EvalCaseResult
from .eval_result import EvalSetResult
from .eval_set_results_manager import EvalSetResultsManager
Expand All @@ -46,6 +47,8 @@ def save_eval_set_result(
eval_case_results: list[EvalCaseResult],
) -> None:
"""Creates and saves a new EvalSetResult given eval_case_results."""
validate_path_segment(app_name, "app_name")
validate_path_segment(eval_set_id, "eval_set_id")
eval_set_result = create_eval_set_result(
app_name, eval_set_id, eval_case_results
)
Expand All @@ -67,6 +70,7 @@ def get_eval_set_result(
self, app_name: str, eval_set_result_id: str
) -> EvalSetResult:
"""Returns an EvalSetResult identified by app_name and eval_set_result_id."""
validate_path_segment(eval_set_result_id, "eval_set_result_id")
# Load the eval set result file data.
maybe_eval_result_file_path = (
os.path.join(
Expand Down Expand Up @@ -97,4 +101,5 @@ def list_eval_set_results(self, app_name: str) -> list[str]:
return eval_result_files

def _get_eval_history_dir(self, app_name: str) -> str:
validate_path_segment(app_name, "app_name")
return os.path.join(self._agents_dir, app_name, _ADK_EVAL_HISTORY_DIR)
4 changes: 4 additions & 0 deletions src/google/adk/evaluation/local_eval_sets_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ._eval_sets_manager_utils import get_eval_case_from_eval_set
from ._eval_sets_manager_utils import get_eval_set_from_app_and_id
from ._eval_sets_manager_utils import update_eval_case_in_eval_set
from ._path_validation import validate_path_segment
from .eval_case import EvalCase
from .eval_case import IntermediateData
from .eval_case import Invocation
Expand Down Expand Up @@ -247,6 +248,7 @@ def list_eval_sets(self, app_name: str) -> list[str]:
Raises:
NotFoundError: If the eval directory for the app is not found.
"""
validate_path_segment(app_name, "app_name")
eval_set_file_path = os.path.join(self._agents_dir, app_name)
eval_sets = []
try:
Expand Down Expand Up @@ -310,6 +312,8 @@ def delete_eval_case(
self._save_eval_set(app_name, eval_set_id, updated_eval_set)

def _get_eval_set_file_path(self, app_name: str, eval_set_id: str) -> str:
validate_path_segment(app_name, "app_name")
validate_path_segment(eval_set_id, "eval_set_id")
return os.path.join(
self._agents_dir,
app_name,
Expand Down
30 changes: 30 additions & 0 deletions tests/unittests/evaluation/test_local_eval_set_results_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ def test_save_eval_set_result(self, mocker):
expected_eval_set_result_data = self.eval_set_result.model_dump(mode="json")
assert expected_eval_set_result_data == actual_eval_set_result_data

@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_save_eval_set_result_rejects_invalid_app_name(self, app_name):
with pytest.raises(ValueError):
self.manager.save_eval_set_result(
app_name, self.eval_set_id, self.eval_case_results
)

@pytest.mark.parametrize(
"eval_set_id", ["", ".", "..", "foo/bar", "foo\\bar"]
)
def test_save_eval_set_result_rejects_invalid_eval_set_id(self, eval_set_id):
with pytest.raises(ValueError):
self.manager.save_eval_set_result(
self.app_name, eval_set_id, self.eval_case_results
)

def test_get_eval_set_result(self, mocker):
mock_time = mocker.patch("time.time")
mock_time.return_value = self.timestamp
Expand All @@ -103,6 +119,20 @@ def test_get_eval_set_result(self, mocker):
)
assert retrieved_result == self.eval_set_result

@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_get_eval_set_result_rejects_invalid_app_name(self, app_name):
with pytest.raises(ValueError):
self.manager.get_eval_set_result(app_name, self.eval_set_result_name)

@pytest.mark.parametrize(
"eval_set_result_id", ["", ".", "..", "foo/bar", "foo\\bar"]
)
def test_get_eval_set_result_rejects_invalid_eval_set_result_id(
self, eval_set_result_id
):
with pytest.raises(ValueError):
self.manager.get_eval_set_result(self.app_name, eval_set_result_id)

def test_get_eval_set_result_double_encoded_legacy(self):
eval_history_dir = os.path.join(
self.agents_dir, self.app_name, _ADK_EVAL_HISTORY_DIR
Expand Down
23 changes: 23 additions & 0 deletions tests/unittests/evaluation/test_local_eval_sets_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,29 @@ def test_local_eval_sets_manager_create_eval_set_invalid_id(
with pytest.raises(ValueError, match="Invalid Eval Set ID"):
local_eval_sets_manager.create_eval_set(app_name, eval_set_id)

@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_local_eval_sets_manager_create_eval_set_rejects_invalid_app_name(
self, local_eval_sets_manager, app_name
):
with pytest.raises(ValueError):
local_eval_sets_manager.create_eval_set(app_name, "test_eval_set")

@pytest.mark.parametrize("app_name", ["", ".", "..", "foo/bar", "foo\\bar"])
def test_local_eval_sets_manager_list_eval_sets_rejects_invalid_app_name(
self, local_eval_sets_manager, app_name
):
with pytest.raises(ValueError):
local_eval_sets_manager.list_eval_sets(app_name)

@pytest.mark.parametrize(
"eval_set_id", ["", ".", "..", "foo/bar", "foo\\bar"]
)
def test_local_eval_sets_manager_get_eval_set_rejects_invalid_eval_set_id(
self, local_eval_sets_manager, eval_set_id
):
with pytest.raises(ValueError):
local_eval_sets_manager.get_eval_set("test_app", eval_set_id)

def test_local_eval_sets_manager_create_eval_set_already_exists(
self, local_eval_sets_manager, mocker
):
Expand Down
Loading