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