Initial commit: llamacppctl – llama.cpp Docker server control CLI

Steuert einen llama.cpp-Server als Docker-Container: --start/--check/--stop/
--change/--chat, INI-Konfiguration (builtin defaults -> [default] ->
[model.<profile>] -> CLI), SSRF-gehärtete Prompt-Eingabe (Datei/HTTPS-URL),
File-Locking für --start/--change und ein OpenAI-kompatibler HTTP-Layer.

Enthält u. a.:
- Env-Var-Expansion in hf_home (hf_home = ${HF_HOME})
- konfigurierbares Chat-Antwortbudget (max_tokens/chat_temperature,
  CLI: --max-tokens/--chat-temp); temperature defer an Server-Default
- DNS-Pinning gegen DNS-Rebinding bei URL-Quellen
- dry-run als nebenwirkungsfreie Vorschau (kein Lock/Removal/Modell-Check)
- 98 Tests (pytest)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-07-06 16:36:27 +02:00
commit 3158d16f9b
32 changed files with 3912 additions and 0 deletions

18
tests/conftest.py Normal file
View file

@ -0,0 +1,18 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llamacppctl.prompt_io import InputPolicy # noqa: E402
@pytest.fixture
def policy() -> InputPolicy:
return InputPolicy()
@pytest.fixture
def small_policy() -> InputPolicy:
return InputPolicy(max_input_bytes=16)

145
tests/test_cli.py Normal file
View file

@ -0,0 +1,145 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llamacppctl.cli import build_parser, validate_args # noqa: E402
def parse(argv):
parser = build_parser()
args = parser.parse_args(argv)
validate_args(args, parser)
return args
def parse_error(argv):
parser = build_parser()
with pytest.raises(SystemExit):
args = parser.parse_args(argv)
validate_args(args, parser)
def test_requires_one_action():
parse_error([])
def test_actions_are_mutually_exclusive():
parse_error(["--start", "--stop"])
def test_start_is_valid_minimal():
args = parse(["--start"])
assert args.start is True
def test_chat_requires_prompt_source():
parse_error(["--chat"])
def test_chat_with_prompt_is_valid():
args = parse(["--chat", "-p", "hello"])
assert args.chat is True
assert args.prompt == "hello"
def test_stop_rejects_prompt_sources():
parse_error(["--stop", "-p", "hello"])
def test_stop_rejects_system_sources():
parse_error(["--stop", "-s", "system text"])
def test_check_with_system_but_no_prompt_rejected():
parse_error(["--check", "-s", "system text"])
def test_check_with_system_and_prompt_ok():
args = parse(["--check", "-s", "system text", "-p", "hello"])
assert args.check is True
def test_mutually_exclusive_system_sources():
parse_error(["--start", "-s", "a", "--system-file", "b.txt"])
def test_mutually_exclusive_prompt_sources():
parse_error(["--start", "-p", "a", "--prompt-url", "https://example.com/p"])
def test_max_input_bytes_must_be_positive():
parse_error(["--start", "--max-input-bytes", "0"])
def test_negative_max_input_bytes_rejected():
parse_error(["--start", "--max-input-bytes", "-5"])
def test_connect_timeout_must_be_positive():
parse_error(["--start", "--connect-timeout", "0"])
def test_read_timeout_must_be_positive():
parse_error(["--start", "--read-timeout", "-1"])
def test_allow_private_url_conflicts_with_allowlist():
parse_error(["--start", "--allow-private-url", "--url-allow-host", "example.com"])
def test_host_port_out_of_range_rejected():
parse_error(["--start", "--host-port", "70000"])
def test_host_port_zero_rejected():
parse_error(["--start", "--host-port", "0"])
def test_container_port_out_of_range_rejected():
parse_error(["--start", "--container-port", "-1"])
def test_container_name_empty_string_rejected():
parse_error(["--start", "--container-name", " "])
def test_container_name_valid():
args = parse(["--start", "--container-name", "my_llm"])
assert args.container_name == "my_llm"
def test_boolean_flag_pairs_default_none():
args = parse(["--start"])
assert args.jinja is None
assert args.fa is None
assert args.kv_unified is None
assert args.cont_batching is None
assert args.no_context_shift is None
def test_boolean_flag_pairs_explicit_true_false():
args = parse(["--start", "--jinja", "--no-fa"])
assert args.jinja is True
assert args.fa is False
def test_dry_run_flag():
args = parse(["--start", "--dry-run"])
assert args.dry_run is True
def test_print_effective_config_flag():
args = parse(["--start", "--print-effective-config"])
assert args.print_effective_config is True
def test_reasoning_choice_validated():
parse_error(["--start", "--reasoning", "maybe"])
def test_reasoning_choice_valid():
args = parse(["--start", "--reasoning", "off"])
assert args.reasoning == "off"

