diff --git a/datamind/config.py b/datamind/config.py index ea512e1..8f182d3 100644 --- a/datamind/config.py +++ b/datamind/config.py @@ -20,10 +20,11 @@ """ from __future__ import annotations +import re from pathlib import Path from typing import Literal -from pydantic import AnyUrl, BaseModel, Field, SecretStr, field_validator +from pydantic import AnyUrl, BaseModel, ConfigDict, Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Repo root — one up from `datamind/`. @@ -128,10 +129,21 @@ class MemoryConfig(BaseModel): class DataConfig(BaseModel): """Profile-based data layout. Paths derive from `profile`.""" + model_config = ConfigDict(validate_assignment=True) + profile: str = "default" # Root resolved at import time; tests / benchmarks can override. base_dir: Path = _REPO_ROOT + @field_validator("profile") + @classmethod + def _profile_name(cls, value: str) -> str: + if value in {".", ".."} or not re.fullmatch(r"[A-Za-z0-9_.-]+", value): + raise ValueError( + "profile must contain only letters, numbers, '.', '_' or '-'" + ) + return value + @property def data_dir(self) -> Path: """Per-profile raw data: data/profiles//""" diff --git a/datamind/tests/test_config.py b/datamind/tests/test_config.py index 8dda7ea..dab841e 100644 --- a/datamind/tests/test_config.py +++ b/datamind/tests/test_config.py @@ -14,7 +14,7 @@ import pytest from pydantic import ValidationError -from datamind.config import Settings +from datamind.config import DataConfig, Settings def test_nested_env_hydrates_required_llm(monkeypatch, tmp_path): @@ -101,3 +101,18 @@ def test_ensure_dirs_is_idempotent(monkeypatch, tmp_path): assert (tmp_path / "data" / "profiles" / "tp").is_dir() assert (tmp_path / "storage" / "tp").is_dir() + + +@pytest.mark.parametrize( + "profile", + ["../escape", r"..\escape", "nested/name", ".", "..", ""], +) +def test_profile_name_cannot_escape_profile_root(profile, tmp_path): + with pytest.raises(ValidationError): + DataConfig(base_dir=tmp_path, profile=profile) + + +def test_profile_assignment_keeps_path_boundary(tmp_path): + config = DataConfig(base_dir=tmp_path) + with pytest.raises(ValidationError): + config.profile = "../escape"