From a3ed4608da149a07f97c9141ac48cd51109645b0 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 01:14:26 +0530 Subject: [PATCH] fix(folio): an amount over the app's cap spends no window budget the corpus reaches TxnSubmit with 999999999999999999999, AMOUNT_REGEX takes it and AddTransactionViewModel refuses it against MAX_TRANSACTION_AMOUNT_CENTS, so counting it was budget a double submit could hide behind. --- examples/folio/sanderling/predicates.ts | 16 +++++++++++---- pkg/spec/test/folio-submit-window.test.ts | 25 +++++++++++++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index 8a0dbfd..21f1b7e 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -283,6 +283,9 @@ export function countSubmitsInWindow(args: { return { reported, next: fresh ? 0 : reported }; } +// Folio's own cap, in cents (core/data/Repository.kt). +const MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000; + // Could the app have committed anything for that submit? The amount field as // the LANDING frame shows it is the form state the tap read: the tap changes // nothing about it, and one action runs per step, so nothing else could have. @@ -302,14 +305,19 @@ export function countSubmitsInWindow(args: { // nothing to say. Measured over four recorded android runs, 19, 11, 25 and 25 // of 35, 26, 42 and 42 submit taps landed with the amount field empty. // -// An amount too large for a Kotlin Long is refused by the app too, and still -// counts here: over-counting can only cost a detection, and the reading that -// would have to prove the overflow is a float that cannot hold the number. +// An amount over Folio's cap is refused before any coroutine starts +// (MAX_TRANSACTION_AMOUNT_CENTS, checked in both AddTransactionViewModel.submit +// and Repository.createTransaction), and the fuzzer's corpus reaches the button +// with one: "999999999999999999999" passes AMOUNT_REGEX, so the field takes it. +// Float is precise enough to say which side of the cap an amount is on. The cap +// is 1e8, every integer cent up to 2^53 is exact, and an amount far enough above +// it to be inexact is far enough above it to be refused. export function submitCouldCommit(amountText: string | undefined): boolean { if (amountText === undefined) return true; const trimmed = amountText.trim().replace(/,/g, ""); if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return false; - return /[1-9]/.test(trimmed); + if (!/[1-9]/.test(trimmed)) return false; + return Number(trimmed) * 100 <= MAX_TRANSACTION_AMOUNT_CENTS; } // Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to diff --git a/pkg/spec/test/folio-submit-window.test.ts b/pkg/spec/test/folio-submit-window.test.ts index 4774165..6349113 100644 --- a/pkg/spec/test/folio-submit-window.test.ts +++ b/pkg/spec/test/folio-submit-window.test.ts @@ -89,11 +89,32 @@ test("a submit the app must have refused does not spend the window's budget", () } }); +// Folio caps a transaction at $1,000,000.00 (MAX_TRANSACTION_AMOUNT_CENTS, in +// core/data/Repository.kt), and AddTransactionViewModel.submit refuses anything +// over it before a coroutine starts. The fuzzer's corpus carries +// "999999999999999999999", AMOUNT_REGEX lets it into the field and it reaches +// the button, so this is a refusal the window used to pay for. +test("an amount over the app's cap cannot commit", () => { + for (const amountText of ["1000000.01", "1,000,001", "999999999999999999999"]) { + assert.deepEqual( + countSubmitsInWindow({ + previousCount: 0, + lastAction: { kind: "Tap", on: submitOn }, + amountText, + fresh: false, + }), + { reported: 0, next: 0 }, + `amount ${JSON.stringify(amountText)} was counted as a possible commit`, + ); + } +}); + // The field as the landing frame shows it, which is the form state the tap read: // nothing between the two changes it. Anywhere but the transaction screen there -// is no field to read, and unknown has to count. +// is no field to read, and unknown has to count. The cap itself is an amount the +// app takes, so it counts too. test("an amount that could commit, or that nobody could read, spends the budget", () => { - for (const amountText of ["5", "0.01", "1,000", "999999999999999999999", undefined]) { + for (const amountText of ["5", "0.01", "1,000", "1000000.00", "999999.99", undefined]) { assert.deepEqual( countSubmitsInWindow({ previousCount: 0,