fix(fava): serialize source mutations, share HTTP client, validate BQL input

Fava-client hardening cluster (CODE-REVIEW-2026-06 #7, #14, #15, #19
+ libra-#23, libra-#53):

- New FavaClient.transform_source_line does the whole
  read-checksum-modify-write under the global write lock and maps
  Fava 409/412 to ChecksumConflictError. The approve and reject
  endpoints used to do this dance with raw httpx and no lock — two
  concurrent mutations raced each other and every other ledger
  writer (libra-#23). They now route through the new method and
  translate conflicts to HTTP 409.
- update_entry_source / delete_entry raise ChecksumConflictError on
  409/412 instead of leaking raw HTTPStatusError.
- One shared httpx.AsyncClient per FavaClient (12 per-call
  instantiations removed — no more TCP handshake per request);
  closed via libra_stop. Health probes keep their 2s timeout
  per-request.
- Account names/patterns are validated against ^[A-Za-z0-9:_-]+$
  before interpolation into BQL string literals.
- The posting amount regexes are consolidated into module-level
  compiled patterns, all decimal-tolerant — the old integer-only
  SATS pattern silently dropped decimal-SATS postings (Fava's @@->@
  normalisation emits them) from balances.
- add-account no longer verifies its own write with a second
  serialized get_all_accounts round-trip (libra-#53):
  sync_single_account_from_beancount grows an assume_exists path.

New test: concurrent approve+reject must both land (was
lost-update/412 before the lock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-12 15:42:44 +02:00
commit 4d63e08a69
5 changed files with 305 additions and 123 deletions

View file

@ -2903,8 +2903,7 @@ async def api_approve_expense_entry(
This updates the transaction in the Beancount file via Fava API.
"""
import httpx
from .fava_client import get_fava_client
from .fava_client import ChecksumConflictError, get_fava_client
fava = get_fava_client()
@ -2939,57 +2938,29 @@ async def api_approve_expense_entry(
detail="Entry metadata missing filename or lineno"
)
# 3. Get the source file from Fava
async with httpx.AsyncClient(timeout=fava.timeout) as client:
response = await client.get(
f"{fava.base_url}/source",
params={"filename": filename}
)
response.raise_for_status()
source_data = response.json()["data"]
# 3. Flip the flag under FavaClient's write lock — the whole
# read-modify-write is atomic against every other ledger writer.
old_pattern = f"{date_str} !"
sha256sum = source_data["sha256sum"]
source = source_data["source"]
lines = source.split('\n')
# 4. Find and modify the entry at the specified line
# Line numbers are 1-indexed, list is 0-indexed
entry_line_idx = lineno - 1
if entry_line_idx >= len(lines):
def _approve(line: str) -> str:
if old_pattern not in line:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Line {lineno} not found in source file"
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {line}"
)
return line.replace(old_pattern, f"{date_str} *", 1)
entry_line = lines[entry_line_idx]
# Check if the line contains the pending flag pattern
old_pattern = f"{date_str} !"
if old_pattern not in entry_line:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}"
)
# Replace the flag
new_pattern = f"{date_str} *"
new_line = entry_line.replace(old_pattern, new_pattern, 1)
lines[entry_line_idx] = new_line
# 5. Write back the modified source
new_source = '\n'.join(lines)
update_response = await client.put(
f"{fava.base_url}/source",
json={
"file_path": filename,
"source": new_source,
"sha256sum": sha256sum
},
headers={"Content-Type": "application/json"}
try:
await fava.transform_source_line(filename, lineno, _approve)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
)
except ChecksumConflictError:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Ledger changed concurrently; retry the approval",
)
update_response.raise_for_status()
logger.info(f"Entry {entry_id} approved (flag changed to *)")
@ -3012,8 +2983,7 @@ async def api_reject_expense_entry(
Adds #voided tag for audit trail while keeping the '!' flag.
Voided transactions are excluded from balances but preserved in the ledger.
"""
import httpx
from .fava_client import get_fava_client
from .fava_client import ChecksumConflictError, get_fava_client
fava = get_fava_client()
@ -3048,50 +3018,26 @@ async def api_reject_expense_entry(
detail="Entry metadata missing filename or lineno"
)
# 3. Get the source file from Fava
async with httpx.AsyncClient(timeout=fava.timeout) as client:
response = await client.get(
f"{fava.base_url}/source",
params={"filename": filename}
# 3. Add the #voided tag under FavaClient's write lock — the whole
# read-modify-write is atomic against every other ledger writer.
def _void(line: str) -> str:
if "#voided" in line:
return line # already voided — no-op
return line.rstrip() + ' #voided'
try:
changed = await fava.transform_source_line(filename, lineno, _void)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
)
response.raise_for_status()
source_data = response.json()["data"]
sha256sum = source_data["sha256sum"]
source = source_data["source"]
lines = source.split('\n')
# 4. Find and modify the entry at the specified line - add #voided tag
entry_line_idx = lineno - 1
if entry_line_idx >= len(lines):
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Line {lineno} not found in source file"
)
entry_line = lines[entry_line_idx]
# Add #voided tag if not already present
if "#voided" not in entry_line:
# Add #voided tag to the transaction line
new_line = entry_line.rstrip() + ' #voided'
lines[entry_line_idx] = new_line
# 5. Write back the modified source
new_source = '\n'.join(lines)
update_response = await client.put(
f"{fava.base_url}/source",
json={
"file_path": filename,
"source": new_source,
"sha256sum": sha256sum
},
headers={"Content-Type": "application/json"}
)
update_response.raise_for_status()
logger.info(f"Entry {entry_id} rejected (added #voided tag)")
except ChecksumConflictError:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Ledger changed concurrently; retry the rejection",
)
if changed:
logger.info(f"Entry {entry_id} rejected (added #voided tag)")
return {
"message": f"Entry {entry_id} rejected (marked as voided)",
@ -3820,8 +3766,14 @@ async def api_admin_add_chart_account(
"already_existed": True,
}
# Mirror into libra DB so permissions / metadata layer sees it.
synced = await sync_single_account_from_beancount(payload.name)
# Mirror into libra DB so permissions / metadata layer sees it. We just
# wrote the Open directive ourselves, so skip the verification
# round-trip through Fava (libra-#53).
synced = await sync_single_account_from_beancount(
payload.name,
description=payload.description,
assume_exists=True,
)
return {
"success": True,