Skip to content

ADR-0077: Imported documents are read with a 16 MiB cap

Context

SettingsViewModel.importJson read the whole picked document with stream.bufferedReader(Charsets.UTF_8).readText() before decoding. Nothing capped the size, and the document comes from an untrusted ContentProvider, so a huge — or merely lying — provider could exhaust the heap before any validation ran. The sibling attachment path already caps its read at 10 MB (ADR-0027); import had no cap at all. ImportError (ADR-0076) distinguished read, validation and save failures, but had no size class, and InputStream.readBytes can size its first buffer from available(), so a provider that over-reports its length worsens the allocation.

Decision

  • ExportCodec.MAX_IMPORT_BYTES = 16 * 1024 * 1024 (16 MiB) is the cap. ExportRow carries up to 22 fields (~400–600 B of JSON per row), so 16 MiB is roughly 25–30k transactions — about 8 transactions a day for ten years. Peak heap stays near 32 MB even holding the whole payload as a UTF-16 String.
  • ExportCodec.readImport(stream, limit = MAX_IMPORT_BYTES) copies in 64 KiB chunks and stops at limit + 1 bytes. One byte past the cap is enough to prove the payload is too large, so an oversize or lying provider is never drained. The provider's length metadata — available(), openAssetFileDescriptor().length — is never consulted.
  • ImportError.TooLarge is added to the sealed hierarchy (ADR-0076). The Data screen shows a distinct localized message that names the limit.
  • Whole-payload limitation, stated explicitly: ImportRead.Ok is the full decoded document, not a streaming parser. This ADR bounds a read that already materialized the payload in memory; replacing the JSON decode with row-by-row streaming is a larger refactor across ExportCodec and ExportRepository and is deliberately out of scope.
  • Cancellation and failure semantics are unchanged: a CancellationException is rethrown, use closes the stream on every exit path, and an oversize document performs no writes because it never reaches the repository.

Consequences

  • A document above 16 MiB is rejected as TooLarge without a full read and without any ledger write; the rejection is distinct from unreadable, malformed, unsupported-version and invalid-payload.
  • Normal exports, far below the cap, round-trip unchanged.
  • JVM tests cover the exact boundary, boundary + 1, an unknown-length stream, lying metadata, a mid-stream IO error, UTF-8 decoding and the default limit; the Android-bound ViewModel read path is covered by compilation and the instrumented read/ledger checks.
  • No Room schema change and no new dependency; ExportCodec stays pure Kotlin.

Rejected alternatives

  • No cap / trust the provider's length — the heap-exhaustion this removes, demonstrated by an OutOfMemoryError from a stream whose available() over-reports.
  • Reuse the attachment 10 MB cap — import rows are text-only and the export rationale above supports 16 MiB.
  • Stream rows one at a time — a larger refactor that changes the decode and repository seams; out of scope, documented as a limitation instead.
  • Silently truncate at the cap — would import a corrupt partial ledger.

Amendment (2026-10-19): export shares the import cap

ExportRepository.exportJson measures the serialized payload with ExportCodec.utf8Size (exact UTF-8 bytes, surrogate-safe) and returns JsonExportResult.TooLarge above MAX_IMPORT_BYTES; the Settings screen stages only Ready, so an over-limit ledger never reaches the destination picker and no un-restorable file is written. The comparison is inclusive (the bounded read accepts exactly the cap). CSV is deliberately unguarded: import is JSON-only, so a CSV has no restore limit to violate and stays a usable spreadsheet export. Streaming or partitioned large backups remain out of scope.