From 31a71120527b59b0ddfe687f2b4d3b06763f96cc Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 1 Jul 2026 14:00:38 +0200 Subject: [PATCH] feat: sidecar planning logic (filename resolution, allowlist, dedup) Co-Authored-By: Claude Opus 4.8 --- gates/sidecar.py | 54 ++++++++++++++++++++++++++++++++++ tests/test_sidecar.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 gates/sidecar.py create mode 100644 tests/test_sidecar.py diff --git a/gates/sidecar.py b/gates/sidecar.py new file mode 100644 index 0000000..cef9b76 --- /dev/null +++ b/gates/sidecar.py @@ -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 + ` + 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 '{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 diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py new file mode 100644 index 0000000..8a8cd89 --- /dev/null +++ b/tests/test_sidecar.py @@ -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) == []