220
tests/test_config.py Normal file
View file

@ -0,0 +1,220 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llamacppctl.config import ConfigError, resolve_effective_config, builtin_defaults # noqa: E402
from llamacppctl.cli import build_parser, validate_args # noqa: E402
CONFIG_BASIC = """
[default]
image = ghcr.io/ggml-org/llama.cpp:server-cuda
hf_home = /srv/models
model_path = qwen3/default.gguf
container_name = test_default
host_port = 8001
container_port = 8000
model_alias = default_llm
gpu_device = 0
restart_policy = unless-stopped
ctx_size = 262144
n_predict = 16384
temp = 0.65
top_p = 0.80
top_k = 20
min_p = 0.01
repeat_penalty = 1.05
main_gpu = 0
ngl = 999
fa = true
kv_unified = true
jinja = true
reasoning = on
no_context_shift = true
cache_type_k = q4_0
cache_type_v = q4_0
batch_size = 1024
ubatch_size = 512
parallel = 1
cont_batching = true
host = 0.0.0.0
health_endpoint = /health
models_endpoint = /v1/models
chat_endpoint = /v1/chat/completions
timeout = 300
poll_interval = 2
[model.alt]
container_name = test_alt
host_port = 9001
model_alias = alt_llm
[model.noname]
host_port = 9002
[prompt.concise]
system_prompt = Be brief.
"""
def _write_config(tmp_path: Path, content: str = CONFIG_BASIC) -> Path:
p = tmp_path / "llama.cpp.config"
p.write_text(content, encoding="utf-8")
return p
def _parse(argv):
parser = build_parser()
args = parser.parse_args(argv)
validate_args(args, parser)
args._resolved_system_prompt = None
args._resolved_user_prompt = None
return args
def test_default_profile_resolves(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path)])
server_cfg, prompt_cfg = resolve_effective_config(args)
assert server_cfg.container_name == "test_default"
assert server_cfg.host_port == 8001
assert server_cfg.jinja is True
assert server_cfg.reasoning == "on"
def test_model_profile_overrides_default(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path), "--profile", "alt"])
server_cfg, _ = resolve_effective_config(args)
assert server_cfg.container_name == "test_alt"
assert server_cfg.host_port == 9001
assert server_cfg.model_alias == "alt_llm"
# inherited from [default]
assert server_cfg.image == "ghcr.io/ggml-org/llama.cpp:server-cuda"
def test_missing_profile_raises(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path), "--profile", "does-not-exist"])
with pytest.raises(ConfigError):
resolve_effective_config(args)
def test_prompt_profile_provides_fallback_system_prompt(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(
["--start", "--config", str(cfg_path), "--system-prompt-profile", "concise"]
)
_, prompt_cfg = resolve_effective_config(args)
assert prompt_cfg.system_prompt == "Be brief."
def test_explicit_system_overrides_prompt_profile(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(
[
"--start",
"--config",
str(cfg_path),
"--system-prompt-profile",
"concise",
"--system",
"Explicit wins.",
]
)
args._resolved_system_prompt = "Explicit wins."
_, prompt_cfg = resolve_effective_config(args)
assert prompt_cfg.system_prompt == "Explicit wins."
def test_missing_container_name_raises(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path), "--profile", "noname"])
# [model.noname] has no container_name and [default] provides one,
# so this should actually succeed by inheriting test_default.
server_cfg, _ = resolve_effective_config(args)
assert server_cfg.container_name == "test_default"
def test_empty_container_name_via_cli_rejected_by_argparse(tmp_path):
# cli.validate_args() already rejects a blank --container-name before
# config resolution is ever reached (defense at the CLI layer).
cfg_path = _write_config(tmp_path)
with pytest.raises(SystemExit):
_parse(["--start", "--config", str(cfg_path), "--container-name", " "])
def test_empty_container_name_from_config_raises(tmp_path):
# container_name can only end up empty via the config file itself
# (CLI-level blanks are already rejected by validate_args above).
bad_config = CONFIG_BASIC.replace("container_name = test_default", "container_name =")
cfg_path = _write_config(tmp_path, bad_config)
args = _parse(["--start", "--config", str(cfg_path)])
with pytest.raises(ConfigError):
resolve_effective_config(args)
def test_cli_override_wins_over_config(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(
["--start", "--config", str(cfg_path), "--host-port", "12345"]
)
server_cfg, _ = resolve_effective_config(args)
assert server_cfg.host_port == 12345
def test_invalid_boolean_raises(tmp_path):
bad_config = CONFIG_BASIC.replace("jinja = true", "jinja = maybe")
cfg_path = _write_config(tmp_path, bad_config)
args = _parse(["--start", "--config", str(cfg_path)])
with pytest.raises(ConfigError):
resolve_effective_config(args)
def test_lock_file_derived_from_container_name(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path)])
server_cfg, _ = resolve_effective_config(args)
assert str(server_cfg.lock_file) == "/tmp/llamacppctl.test_default.lock"
def test_builtin_defaults_has_container_name():
defaults = builtin_defaults()
assert defaults["container_name"] == "va_llm"
def test_chat_params_default_and_defer_temperature(tmp_path):
cfg_path = _write_config(tmp_path)
args = _parse(["--start", "--config", str(cfg_path)])
_, prompt_cfg = resolve_effective_config(args)
assert prompt_cfg.max_tokens == 2048 # built-in default
assert prompt_cfg.temperature is None # empty chat_temperature => server temp
def test_chat_params_from_config(tmp_path):
cfg = CONFIG_BASIC.replace(
"poll_interval = 2",
"poll_interval = 2\nmax_tokens = 32768\nchat_temperature = 0.6",
)
cfg_path = _write_config(tmp_path, cfg)
args = _parse(["--start", "--config", str(cfg_path)])
_, prompt_cfg = resolve_effective_config(args)
assert prompt_cfg.max_tokens == 32768
assert prompt_cfg.temperature == 0.6
def test_chat_params_cli_overrides_config(tmp_path):
cfg = CONFIG_BASIC.replace(
"poll_interval = 2",
"poll_interval = 2\nmax_tokens = 32768\nchat_temperature = 0.6",
)
cfg_path = _write_config(tmp_path, cfg)
args = _parse(
["--start", "--config", str(cfg_path), "--max-tokens", "512", "--chat-temp", "0.1"]
)
_, prompt_cfg = resolve_effective_config(args)
assert prompt_cfg.max_tokens == 512
assert prompt_cfg.temperature == 0.1

