{"record":{"id":"48f7805ba38e6c29","repo":"we-promise/sure","slug":"accountstatement-invaliduploaderror","errorCode":null,"errorMessage":"AccountStatement::InvalidUploadError","messagePattern":"AccountStatement::InvalidUploadError","errorType":"exception","errorClass":"AccountStatement::InvalidUploadError","httpStatus":null,"severity":"error","filePath":"app/models/account_statement.rb","lineNumber":143,"sourceCode":"\n    def reconciliation_statuses_for(statements, account:)\n      statement_list = statements.to_a\n      balance_lookup = balance_lookup_for(account, statement_list)\n\n      statement_list.to_h do |statement|\n        [ statement.id, statement.reconciliation_status(balance_lookup: balance_lookup) ]\n      end\n    end\n\n    def prepare_upload!(file)\n      filename = file.original_filename.to_s\n      content = read_upload_content!(file)\n      byte_size = content.bytesize\n      raise InvalidUploadError if byte_size.zero?\n\n      content_type = detected_content_type(content:, filename:, declared_content_type: file.content_type)\n      raise InvalidUploadError unless allowed_upload?(filename:, content_type:)\n      raise InvalidUploadError if content_type == \"application/pdf\" && !valid_pdf_content?(content)\n\n      PreparedUpload.new(\n        content: content,\n        filename: filename,\n        content_type: content_type,\n        byte_size: byte_size,\n        checksum: Digest::MD5.base64digest(content),\n        content_sha256: Digest::SHA256.hexdigest(content)\n      )\n    end\n\n    def detected_content_type(content:, filename:, declared_content_type:)\n      Marcel::MimeType.for(\n        StringIO.new(content),\n        name: filename,\n        declared_type: declared_content_type.presence\n      )\n    end","sourceCodeStart":125,"sourceCodeEnd":161,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/account_statement.rb#L125-L161","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Re-download the statement directly from the bank in a fresh session and verify it opens in a real PDF viewer before uploading","Check the magic bytes yourself: head -c 5 statement.pdf should print %PDF-","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","Regenerate broken fixtures/tests with a minimal valid PDF (e.g. \"%PDF-1.4…%%EOF\" minimal document)"],"exampleFix":"# before\nfile = Tempfile.new([\"statement\", \".pdf\"])\nfile.write(\"<html>Session expired</html>\") # bytes are HTML, name says .pdf\nAccountStatement.prepare_upload!(file) # => AccountStatement::InvalidUploadError\n\n# after\n# verify and upload a real PDF\nraise \"not a PDF\" unless content.start_with?(\"%PDF-\")\nFile.binwrite(\"statement.pdf\", content) # genuine %PDF bytes\nAccountStatement.prepare_upload!(uploaded_real_pdf)","handlingStrategy":"validation","validationCode":"# Before uploading a .pdf\ncontent = File.binread(path)\nraise \"not a PDF\" unless content.start_with?(\"%PDF-\")\nraise \"empty file\" if content.bytesize.zero?","typeGuard":"def real_pdf?(path)\n  content = File.binread(path, 5)\n  content == \"%PDF-\"\nend","tryCatchPattern":"rescue AccountStatement::InvalidUploadError\n  # tell the user the file is not a real PDF; ask them to re-download and verify\n  render json: { error: \"File is not a valid PDF. Re-download it and confirm it opens in a viewer.\" }, status: :unprocessable_entity\nend","preventionTips":["Verify downloaded bank PDFs open in a real viewer before uploading","Check magic bytes (head -c 5 file.pdf) when automating uploads","Beware bank portals serving HTML error pages with .pdf URLs on session expiry","Generate test fixtures from real PDFs, not renamed text files"],"tags":["rails","file-upload","pdf","content-validation","magic-bytes"],"backgroundTag":"file-content-validation-failed","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}