Skip to content

Commit f6b2947

Browse files
committed
Harden preset deletion
1 parent a357e8c commit f6b2947

3 files changed

Lines changed: 78 additions & 4 deletions

File tree

src/mini_eq/core.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,10 @@ def ensure_preset_storage_dir() -> Path:
266266
def user_config_dir() -> Path:
267267
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
268268
if xdg_config_home:
269-
return Path(xdg_config_home)
269+
# codeql[py/path-injection] XDG_CONFIG_HOME is local process configuration; relative paths are rejected.
270+
candidate = Path(xdg_config_home).expanduser()
271+
if candidate.is_absolute():
272+
return Path(os.path.normpath(str(candidate)))
270273

271274
return Path.home() / ".config"
272275

@@ -313,6 +316,22 @@ def preset_path_for_name(name: str) -> Path:
313316
return ensure_preset_storage_dir() / f"{preset_name}{PRESET_FILE_SUFFIX}"
314317

315318

319+
def delete_preset_file(name: str) -> None:
320+
preset_name = sanitize_preset_name(name)
321+
if not preset_name:
322+
raise ValueError("preset name is empty")
323+
324+
storage_dir = ensure_preset_storage_dir()
325+
dir_fd = os.open(storage_dir, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
326+
try:
327+
try:
328+
os.unlink(f"{preset_name}{PRESET_FILE_SUFFIX}", dir_fd=dir_fd)
329+
except FileNotFoundError:
330+
return
331+
finally:
332+
os.close(dir_fd)
333+
334+
316335
def list_preset_names() -> list[str]:
317336
names = [
318337
path.stem

src/mini_eq/window_presets.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .core import (
1414
PRESET_FILE_SUFFIX,
1515
PRESET_VERSION,
16+
delete_preset_file,
1617
ensure_json_suffix,
1718
fader_band_count_for_profile,
1819
list_preset_names,
@@ -241,9 +242,7 @@ def on_preset_delete_dialog_done(
241242
return
242243

243244
try:
244-
preset_path = preset_path_for_name(preset_name)
245-
if preset_path.exists():
246-
preset_path.unlink()
245+
delete_preset_file(preset_name)
247246
self.current_preset_name = None
248247
self.saved_preset_signature = self.controller.state_signature()
249248
self.refresh_preset_list()

tests/test_mini_eq_core.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ def test_default_preset_storage_uses_standalone_config_namespace(
4141
assert core.preset_storage_dir() == core.default_preset_storage_dir()
4242

4343

44+
def test_user_config_dir_ignores_relative_xdg_config_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
45+
home_dir = tmp_path / "home"
46+
monkeypatch.setenv("XDG_CONFIG_HOME", "relative-config")
47+
monkeypatch.setattr(Path, "home", lambda: home_dir)
48+
49+
assert core.user_config_dir() == home_dir / ".config"
50+
51+
52+
def test_user_config_dir_normalizes_absolute_xdg_config_home(
53+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
54+
) -> None:
55+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config" / ".." / "xdg-config"))
56+
57+
assert core.user_config_dir() == tmp_path / "xdg-config"
58+
59+
4460
def test_sanitize_preset_name_removes_invalid_chars_and_trims() -> None:
4561
assert core.sanitize_preset_name(' bad<>:"/\\\\|?* name... ') == "bad name"
4662

@@ -59,6 +75,46 @@ def test_preset_roundtrip_and_listing_uses_storage_dir(monkeypatch: pytest.Monke
5975
assert core.load_mini_eq_preset_file(core.preset_path_for_name("beta")) == beta_payload
6076

6177

78+
def test_delete_preset_file_removes_only_named_storage_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
79+
storage_dir = tmp_path / "mini-eq-presets"
80+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", storage_dir)
81+
payload = {"version": core.PRESET_VERSION, "name": "Alpha", "bands": []}
82+
83+
preset_path = core.preset_path_for_name("Alpha")
84+
core.write_mini_eq_preset_file(preset_path, payload)
85+
86+
core.delete_preset_file("Alpha")
87+
88+
assert not preset_path.exists()
89+
90+
91+
def test_delete_preset_file_ignores_missing_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
92+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "mini-eq-presets")
93+
94+
core.delete_preset_file("Missing")
95+
96+
97+
def test_delete_preset_file_rejects_empty_name(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
98+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", tmp_path / "mini-eq-presets")
99+
100+
with pytest.raises(ValueError, match="preset name is empty"):
101+
core.delete_preset_file("../")
102+
103+
104+
def test_delete_preset_file_uses_sanitized_basename(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
105+
storage_dir = tmp_path / "mini-eq-presets"
106+
monkeypatch.setattr(core, "PRESET_STORAGE_DIR", storage_dir)
107+
outside_path = tmp_path / "outside.json"
108+
sanitized_path = storage_dir / "outside.json"
109+
outside_path.write_text("outside", encoding="utf-8")
110+
core.write_mini_eq_preset_file(sanitized_path, {"version": core.PRESET_VERSION, "name": "outside", "bands": []})
111+
112+
core.delete_preset_file("../outside")
113+
114+
assert outside_path.read_text(encoding="utf-8") == "outside"
115+
assert not sanitized_path.exists()
116+
117+
62118
def test_load_mini_eq_preset_file_rejects_invalid_shape(tmp_path) -> None:
63119
preset_path = tmp_path / "broken.json"
64120
preset_path.write_text('{"version": 1, "bands": "nope"}', encoding="utf-8")

0 commit comments

Comments
 (0)