154
tests/test_docker_ops.py Normal file
View file

@ -0,0 +1,154 @@
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llamacppctl.docker_ops import ( # noqa: E402
build_run_command,
container_exists,
container_running,
docker_available,
format_command_for_display,
inspect_container,
)
from llamacppctl.schema import ServerConfig # noqa: E402
def make_cfg(**overrides) -> ServerConfig:
base = dict(
image="ghcr.io/ggml-org/llama.cpp:server-cuda",
hf_home=Path("/srv/models"),
model_path="qwen3/default.gguf",
container_name="test_llm",
host_port=8001,
container_port=8000,
model_alias="default_llm",
gpu_device="0",
restart_policy="unless-stopped",
ctx_size=262144,
n_predict=16384,
temp=0.65,
top_p=0.80,
top_k=20,
min_p=0.01,
repeat_penalty=1.05,
main_gpu=0,
ngl=999,
batch_size=1024,
ubatch_size=512,
parallel=1,
cache_type_k="q4_0",
cache_type_v="q4_0",
reasoning="on",
jinja=True,
fa=True,
kv_unified=True,
cont_batching=True,
no_context_shift=True,
host="0.0.0.0",
health_endpoint="/health",
models_endpoint="/v1/models",
chat_endpoint="/v1/chat/completions",
timeout=300,
poll_interval=2.0,
lock_file=Path("/tmp/llamacppctl.test_llm.lock"),
)
base.update(overrides)
return ServerConfig(**base)
def test_docker_available_true():
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
assert docker_available() is True
def test_docker_available_false_when_missing():
with patch("subprocess.run", side_effect=FileNotFoundError()):
assert docker_available() is False
def test_container_exists_true():
with patch("llamacppctl.docker_ops._run") as mock_run:
mock_run.return_value = MagicMock(stdout="test_llm\nother\n")
assert container_exists("test_llm") is True
def test_container_exists_false():
with patch("llamacppctl.docker_ops._run") as mock_run:
mock_run.return_value = MagicMock(stdout="other\n")
assert container_exists("test_llm") is False
def test_container_running_true():
with patch("llamacppctl.docker_ops._run") as mock_run:
mock_run.return_value = MagicMock(stdout="test_llm\n")
assert container_running("test_llm") is True
def test_inspect_container_missing():
with patch("llamacppctl.docker_ops.container_exists", return_value=False):
info = inspect_container("ghost")
assert info.status == "missing"
assert info.health == "none"
def test_inspect_container_running():
fake_json = (
'[{"State": {"Status": "running", "Health": {"Status": "healthy"}}}]'
)
with patch("llamacppctl.docker_ops.container_exists", return_value=True), patch(
"llamacppctl.docker_ops._run"
) as mock_run:
mock_run.return_value = MagicMock(stdout=fake_json)
info = inspect_container("test_llm")
assert info.status == "running"
assert info.health == "healthy"
def test_build_run_command_contains_key_flags():
cfg = make_cfg()
cmd = build_run_command(cfg)
assert cmd[0:2] == ["docker", "run"]
assert "--gpus" in cmd
assert "device=0" in cmd
assert "--name" in cmd
assert "test_llm" in cmd
assert "-p" in cmd
assert "8001:8000" in cmd
assert "--jinja" in cmd
assert "--reasoning" in cmd
assert "on" in cmd
assert "--no-context-shift" in cmd
assert "--kv-unified" in cmd
assert "--cont-batching" in cmd
def test_build_run_command_respects_disabled_flags():
cfg = make_cfg(jinja=False, kv_unified=False, cont_batching=False, no_context_shift=False, fa=False)
cmd = build_run_command(cfg)
assert "--jinja" not in cmd
assert "--kv-unified" not in cmd
assert "--cont-batching" not in cmd
assert "--no-context-shift" not in cmd
def test_build_run_command_absolute_model_path():
cfg = make_cfg(model_path="/abs/path/model.gguf")
cmd = build_run_command(cfg)
idx = cmd.index("-m")
assert cmd[idx + 1] == "/abs/path/model.gguf"
def test_build_run_command_relative_model_path():
cfg = make_cfg(model_path="qwen3/default.gguf")
cmd = build_run_command(cfg)
idx = cmd.index("-m")
assert cmd[idx + 1] == "/hf_home/qwen3/default.gguf"
def test_format_command_for_display_quotes_properly():
cmd = ["docker", "run", "--name", "has space"]
out = format_command_for_display(cmd)
assert "'has space'" in out

