Commit Graph
1 Commits
Author SHA1 Message Date
Palash Debnath 18793a99a7 feat(audiobook): durable crash-resume for interrupted longform renders (#470)
* feat(audiobook): durable crash-resume for interrupted longform renders

Chapter WAVs were already content-addressed (a re-run reused finished chapters),
but resume only worked if the user could re-submit the EXACT script — impossible
for Stories, whose plan is compiled from cast+lines. This persists the plan
itself so an interrupted render is resumable without the original input.

- New services/longform_resume.py (pure file/JSON): on render start, write a
  resume.json manifest (compiled plan + render params + title) into the job work
  dir, atomically; clear it on successful completion. read/has/clear/build
  helpers, schema-versioned (a foreign/corrupt manifest is ignored, never
  resumed).
- _render_longform_sse: accepts an optional job_id + resume flag (resume reuses
  the original job row + cached chapters instead of creating a new one); writes
  the manifest at start, clears it on done. Both front doors (/audiobook,
  /longform/render) unchanged for callers.
- GET /audiobook/jobs — lists interrupted renders (running/failed longform jobs
  that still have a manifest; a job left "running" across an app restart is
  interrupted by definition), with title + total/done chapter counts for the UI.
- POST /audiobook/resume/{job_id} — rebuilds the plan from the manifest and
  replays _render_longform_sse under the original job_id; the content-addressed
  cache makes finished chapters instant, so only the unrendered ones synthesize.
  404 on unknown id / missing manifest.

Resume durability is best-effort — a manifest failure never blocks the render.
The resume UI affordance is a follow-up (the endpoints are ready for it).

Tests: tests/test_longform_resume.py (7, pure manifest round-trip / version &
corrupt rejection / atomic write — monkeypatches OUTPUTS_DIR, no global
core.config stub so the shared tests/ session isn't polluted) +
backend/tests/test_audiobook_resume_api.py (6, config-stub: jobs-list with
progress, failed-included, done/manifestless/non-longform excluded, resume
404s). 13 passed. CJK green. Stale module docstring updated.

* fix(audiobook): confine resume paths — py/path-injection (CodeQL) + quality

The default-setup CodeQL (security-and-quality suite) flagged the crash-resume
work: longform_resume built filesystem paths from job_id, which on the
POST /audiobook/resume/{job_id} endpoint is a request-supplied path param →
py/path-injection (10 high-severity sinks: open/replace/remove/makedirs/isfile).

- longform_resume.work_dir now confines like profiles._voices_path: reject an
  unknown job_type or an id that isn't a bare safe token (^[A-Za-z0-9_-]{1,64}$),
  then realpath + startswith(OUTPUTS_DIR + os.sep) — a crafted id (`../`, NUL,
  separators) can never escape OUTPUTS_DIR. Returns None on violation; all
  callers (manifest_path/read/write/clear/has) degrade gracefully.
- The resume endpoint also gates the path-param id up front (404 on a bad
  token) — barrier at the source as well as the sink.

Also cleared the quality alerts the same diff introduced:
- py/repeated-import: the 4 inline `from services import longform_resume` calls
  collapse to one module-top import (it's pure, no torch).
- py/empty-except: the best-effort manifest blocks now logger.debug instead of
  a bare `pass`.

13 resume tests still pass; all job ids in tests are safe tokens.

* fix(audiobook): sanitize resume job_id at the source (path + log injection)

The first CodeQL pass wasn't enough: resume made job_id request-controlled, so
it tainted not just the manifest paths but the EXISTING work-dir join and the
progress log lines too (py/path-injection + py/log-injection, ~14 alerts).

Fix at the source so the whole dataflow is clean:
- _render_longform_sse strips job_id to a safe token (`re.sub` removing anything
  but [A-Za-z0-9_-], capped 64) right after it's resolved — no path separator,
  no CR/LF can survive, whether the id came from the resume path param or a
  fresh uuid.
- The work dir now routes through longform_resume.work_dir, which adds the
  proven os.path.basename(seg)==seg barrier (the shape CodeQL accepts in
  _voices_path) on top of the realpath+startswith confinement — so the join and
  every path derived from it (meta/concat/out) is sanitized.
- The best-effort manifest-write log no longer interpolates the raw exception
  (uses exc_info); clear_manifest's OSError handler returns instead of bare pass
  (py/empty-except).

13 resume tests still pass.

* fix(audiobook): launder resume job_id via trusted FS scan (CodeQL path/log-injection)

The custom realpath/regex barriers weren't in CodeQL's recognized sanitizer set,
so the request-supplied resume job_id kept tainting the work-dir/manifest paths
and the progress logs. Switch to the pattern CodeQL does accept — launder the id
through a trusted filesystem enumeration:

- longform_resume.scan_resumable() lists resumable jobs by scanning OUTPUTS_DIR
  for <type>_<id>/resume.json; every id it returns is sourced from os.listdir
  (never request input).
- POST /audiobook/resume/{job_id} now only resumes an id that scan_resumable()
  reports (membership match), and uses the (job_type, job_id) pair FROM that
  trusted list for everything downstream — so nothing request-controlled reaches
  a filesystem path or a log line.
- GET /audiobook/jobs lists from scan_resumable() too (filesystem-sourced ids).

work_dir keeps the realpath+startswith+basename confinement as genuine defense;
the render path's job_id is now always either a fresh uuid or a laundered id.
13 resume tests still pass.

* fix(audiobook): exact-match allowlist on the work-dir name (CodeQL path-injection)

The remaining 4 path-injection alerts were inside work_dir: I validated job_id
with an anchored regex but then joined a DIFFERENT f-string (`{job_type}_{job_id}`),
so CodeQL didn't carry the sanitization to the joined value. Mirror the pattern
the repo's _safe_cover_path uses (which CodeQL accepts): validate the WHOLE
joined component against an exact-match allowlist regex (_SAFE_SEG_RE), then
confine with os.path.commonpath containment (the recognized barrier) instead of
startswith. 13 resume tests still pass.

* fix(audiobook): basename-sanitize the work-dir name for CodeQL path-injection

The exact-match regex alone wasn't credited; route the joined value through os.path.basename() first — the sanitizer CodeQL recognizes (mirrors _safe_cover_path) — then the regex + commonpath. Functionally identical (no separator in the name) but clears the 4 remaining alerts. 13 tests pass.

* fix(audiobook): allow-list membership guard launders resume job_id (CodeQL)

The next(... if pair[1]==job_id) comparison-select didn't sanitize for CodeQL. Build a dict of resumable ids from the trusted scan and gate with 'if job_id not in resumable' — the membership barrier CodeQL recognizes — then use job_id directly downstream. 13 tests pass.

* fix(audiobook): eliminate request→path flow in resume (definitive CodeQL fix)

Five rounds of recognized path-injection barriers (regex, basename, exact-match,
commonpath, membership-guard) still left CodeQL flagging the resume job_id →
work-dir/manifest/log flow. Remove the flow entirely instead of guarding it:

- scan_resumable() now returns {job_type, job_id, manifest_path} where
  manifest_path is built from the os.listdir dir name (trusted), plus
  load_manifest_file(path) / discard_manifest_file(path) that operate on those
  trusted paths. The request job_id is used ONLY to *select* a scan entry, never
  to build a path.
- POST /audiobook/resume/{job_id} reads the manifest via the trusted scan path
  and renders under a FRESH server uuid (job_id=None). The chapter cache is
  content-addressed (keyed by chapter content, not the job id), so finished
  chapters still hit instantly — resume works, but the request's id never names
  a work dir, output file, or log line.
- The interrupted job's manifest is discarded (trusted path) once the fresh-id
  resume kicks off, so it stops showing as resumable.

Net: no request-controlled value reaches any file operation or log on the
render path (job_id there is always a server uuid). work_dir keeps its
confinement barriers as defence-in-depth. 13 resume tests pass.
2026-06-14 23:30:11 +05:30