fix(issue-45): review round 2 R3 - the .mam fallback obeys the same no-overwrite rules

The fallback wrote <.mam>/herdr-server-config.toml unchecked, so it could
replace a foreign file there, the source itself, or a link to it. It now goes
through output_path like the normal path (source identity incl. aliases,
generated-for-this-source ownership, then a per-source name) and writes
nothing when no name is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-04 11:49:08 +00:00
1 parent 7c544c06ab
commit 35935f9e8a
3 files changed
+82 -11

No files matched your search

+13 -10
View File
@@ -14,9 +14,10 @@ to false. The user's file is never modified.
from the server) is followed back to the source recorded in that file.
* Output: `mam-server-config.toml` next to the source, so relative paths in
it (herdr resolves them against the config file's directory) keep their
meaning. Never the source itself and never a file this module did not
write for the same source: then a name with a hash of the source path.
`<.mam>/herdr-server-config.toml` only when the directory is not writable.
meaning. `<.mam>/herdr-server-config.toml` only when that directory is not
writable. Either way never the source itself and never a file this module
did not write for the same source: then a name with a hash of the source
path, and no write at all when that is taken too.
* The source is parsed, the key set, and the result serialized; the result
must parse back to exactly the source plus the key (any TOML spelling of
the key - header, dotted, inline - works). Then herdr's own `config check`
@@ -188,19 +189,20 @@ def _same_file(a: str, b: str) -> bool:
return os.path.realpath(a) == os.path.realpath(b)
def output_path(source: str) -> str:
"""Beside the source: OUTPUT_NAME, unless that is the source itself or a
file this module did not write for this source; then a per-source name."""
d = os.path.dirname(source)
def output_path(source: str, directory: Optional[str] = None, name: str = OUTPUT_NAME) -> str:
"""`name` in `directory` (default: beside the source), unless that is the
source itself (aliases included) or a file this module did not write for
this source; then a per-source name. Every write goes through here."""
d = directory or os.path.dirname(source)
tag = hashlib.sha256(os.path.realpath(source).encode()).hexdigest()[:12]
for name in (OUTPUT_NAME, f"mam-server-config-{tag}.toml"):
target = os.path.join(d, name)
for n in (name, f"{name[:-len('.toml')]}-{tag}.toml"):
target = os.path.join(d, n)
if _same_file(target, source):
continue
if os.path.lexists(target) and _generated_source(target) not in ("", source):
continue
return target
raise OSError(f"no free output name beside {source}")
raise OSError(f"no free output name in {d}")
def _write_atomic(path: str, text: str, mode: int) -> None:
@@ -268,6 +270,7 @@ def write_server_config(env: Mapping[str, str], fallback_dir: Optional[str] = No
if fallback_dir:
target = os.path.join(fallback_dir, FALLBACK_NAME)
try:
target = output_path(source, fallback_dir, FALLBACK_NAME)
if _write_checked(target, source, body, mode, herdr, warnings):
warnings.append(f"wrote {target} instead; relative paths in the herdr config may not resolve")
return target, warnings