Skip to content

Commit 2925855

Browse files
authored
fix(config): validate domain and state_dir (#195)
* fix(config): reject a hostless domain and a relative state_dir - domain accepts a bare host or a full URL, so the check is on what _derive_domain yields rather than on the form of the value - a hostless domain used to fail later in OswExpress.validate_domain, quoting a regex instead of naming OSW_DOMAIN - state_dir "~/osw" created a directory literally named "~", because Ledger builds its path with Path() and never expands it - a relative state_dir resolves against a working directory the MCP client chooses, so the ledger landed in an unpredictable place - last open item from #143 * test(config): cover drive-relative state_dir and an unknown home - reject "\osw-state" and "C:osw-state", non-absolute on both platforms - assert the expanduser RuntimeError becomes a named config error - state in the docs that a domain without a host is rejected
1 parent 11bde33 commit 2925855

3 files changed

Lines changed: 101 additions & 2 deletions

File tree

‎docs/tools/configuration.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,14 @@ set wins:
100100

101101
| Canonical | Also accepted | Meaning |
102102
| --- | --- | --- |
103-
| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to |
103+
| `OSW_DOMAIN` | `OSL_DOMAIN` | Instance to connect to. A bare host (`wiki.example.org`) or a full URL (`https://wiki.example.org/w/`); the host is taken from either, and a value no host can be read from (`https://`, `/w/index.php`) is rejected at startup |
104104
| `OSW_USERNAME` | `OSL_USERNAME` | Login user |
105105
| `OSW_PASSWORD` | `OSL_PASSWORD` | Login password |
106106
| `OSW_CRED_FILEPATH` | `OSW_MCP_CRED_FILEPATH`, `OSL_CRED_FILEPATH` | YAML credential file, keyed by iri (falls back to `accounts.pwd.yaml` in the working directory, CLI only) |
107107
| `OSW_ENV_FILE` | `OSW_MCP_ENV_FILE` | `.env` file to load |
108108
| `OSW_READ_ONLY` | `OSW_MCP_READ_ONLY` | `true` refuses every write |
109109
| `OSW_SPARQL_ENDPOINT` | | Endpoint for `sparql` queries |
110-
| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept |
110+
| `OSW_STATE_DIR` | `OSW_MCP_STATE_DIR` | Where the provenance ledger is kept. Must be an absolute path; a leading `~` is expanded |
111111
| `OSW_MAX_RESULTS` | `OSW_MCP_MAX_RESULTS` | Default result cap (100) |
112112
| `OSW_MAX_CHARS` | `OSW_MCP_MAX_CHARS` | Result size cap in characters (100000) |
113113
| `OSW_VERBOSE` | `OSW_MCP_VERBOSE` | `true` prints the configuration source report |

‎src/osw/service/config.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ def _validate_domain(cls, value: Optional[str]) -> Optional[str]:
117117
raise ValueError("must not contain whitespace")
118118
if any(ord(char) < 32 for char in value):
119119
raise ValueError("must not contain control characters")
120+
# A bare host and a full URL are both legal here, so the check is on
121+
# what _derive_domain makes of the value, not on its form.
122+
# OswExpress.validate_domain rejects a hostless value too, but only on
123+
# the first connection, and its message quotes a regex rather than
124+
# naming the variable the operator has to correct.
125+
if not _derive_domain(value):
126+
raise ValueError(
127+
"must contain a host name (e.g. 'wiki.example.org' or "
128+
"'https://wiki.example.org/w/')"
129+
)
120130
return value
121131

122132
@field_validator("sparql_endpoint")
@@ -138,6 +148,22 @@ def _validate_state_dir(cls, value: Optional[str]) -> Optional[str]:
138148
return value
139149
if not value.strip():
140150
raise ValueError("must not be empty or whitespace-only")
151+
# The only validator here that rewrites its value. Ledger builds its
152+
# file as Path(state_dir) / ... and never expands a leading ~, so
153+
# "~/osw" used to create a directory literally named "~".
154+
if value.startswith("~"):
155+
try:
156+
value = str(Path(value).expanduser())
157+
except RuntimeError as exc:
158+
raise ValueError(
159+
f"starts with '~' but the home directory cannot be "
160+
f"determined ({exc})"
161+
) from exc
162+
if not Path(value).is_absolute():
163+
raise ValueError(
164+
"must be an absolute path: a relative one resolves against the "
165+
"working directory, which for osw-mcp is chosen by the client"
166+
)
141167
return value
142168

143169
@field_validator("cred_filepath")

‎tests/test_service_config.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
from pathlib import Path
56

67
import pytest
78
import yaml
@@ -1055,6 +1056,78 @@ def test_domain_as_full_url_accepted():
10551056
assert settings.domain == "https://wiki.example.org/w/"
10561057

10571058

1059+
@pytest.mark.parametrize("value", ["https://", "/w/index.php", "//", "https:///w/"])
1060+
def test_domain_without_a_host_rejected(value):
1061+
"""A value _derive_domain cannot reduce to a host is unusable.
1062+
1063+
Caught here rather than in OswExpress.validate_domain, which only runs on
1064+
the first connection and reports a regex rather than the variable name.
1065+
"""
1066+
with pytest.raises(ValidationError) as exc:
1067+
Settings(domain=value)
1068+
assert "host" in str(exc.value)
1069+
1070+
1071+
def test_state_dir_expands_a_leading_tilde():
1072+
"""Path(state_dir) never expands it, so '~/osw' made a directory named '~'.
1073+
1074+
Ledger builds its file as Path(state_dir) / ... with no expanduser call
1075+
(src/osw/service/ledger.py:70), so the expansion has to happen here.
1076+
"""
1077+
settings = Settings(domain="wiki.example.org", state_dir="~/osw-state")
1078+
assert settings.state_dir == str(Path.home() / "osw-state")
1079+
1080+
1081+
@pytest.mark.parametrize(
1082+
"value",
1083+
[
1084+
"osw-state",
1085+
"./osw-state",
1086+
"../osw-state",
1087+
# Drive-relative Windows forms: rooted without a drive, and a drive
1088+
# without a root. Both resolve against process state (the current drive,
1089+
# and that drive's working directory), so both are as unpredictable as a
1090+
# plain relative path. is_absolute() is False for both on Windows and on
1091+
# POSIX, so these parameters need no platform marker.
1092+
"\\osw-state",
1093+
"C:osw-state",
1094+
],
1095+
)
1096+
def test_state_dir_relative_rejected(value):
1097+
"""A relative path resolves against a working directory the user may not own.
1098+
1099+
The MCP client chooses the server's working directory, so the ledger would
1100+
be created in an unpredictable place.
1101+
"""
1102+
with pytest.raises(ValidationError) as exc:
1103+
Settings(domain="wiki.example.org", state_dir=value)
1104+
assert "absolute" in str(exc.value)
1105+
1106+
1107+
def test_state_dir_reports_an_undeterminable_home(monkeypatch):
1108+
"""The '~' expansion can fail, and the failure has to name the setting.
1109+
1110+
Path.expanduser() raises RuntimeError when no home directory can be found.
1111+
Uncaught it would surface as a bare RuntimeError with no mention of
1112+
OSW_STATE_DIR. Reproducing that state differs per platform (Windows reads
1113+
USERPROFILE, POSIX falls back to the password database), so the raise itself
1114+
is patched in.
1115+
"""
1116+
1117+
def _no_home(self):
1118+
raise RuntimeError("Could not determine home directory.")
1119+
1120+
monkeypatch.setattr(Path, "expanduser", _no_home)
1121+
with pytest.raises(ValidationError) as exc:
1122+
Settings(domain="wiki.example.org", state_dir="~/osw-state")
1123+
assert "home directory" in str(exc.value)
1124+
1125+
1126+
def test_state_dir_absolute_is_left_alone(tmp_path):
1127+
settings = Settings(domain="wiki.example.org", state_dir=str(tmp_path / "state"))
1128+
assert settings.state_dir == str(tmp_path / "state")
1129+
1130+
10581131
def test_settings_is_frozen():
10591132
settings = Settings(domain="wiki.example.org")
10601133
with pytest.raises(ValidationError):

0 commit comments

Comments
 (0)