feat: sidecar planning logic (filename resolution, allowlist, dedup)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:00:38 +02:00
parent 66e664247c
commit 31a7112052
2 changed files with 121 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
"""Pure sidecar-file planning: filename resolution, extension allowlist, and
duplicate detection. No comfy/torch imports so it stays unit-testable."""
import os
# Plain-text / data formats only — never image or executable extensions.
ALLOWED_EXTENSIONS = {
".txt", ".caption", ".json", ".yaml", ".yml", ".md",
".csv", ".tsv", ".xml", ".log", ".ini", ".toml",
}
def normalize_ext(ext):
"""Sanitize an extension to a leading-dot basename and allowlist it."""
ext = os.path.basename((ext or "").strip())
if ext and not ext.startswith("."):
ext = "." + ext
if ext.lower() not in ALLOWED_EXTENSIONS:
raise ValueError(
f"Disallowed sidecar extension {ext!r}. "
f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
return ext
def sanitize_name(name):
"""Reduce a name field to a bare filename token (no path traversal)."""
return os.path.basename((name or "").strip())
def append_spec(chain, content, name, ext):
"""Return a new chain list with this sidecar spec appended (no mutation)."""
out = list(chain) if chain else []
out.append({"content": content, "name": name, "ext": ext})
return out
def build_plan(specs):
"""Resolve specs to a list of (suffix, content), where the file written is
`<image_base> + suffix` and suffix is `name + ext`. Validates extensions and
rejects duplicate filenames, raising ValueError *before* any I/O so a bad
chain writes nothing."""
seen = set()
plan = []
for s in specs or []:
ext = normalize_ext(s.get("ext"))
name = sanitize_name(s.get("name"))
suffix = f"{name}{ext}"
if suffix in seen:
raise ValueError(
f"Duplicate sidecar file '<base>{suffix}': two sidecars resolve "
f"to the same name (name={name!r}, ext={ext}). "
f"Give one a distinct name.")
seen.add(suffix)
plan.append((suffix, s.get("content", "")))
return plan
+67
View File
@@ -0,0 +1,67 @@
import pytest
from gates import sidecar
def test_append_spec_builds_chain_without_mutating():
c1 = sidecar.append_spec(None, "hello", "", ".txt")
c2 = sidecar.append_spec(c1, "{}", "meta", ".json")
assert c1 == [{"content": "hello", "name": "", "ext": ".txt"}]
assert len(c2) == 2
assert c2[1] == {"content": "{}", "name": "meta", "ext": ".json"}
assert len(c1) == 1 # original chain untouched
def test_normalize_ext_adds_dot_and_allowlists():
assert sidecar.normalize_ext("txt") == ".txt"
assert sidecar.normalize_ext(".json") == ".json"
with pytest.raises(ValueError):
sidecar.normalize_ext(".png")
with pytest.raises(ValueError):
sidecar.normalize_ext(".exe")
def test_sanitize_name_strips_path_and_space():
assert sidecar.sanitize_name(" variant_a ") == "variant_a"
assert sidecar.sanitize_name("../evil") == "evil"
assert sidecar.sanitize_name("a/b") == "b"
assert sidecar.sanitize_name("") == ""
def test_build_plan_resolves_suffixes():
specs = [
{"content": "cap", "name": "", "ext": ".txt"},
{"content": "{}", "name": "", "ext": ".json"},
{"content": "v", "name": "variant_a", "ext": ".txt"},
]
assert sidecar.build_plan(specs) == [
(".txt", "cap"),
(".json", "{}"),
("variant_a.txt", "v"),
]
def test_build_plan_duplicate_empty_names_raises():
specs = [
{"content": "a", "name": "", "ext": ".txt"},
{"content": "b", "name": "", "ext": ".txt"},
]
with pytest.raises(ValueError):
sidecar.build_plan(specs)
def test_build_plan_empty_txt_and_json_do_not_collide():
specs = [
{"content": "a", "name": "", "ext": ".txt"},
{"content": "b", "name": "", "ext": ".json"},
]
assert len(sidecar.build_plan(specs)) == 2
def test_build_plan_bad_extension_raises():
with pytest.raises(ValueError):
sidecar.build_plan([{"content": "x", "name": "", "ext": ".png"}])
def test_build_plan_none_is_empty():
assert sidecar.build_plan(None) == []