Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 38 additions & 22 deletions pyrit/cli/_config_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,40 @@ class ServerSettings:

class ConfigError(Exception):
"""
Raised when a CLI config file exists but cannot be parsed or is structurally
invalid (e.g. malformed YAML, a non-mapping root, or a wrong-typed
``server`` setting).

A *missing* config file or a *missing* field is not an error -- the CLI just
falls back to its defaults. This is reserved for configs the user clearly
intended to set but got wrong, so callers can surface a clear message instead
of silently using the default.
Raised when an explicitly supplied CLI config file does not exist, or when a
config file exists but cannot be parsed or is structurally invalid (e.g.
malformed YAML, a non-mapping root, or a wrong-typed ``server`` setting).

A missing *default* config file or a *missing* field is not an error -- the
CLI just falls back to its defaults. This is reserved for configs the user
clearly intended to set but got wrong, so callers can surface a clear message
instead of silently using the default.
"""


def _resolve_config_paths(*, config_file: Path | None) -> list[Path]:
"""
Return the config files to load, in layering order.

Args:
config_file (Path | None): Optional explicit config path.

Returns:
list[Path]: The default config (if it exists) followed by *config_file* (if provided).

Raises:
ConfigError: If *config_file* is provided but does not exist.
"""
paths: list[Path] = []
if _DEFAULT_CONFIG_FILE.exists():
paths.append(_DEFAULT_CONFIG_FILE)
if config_file is not None:
if not config_file.exists():
raise ConfigError(f"Config file {config_file} does not exist.")
paths.append(config_file)
return paths


def _load_config_mapping(*, path: Path, yaml_module: Any) -> dict | None:
"""
Load a single YAML config file and return its top-level mapping.
Expand Down Expand Up @@ -85,7 +108,7 @@ def read_server_url(*, config_file: Path | None = None) -> str | None:

Layers (later wins):
1. ``~/.pyrit/.pyrit_conf`` (if it exists)
2. *config_file* (if provided and exists)
2. *config_file* (if provided; it must exist)

Args:
config_file (Path | None): Optional explicit config path.
Expand All @@ -94,7 +117,7 @@ def read_server_url(*, config_file: Path | None = None) -> str | None:
str | None: The server URL, or ``None`` if not configured.

Raises:
ConfigError: If a config file exists but is malformed.
ConfigError: If *config_file* does not exist or a config file is malformed.
"""
return read_server_settings(config_file=config_file).url

Expand All @@ -113,15 +136,11 @@ def read_server_settings(*, config_file: Path | None = None) -> ServerSettings:
ServerSettings: The resolved URL, startup timeout, and authentication mode.

Raises:
ConfigError: If a config file exists but is malformed.
ConfigError: If *config_file* does not exist or a config file is malformed.
"""
import yaml

paths: list[Path] = []
if _DEFAULT_CONFIG_FILE.exists():
paths.append(_DEFAULT_CONFIG_FILE)
if config_file is not None and config_file.exists():
paths.append(config_file)
paths = _resolve_config_paths(config_file=config_file)

settings = ServerSettings()
for p in paths:
Expand All @@ -141,15 +160,12 @@ def validate_client_config(*, config_file: Path | None = None) -> None:
is always checked when present.

Raises:
ConfigError: If a config file is malformed or contains a removed option.
ConfigError: If *config_file* does not exist, or a config file is
malformed or contains a removed option.
"""
import yaml

paths: list[Path] = []
if _DEFAULT_CONFIG_FILE.exists():
paths.append(_DEFAULT_CONFIG_FILE)
if config_file is not None and config_file.exists():
paths.append(config_file)
paths = _resolve_config_paths(config_file=config_file)

for p in paths:
data = _load_config_mapping(path=p, yaml_module=yaml)
Expand Down
31 changes: 28 additions & 3 deletions tests/unit/cli/test_config_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ def test_default_server_url_constant():


def test_read_server_url_returns_none_when_no_files(tmp_path):
with patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", tmp_path / "missing_default.yaml"):
assert read_server_url(config_file=None) is None


def test_read_server_url_missing_explicit_config_raises(tmp_path):
nonexistent = tmp_path / "missing.yaml"
with patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", tmp_path / "missing_default.yaml"):
assert read_server_url(config_file=nonexistent) is None
with pytest.raises(ConfigError, match="does not exist"):
read_server_url(config_file=nonexistent)


def test_read_server_url_reads_from_default_when_no_overlay(tmp_path):
Expand Down Expand Up @@ -114,14 +120,22 @@ def test_read_server_url_empty_string_treated_as_missing(tmp_path):


