we-promise/sure · error · AccountStatement::InvalidUploadError

AccountStatement::InvalidUploadError

Error message

AccountStatement::InvalidUploadError

What it means

AccountStatement.prepare_upload! validates uploads before storing them: after reading the content, it sniffs the real content type via Marcel and runs three guards — this one (:143) fires when the detected type is application/pdf but the bytes do not start with the %PDF- magic marker (valid_pdf_content? is a plain content.start_with?("%PDF-") check). It raises AccountStatement::InvalidUploadError to reject files that merely claim or appear to be PDFs but aren't structurally PDFs at the byte level.

Source

Thrown at app/models/account_statement.rb:143

    def reconciliation_statuses_for(statements, account:)
      statement_list = statements.to_a
      balance_lookup = balance_lookup_for(account, statement_list)

      statement_list.to_h do |statement|
        [ statement.id, statement.reconciliation_status(balance_lookup: balance_lookup) ]
      end
    end

    def prepare_upload!(file)
      filename = file.original_filename.to_s
      content = read_upload_content!(file)
      byte_size = content.bytesize
      raise InvalidUploadError if byte_size.zero?

      content_type = detected_content_type(content:, filename:, declared_content_type: file.content_type)
      raise InvalidUploadError unless allowed_upload?(filename:, content_type:)
      raise InvalidUploadError if content_type == "application/pdf" && !valid_pdf_content?(content)

      PreparedUpload.new(
        content: content,
        filename: filename,
        content_type: content_type,
        byte_size: byte_size,
        checksum: Digest::MD5.base64digest(content),
        content_sha256: Digest::SHA256.hexdigest(content)
      )
    end

    def detected_content_type(content:, filename:, declared_content_type:)
      Marcel::MimeType.for(
        StringIO.new(content),
        name: filename,
        declared_type: declared_content_type.presence
      )
    end

View on GitHub (pinned to e69894adb9)

Solutions

  1. Re-download the statement directly from the bank in a fresh session and verify it opens in a real PDF viewer before uploading
  2. Check the magic bytes yourself: head -c 5 statement.pdf should print %PDF-
  3. If the file is genuinely another format (PNG/CSV), give it the correct extension so it goes down the right allowed-type path instead of the PDF one
  4. Regenerate broken fixtures/tests with a minimal valid PDF (e.g. "%PDF-1.4…%%EOF" minimal document)

Example fix

# before
file = Tempfile.new(["statement", ".pdf"])
file.write("<html>Session expired</html>") # bytes are HTML, name says .pdf
AccountStatement.prepare_upload!(file) # => AccountStatement::InvalidUploadError

# after
# verify and upload a real PDF
raise "not a PDF" unless content.start_with?("%PDF-")
File.binwrite("statement.pdf", content) # genuine %PDF bytes
AccountStatement.prepare_upload!(uploaded_real_pdf)
Defensive patterns

Strategy: validation

Validate before calling

# Before uploading a .pdf
content = File.binread(path)
raise "not a PDF" unless content.start_with?("%PDF-")
raise "empty file" if content.bytesize.zero?

Type guard

def real_pdf?(path)
  content = File.binread(path, 5)
  content == "%PDF-"
end

Try / catch

rescue AccountStatement::InvalidUploadError
  # tell the user the file is not a real PDF; ask them to re-download and verify
  render json: { error: "File is not a valid PDF. Re-download it and confirm it opens in a viewer." }, status: :unprocessable_entity
end

Prevention

When it happens

Trigger: A .pdf extension whose bytes are actually HTML (a bank's error page saved as .pdf) — note Marcel can smell HTML-in-pdf-clothing via content; a truncated PDF download (connection dropped mid-download, first bytes intact but header claims pdf) where Marcel still detects pdf from name+magic but content check differs; a text file renamed to statement.pdf; a zero-byte-then-garbage file where content sniffing leaned on the filename.

Common situations: Bank portals that serve an HTML login/error page with a .pdf URL when the session expired; wget/curl downloads interrupted and silently saved partial files; files passed through Excel or preview apps that 'helpfully' re-encoded them; test fixtures generated with Faker text but a .pdf name.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/48f7805ba38e6c29. Report an issue: GitHub.