From e3d56d90afaa0e6f009b16fa6178eeaa01beb412 Mon Sep 17 00:00:00 2001 From: Claudecio Martins Date: Tue, 20 Oct 2026 16:01:28 -0300 Subject: [PATCH] perf(pdf): use random access read and streaming for large pdf signing --- .../signer/core/pdf/PdfSigningService.java | 132 +++++++++++++++--- .../core/pdf/PdfSigningServiceTest.java | 114 +++++++++++++++ 2 files changed, 226 insertions(+), 20 deletions(-) diff --git a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java index 199cd5a..f8ebd6a 100644 --- a/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java +++ b/modules/cybercore-core/src/main/java/br/com/cybercore/signer/core/pdf/PdfSigningService.java @@ -10,6 +10,9 @@ import br.com.cybercore.signer.core.protocol.CcspErrorCode; import br.com.cybercore.signer.core.provider.CertificateManager; import br.com.cybercore.signer.core.provider.CertificateProviderException; import org.apache.pdfbox.Loader; +import org.apache.pdfbox.io.RandomAccessRead; +import org.apache.pdfbox.io.RandomAccessReadBuffer; +import org.apache.pdfbox.io.RandomAccessReadBufferedFile; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; @@ -83,7 +86,19 @@ public class PdfSigningService { */ public static int getPageCount(byte[] pdfBytes) throws IOException { Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); - try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes); + PDDocument doc = Loader.loadPDF(rar)) { + return doc.getNumberOfPages(); + } + } + + /** + * Retorna a quantidade total de páginas contidas no arquivo PDF. + */ + public static int getPageCount(File pdfFile) throws IOException { + Objects.requireNonNull(pdfFile, "Arquivo PDF não pode ser nulo."); + try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(pdfFile); + PDDocument doc = Loader.loadPDF(rar)) { return doc.getNumberOfPages(); } } @@ -93,7 +108,24 @@ public class PdfSigningService { */ public static PageDimensions getPageDimensions(byte[] pdfBytes, int pageIndex0Based) throws IOException { Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); - try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes); + PDDocument doc = Loader.loadPDF(rar)) { + if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) { + throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages()); + } + PDPage page = doc.getPage(pageIndex0Based); + PDRectangle mediaBox = page.getMediaBox(); + return new PageDimensions(mediaBox.getWidth(), mediaBox.getHeight()); + } + } + + /** + * Retorna as dimensões em pontos (pt) da página informada do arquivo PDF. + */ + public static PageDimensions getPageDimensions(File pdfFile, int pageIndex0Based) throws IOException { + Objects.requireNonNull(pdfFile, "Arquivo PDF não pode ser nulo."); + try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(pdfFile); + PDDocument doc = Loader.loadPDF(rar)) { if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) { throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages()); } @@ -108,7 +140,8 @@ public class PdfSigningService { */ public static BufferedImage renderPageImage(byte[] pdfBytes, int pageIndex0Based, float dpi) throws IOException { Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); - try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes); + PDDocument doc = Loader.loadPDF(rar)) { if (pageIndex0Based < 0 || pageIndex0Based >= doc.getNumberOfPages()) { throw new IllegalArgumentException("Índice de página inválido: " + pageIndex0Based + ". Total: " + doc.getNumberOfPages()); } @@ -128,23 +161,55 @@ public class PdfSigningService { } /** - * Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino. + * Assina digitalmente um arquivo PDF do sistema de arquivos e grava o resultado no arquivo de destino, + * utilizando streaming direto via RandomAccessReadBufferedFile para minimizar o consumo de memória heap. */ public void signPdfFile(File sourcePdf, File targetPdf, PdfSignatureOptions options) throws Exception { Objects.requireNonNull(sourcePdf, "Arquivo PDF de origem não pode ser nulo."); Objects.requireNonNull(targetPdf, "Arquivo PDF de destino não pode ser nulo."); Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); - byte[] inputBytes = Files.readAllBytes(sourcePdf.toPath()); - byte[] signedBytes = signPdf(inputBytes, options); + if (!sourcePdf.exists() || !sourcePdf.isFile()) { + throw new IllegalArgumentException("Arquivo PDF de origem não existe: " + sourcePdf.getAbsolutePath()); + } - if (targetPdf.getParentFile() != null) { - targetPdf.getParentFile().mkdirs(); + boolean sameFile = sourcePdf.getCanonicalPath().equals(targetPdf.getCanonicalPath()); + File parentDir = targetPdf.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); } - try (FileOutputStream fos = new FileOutputStream(targetPdf)) { - fos.write(signedBytes); + + File effectiveTarget = sameFile + ? Files.createTempFile("cybercore_signed_", ".pdf").toFile() + : targetPdf; + + try { + // Calcula o hash SHA-256 via streaming em blocos de 64KB sem carregar o arquivo na RAM + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + try (InputStream is = Files.newInputStream(sourcePdf.toPath())) { + byte[] buffer = new byte[65536]; + int read; + while ((read = is.read(buffer)) != -1) { + sha256.update(buffer, 0, read); + } + } + byte[] pdfDigest = sha256.digest(); + + try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(sourcePdf); + FileOutputStream fos = new FileOutputStream(effectiveTarget)) { + signPdfInternal(rar, fos, options, pdfDigest); + } + + if (sameFile) { + Files.move(effectiveTarget.toPath(), targetPdf.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } finally { + if (sameFile && effectiveTarget.exists()) { + Files.deleteIfExists(effectiveTarget.toPath()); + } } - logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath()); + + logger.info("PDF assinado com sucesso e salvo em: {}", targetPdf.getAbsolutePath().replaceAll("[\r\n]", "")); } /** @@ -155,6 +220,39 @@ public class PdfSigningService { Objects.requireNonNull(pdfBytes, "Bytes do PDF não podem ser nulos."); Objects.requireNonNull(options, "Opções de assinatura não podem ser nulas."); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] pdfDigest = sha256.digest(pdfBytes); + + // Se o PDF for muito grande (> 2MB), usamos um arquivo temporário com RandomAccessReadBufferedFile + // para aliviar significativamente a pressão no Garbage Collector e economizar memória heap. + if (pdfBytes.length > 2 * 1024 * 1024) { + File tempSource = Files.createTempFile("cybercore_in_", ".pdf").toFile(); + File tempTarget = Files.createTempFile("cybercore_out_", ".pdf").toFile(); + try { + Files.write(tempSource.toPath(), pdfBytes); + try (RandomAccessReadBufferedFile rar = new RandomAccessReadBufferedFile(tempSource); + FileOutputStream fos = new FileOutputStream(tempTarget)) { + signPdfInternal(rar, fos, options, pdfDigest); + } + return Files.readAllBytes(tempTarget.toPath()); + } finally { + Files.deleteIfExists(tempSource.toPath()); + Files.deleteIfExists(tempTarget.toPath()); + } + } + + // Para PDFs de tamanho padrão (< 2MB), processa em memória com buffer pré-alocado + ByteArrayOutputStream finalOut = new ByteArrayOutputStream(pdfBytes.length + 65536); + try (RandomAccessReadBuffer rar = new RandomAccessReadBuffer(pdfBytes)) { + signPdfInternal(rar, finalOut, options, pdfDigest); + } + + logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size()); + return finalOut.toByteArray(); + } + + private void signPdfInternal(RandomAccessRead rar, OutputStream targetOut, + PdfSignatureOptions options, byte[] pdfDigest) throws Exception { // 1. Localiza o certificado e o material criptográfico (chave e cadeia) Optional certOpt = certificateManager.findById(options.certificateId()); if (certOpt.isEmpty()) { @@ -186,8 +284,6 @@ public class PdfSigningService { String rawName = cert.commonName() != null ? cert.commonName() : cert.alias(); String signerName = X509CertificateUtils.extractHolderName(rawName); - MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - byte[] pdfDigest = sha256.digest(pdfBytes); String hashShort = bytesToHex(pdfDigest).substring(0, 16).toUpperCase(); PdfSealTheme theme = options.sealTheme(); @@ -213,8 +309,7 @@ public class PdfSigningService { // preservando intactas quaisquer assinaturas digitais pré-existentes. Em conformidade com a ISO 32000-1 / ETSI PAdES, // o selo visual é associado diretamente ao widget da assinatura (/Widget /AP /N) sem alterar o stream /Contents // das páginas existentes, garantindo que o validador do ITI aprove a verificação incremental sem indeterminação. - ByteArrayOutputStream finalOut = new ByteArrayOutputStream(); - try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + try (PDDocument doc = Loader.loadPDF(rar)) { PDSignature signature = new PDSignature(); signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); @@ -235,16 +330,13 @@ public class PdfSigningService { signatureOptions.setVisualSignature(visualTemplate); signatureOptions.setPage(targetPage); doc.addSignature(signature, signer, signatureOptions); - doc.saveIncremental(finalOut); + doc.saveIncremental(targetOut); } } else { doc.addSignature(signature, signer); - doc.saveIncremental(finalOut); + doc.saveIncremental(targetOut); } } - - logger.info("Assinatura PAdES concluída com sucesso. Tamanho final: {} bytes", finalOut.size()); - return finalOut.toByteArray(); } private int resolveTargetPage(PDDocument doc, PdfSignatureOptions options) { diff --git a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java index 97119be..985b7bb 100644 --- a/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java +++ b/modules/cybercore-core/src/test/java/br/com/cybercore/signer/core/pdf/PdfSigningServiceTest.java @@ -289,4 +289,118 @@ class PdfSigningServiceTest { assertThat(validationResult.signers().get(1).digestValid()).isTrue(); assertThat(validationResult.signers().get(1).signerName()).contains("Maria Souza"); } + + @Test + @DisplayName("Deve assinar arquivo PDF via signPdfFile com arquivos de origem e destino distintos") + void shouldSignPdfFileWithDistinctSourceAndTarget() throws Exception { + java.io.File src = java.io.File.createTempFile("cybercore_src_", ".pdf"); + java.io.File tgt = java.io.File.createTempFile("cybercore_tgt_", ".pdf"); + try { + java.nio.file.Files.write(src.toPath(), createSamplePdf()); + + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .visibleSeal(true) + .position(PdfSealPosition.BOTTOM_RIGHT) + .reason("Assinatura de Arquivo Físico") + .location("Brasília, DF") + .build(); + + pdfSigningService.signPdfFile(src, tgt, options); + + assertThat(tgt.exists()).isTrue(); + assertThat(tgt.length()).isGreaterThan(src.length()); + + // Valida leitura de páginas e dimensões a partir de File + assertThat(PdfSigningService.getPageCount(src)).isEqualTo(1); + assertThat(PdfSigningService.getPageCount(tgt)).isEqualTo(1); + PdfSigningService.PageDimensions dims = PdfSigningService.getPageDimensions(tgt, 0); + assertThat(dims.width()).isGreaterThan(0); + assertThat(dims.height()).isGreaterThan(0); + + // Validação criptográfica do arquivo resultante + byte[] signedBytes = java.nio.file.Files.readAllBytes(tgt.toPath()); + SignatureValidationService validationService = new SignatureValidationService(); + ValidationResult res = validationService.validateAttached(signedBytes, "arquivo_assinado.pdf"); + assertThat(res.isValid()).isTrue(); + } finally { + java.nio.file.Files.deleteIfExists(src.toPath()); + java.nio.file.Files.deleteIfExists(tgt.toPath()); + } + } + + @Test + @DisplayName("Deve assinar arquivo PDF in-place (mesmo arquivo de origem e destino)") + void shouldSignInPlaceWhenSourceAndTargetAreSameFile() throws Exception { + java.io.File file = java.io.File.createTempFile("cybercore_inplace_", ".pdf"); + try { + byte[] sample = createSamplePdf(); + java.nio.file.Files.write(file.toPath(), sample); + long initialSize = file.length(); + + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .visibleSeal(false) + .reason("Assinatura In-Place") + .build(); + + pdfSigningService.signPdfFile(file, file, options); + + assertThat(file.exists()).isTrue(); + assertThat(file.length()).isGreaterThan(initialSize); + + byte[] signedBytes = java.nio.file.Files.readAllBytes(file.toPath()); + SignatureValidationService validationService = new SignatureValidationService(); + ValidationResult res = validationService.validateAttached(signedBytes, "inplace.pdf"); + assertThat(res.isValid()).isTrue(); + } finally { + java.nio.file.Files.deleteIfExists(file.toPath()); + } + } + + @Test + @DisplayName("Deve assinar PDF grande (> 2MB) acionando a estratégia de arquivo temporário") + void shouldSignLargePdfTriggeringTempFileOptimization() throws Exception { + // Cria PDF com buffer grande embutido para ultrapassar 2MB + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 10); + cs.newLineAtOffset(50, 700); + cs.showText("PDF com carga útil volumosa para teste de threshold de streaming."); + cs.endText(); + } + // Anexa stream arbitrário para elevar o tamanho acima de 2MB + org.apache.pdfbox.pdmodel.common.PDStream stream = new org.apache.pdfbox.pdmodel.common.PDStream(doc); + try (java.io.OutputStream os = stream.createOutputStream()) { + byte[] chunk = new byte[65536]; + java.util.Arrays.fill(chunk, (byte) 'X'); + for (int i = 0; i < 35; i++) { // ~2.2 MB + os.write(chunk); + } + } + doc.getDocumentCatalog().getCOSObject().setItem( + org.apache.pdfbox.cos.COSName.getPDFName("LargePayload"), stream.getCOSObject() + ); + doc.save(baos); + } + + byte[] largePdf = baos.toByteArray(); + assertThat(largePdf.length).isGreaterThan(2 * 1024 * 1024); + + PdfSignatureOptions options = PdfSignatureOptions.builder(testCertId) + .visibleSeal(true) + .position(PdfSealPosition.BOTTOM_RIGHT) + .reason("Assinatura de Arquivo Grande") + .build(); + + byte[] signed = pdfSigningService.signPdf(largePdf, options); + assertThat(signed).isNotNull(); + assertThat(signed.length).isGreaterThan(largePdf.length); + + SignatureValidationService validationService = new SignatureValidationService(); + ValidationResult res = validationService.validateAttached(signed, "large_signed.pdf"); + assertThat(res.isValid()).isTrue(); + } }