perf(pdf): use random access read and streaming for large pdf signing
This commit is contained in:
+112
-20
@@ -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<ManagedCertificate> 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) {
|
||||
|
||||
+114
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user