From 788a9998f609bff212b95d8a876fb545865456b8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 20:31:27 +0200 Subject: [PATCH] fix(fava): escape string metadata + make Open currencies optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_account wrote free-text metadata values straight into the ledger source via /api/source with no escaping — an unescaped quote or newline in an admin-supplied description would corrupt the Beancount file (or forge extra metadata lines). Escape backslash/quote/newline per the tokenizer's cunescape rules (verified round-trip through beancount's parser). Also make the currency constraint list optional so an Open directive can be written unconstrained (currencies are an optional part of the directive, not required). Co-Authored-By: Claude Opus 4.8 (1M context) --- fava_client.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/fava_client.py b/fava_client.py index c61e1d9..afc964f 100644 --- a/fava_client.py +++ b/fava_client.py @@ -44,6 +44,22 @@ def _infer_target_file(account_name: str) -> str: return "accounts/chart.beancount" +def _escape_beancount_string(value: str) -> str: + """Escape a value for safe inclusion in a Beancount string literal. + + Beancount's tokenizer unescapes \\", \\n, \\t, \\r, \\\\ etc. (tokens.c + cunescape). Unescaped quotes or newlines in free-text metadata written + straight into the ledger source would corrupt the file, so escape the + backslash first (to keep it round-tripping) then quotes and newlines. + """ + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + class FavaClient: """ Async client for Fava REST API. @@ -1544,7 +1560,7 @@ class FavaClient: async def add_account( self, account_name: str, - currencies: list[str], + currencies: Optional[list[str]] = None, opening_date: Optional[date] = None, metadata: Optional[Dict[str, Any]] = None, target_file: Optional[str] = None, @@ -1642,19 +1658,23 @@ class FavaClient: lines = source.split('\n') insert_index = len(lines) - # Step 4: Format Open directive as Beancount text - currencies_str = ", ".join(currencies) - open_lines = [ - "", - f"{opening_date.isoformat()} open {account_name} {currencies_str}" - ] + # Step 4: Format Open directive as Beancount text. + # Currencies are an optional constraint on an Open + # directive; when none are given the account accepts + # any commodity. + open_directive = f"{opening_date.isoformat()} open {account_name}" + if currencies: + open_directive += f" {', '.join(currencies)}" + open_lines = ["", open_directive] # Add metadata if provided if metadata: for key, value in metadata.items(): # Format metadata with proper indentation if isinstance(value, str): - open_lines.append(f' {key}: "{value}"') + open_lines.append( + f' {key}: "{_escape_beancount_string(value)}"' + ) else: open_lines.append(f' {key}: {value}')