UI Output Folder — Write Calculation Results to a Chosen Local Folder¶
Status: In progress · Owner: UI surface (src/rwa_calc/ui/app/) + api/ export layer
Goal: Let a UI user choose where calculation outputs are written on disk, instead of
the results living only in an in-memory registry reachable through a hand-typed REST URL.
Problem¶
Today the /calculator form collects only data_path, reporting date, framework,
permission mode and the input data_format — there is no output destination. A run
registers its CalculationResponse in an in-memory registry (_RUNS, keyed by run_id)
and the only way to get files out is GET /api/export/{fmt}?run_id=…, which streams a
browser download from a server temp dir. results.html surfaces this only as inert
text, not even a button. In most real use the operator wants the outputs written to a
folder they choose.
Enabling fact¶
The write primitive already exists. CalculationResponse.to_parquet(dir) / to_csv(dir) /
to_excel(path) / to_corep(path) (api/models.py) already mkdir(parents=True) and
return a frozen ExportResult(format, files, row_count). Because the app is a loopback
single-user uvicorn process (127.0.0.1, no sandbox — ui/app/main.py), a server-side
write is a write to the user's own disk. So this is wiring, a validator, and a guard —
not new low-level writer code.
Recommendation¶
Adopt server-side write driven by a plain text "Output folder" field (mirroring the
existing data_path field), reusing the to_* wrappers. Two write entry points share one
helper so they can never drift:
POST /results/{run_id}/save(primary) — write an already-computed run's selected formats on demand, gated on the unguessablerun_idfrom_RUNS.- Calc-time write — an optional "Output folder" + "Output format(s)" on the calculator form so one submit runs and writes; folder/formats remembered for next time.
Plus real Download buttons on the results page (the safe half of the browser approach — the existing endpoint, zero new server code).
Why not the alternatives¶
| Approach | Verdict | Reason |
|---|---|---|
| A. Text field + server write | Adopt | Reuses existing writers; server == disk so no round-trip; fully testable via TestClient; can echo the absolute path written. |
| B. Browser File System Access API | Reject (keep only download buttons) | Chromium-only; the load-bearing JS write path is untestable in the pytest stack (violates TDD); buffers the whole export in browser memory; can only show the folder leaf name; multi-file parquet/csv arrive as a ZIP. Round-trips bytes through the browser to reach a folder Python can write directly. |
C. Server-side /api/browse directory picker |
Reject | Adds a filesystem-enumeration oracle — the largest net-new attack surface — for a marginal "don't type a path" gain a text field already satisfies. A server-side tkinter.filedialog.askdirectory() is a safer fallback if that UX is ever required. |
Two non-obvious findings that gate this work¶
1. The server has zero network protection today¶
create_app() (and create_api_app()) mount the router with no TrustedHost / CORS /
Origin middleware, and there is no DNS-rebinding defence. Adding any user-driven write
endpoint turns the app into a remotely-triggerable arbitrary-file-write gadget: a form
POST is a CORS "simple request" (no preflight), so a malicious page the user visits can
submit it cross-origin (CORS blocks reading the response, not the side effect); DNS
rebinding (evil.com → 127.0.0.1) defeats even that barrier. The network guard
(TrustedHostMiddleware(['localhost','127.0.0.1']) + an Origin/Sec-Fetch-Site
same-origin check on state-changing routes) must land before any write endpoint ships.
This is the dominant risk — not path traversal (the path is intended to be arbitrary).
2. Fixed filenames + 4 workers = silent data loss¶
All writers use fixed names (results.parquet, summary_by_class.parquet, …) with
exist_ok=True. Re-exporting into a folder silently clobbers the user's existing
same-named files, and two concurrent runs targeting one folder race on the same names.
Fix both at once by writing into a run-stamped subfolder <chosen>/rwa_export_<run_id>/
and echoing the resolved absolute path back.
Phased plan (smallest shippable slice first)¶
Each phase is TDD (failing test first) and ends with the gate:
uv run python scripts/arch_check.py, uv run ruff check/format, uv run ty, contract
tests, then uv run pytest tests/.
Phase 1 — Download buttons (no new write surface)¶
ui/app/main.py_results_context: addexport_parquet_url/export_csv_url/export_excel_url/export_corep_url=/api/export/{fmt}?run_id={run_id}; addxlsx_available = importlib.util.find_spec("xlsxwriter") is not None.templates/results.html: replace the inert REST text with.btn .btn-ghostanchors (copyreconciliation.htmlCSV/Excel pattern); grey/disable excel+corep whennot xlsx_available.- Failing test:
tests/integration/test_ui_app.py—/results/{run_id}body contains the four/api/export/{fmt}?run_id=anchors.
Phase 2 — Network guard (prerequisite for any write)¶
ui/app/main.pycreate_app(): addTrustedHostMiddleware(allowed_hosts= ["localhost","127.0.0.1"]); keep the127.0.0.1bind.ui/app/main.py: add arequire_same_origin(request)dependency that rejects state-changing requests whoseOrigin/Sec-Fetch-Siteis cross-site; attach it toPOST /calculateandPOST /results/{run_id}/save.- Test trap:
TestClientsendsHost: testserver→ rejected byTrustedHostMiddleware. Build fixtures withTestClient(create_app(), base_url="http://localhost")rather than widening the allowlist. - Failing tests:
Host: evil.com→ rejected; cross-siteOriginon a POST → rejected;localhoststill 200.
Phase 3 — Shared write helper + output-path validator (pure, unit-tested)¶
ui/app/output_writer.py(NEW):@dataclass(frozen=True, slots=True) OutputWriteResult(folder, files, errors)andwrite_selected_formats(response, folder, formats, *, run_id) -> OutputWriteResult. Buildssubdir = folder / f"rwa_export_{run_id}";parquet→to_parquet(subdir),csv→to_csv(subdir),excel→to_excel(subdir/"rwa_results.xlsx"),corep→to_corep(subdir/"rwa_corep.xlsx"); collect eachExportResult.files; catchModuleNotFoundError(xlsxwriter) andOSError→ append a user-facing string toerrors, never raise. Atomic-ish: write to a temp sibling andos.replaceon full success.api/validation.py: addvalidate_output_path(output_path) -> ValidationResponse(sibling tovalidate_data_path; reuseAPIError/ValidationResponse/create_validation_errorVAL001; do not reuse the required-files machinery). Checks: resolvable + absolute (reject relative — cwd is ephemeral in the packaged.pyz); reject Windows reserved device names (CON,NUL,COM1,LPT1, …); if it exists it must be a directory; else its nearest existing ancestor must exist and be writable;os.access(W_OK)treated as advisory.- Failing tests:
tests/unit/api/test_export.py(writer: files land undertmp/rwa_export_<run_id>/; a monkeypatchedto_excelraisingModuleNotFoundErroris captured in.errors, not raised) andtests/unit/api/test_api_validation.py(validate_output_pathmatrix).
Phase 4 — POST /results/{run_id}/save (primary feature)¶
ui/app/main.py: module-levelExportFormatArg = Literal["parquet","csv","excel","corep"]alongsideFrameworkArg/PermissionArg/FormatArg(module-level so ruff's fix hook doesn't strip the quotes underfrom __future__ import annotations).- New
@app.post("/results/{run_id}/save")withrequire_same_origin:get_run(run_id)→ friendly 404 "this result has expired — re-run" ifNone(mirror_RECON_NOT_FOUND_MESSAGE, never 500);validate_output_path→ 400 re-render on failure; elsewrite_selected_formats(...)and re-renderresults.htmlwith the written-files / per-format-error callout echoing the resolved absolute subfolder. _results_context: adddefault_output_folder+export_formatsfor the save form.templates/results.html: add a<form method="post" action="/results/{{ run_id }}/save">block (folder input + format checkboxes + submit) near "Run another"; render the callout.- Failing tests: save writes files under
<tmp>/rwa_export_<run_id>/; unknown id → 404; unwritable/relative folder → 400; cross-siteOrigin→ rejected.
Phase 5 — Calc-time write + form fields + remembered last-run¶
POST /calculate: addoutput_folder: Annotated[str, Form()] = ""andoutput_formats: Annotated[list[str], Form()] = [];require_same_origin; if folder non-emptyvalidate_output_path→ 400 re-render preserving all selects + folder + formats; thread both into_calculation_worker._calculation_worker: afterregister_run_with_id, if folder set callwrite_selected_formats(...)and stash theOutputWriteResultin a module-level_EXPORT_OUTCOMES: dict[str, OutputWriteResult]; thensave_calculator_state(...). Write happens aftercalculate()and outsideSTAGE_SEQUENCE, so the live stepper stays responsive and the existingtotal_stages == 10assertion holds.ui/app/calculator_state.py(NEW):@dataclass(frozen=True, slots=True) CalculatorFormState(...)— allstr;output_formatscomma-encoded to satisfy the recon_state all-strings invariant.save/load/clear_calculator_state(distinct names —recon_state.save_last_runis already imported), JSON at$RWA_STATE_DIR/ calculator_last_run.jsonelse~/.rwa_calc/...; both swallow IO/parse errors.GET /calculator: pre-fill fromload_calculator_state(override > saved > fallback); addselected_framework/selected_permission/selected_format+default_output_folder+selected_output_formats. Default folder computed at submit time from the posteddata_path(Path(data_path).parent/"rwa_output", fallbackPath.home()/"Downloads"thenPath.home()) — never__file__/cwd.templates/calculator.html: full-width optionaloutput_foldertext input underdata_path(norequired); an "Output format(s)" checkbox groupname=output_formats(labelled distinctly from the existing input-format "Data format" select); retrofit{% if selected_* %}selected{% endif %}on the framework/permission/data_format selects so a validation bounce is non-destructive.- Failing tests:
tests/unit/ui/test_calculator_state.py(round-trip incl. comma-encoded formats); integration POST/calculatewith folder+formats → files on disk under<tmp>/rwa_export_<run_id>/;total_stages == 10still holds; invalid folder → 400 with selects preserved.
Decided defaults¶
- Scope: full (Phases 1–5).
- Formats: pre-tick
parquet+csv; offerexcel/corepbut grey them out with a friendly message whenxlsxwriteris absent (it stays an optional dependency). - Overwrite policy: run-stamped subfolder
rwa_export_<run_id>(no clobber, no race). - Default folder: derived at submit-time from
data_path, with~/Downloadsthen~fallback. - Remember last folder/formats: yes (
calculator_state.py, mirroringrecon_state). - Pillar3: out of scope (no
CalculationResponsewrapper, not in the exportLiteral).
Must-have guardrails → where enforced¶
| Guardrail | Enforced in |
|---|---|
Network guard first — TrustedHostMiddleware + require_same_origin; bind stays 127.0.0.1 |
Phase 2; applied on POST /calculate (P5) and POST /results/{run_id}/save (P4) |
Save is POST, gated on unguessable uuid4 run_id; output_folder is the only user string reaching the FS; run_id never a path component |
Phase 4 route |
Run-stamped subfolder kills silent clobber and the max_workers=4 race; echo resolved absolute dest |
Phase 3 write_selected_formats |
Non-raising output-path validation (absolute, reject relative + reserved names, dir-or-creatable-ancestor, advisory os.access) |
Phase 3 validate_output_path; wired P4 + P5 |
Atomic-ish honest writes — temp + os.replace; per-format try/except; never raise mid-write; report exactly what was written vs failed; no "success" on partial failure |
Phase 3 output_writer |
Clean expiry — run_id absent from _RUNS (no TTL, restart-only) or a cleaned rwa_cache_* → friendly 404, never 500 |
Phase 4 route; Phase 1 downloads |
xlsxwriter capability probe — grey out excel/corep with a friendly message; writer catches the raised ModuleNotFoundError |
Phase 1 _results_context; Phase 3 writer |
Cross-platform path discipline — pathlib throughout, expanduser().resolve(), validate UNC share roots, MAX_PATH note, default from posted data_path not __file__/cwd |
Phase 3 validator; Phase 5 default computation |
TDD seams + total_stages == 10 invariant (write after calculate(), outside STAGE_SEQUENCE) |
Failing test per phase; Phase 5 worker hook |
Document the conscious reversal of the "no user input in FS paths" posture (rest.py temp-dir + literal names), acceptable only under loopback single-user |
Phase 4/5 spec + changelog |
Key files¶
src/rwa_calc/ui/app/main.pysrc/rwa_calc/ui/app/output_writer.py(new)src/rwa_calc/ui/app/calculator_state.py(new)src/rwa_calc/ui/app/templates/calculator.html,templates/results.htmlsrc/rwa_calc/api/validation.py,api/export.py,api/models.py,api/rest.pysrc/rwa_calc/contracts/results.pytests/unit/api/test_export.py,tests/unit/api/test_api_validation.pytests/unit/ui/test_calculator_state.py(new),tests/integration/test_ui_app.pydocs/specifications/interfaces.md,docs/specifications/output-reporting.md,docs/user-guide/interactive-ui.md,docs/appendix/changelog.md