From d8434bdc9839b28c5b8f14b1e37e7b003a8640bc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:24:31 -0800 Subject: [PATCH] fix(accounting): refuse oversized savings ledger token counts Reject unrepresentable before/after token counts before inserting into SQLite's signed INTEGER columns. Preserve exact stored savings evidence. Cover invalid boundaries and the largest representable count. Refs #4447 --- .../src/registered_accounting.rs | 12 +++++- crates/tracedecay-global-db/src/tests.rs | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_accounting.rs b/crates/tracedecay-global-db/src/registered_accounting.rs index ad42cce109..f86b063337 100644 --- a/crates/tracedecay-global-db/src/registered_accounting.rs +++ b/crates/tracedecay-global-db/src/registered_accounting.rs @@ -87,6 +87,14 @@ impl RegisteredGlobalDb { after_tokens: u64, timestamp: i64, ) -> tracedecay_domain::errors::Result<()> { + // SQLite's signed INTEGER cannot represent every u64. Refuse before + // writing rather than wrapping a valid token count into a negative. + let before_tokens = i64::try_from(before_tokens).map_err(|error| { + global_db_operation_error("encode savings ledger before tokens", error) + })?; + let after_tokens = i64::try_from(after_tokens).map_err(|error| { + global_db_operation_error("encode savings ledger after tokens", error) + })?; let project_path = RegisteredGlobalDb::canonical_project_key(Path::new(project_path)); let tool_name = tool_name.to_owned(); self.transact(async move |transaction| { @@ -99,8 +107,8 @@ impl RegisteredGlobalDb { timestamp, project_path.as_str(), tool_name.as_str(), - before_tokens as i64, - after_tokens as i64 + before_tokens, + after_tokens ], ) .await diff --git a/crates/tracedecay-global-db/src/tests.rs b/crates/tracedecay-global-db/src/tests.rs index 19cb23afe0..4b100c5093 100644 --- a/crates/tracedecay-global-db/src/tests.rs +++ b/crates/tracedecay-global-db/src/tests.rs @@ -1644,6 +1644,45 @@ async fn registered_runtime_does_not_recreate_a_missing_database() { ); } +#[asupersync::test] +async fn savings_ledger_refuses_unrepresentable_token_counts() { + let harness = RegisteredGlobalDbHarness::open("savings-ledger-token-encoding").await; + let database = &harness.registered; + let out_of_range = i64::MAX as u64 + 1; + + for (before, after) in [ + (out_of_range, 0), + (0, out_of_range), + (u64::MAX, 3), + (3, u64::MAX), + ] { + let error = database + .try_record_savings("/project", "tool", before, after, 1) + .await + .expect_err("unrepresentable token count must be refused"); + assert!( + error.to_string().contains("encode savings ledger"), + "unexpected refusal: {error}" + ); + } + assert_eq!( + row_count(database, "savings_ledger").await, + 0, + "a rejected count must not append a corrupted or partial row" + ); + + database + .try_record_savings("/project", "tool", i64::MAX as u64, 0, 1) + .await + .expect("the largest representable count must be accepted"); + let total = database + .sum_savings(Some("/project"), 0) + .await + .expect("read the committed savings row"); + assert_eq!(total.saved_tokens, i64::MAX as u64); + assert_eq!(total.calls, 1); +} + #[asupersync::test] async fn project_token_projection_rejects_unrepresentable_totals() { let harness = RegisteredGlobalDbHarness::open("project-tokens-overflow").await;