132
tests/test_http_ops.py Normal file
View file

@ -0,0 +1,132 @@
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import requests # noqa: E402
from llamacppctl.http_ops import ( # noqa: E402
HttpError,
base_url,
chat_completion,
chat_completion_text,
check_health,
check_models,
wait_until_ready,
)
from llamacppctl.schema import PromptConfig, ServerConfig # noqa: E402
from tests.test_docker_ops import make_cfg # noqa: E402
def test_base_url():
cfg = make_cfg(host_port=8001)
assert base_url(cfg) == "http://127.0.0.1:8001/v1"
def test_check_health_ok():
cfg = make_cfg()
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(ok=True)
assert check_health(cfg) is True
def test_check_health_exception_returns_false():
cfg = make_cfg()
with patch("requests.get", side_effect=requests.RequestException("boom")):
assert check_health(cfg) is False
def test_check_models_ok():
cfg = make_cfg()
with patch("requests.get") as mock_get:
mock_get.return_value = MagicMock(ok=True)
assert check_models(cfg) is True
def test_chat_completion_posts_expected_payload():
cfg = make_cfg()
prompt_cfg = PromptConfig(system_prompt="sys", user_prompt="hi", max_tokens=32, temperature=0.1)
with patch("requests.post") as mock_post:
mock_post.return_value = MagicMock(status_code=200)
chat_completion(cfg, prompt_cfg)
_, kwargs = mock_post.call_args
payload = kwargs["json"]
assert payload["messages"][0] == {"role": "system", "content": "sys"}
assert payload["messages"][1] == {"role": "user", "content": "hi"}
assert payload["max_tokens"] == 32
def test_chat_completion_text_success():
cfg = make_cfg()
prompt_cfg = PromptConfig(user_prompt="hi")
with patch("requests.post") as mock_post:
mock_resp = MagicMock(status_code=200)
mock_resp.json.return_value = {"choices": [{"message": {"content": "ok"}}]}
mock_post.return_value = mock_resp
assert chat_completion_text(cfg, prompt_cfg) == "ok"
def test_chat_completion_text_honors_chat_endpoint():
cfg = make_cfg(host_port=8001, chat_endpoint="/custom/chat")
prompt_cfg = PromptConfig(user_prompt="hi")
with patch("requests.post") as mock_post:
mock_resp = MagicMock(status_code=200)
mock_resp.json.return_value = {"choices": [{"message": {"content": "ok"}}]}
mock_post.return_value = mock_resp
chat_completion_text(cfg, prompt_cfg)
args, _ = mock_post.call_args
assert args[0] == "http://127.0.0.1:8001/custom/chat"
def test_chat_completion_text_http_error():
cfg = make_cfg()
prompt_cfg = PromptConfig(user_prompt="hi")
with patch("requests.post") as mock_post:
mock_post.return_value = MagicMock(status_code=500, text="server error")
try:
chat_completion_text(cfg, prompt_cfg)
assert False, "expected HttpError"
except HttpError:
pass
def test_chat_completion_text_transport_error():
cfg = make_cfg()
prompt_cfg = PromptConfig(user_prompt="hi")
with patch("requests.post", side_effect=requests.RequestException("down")):
try:
chat_completion_text(cfg, prompt_cfg)
assert False, "expected HttpError"
except HttpError:
pass
def test_chat_completion_text_malformed_response():
cfg = make_cfg()
prompt_cfg = PromptConfig(user_prompt="hi")
with patch("requests.post") as mock_post:
mock_resp = MagicMock(status_code=200)
mock_resp.json.return_value = {"unexpected": True}
mock_post.return_value = mock_resp
try:
chat_completion_text(cfg, prompt_cfg)
assert False, "expected HttpError"
except HttpError:
pass
def test_wait_until_ready_succeeds_immediately():
cfg = make_cfg(timeout=5, poll_interval=0.01)
probe = PromptConfig(user_prompt="ping", max_tokens=1)
with patch("llamacppctl.http_ops.chat_completion") as mock_chat:
mock_chat.return_value = MagicMock(status_code=200)
assert wait_until_ready(cfg, probe) is True
def test_wait_until_ready_times_out():
cfg = make_cfg(timeout=0, poll_interval=0.01)
probe = PromptConfig(user_prompt="ping", max_tokens=1)
with patch("llamacppctl.http_ops.chat_completion") as mock_chat:
mock_chat.return_value = MagicMock(status_code=500)
assert wait_until_ready(cfg, probe) is False

