Files
VoiceStudio/backend/core/config.py
T
Palash DebnathandClaude Opus 4.8 7d9338bd2d fix(dub): key per-segment WAVs by stable id, not list index (#185) (#187)
* fix(dub): key per-segment WAVs by stable id, not list index (#185)

Partial regeneration ('regenerate only changed segments') reloaded/wrote
seg_{i}.wav by LIST INDEX while the regen allow-list + fingerprints were keyed
by STABLE id. After a delete/merge/split (ids preserved, positions shifted),
unchanged segments reused a different segment's audio → silently corrupted dub
output on the default in-UI incremental path.

- core.config.dub_seg_path(job_id, seg_id): per-segment path keyed by stable
  id, sanitized to a bare filename (defends against path traversal via crafted
  ids). A numeric index sanitizes to the legacy seg_{i}.wav, so old jobs resolve
  through the same helper.
- dub_generate: write/reload per-segment WAVs by stable seg_id (deferred write,
  RVC write, regen reload) with a legacy seg_{i}.wav fallback; persist a
  job['seg_order'] manifest (index -> stable id) for index-keyed readers.
- dub_export: preview + stems-zip resolve the file via seg_order (-> stable id)
  with legacy fallback, so they keep finding the right audio.
- test: dub_seg_path id-naming, legacy-index equivalence, traversal sanitization.

Back-compatible with in-flight jobs (legacy index files still resolve). Closes #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dub): harden dub_seg_path with realpath containment; route all seg paths through it (CodeQL path-injection)

Both job_id and seg_id are request-derived. Sanitise both and verify the
resolved path stays inside DUB_DIR (realpath + startswith) — raises on escape.
Route the legacy index fallbacks in dub_generate/dub_export through dub_seg_path
so no raw os.path.join(DUB_DIR, job_id, ...) remains and a bare '..' component
can't traverse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dub): assert path containment at export sinks (recognized CodeQL barrier)

dub_seg_path already validates realpath containment, but CodeQL doesn't
propagate the barrier across the call. Re-assert at the FileResponse / zf.write
sinks (realpath + startswith on the value used) so the guard is recognized
in-function — clears the py/path-injection false positives.

* fix(dub): realpath+containment guard before any path sink in export/preview

CodeQL flags os.path.exists/FileResponse/zf.write as path sinks and won't
propagate dub_seg_path's internal guard. Resolve each candidate, realpath it,
and containment-check (startswith DUB_DIR) BEFORE any filesystem access — the
guard now dominates every sink in-function, clearing py/path-injection.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 03:31:03 +05:30

90 lines
3.8 KiB
Python

import os
import re
import sys
def get_app_data_dir():
custom_dir = os.environ.get("OMNIVOICE_DATA_DIR")
if custom_dir:
return custom_dir
if sys.platform == "darwin":
return os.path.expanduser("~/Library/Application Support/OmniVoice")
elif sys.platform == "win32":
return os.path.join(os.environ.get("APPDATA", ""), "OmniVoice")
else:
return os.path.expanduser("~/.omnivoice")
def _ensure_short_hf_cache_on_windows():
"""Redirect HuggingFace cache to a short path on Windows.
The default ``~/.cache/huggingface/hub/models--org--name/snapshots/<hash>/…``
path regularly exceeds the 260-char MAX_PATH limit on NTFS, causing
``FileNotFoundError`` or truncated downloads on first install. We shorten
it to ``%LOCALAPPDATA%\\OmniVoice\\hf_cache`` (~40 chars) so even the
deepest blob path stays well under the limit.
Respects any explicit override the user already set via
``OMNIVOICE_CACHE_DIR``, ``HF_HOME``, or ``HF_HUB_CACHE``.
"""
if sys.platform != "win32":
return
# Don't override if the user (or main.py's OMNIVOICE_CACHE_DIR block)
# already pointed the cache somewhere specific.
if os.environ.get("OMNIVOICE_CACHE_DIR") or os.environ.get("HF_HOME") or os.environ.get("HF_HUB_CACHE"):
return
local_app = os.environ.get("LOCALAPPDATA", "")
if not local_app:
return
short_cache = os.path.join(local_app, "OmniVoice", "hf_cache")
os.makedirs(short_cache, exist_ok=True)
os.environ["HF_HOME"] = short_cache
os.environ["HF_HUB_CACHE"] = short_cache
_ensure_short_hf_cache_on_windows()
DATA_DIR = get_app_data_dir()
VOICES_DIR = os.path.join(DATA_DIR, "voices") # Reference audio for profiles
OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs") # Generated audio files
DUB_DIR = os.path.join(DATA_DIR, "dub_jobs")
DB_PATH = os.path.join(DATA_DIR, "omnivoice.db")
def dub_seg_path(job_id, seg_id):
"""Per-segment dub WAV path keyed by the STABLE segment id (not its list
index), so partial regeneration reuses the right audio after a
delete/merge/split shifts positions (#185). A numeric index `i` sanitises to
`seg_{i}.wav`, i.e. the legacy index-based name, so old jobs keep resolving
via the same helper.
Both `job_id` and `seg_id` come from the request, so the result is sanitised
(separators stripped) AND verified to stay inside DUB_DIR via realpath
containment — raises ValueError on any attempt to escape the dub directory.
"""
safe_job = re.sub(r"[^A-Za-z0-9._-]", "_", str(job_id))
safe_seg = re.sub(r"[^A-Za-z0-9._-]", "_", str(seg_id))
base = os.path.realpath(DUB_DIR)
full = os.path.realpath(os.path.join(base, safe_job, f"seg_{safe_seg}.wav"))
if full != base and not full.startswith(base + os.sep):
raise ValueError(f"dub segment path escapes DUB_DIR: {job_id!r}/{seg_id!r}")
return full
PREVIEW_DIR = os.path.join(DATA_DIR, "preview")
CRASH_LOG_PATH = os.path.join(DATA_DIR, "crash_log.txt") # only written on unhandled exceptions
LOG_PATH = os.path.join(DATA_DIR, "omnivoice.log") # rolling runtime log — what the Settings UI reads
IDLE_TIMEOUT_SECONDS = int(os.environ.get("OMNIVOICE_IDLE_TIMEOUT", "900"))
CPU_POOL_WORKERS = int(os.environ.get("OMNIVOICE_CPU_POOL", "0")) or min(8, (os.cpu_count() or 4))
def ensure_dirs():
for d in [DATA_DIR, VOICES_DIR, OUTPUTS_DIR, DUB_DIR, PREVIEW_DIR]:
os.makedirs(d, exist_ok=True)
ensure_dirs()
# Ensure ffmpeg is on PATH for Whisper and other subprocesses (mostly relevant for Mac/Linux)
if sys.platform != "win32":
for _fpath in ["/opt/homebrew/bin", "/usr/local/bin"]:
if _fpath not in os.environ.get("PATH", "") and os.path.exists(_fpath):
os.environ["PATH"] = _fpath + os.pathsep + os.environ.get("PATH", "")