feat: sidecar planning logic (filename resolution, allowlist, dedup)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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) == []
|
||||||
Reference in New Issue
Block a user