def test_read_server_settings_returns_defaults_when_no_files(tmp_path):
nonexistent = tmp_path / "missing.yaml"
with patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", tmp_path / "missing_default.yaml"):
assert read_server_settings(config_file=nonexistent) == ServerSettings(
assert read_server_settings(config_file=None) == ServerSettings(
url=None,
startup_timeout=120.0,
)


def test_read_server_settings_missing_explicit_config_raises_even_with_default(tmp_path):
default = tmp_path / "default.yaml"
default.write_text("server:\n url: http://default:8000\n", encoding="utf-8")
nonexistent = tmp_path / "missing.yaml"
with patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", default):
with pytest.raises(ConfigError, match="does not exist"):
read_server_settings(config_file=nonexistent)


def test_read_server_settings_overlay_overrides_startup_timeout(tmp_path):
default = tmp_path / "default.yaml"
default.write_text("server:\n url: http://default:8000\n startup_timeout: 180\n", encoding="utf-8")
Expand Down Expand Up @@ -225,6 +239,17 @@ def test_validate_client_config_rejects_removed_scenario_block(tmp_path):
validate_client_config(config_file=cfg)


def test_validate_client_config_raises_on_missing_explicit_config(tmp_path):
with patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", tmp_path / "missing_default.yaml"):
with pytest.raises(ConfigError, match="does not exist"):
validate_client_config(config_file=tmp_path / "missing.yaml")


def test_validate_client_config_allows_missing_default_config(tmp_path):
with patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", tmp_path / "missing_default.yaml"):
validate_client_config(config_file=None)


def test_validate_client_config_raises_on_malformed_yaml(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text(": :\nnot yaml: [unbalanced\n", encoding="utf-8")
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/cli/test_pyrit_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,19 @@ def test_main_malformed_config_is_hard_error(self, tmp_path, capsys):
assert result == 1
assert "not valid YAML" in capsys.readouterr().err

def test_main_missing_config_is_hard_error(self, tmp_path, capsys):
"""A nonexistent --config-file should fail loudly, not silently use defaults."""
missing = tmp_path / "missing.yaml"
with patch.object(
pyrit_scan_config_reader,
"_DEFAULT_CONFIG_FILE",
tmp_path / "missing_default.yaml",
):
result = pyrit_scan.main(["list-scenarios", "--config-file", str(missing)])

assert result == 1
assert "does not exist" in capsys.readouterr().err

@patch(
"pyrit.cli._server_launcher.ServerLauncher.probe_health_async",
new_callable=AsyncMock,
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/cli/test_pyrit_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@

import asyncio
from datetime import UTC
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID

import pytest

from pyrit.cli import pyrit_shell
from pyrit.cli import _config_reader, pyrit_shell
from pyrit.models import Parameter
from unit.mocks import make_scenario_result

Expand Down Expand Up @@ -384,6 +385,38 @@ async def quick():
class TestShellMain:
"""Tests for the shell main() entry point."""

@pytest.mark.parametrize("has_default_config", [False, True])
def test_main_missing_config_is_hard_error(
self, *, tmp_path: Path, capsys: pytest.CaptureFixture[str], has_default_config: bool
) -> None:
default = tmp_path / "default.yaml"
if has_default_config:
default.write_text("server:\n url: http://default:9000\n", encoding="utf-8")
missing = tmp_path / "missing.yaml"
with (
patch.object(_config_reader, "_DEFAULT_CONFIG_FILE", default),
patch.object(pyrit_shell.banner, "play_animation", return_value="") as mock_banner,
patch.object(pyrit_shell, "PyRITShell", autospec=True) as mock_shell_class,
patch(
"sys.argv",
[
"pyrit_shell",
"--config-file",
str(missing),
"--server-url",
"http://127.0.0.1:8000",
"--auth-mode",
"none",
"--start-server",
],
),
):
assert pyrit_shell.main() == 1

assert f"Config file {missing} does not exist." in capsys.readouterr().err
mock_banner.assert_not_called()
mock_shell_class.assert_not_called()

def test_main_parses_server_url(self):
with (
patch("pyrit.cli._banner.play_animation", return_value=""),
Expand Down Expand Up @@ -445,6 +478,7 @@ def test_main_generic_exception(self, capsys):
assert "boom" in captured.out

def test_main_log_level_and_config_file(self, tmp_path):
(tmp_path / "conf.yaml").write_text("", encoding="utf-8")
Comment thread
romanlutz marked this conversation as resolved.
Resolved
with (
patch("pyrit.cli._banner.play_animation", return_value=""),
patch("pyrit.cli.pyrit_shell.PyRITShell") as mock_shell_class,
Expand Down
Loading