31
tests/test_lock_ops.py Normal file
View file

@ -0,0 +1,31 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llamacppctl.lock_ops import FileLock, LockError # noqa: E402
def test_lock_acquire_and_release(tmp_path):
lock_path = tmp_path / "test.lock"
with FileLock(lock_path):
assert lock_path.exists()
# released cleanly, can acquire again
with FileLock(lock_path):
pass
def test_lock_busy_raises(tmp_path):
lock_path = tmp_path / "busy.lock"
with FileLock(lock_path):
with pytest.raises(LockError):
with FileLock(lock_path):
pass
def test_lock_creates_parent_dirs(tmp_path):
lock_path = tmp_path / "nested" / "dir" / "test.lock"
with FileLock(lock_path):
assert lock_path.exists()

View file

@ -0,0 +1,81 @@
import os
import pytest
from llamacppctl.prompt_io import PromptSourceError, load_text_file
def test_load_utf8_file(tmp_path, policy):
p = tmp_path / "sys.txt"
p.write_text("Du bist präzise.", encoding="utf-8")
assert load_text_file(str(p), policy) == "Du bist präzise."
def test_strip_bom(tmp_path, policy):
p = tmp_path / "bom.txt"
p.write_bytes(b"\xef\xbb\xbfhello")
assert load_text_file(str(p), policy) == "hello"
def test_normalize_crlf(tmp_path, policy):
p = tmp_path / "crlf.txt"
p.write_bytes(b"line1\r\nline2\r\n")
assert load_text_file(str(p), policy) == "line1\nline2\n"
def test_reject_missing_file(tmp_path, policy):
with pytest.raises(PromptSourceError):
load_text_file(str(tmp_path / "nope.txt"), policy)
def test_reject_directory(tmp_path, policy):
d = tmp_path / "adir"
d.mkdir()
with pytest.raises(PromptSourceError):
load_text_file(str(d), policy)
def test_reject_binary_file(tmp_path, policy):
p = tmp_path / "bin.dat"
p.write_bytes(b"\x00\x01\x02binary")
with pytest.raises(PromptSourceError):
load_text_file(str(p), policy)
def test_reject_too_large_file(tmp_path, small_policy):
p = tmp_path / "big.txt"
p.write_text("x" * 100, encoding="utf-8")
with pytest.raises(PromptSourceError):
load_text_file(str(p), small_policy)
def test_reject_invalid_utf8(tmp_path, policy):
p = tmp_path / "invalid.txt"
p.write_bytes(b"\xff\xfe\xfa\xfb\x80\x81")
with pytest.raises(PromptSourceError):
load_text_file(str(p), policy)
def test_reject_symlink_when_disabled(tmp_path, policy):
target = tmp_path / "target.txt"
target.write_text("hi", encoding="utf-8")
link = tmp_path / "link.txt"
os.symlink(target, link)
from dataclasses import replace
strict_policy = replace(policy, allow_symlinks=False)
with pytest.raises(PromptSourceError):
load_text_file(str(link), strict_policy)
def test_allow_symlink_when_enabled(tmp_path, policy):
target = tmp_path / "target2.txt"
target.write_text("hi again", encoding="utf-8")
link = tmp_path / "link2.txt"
os.symlink(target, link)
from dataclasses import replace
permissive_policy = replace(policy, allow_symlinks=True)
assert load_text_file(str(link), permissive_policy) == "hi again"

