Plan Step 3. Verify green: `.venv/bin/python -m pytest tests/test_paths.py` ->
8 passed, exit 0. Full suite: 11 passed.
Test first, and RED was observed before a line of paths.py existed
(ModuleNotFoundError on collection).
Two measured risks drive the design, not one.
Risk C6 -- the host-side server runs unsandboxed with caller-supplied paths.
safe_join resolves BOTH operands with os.path.realpath before comparing, so
lexical traversal and symlink escape fall to the same check; a lexical check on
the joined string would have called the symlink case safe. The comparison is
component-aware, so a sibling whose name merely prefixes the root is outside.
Escape raises; it never clamps to an adjacent path.
Risk H8 -- macOS hands back NFD. slug normalises to NFC first, so a decomposed
name and its composed twin produce one slug rather than two directories. The
test asserts that equality on a real NFC/NFD pair, not on a lookalike.
No implicit default workspace: --workspace, else JOBBSOK_WORKSPACE, else a
`workspace:` line in ${CLAUDE_PLUGIN_DATA}/jobbsok.conf, else WorkspaceUnresolved.
Guessing at the operator's home directory is the one behaviour this module must
not have. This tightens build-brief section 5's `~/jobbsok-workspace` default
into an explicit order; the deviation is recorded in the plan's Assumptions.
scaffold is idempotent by only creating what is absent -- an existing
beslutninger.jsonl is never truncated. It is append-only, and a scaffold that
emptied it would destroy the decision log. The test proves this by populating
the workspace and asserting a byte-identical snapshot across a second run.
Collision rule is documented and tested: -2, then -3, counting up.
sak_id carries no `taken` parameter. An earlier draft had one; no step and no
test requires it, so it was unproven code and was removed rather than kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
5.1 KiB
Python
127 lines
5.1 KiB
Python
"""The workspace path contract every later script depends on (plan Step 3).
|
|
|
|
Two risks are being tested here, not one. Risk C6: the host-side server runs
|
|
unsandboxed with caller-supplied paths, so a path that escapes the workspace
|
|
escapes onto the operator's machine -- traversal and symlink escape are the two
|
|
ways out and both are exercised below. Risk H8: macOS hands back filenames in
|
|
NFD, so a name that looks identical to an NFC one is a different byte string;
|
|
every generated path component is asserted to be pure ASCII rather than merely
|
|
looking right.
|
|
|
|
The third theme is refusing to guess. There is no implicit default workspace:
|
|
if none of the three resolution sources answers, that is an error, never a
|
|
silent write into the operator's home directory.
|
|
|
|
Style note: this file follows tests/test_toolchain.py.
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from jobbsok_lib import paths
|
|
|
|
|
|
def test_traversal_is_refused(tmp_path):
|
|
root = str(tmp_path / "ws")
|
|
os.makedirs(root)
|
|
with pytest.raises(paths.WorkspaceEscape):
|
|
paths.safe_join(root, "..", "..", "etc", "passwd")
|
|
|
|
|
|
def test_symlink_escape_is_refused(tmp_path):
|
|
root = str(tmp_path / "ws")
|
|
outside = str(tmp_path / "outside")
|
|
os.makedirs(root)
|
|
os.makedirs(outside)
|
|
# A link that lives inside the workspace but resolves outside it. A
|
|
# lexical check on the joined string would call this safe; realpath does
|
|
# not, which is the whole reason safe_join resolves before it compares.
|
|
os.symlink(outside, os.path.join(root, "escape"))
|
|
with pytest.raises(paths.WorkspaceEscape):
|
|
paths.safe_join(root, "escape", "stolen.md")
|
|
|
|
|
|
def test_missing_workspace_raises_rather_than_defaulting(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("JOBBSOK_WORKSPACE", raising=False)
|
|
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "no-such-data-dir"))
|
|
with pytest.raises(paths.WorkspaceUnresolved):
|
|
paths.workspace_root(None)
|
|
|
|
|
|
def test_slug_of_norwegian_place_name_is_pure_ascii():
|
|
# The three folds the contract names, each in a real place name.
|
|
assert paths.slug("Tromsø og Bodø") == "tromso-og-bodo"
|
|
assert paths.slug("Målselv") == "malselv"
|
|
assert paths.slug("Ærøy Bærum") == "aeroy-baerum"
|
|
# And the same word arriving decomposed (what macOS hands back) must
|
|
# fold to the identical slug as the composed form -- risk H8.
|
|
composed = "Målselv"
|
|
decomposed = "Målselv"
|
|
assert composed != decomposed
|
|
assert paths.slug(composed) == paths.slug(decomposed) == "malselv"
|
|
|
|
|
|
def test_every_generated_path_component_is_ascii():
|
|
messy = "Blåbær & Sønn AS — Seniør Rådgiver (København)"
|
|
generated = paths.slug(messy)
|
|
assert generated.isascii(), generated
|
|
assert generated == generated.strip("-")
|
|
assert "--" not in generated
|
|
for component in paths.sak_dirname("2026-09", "Blåbær AS", "Seniør Rådgiver").split("/"):
|
|
assert component.isascii(), component
|
|
|
|
|
|
def test_colliding_slugs_get_distinct_suffixes():
|
|
taken = set()
|
|
first = paths.slug("Bergen Kommune", taken=taken)
|
|
taken.add(first)
|
|
second = paths.slug("bergen kommune!", taken=taken)
|
|
taken.add(second)
|
|
third = paths.slug("BERGEN-KOMMUNE", taken=taken)
|
|
assert first == "bergen-kommune"
|
|
assert second == "bergen-kommune-2"
|
|
assert third == "bergen-kommune-3"
|
|
assert len({first, second, third}) == 3
|
|
|
|
|
|
def test_scaffold_creates_every_section_5_directory_and_gitignore(tmp_path):
|
|
root = str(tmp_path / "jobbsok-workspace")
|
|
paths.scaffold(root)
|
|
for directory in paths.WORKSPACE_DIRS:
|
|
assert os.path.isdir(os.path.join(root, directory)), directory
|
|
for filename in paths.WORKSPACE_FILES:
|
|
assert os.path.isfile(os.path.join(root, filename)), filename
|
|
# beslutninger.jsonl is append-only and starts empty, not absent: a missing
|
|
# file and an empty one read very differently at the first append.
|
|
assert os.path.getsize(os.path.join(root, "beslutninger.jsonl")) == 0
|
|
gitignore = os.path.join(root, ".gitignore")
|
|
with open(gitignore, "r", encoding="utf-8") as handle:
|
|
assert "*" in handle.read()
|
|
|
|
|
|
def test_scaffold_is_idempotent_over_a_populated_workspace(tmp_path):
|
|
root = str(tmp_path / "jobbsok-workspace")
|
|
paths.scaffold(root)
|
|
# Populate it the way real use would, including the two files scaffold
|
|
# itself creates -- an idempotent scaffold must not truncate them.
|
|
with open(os.path.join(root, "beslutninger.jsonl"), "w", encoding="utf-8") as handle:
|
|
handle.write('{"sak_id": "2026-09-eksempel-as-radgiver", "beslutning": "soker"}\n')
|
|
with open(os.path.join(root, "referanser.md"), "w", encoding="utf-8") as handle:
|
|
handle.write("# Referanser\n\nEn kontakt.\n")
|
|
os.makedirs(os.path.join(root, "saker", "2026-09-eksempel-as-radgiver"))
|
|
|
|
before = _snapshot(root)
|
|
paths.scaffold(root)
|
|
assert _snapshot(root) == before
|
|
|
|
|
|
def _snapshot(root):
|
|
"""Every file under root as {relative path: bytes}."""
|
|
out = {}
|
|
for dirpath, _dirnames, filenames in os.walk(root):
|
|
for name in filenames:
|
|
full = os.path.join(dirpath, name)
|
|
with open(full, "rb") as handle:
|
|
out[os.path.relpath(full, root)] = handle.read()
|
|
return out
|