10 Commits

Author SHA1 Message Date
red person df9c20e6c2 Ignore invalid context budget numbers (#1831) 2026-06-29 19:56:17 +01:00
red person bbbe145247 Ignore non-string personal doc text (#1832) 2026-06-29 19:24:29 +01:00
red person 387f95187e Ignore invalid harmonize mask layers (#1829) 2026-06-29 19:16:26 +01:00
red person 00dfd2d47a Keep snap helper safe without context (#1828) 2026-06-29 18:54:44 +01:00
red person d2a6d73aa5 Ignore invalid serve profile inputs (#1827) 2026-06-29 18:47:19 +01:00
red person 139d76ab57 Reject resolver results without IPs (#1826) 2026-06-29 16:32:32 +01:00
red person 3021569081 Reject non-string atomic text writes (#1819) 2026-06-29 14:36:21 +01:00
red person a326a6a555 Skip invalid notes CLI item rows (#2005) 2026-06-29 14:26:46 +01:00
red person dff79319d7 Normalize gallery CLI text fields (#2012) 2026-06-29 13:47:29 +01:00
red person 9731048ecd Ignore non-string mail CLI recipients (#1824) 2026-06-29 13:41:22 +01:00
20 changed files with 195 additions and 20 deletions
+2
View File
@@ -34,6 +34,8 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
def atomic_write_text(path: str, text: str) -> None: def atomic_write_text(path: str, text: str) -> None:
if not isinstance(text, str):
raise TypeError("atomic_write_text expects a string")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}" tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
+15 -11
View File
@@ -38,23 +38,27 @@ def _preview_text(value, limit: int = 200) -> str:
return text[:limit] return text[:limit]
def _text_field(value) -> str:
return value if isinstance(value, str) else ""
def _serialize_image(i: "GalleryImage") -> dict: def _serialize_image(i: "GalleryImage") -> dict:
return { return {
"id": i.id, "id": i.id,
"filename": i.filename, "filename": _text_field(i.filename),
"prompt": _preview_text(i.prompt), "prompt": _preview_text(i.prompt),
"model": i.model or "", "model": _text_field(i.model),
"size": i.size or "", "size": _text_field(i.size),
"tags": i.tags or "", "tags": _text_field(i.tags),
"favorite": bool(i.favorite), "favorite": bool(i.favorite),
"album_id": i.album_id or "", "album_id": _text_field(i.album_id),
"session_id": i.session_id or "", "session_id": _text_field(i.session_id),
"width": i.width, "width": i.width,
"height": i.height, "height": i.height,
"file_size": i.file_size, "file_size": i.file_size,
"taken_at": i.taken_at.isoformat() if i.taken_at else "", "taken_at": i.taken_at.isoformat() if i.taken_at else "",
"camera_make": i.camera_make or "", "camera_make": _text_field(i.camera_make),
"camera_model": i.camera_model or "", "camera_model": _text_field(i.camera_model),
"created_at": i.created_at.isoformat() if i.created_at else "", "created_at": i.created_at.isoformat() if i.created_at else "",
} }
@@ -93,11 +97,11 @@ def cmd_show(args):
if not i: if not i:
fail(f"no image with id {args.id!r}") fail(f"no image with id {args.id!r}")
out = _serialize_image(i) out = _serialize_image(i)
out["prompt_full"] = i.prompt or "" out["prompt_full"] = _text_field(i.prompt)
out["ai_tags"] = i.ai_tags or "" out["ai_tags"] = _text_field(i.ai_tags)
out["gps_lat"] = i.gps_lat or "" out["gps_lat"] = i.gps_lat or ""
out["gps_lng"] = i.gps_lng or "" out["gps_lng"] = i.gps_lng or ""
out["file_hash"] = i.file_hash or "" out["file_hash"] = _text_field(i.file_hash)
emit(out, args) emit(out, args)
finally: finally:
db.close() db.close()
+2
View File
@@ -108,6 +108,8 @@ def _q(name: str) -> str:
def _split_recipients(value: str) -> list[str]: def _split_recipients(value: str) -> list[str]:
if not isinstance(value, str):
return []
return [r.strip() for r in (value or "").split(",") if r.strip()] return [r.strip() for r in (value or "").split(",") if r.strip()]
+3 -1
View File
@@ -36,7 +36,9 @@ def _load_items(raw) -> list:
items = json.loads(raw) items = json.loads(raw)
except (TypeError, json.JSONDecodeError): except (TypeError, json.JSONDecodeError):
return [] return []
return items if isinstance(items, list) else [] if not isinstance(items, list):
return []
return [item for item in items if isinstance(item, dict)]
def _serialize(n: "Note") -> dict: def _serialize(n: "Note") -> dict:
+3
View File
@@ -103,6 +103,9 @@ def compute_serve_profiles(system, model, serve_weights_gb=None, serve_quant=Non
in the actual serving knobs (n_cpu_moe, KV-cache type, context). serve_quant in the actual serving knobs (n_cpu_moe, KV-cache type, context). serve_quant
is the file's quant label (e.g. "Q4_K_M") just for display. is the file's quant label (e.g. "Q4_K_M") just for display.
""" """
if not isinstance(system, dict) or not isinstance(model, dict):
return []
vram = float(system.get("gpu_vram_gb") or 0) vram = float(system.get("gpu_vram_gb") or 0)
if vram <= 0: if vram <= 0:
return [] return []
+9 -2
View File
@@ -18,6 +18,13 @@ DEFAULT_BUDGET = 6000
DEFAULT_HEADROOM = 0.85 DEFAULT_HEADROOM = 0.85
def _int_or_zero(value) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def compute_input_token_budget( def compute_input_token_budget(
configured: int, configured: int,
context_length: int, context_length: int,
@@ -48,8 +55,8 @@ def compute_input_token_budget(
- When the window is unknown (context_length <= 0), use the conservative - When the window is unknown (context_length <= 0), use the conservative
``default`` budget and do NOT scale off the fallback. ``default`` budget and do NOT scale off the fallback.
""" """
configured = int(configured or 0) configured = _int_or_zero(configured)
context_length = int(context_length or 0) context_length = _int_or_zero(context_length)
if explicit and configured > 0: if explicit and configured > 0:
return min(configured, context_length) if context_length > 0 else configured return min(configured, context_length) if context_length > 0 else configured
+4 -1
View File
@@ -68,6 +68,8 @@ def read_text_file(path: str) -> str:
def split_chunks(text: str, size: int = config.CHUNK_SIZE, overlap: int = config.CHUNK_OVERLAP) -> List[str]: def split_chunks(text: str, size: int = config.CHUNK_SIZE, overlap: int = config.CHUNK_OVERLAP) -> List[str]:
"""Split text into overlapping chunks.""" """Split text into overlapping chunks."""
if not isinstance(text, str):
return []
text = text.strip() text = text.strip()
if not text: if not text:
return [] return []
@@ -87,7 +89,8 @@ def split_chunks(text: str, size: int = config.CHUNK_SIZE, overlap: int = config
def tokenize(s: str) -> Set[str]: def tokenize(s: str) -> Set[str]:
"""Tokenize string into words, excluding stop words.""" """Tokenize string into words, excluding stop words."""
tokens = re.findall(r"[A-Za-z0-9_\-]+", (s or "").lower()) text = s if isinstance(s, str) else ""
tokens = re.findall(r"[A-Za-z0-9_\-]+", text.lower())
return set(t for t in tokens if t not in config.STOP_WORDS and len(t) > 1) return set(t for t in tokens if t not in config.STOP_WORDS and len(t) > 1)
def load_personal_index( def load_personal_index(
+6
View File
@@ -79,12 +79,18 @@ def check_outbound_url(
if not raw_ips: if not raw_ips:
return False, "host does not resolve" return False, "host does not resolve"
saw_ip = False
for raw in raw_ips: for raw in raw_ips:
if not isinstance(raw, str):
continue
try: try:
ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
except ValueError: except ValueError:
continue continue
saw_ip = True
reason = _classify(ip, block_private=block_private) reason = _classify(ip, block_private=block_private)
if reason: if reason:
return False, reason return False, reason
if not saw_ip:
return False, "host does not resolve to an IP"
return True, "ok" return True, "ok"
+1
View File
@@ -32,6 +32,7 @@
* @returns {HTMLCanvasElement|null} * @returns {HTMLCanvasElement|null}
*/ */
export function layerUnionAlpha(w, h, layers) { export function layerUnionAlpha(w, h, layers) {
if (!Array.isArray(layers)) return null;
const visible = layers.filter(l => l.visible); const visible = layers.filter(l => l.visible);
if (visible.length < 2) return null; if (visible.length < 2) return null;
const bgId = visible[0].id; const bgId = visible[0].id;
+4 -2
View File
@@ -23,8 +23,10 @@
* @returns {{x: number, y: number, guides: Array}} * @returns {{x: number, y: number, guides: Array}}
*/ */
export function computeSnap(layer, nx, ny, ctx) { export function computeSnap(layer, nx, ny, ctx) {
const SNAP_PX = 6 / Math.max(ctx.zoom, 0.0001); if (!layer || !layer.canvas || !ctx) return { x: nx, y: ny, guides: [] };
const cw = ctx.canvasW, ch = ctx.canvasH; const zoom = Number.isFinite(Number(ctx.zoom)) ? Number(ctx.zoom) : 1;
const SNAP_PX = 6 / Math.max(zoom, 0.0001);
const cw = Number(ctx.canvasW) || 0, ch = Number(ctx.canvasH) || 0;
const w = layer.canvas.width, h = layer.canvas.height; const w = layer.canvas.width, h = layer.canvas.height;
const vTargets = [ const vTargets = [
+7 -2
View File
@@ -15,16 +15,21 @@ def test_preview_text_ignores_non_string(monkeypatch):
assert cli._preview_text(None) == "" assert cli._preview_text(None) == ""
assert cli._preview_text(123) == "" assert cli._preview_text(123) == ""
assert cli._preview_text("p" * 250) == "p" * 200 assert cli._preview_text("p" * 250) == "p" * 200
assert cli._text_field("ok") == "ok"
assert cli._text_field(123) == ""
def test_serialize_image_does_not_crash_on_non_string_prompt(monkeypatch): def test_serialize_image_does_not_crash_on_non_string_prompt(monkeypatch):
make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"]) make_core_db_stub(monkeypatch, models=["GalleryImage", "GalleryAlbum"])
cli = load_script("odysseus-gallery") cli = load_script("odysseus-gallery")
img = SimpleNamespace( img = SimpleNamespace(
id="i1", filename="a.png", prompt=123, model=None, size=None, tags=None, id="i1", filename=123, prompt=123, model=123, size=None, tags=["bad"],
favorite=0, album_id=None, session_id=None, width=1, height=1, file_size=1, favorite=0, album_id=None, session_id=None, width=1, height=1, file_size=1,
taken_at=None, camera_make=None, camera_model=None, created_at=None, taken_at=None, camera_make=123, camera_model=None, created_at=None,
) )
out = cli._serialize_image(img) out = cli._serialize_image(img)
assert out["prompt"] == "" assert out["prompt"] == ""
assert out["filename"] == ""
assert out["model"] == ""
assert out["tags"] == ""
assert out["id"] == "i1" assert out["id"] == "i1"
+7
View File
@@ -48,3 +48,10 @@ def test_recipient_list_rejects_empty_envelope(monkeypatch):
assert exc.code == 1 assert exc.code == 1
else: else:
raise AssertionError("expected empty recipient list to exit") raise AssertionError("expected empty recipient list to exit")
def test_split_recipients_ignores_non_string_values(monkeypatch):
cli = _load_mail_cli(monkeypatch)
assert cli._split_recipients(None) == []
assert cli._split_recipients(["a@example.test"]) == []
+22
View File
@@ -46,3 +46,25 @@ def test_serialize_keeps_list_note_items(monkeypatch):
) )
assert cli._serialize(note)["items"] == [{"text": "done"}] assert cli._serialize(note)["items"] == [{"text": "done"}]
def test_serialize_skips_invalid_note_item_rows(monkeypatch):
make_core_db_stub(monkeypatch, models=["Note"])
cli = load_script("odysseus-notes")
note = SimpleNamespace(
id="n1",
title="Checklist",
content="",
items='[{"text": "done"}, "bad", null, 3]',
note_type="checklist",
color=None,
label=None,
pinned=False,
archived=False,
due_date=None,
source=None,
created_at=None,
updated_at=None,
)
assert cli._serialize(note)["items"] == [{"text": "done"}]
+10
View File
@@ -138,6 +138,16 @@ def test_atomic_write_text_leaves_no_tmp_file(tmp_path):
assert _tmp_siblings(tmp_path, "note.txt") == [] assert _tmp_siblings(tmp_path, "note.txt") == []
def test_atomic_write_text_rejects_non_string_before_tmp_file(tmp_path):
target = tmp_path / "note.txt"
with pytest.raises(TypeError):
atomic_write_text(str(target), 123)
assert not target.exists()
assert _tmp_siblings(tmp_path, "note.txt") == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# atomic_write_text — failure path: target preserved when replace fails. # atomic_write_text — failure path: target preserved when replace fails.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+5
View File
@@ -33,6 +33,11 @@ def test_unknown_window_falls_back_to_configured():
assert compute_input_token_budget(0, 0, explicit=False) == 6000 # default assert compute_input_token_budget(0, 0, explicit=False) == 6000 # default
def test_invalid_numeric_inputs_fall_back_cleanly():
assert compute_input_token_budget("bad", "also bad", explicit=False) == 6000
assert compute_input_token_budget("bad", 128000, explicit=True) == int(128000 * 0.85)
def test_is_setting_overridden_reads_raw_saved_file(tmp_path, monkeypatch): def test_is_setting_overridden_reads_raw_saved_file(tmp_path, monkeypatch):
import src.settings as settings import src.settings as settings
@@ -0,0 +1,32 @@
"""Pin harmonize mask helpers against invalid layer lists.
Driven through `node --input-type=module`; skips without node.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HELPER = _REPO / "static" / "js" / "editor" / "harmonize-masks.js"
_HAS_NODE = shutil.which("node") is not None
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_layer_union_alpha_returns_null_for_non_array_layers():
js = f"""
import {{ layerUnionAlpha, seamMask, layerBodyMask }} from '{_HELPER.as_posix()}';
console.log(JSON.stringify([
layerUnionAlpha(10, 10, null),
seamMask(10, 10, {{"bad": true}}),
layerBodyMask(10, 10, "bad")
]));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == [None, None, None]
+15 -1
View File
@@ -1,4 +1,4 @@
from src.personal_docs import retrieve_personal_keyword from src.personal_docs import retrieve_personal_keyword, split_chunks
def test_retrieve_personal_keyword_skips_non_dict_rows(): def test_retrieve_personal_keyword_skips_non_dict_rows():
@@ -19,3 +19,17 @@ def test_retrieve_personal_keyword_tolerates_missing_chunks_key():
index = [{"name": "empty.txt"}, {"name": "doc.txt", "chunks": ["alpha beta gamma"]}] index = [{"name": "empty.txt"}, {"name": "doc.txt", "chunks": ["alpha beta gamma"]}]
out = retrieve_personal_keyword(index, "beta", k=5) out = retrieve_personal_keyword(index, "beta", k=5)
assert out == ["[doc.txt :: chunk 1]\nalpha beta gamma"] assert out == ["[doc.txt :: chunk 1]\nalpha beta gamma"]
def test_retrieve_personal_keyword_ignores_non_string_text():
index = [{"name": "doc.txt", "chunks": [None, ["beta"], "alpha beta gamma"]}]
assert retrieve_personal_keyword(index, ["beta"], k=5) == []
assert retrieve_personal_keyword(index, "beta", k=5) == [
"[doc.txt :: chunk 3]\nalpha beta gamma"
]
def test_split_chunks_ignores_non_string_text():
assert split_chunks(None, size=1000, overlap=200) == []
assert split_chunks(["hello"], size=1000, overlap=200) == []
+6
View File
@@ -28,6 +28,12 @@ def _sys(vram, family="rdna"):
return {"backend": "rocm", "gpu_vram_gb": vram, "gpu_family": family} return {"backend": "rocm", "gpu_vram_gb": vram, "gpu_family": family}
def test_compute_serve_profiles_ignores_invalid_inputs():
assert compute_serve_profiles(None, _DENSE_8B) == []
assert compute_serve_profiles(_sys(8), None) == []
assert compute_serve_profiles(["bad"], _DENSE_8B) == []
def test_big_moe_on_small_card_offloads_not_fails(): def test_big_moe_on_small_card_offloads_not_fails():
"""A 35B MoE can't hold its weights on 16 GB, so the Quality profile must """A 35B MoE can't hold its weights on 16 GB, so the Quality profile must
offload experts to CPU (n_cpu_moe > 0) rather than be dropped.""" offload experts to CPU (n_cpu_moe > 0) rather than be dropped."""
@@ -36,6 +36,28 @@ def test_compute_snap_tolerates_non_array_other_layers():
assert r["x"] == 10 and r["y"] == 10 and r["guides"] == [] assert r["x"] == 10 and r["y"] == 10 and r["guides"] == []
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_compute_snap_tolerates_missing_layer_or_context():
js = f"""
import {{ computeSnap }} from '{_HELPER.as_posix()}';
console.log(JSON.stringify([
computeSnap(null, 10, 20, {{ zoom: 1, canvasW: 800, canvasH: 600 }}),
computeSnap({{ id: 'L1' }}, 11, 21, {{ zoom: 1, canvasW: 800, canvasH: 600 }}),
computeSnap({{ id: 'L1', canvas: {{ width: 100, height: 50 }} }}, 12, 22, null)
]));
"""
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout.strip()) == [
{"x": 10, "y": 20, "guides": []},
{"x": 11, "y": 21, "guides": []},
{"x": 12, "y": 22, "guides": []},
]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_compute_snap_still_snaps_to_a_layer_edge(): def test_compute_snap_still_snaps_to_a_layer_edge():
other = [{"id": "L2", "visible": True, "offset": {"x": 12, "y": 300}, other = [{"id": "L2", "visible": True, "offset": {"x": 12, "y": 300},
+20
View File
@@ -68,3 +68,23 @@ def test_unresolvable_host_blocked():
ok, reason = check_outbound_url("http://does-not-resolve.invalid", resolver=PUBLIC) ok, reason = check_outbound_url("http://does-not-resolve.invalid", resolver=PUBLIC)
assert ok is False assert ok is False
assert "resolve" in reason assert "resolve" in reason
def test_resolver_values_must_include_a_parseable_ip():
ok, reason = check_outbound_url(
"https://example.test",
resolver=lambda _host: [None, 123, "not-an-ip"],
)
assert ok is False
assert "does not resolve to an IP" in reason
def test_resolver_skips_invalid_values_but_accepts_public_ip():
ok, reason = check_outbound_url(
"https://example.test",
resolver=lambda _host: [None, "not-an-ip", "93.184.216.34"],
)
assert ok is True
assert reason == "ok"