View file

@ -0,0 +1,28 @@
from llamacppctl.prompt_io import PromptSource, PromptSourceError, resolve_source
def test_resolve_literal_source():
src = resolve_source("hello", None, None)
assert src == PromptSource("literal", "hello")
def test_resolve_file_source():
src = resolve_source(None, "/tmp/foo.txt", None)
assert src == PromptSource("file", "/tmp/foo.txt")
def test_resolve_url_source():
src = resolve_source(None, None, "https://example.org/x.txt")
assert src == PromptSource("url", "https://example.org/x.txt")
def test_resolve_none():
assert resolve_source(None, None, None) is None
def test_resolve_rejects_multiple_sources():
try:
resolve_source("a", "b", None)
assert False, "expected PromptSourceError"
except PromptSourceError:
pass

203
tests/test_prompt_urls.py Normal file
View file

@ -0,0 +1,203 @@
import socket
from dataclasses import replace
import pytest
from llamacppctl.prompt_io import (
PromptSourceError,
load_text_url,
validate_url_target,
)
class FakeResponse:
def __init__(self, status_code=200, headers=None, chunks=None, text=""):
self.status_code = status_code
self.headers = headers or {}
self._chunks = chunks if chunks is not None else [text.encode("utf-8")]
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
def iter_content(self, chunk_size=8192):
for c in self._chunks:
yield c
def _fake_getaddrinfo_factory(mapping):
def fake_getaddrinfo(host, port, *args, **kwargs):
ips = mapping.get(host, [])
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, port or 0)) for ip in ips]
return fake_getaddrinfo
def test_reject_http_without_opt_in(policy):
with pytest.raises(PromptSourceError):
validate_url_target("http://example.org/x.txt", policy)
def test_reject_localhost(policy):
with pytest.raises(PromptSourceError):
validate_url_target("https://localhost/x.txt", policy)
def test_reject_ip_literal_without_opt_in(policy):
with pytest.raises(PromptSourceError):
validate_url_target("https://93.184.216.34/x.txt", policy)
def test_allow_ip_literal_with_opt_in(monkeypatch, policy):
strict_policy = replace(policy, allow_ip_host=True)
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"93.184.216.34": ["93.184.216.34"]})
)
validate_url_target("https://93.184.216.34/x.txt", strict_policy)
def test_reject_private_ip_resolution(monkeypatch, policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"internal.example.org": ["10.0.0.5"]})
)
with pytest.raises(PromptSourceError):
validate_url_target("https://internal.example.org/x.txt", policy)
def test_reject_metadata_ip(monkeypatch, policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"evil.example.org": ["169.254.169.254"]})
)
with pytest.raises(PromptSourceError):
validate_url_target("https://evil.example.org/x.txt", policy)
def test_reject_embedded_credentials(policy):
with pytest.raises(PromptSourceError):
validate_url_target("https://user:pass@example.org/x.txt", policy)
def test_allowlist_blocks_other_hosts(monkeypatch, policy):
strict_policy = replace(policy, url_allow_hosts=["good.example.org"])
monkeypatch.setattr(
socket,
"getaddrinfo",
_fake_getaddrinfo_factory({"bad.example.org": ["93.184.216.34"]}),
)
with pytest.raises(PromptSourceError):
validate_url_target("https://bad.example.org/x.txt", strict_policy)
def test_accept_https_text_plain(monkeypatch, policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
)
def fake_get(url, **kwargs):
return FakeResponse(
status_code=200,
headers={"Content-Type": "text/plain", "Content-Length": "5"},
text="hello",
)
import llamacppctl.prompt_io as prompt_io
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
text = load_text_url("https://example.org/x.txt", policy)
assert text == "hello"
def test_reject_disallowed_content_type(monkeypatch, policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
)
def fake_get(url, **kwargs):
return FakeResponse(status_code=200, headers={"Content-Type": "text/html"}, text="<h1>hi</h1>")
import llamacppctl.prompt_io as prompt_io
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
with pytest.raises(PromptSourceError):
load_text_url("https://example.org/x.txt", policy)
def test_reject_large_content_length(monkeypatch, small_policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
)
def fake_get(url, **kwargs):
return FakeResponse(
status_code=200,
headers={"Content-Type": "text/plain", "Content-Length": "1000"},
text="x" * 1000,
)
import llamacppctl.prompt_io as prompt_io
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
with pytest.raises(PromptSourceError):
load_text_url("https://example.org/x.txt", small_policy)
def test_reject_large_streamed_response(monkeypatch, small_policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
)
def fake_get(url, **kwargs):
return FakeResponse(
status_code=200,
headers={"Content-Type": "text/plain"},
chunks=[b"x" * 10, b"x" * 10, b"x" * 10],
)
import llamacppctl.prompt_io as prompt_io
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
with pytest.raises(PromptSourceError):
load_text_url("https://example.org/x.txt", small_policy)
def test_reject_redirect_by_default(monkeypatch, policy):
monkeypatch.setattr(
socket, "getaddrinfo", _fake_getaddrinfo_factory({"example.org": ["93.184.216.34"]})
)
def fake_get(url, **kwargs):
return FakeResponse(status_code=302, headers={"Location": "https://example.org/other.txt"})
import llamacppctl.prompt_io as prompt_io
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
with pytest.raises(PromptSourceError):
load_text_url("https://example.org/x.txt", policy)
def test_revalidate_redirect_target(monkeypatch, policy):
strict_policy = replace(policy, follow_redirects=True)
monkeypatch.setattr(
socket,
"getaddrinfo",
_fake_getaddrinfo_factory(
{"example.org": ["93.184.216.34"], "internal.example.org": ["10.0.0.9"]}
),
)
calls = {"n": 0}
def fake_get(url, **kwargs):
calls["n"] += 1
if "internal" not in url:
return FakeResponse(
status_code=302, headers={"Location": "https://internal.example.org/secret.txt"}
)
return FakeResponse(status_code=200, headers={"Content-Type": "text/plain"}, text="leak")
import llamacppctl.prompt_io as prompt_io
monkeypatch.setattr(prompt_io.requests, "get", fake_get)
with pytest.raises(PromptSourceError):
load_text_url("https://example.org/x.txt", strict_policy)
assert calls["n"] == 1 # must fail validation before the second request